Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@ agent-slack message draft update "DR_ID" "Here's my revised update"
agent-slack message draft delete "DR_ID"
```

### Safe mode (enforced human-in-the-loop)

Skill instructions like "always use `draft`, never `send`" are guidance an agent can ignore. Safe mode enforces it at the tool level — useful when an AI agent has access to `agent-slack` and you want a guarantee that nothing posts without human review.

```bash
# Env var (recommended for agent environments)
export AGENT_SLACK_SAFE_MODE=1

# Or a global CLI flag
agent-slack --safe-mode message send "#general" "hello"
```

While safe mode is active:

- `message send` → redirected to the draft editor with the text pre-filled; you review and send from the browser. The output includes `"safe_mode": true` and `"redirected_from": "send"`, and a warning is printed to stderr. Flags the editor cannot represent (`--attach`, `--blocks`, `--schedule`, `--schedule-in`, `--reply-broadcast`) are rejected with an error instead of being silently dropped.
- `message edit` and `message delete` → blocked with an error.
- All read operations (`get`, `list`, `search`, etc.) and reactions are unchanged.

The env var accepts `1`, `true`, `yes`, or `on` (case-insensitive); anything else leaves safe mode off.

### Reply, edit, delete, and react

```bash
Expand Down
3 changes: 2 additions & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ If a capability named here is absent from installed help, report version skew in
- Read and search freely.
- Perform write actions only when explicitly requested: sends, edits, deletes, reactions, invitations, channel or canvas creation, mark-read operations, scheduling or canceling delivery, uploads, Later state/reminder changes, DM/group-DM creation, and `workflow run`. Workflow runs can execute downstream actions.
- For compose- or review-only requests, return proposed text without invoking Slack, or use `message draft create` to add a Slack-native draft the user can review and send (nothing is posted). `message compose` is send-capable; use it only when the user explicitly asks to open the interactive editor. In CI or another noninteractive environment, do not invoke it without separate authorization to send immediately: CI skips the editor and sends supplied text.
- With `AGENT_SLACK_SAFE_MODE=1` (or the global `--safe-mode` flag) set, safe mode is enforced at the tool level: `message send` is redirected to the draft editor and `message edit`/`message delete` are blocked. Use it when nothing should post without human review.

## Workflow

Expand All @@ -34,7 +35,7 @@ For scheduled writes, prefer `--schedule` with an ISO 8601 timestamp and explici

Named `later remind --in` values such as `tomorrow` or `monday` also use the executing environment's local timezone at 9:00. Confirm that timezone or pass an explicit Unix timestamp.

Ordinary `message send` and `message edit` calls auto-convert lists. `message send --blocks` uses supplied blocks, while `message send --attach` sends its initial comment without automatic list conversion. Inside auto-converted lists, use Slack's `<URL|label>` syntax because CommonMark `[label](URL)` links are not converted into labeled link elements.
Ordinary `message send` and `message edit` calls auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Inside auto-converted lists, use Slack's `<URL|label>` syntax because CommonMark `[label](URL)` links are not converted into labeled link elements.

Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd).

Expand Down
2 changes: 2 additions & 0 deletions src/auth/brave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir, platform } from "node:os";
import { join } from "node:path";
import { queryReadonlySqlite } from "./firefox-profile.ts";
import { decryptChromiumCookieValue } from "./chromium-cookie.ts";
import { getKeychainTimeoutMs } from "./keychain.ts";
import { isRecord } from "../lib/object-type-guards.ts";

type BraveExtractedTeam = { url: string; name?: string; token: string };
Expand Down Expand Up @@ -136,6 +137,7 @@ function getSafeStoragePasswords(): string[] {
const out = execFileSync("security", ["find-generic-password", "-w", "-s", service], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: getKeychainTimeoutMs(),
}).trim();
if (out) {
passwords.push(out);
Expand Down
3 changes: 3 additions & 0 deletions src/auth/desktop-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createDecipheriv, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isRecord } from "../lib/object-type-guards.ts";
import { getKeychainTimeoutMs } from "./keychain.ts";

