diff --git a/README.md b/README.md index e081803..4637673 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ 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 compose` → opens the browser editor normally. In CI, where compose would skip the editor and send directly, it is blocked with an error. - `message edit` and `message delete` → blocked with an error. - All read operations (`get`, `list`, `search`, etc.) and reactions are unchanged. diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index 7512344..6c2a8e5 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -21,7 +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. +- 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, the CI `message compose` direct-send shortcut is blocked, and `message edit`/`message delete` are blocked. Use it when nothing should post without human review. ## Workflow diff --git a/src/cli/message-command.ts b/src/cli/message-command.ts index 52864a6..9e6d97e 100644 --- a/src/cli/message-command.ts +++ b/src/cli/message-command.ts @@ -295,6 +295,11 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex { workspace?: string; threadTs?: string }, ]; try { + if (safeModeActive() && process.env.CI) { + throw new Error( + 'Safe mode is active: "message compose" cannot skip the editor in CI because that would post without human review.', + ); + } const payload = await composeMessage({ ctx: input.ctx, targetInput, diff --git a/src/index.ts b/src/index.ts index 7e61630..fedbb7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,7 @@ program .version(getPackageVersion()) .option( "--safe-mode", - 'Human-in-the-loop enforcement: redirect "message send" to the draft editor and block "message edit"/"message delete" (also: AGENT_SLACK_SAFE_MODE=1)', + 'Human-in-the-loop enforcement: redirect "message send" to the draft editor, block the CI "message compose" direct-send shortcut, and block "message edit"/"message delete" (also: AGENT_SLACK_SAFE_MODE=1)', ); startCommandWatchdog(process.argv.slice(2)); diff --git a/test/safe-mode.test.ts b/test/safe-mode.test.ts index 3d398ac..bac47bd 100644 --- a/test/safe-mode.test.ts +++ b/test/safe-mode.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { Command } from "commander"; import type { CliContext } from "../src/cli/context.ts"; +import { registerMessageCommand } from "../src/cli/message-command.ts"; import { isSafeModeEnabled, redirectSendToDraft, @@ -34,6 +36,47 @@ describe("safeModeBlockedError", () => { }); }); +test("safe mode blocks CI compose before workspace or API work", async () => { + const originalCi = process.env.CI; + const originalExitCode = process.exitCode; + const originalLog = console.log; + const originalError = console.error; + const log = mock(() => {}); + const error = mock(() => {}); + const noWorkCtx = { + errorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + } as CliContext; + + try { + process.env.CI = "1"; + process.exitCode = 0; + console.log = log as typeof console.log; + console.error = error as typeof console.error; + + const program = new Command().option("--safe-mode"); + registerMessageCommand({ program, ctx: noWorkCtx }); + await program.parseAsync( + ["--safe-mode", "message", "compose", "C12345678", "review this first"], + { from: "user" }, + ); + + expect(log).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + 'Safe mode is active: "message compose" cannot skip the editor in CI because that would post without human review.', + ); + expect(process.exitCode).toBe(1); + } finally { + if (originalCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCi; + } + process.exitCode = originalExitCode ?? 0; + console.log = originalLog; + console.error = originalError; + } +}); + describe("redirectSendToDraft", () => { const originalCi = process.env.CI;