const IS_MACOS = process.platform === "darwin";
const IS_LINUX = process.platform === "linux";
Expand Down Expand Up @@ -31,6 +32,7 @@ export function getSafeStoragePasswords(prefix: string): string[] {
const out = execFileSync("security", ["find-generic-password", ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: getKeychainTimeoutMs(),
}).trim();
if (out) {
passwords.push(out);
Expand All @@ -57,6 +59,7 @@ export function getSafeStoragePasswords(prefix: string): string[] {
const out = execFileSync("secret-tool", ["lookup", ...pair], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: getKeychainTimeoutMs(),
}).trim();
if (out) {
passwords.push(out);
Expand Down
21 changes: 20 additions & 1 deletion src/auth/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import { platform } from "node:os";
import { execFileSync } from "node:child_process";

const IS_MACOS = platform() === "darwin";
const DEFAULT_KEYCHAIN_TIMEOUT_MS = 3_000;

export function getKeychainTimeoutMs(): number {
const raw = process.env.AGENT_SLACK_KEYCHAIN_TIMEOUT_MS?.trim();
if (!raw) {
return DEFAULT_KEYCHAIN_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_KEYCHAIN_TIMEOUT_MS;
}
return Math.floor(parsed);
}

export function keychainGet(account: string, service: string): string | null {
if (!IS_MACOS) {
Expand All @@ -11,7 +24,11 @@ export function keychainGet(account: string, service: string): string | null {
const result = execFileSync(
"security",
["find-generic-password", "-s", service, "-a", account, "-w"],
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
{
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
timeout: getKeychainTimeoutMs(),
},
);
return result.trim() || null;
} catch {
Expand All @@ -28,12 +45,14 @@ export function keychainSet(input: { account: string; value: string; service: st
try {
execFileSync("security", ["delete-generic-password", "-s", service, "-a", account], {
stdio: ["pipe", "pipe", "ignore"],
timeout: getKeychainTimeoutMs(),
});
} catch {
// ignore
}
execFileSync("security", ["add-generic-password", "-s", service, "-a", account, "-w", value], {
stdio: "pipe",
timeout: getKeychainTimeoutMs(),
});
return true;
} catch {
Expand Down
15 changes: 15 additions & 0 deletions src/cli/channel-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CliContext } from "./context.ts";
import { pruneEmpty } from "../lib/compact-json.ts";
import {
listAllConversations,
listConversationsViaCounts,
listUserConversations,
markConversation,
resolveChannelId,
Expand All @@ -21,6 +22,7 @@ type ChannelListOptions = {
workspace?: string;
user?: string;
all?: boolean;
viaCounts?: boolean;
limit: string;
cursor?: string;
};
Expand All @@ -39,6 +41,10 @@ export function registerChannelCommand(input: { program: Command; ctx: CliContex
)
.option("--user <user>", "User ID (U.../W...) or @handle/handle")
.option("--all", "List all conversations (conversations.list); incompatible with --user")
.option(
"--via-counts",
"List joined conversations via client.counts (works on Enterprise Grid where users.conversations/conversations.list are restricted for browser tokens; incompatible with --all/--user)",
)
.option("--limit <n>", "Max conversations in one page (default 100)", "100")
.option("--cursor <cursor>", "Pagination cursor for the next page")
.action(async (...args) => {
Expand All @@ -47,6 +53,12 @@ export function registerChannelCommand(input: { program: Command; ctx: CliContex
if (options.all && options.user) {
throw new Error("--all cannot be used with --user");
}
if (options.viaCounts && options.all) {
throw new Error("--via-counts cannot be used with --all");
}
if (options.viaCounts && options.user) {
throw new Error("--via-counts cannot be used with --user");
}

const limit = Number.parseInt(options.limit, 10);
if (!Number.isFinite(limit) || limit < 1) {
Expand All @@ -58,6 +70,9 @@ export function registerChannelCommand(input: { program: Command; ctx: CliContex
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
if (options.viaCounts) {
return await listConversationsViaCounts(client, { limit });
}
if (options.all) {
return await listAllConversations(client, {
limit,
Expand Down
8 changes: 6 additions & 2 deletions src/cli/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export async function editMessage(input: {
ctx: CliContext;
targetInput: string;
text: string;
options: { workspace?: string; ts?: string };
options: { workspace?: string; ts?: string; blocks?: string };
}): Promise<Record<string, unknown>> {
const target = parseMsgTarget(String(input.targetInput));
if (target.kind === "user") {
Expand All @@ -332,7 +332,11 @@ export async function editMessage(input: {
}
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
const formattedText = formatOutboundSlackText(input.text);
const blocks = input.text ? textToRichTextBlocks(input.text) : null;
const blocks = input.options.blocks
? loadBlocksFromPath(input.options.blocks)
: input.text
? textToRichTextBlocks(input.text)
: null;

await input.ctx.withAutoRefresh({
workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
Expand Down
31 changes: 24 additions & 7 deletions src/cli/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import {
import { composeMessage } from "./compose-actions.ts";
import { registerScheduledMessageCommand } from "./message-scheduled-command.ts";
import { registerMessageDraftCommand } from "./message-draft-command.ts";
import { isSafeModeEnabled, redirectSendToDraft, safeModeBlockedError } from "./safe-mode.ts";

function collectOptionValue(value: string, previous: string[] = []): string[] {
return [...previous, value];
}

export function registerMessageCommand(input: { program: Command; ctx: CliContext }): void {
const safeModeActive = (): boolean =>
isSafeModeEnabled({ cliFlag: Boolean(input.program.opts().safeMode) });
const messageCmd = input.program
.command("message")
.description("Read/write Slack messages (token-efficient JSON)");
Expand Down Expand Up @@ -114,13 +117,17 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
"Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)",
)
.option("--ts <ts>", "Message ts (required when using #channel/channel id)")
.option("--blocks <path>", "Read Block Kit blocks JSON from file or '-'")
.action(async (...args) => {
const [targetInput, text, options] = args as [
string,
string,
{ workspace?: string; ts?: string },
{ workspace?: string; ts?: string; blocks?: string },
];
try {
if (safeModeActive()) {
throw safeModeBlockedError("edit");
}
const payload = await editMessage({
ctx: input.ctx,
targetInput,
Expand All @@ -146,6 +153,9 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
.action(async (...args) => {
const [targetInput, options] = args as [string, { workspace?: string; ts?: string }];
try {
if (safeModeActive()) {
throw safeModeBlockedError("delete");
}
const payload = await deleteMessage({
ctx: input.ctx,
targetInput,
Expand Down Expand Up @@ -240,12 +250,19 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
return;
}
try {
const payload = await sendMessage({
ctx: input.ctx,
targetInput,
text: text ?? "",
options,
});
const payload = safeModeActive()
? await redirectSendToDraft({
ctx: input.ctx,
targetInput,
text: text ?? "",
options,
})
: await sendMessage({
ctx: input.ctx,
targetInput,
text: text ?? "",
options,
});
console.log(JSON.stringify(payload, null, 2));
} catch (err: unknown) {
console.error(input.ctx.errorMessage(err));
Expand Down
93 changes: 93 additions & 0 deletions src/cli/safe-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { CliContext } from "./context.ts";
import { composeMessage } from "./compose-actions.ts";

const TRUTHY_VALUES = new Set(["1", "true", "yes", "on"]);

/**
* Safe mode keeps a human in the loop for anything that posts to Slack:
* `message send` is redirected to the draft editor, and `message edit` /
* `message delete` are blocked. Enabled via the global `--safe-mode` flag
* or the AGENT_SLACK_SAFE_MODE env var ("1", "true", "yes", "on").
*/
export function isSafeModeEnabled(input?: {
cliFlag?: boolean;
env?: Record<string, string | undefined>;
}): boolean {
if (input?.cliFlag) {
return true;
}
const env = input?.env ?? process.env;
const raw = env.AGENT_SLACK_SAFE_MODE?.trim().toLowerCase();
return raw !== undefined && TRUTHY_VALUES.has(raw);
}

export function safeModeBlockedError(action: "edit" | "delete"): Error {
return new Error(
`Safe mode is active (AGENT_SLACK_SAFE_MODE or --safe-mode): "message ${action}" is blocked so an agent cannot ${action} messages without human review. Disable safe mode to ${action} messages.`,
);
}

export type SendOptionsForRedirect = {
workspace?: string;
threadTs?: string;
attach?: string[];
blocks?: string;
replyBroadcast?: boolean;
schedule?: string;
scheduleIn?: string;
};

/**
* Redirects `message send` to the interactive draft editor so a human
* reviews and sends the message. Flags the draft editor cannot represent
* (attachments, raw blocks, scheduling, broadcasts) are rejected instead
* of being silently dropped.
*/
export async function redirectSendToDraft(
input: {
ctx: CliContext;
targetInput: string;
text: string;
options: SendOptionsForRedirect;
},
draftFn: typeof composeMessage = composeMessage,
): Promise<Record<string, unknown>> {
const unsupported: string[] = [];
if ((input.options.attach ?? []).length > 0) {
unsupported.push("--attach");
}
if (input.options.blocks !== undefined) {
unsupported.push("--blocks");
}
if (input.options.schedule !== undefined) {
unsupported.push("--schedule");
}
if (input.options.scheduleIn !== undefined) {
unsupported.push("--schedule-in");
}
if (input.options.replyBroadcast) {
unsupported.push("--reply-broadcast");
}
if (unsupported.length > 0) {
throw new Error(
`Safe mode is active: "message send" is redirected to the draft editor, which does not support ${unsupported.join(", ")}. Drop those flags or disable safe mode.`,
);
}
if (process.env.CI) {
throw new Error(
"Safe mode is active but the interactive draft editor is unavailable in CI. Disable safe mode (or unset CI) to send messages.",
);
}

console.error(
'⚠ Safe mode active: redirecting "message send" → draft editor. Nothing posts until a human sends it from the editor.',
);

const payload = await draftFn({
ctx: input.ctx,
targetInput: input.targetInput,
initialText: input.text,
options: { workspace: input.options.workspace, threadTs: input.options.threadTs },
});
return { safe_mode: true, redirected_from: "send", ...payload };
}
Loading
Loading