Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -217,6 +217,26 @@ agent-slack message draft "https://workspace.slack.com/archives/C123/p1700000000

After sending, the editor shows a "View in Slack" link to the posted message.

### 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
2 changes: 1 addition & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ curl -fsSL https://raw.githubusercontent.com/stablyai/agent-slack/main/install.s

Fallback: `npm i -g agent-slack` (Node >= 22.5).

Safety: read/search freely. Do not send, edit, delete, react, invite, create channels, mark read, schedule, upload, or cancel scheduled messages unless explicitly asked. Prefer `message draft`.
Safety: read/search freely. Do not send, edit, delete, react, invite, create channels, mark read, schedule, upload, or cancel scheduled messages unless explicitly asked. Prefer `message draft`. With `AGENT_SLACK_SAFE_MODE=1` set, `message send` redirects to the draft editor and `edit`/`delete` are blocked.

Auth: `agent-slack auth whoami`; if needed `auth import-desktop`, `auth import-brave`, `auth import-chrome`, or `auth import-firefox`, then `auth test`.

Expand Down
3 changes: 3 additions & 0 deletions skills/agent-slack/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Run `agent-slack --help` (or `agent-slack <command> --help`) for the full option
- `--reply-broadcast` when replying in a thread, also post the reply to the parent channel. For channel targets, pair with `--thread-ts`; for URL targets, the thread context is derived from the message. Not supported for DM targets; cannot be combined with `--attach`.
- `--schedule <time>` schedule delivery at an ISO 8601 timestamp with explicit timezone (or Unix timestamp). Must be in the future and within Slack's 120-day scheduled-send limit. Compatible with `--blocks`, `--thread-ts`, and `--reply-broadcast`; cannot be combined with `--attach`.
- `--schedule-in <duration>` schedule delivery after a duration or simple future phrase (`30m`, `3h`, `2d`, `tomorrow 9am`, `monday 9am`; phrases use your local timezone). Mutually exclusive with `--schedule`; cannot be combined with `--attach`.
- Safe mode (`AGENT_SLACK_SAFE_MODE=1` or global `--safe-mode`): `send` is redirected to the draft editor for human review (output includes `"safe_mode": true`); `--attach`/`--blocks`/`--schedule`/`--schedule-in`/`--reply-broadcast` are rejected while it is active.

- `agent-slack message scheduled list`
- Lists pending scheduled messages from Slack's server-side scheduled message queue.
Expand All @@ -99,13 +100,15 @@ Run `agent-slack --help` (or `agent-slack <command> --help`) for the full option
- Options:
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--ts <seconds>.<micros>` (required for channel targets)
- Blocked when safe mode is active (`AGENT_SLACK_SAFE_MODE=1` or `--safe-mode`).

- `agent-slack message delete <target>`
- URL target deletes that exact message.
- Channel target requires `--ts`.
- Options:
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--ts <seconds>.<micros>` (required for channel targets)
- Blocked when safe mode is active (`AGENT_SLACK_SAFE_MODE=1` or `--safe-mode`).

- `agent-slack message react add <target> <emoji>`
- `agent-slack message react remove <target> <emoji>`
Expand Down
28 changes: 22 additions & 6 deletions src/cli/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import {
} from "./message-actions.ts";
import { draftMessage } from "./draft-actions.ts";
import { registerScheduledMessageCommand } from "./message-scheduled-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 @@ -117,6 +120,9 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
{ workspace?: string; ts?: string },
];
try {
if (safeModeActive()) {
throw safeModeBlockedError("edit");
}
const payload = await editMessage({
ctx: input.ctx,
targetInput,
Expand All @@ -142,6 +148,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 @@ -228,12 +237,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 { draftMessage } from "./draft-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 draftMessage = draftMessage,
): 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 };
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ const program = new Command();
program
.name("agent-slack")
.description("Slack automation CLI for AI agents")
.version(getPackageVersion());
.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)',
);

const ctx = createCliContext();

Expand Down
107 changes: 107 additions & 0 deletions test/safe-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { CliContext } from "../src/cli/context.ts";
import {
isSafeModeEnabled,
redirectSendToDraft,
safeModeBlockedError,
} from "../src/cli/safe-mode.ts";

const stubCtx = {} as CliContext;

describe("isSafeModeEnabled", () => {
test("enabled via CLI flag", () => {
expect(isSafeModeEnabled({ cliFlag: true, env: {} })).toBe(true);
});

test.each(["1", "true", "TRUE", "yes", "on", " 1 "])("enabled for env value %p", (value) => {
expect(isSafeModeEnabled({ env: { AGENT_SLACK_SAFE_MODE: value } })).toBe(true);
});

test.each(["0", "false", "off", "no", ""])("disabled for env value %p", (value) => {
expect(isSafeModeEnabled({ env: { AGENT_SLACK_SAFE_MODE: value } })).toBe(false);
});

test("disabled when env var is unset and flag is absent", () => {
expect(isSafeModeEnabled({ env: {} })).toBe(false);
expect(isSafeModeEnabled({ cliFlag: false, env: {} })).toBe(false);
});
});

describe("safeModeBlockedError", () => {
test("names the blocked action", () => {
expect(safeModeBlockedError("edit").message).toContain('"message edit" is blocked');
expect(safeModeBlockedError("delete").message).toContain('"message delete" is blocked');
});
});

describe("redirectSendToDraft", () => {
const originalCi = process.env.CI;

afterEach(() => {
if (originalCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = originalCi;
}
});

test.each([
[{ attach: ["./report.md"] }, "--attach"],
[{ blocks: "/tmp/blocks.json" }, "--blocks"],
[{ schedule: "2030-01-01T00:00:00Z" }, "--schedule"],
[{ scheduleIn: "3h" }, "--schedule-in"],
[{ replyBroadcast: true }, "--reply-broadcast"],
])("rejects unsupported send option %p", async (options, flag) => {
await expect(
redirectSendToDraft({ ctx: stubCtx, targetInput: "#general", text: "hi", options }),
).rejects.toThrow(flag);
});

test("lists all unsupported flags at once", async () => {
const promise = redirectSendToDraft({
ctx: stubCtx,
targetInput: "#general",
text: "hi",
options: { attach: ["./a.md"], scheduleIn: "3h" },
});
await expect(promise).rejects.toThrow("--attach, --schedule-in");
});

test("rejects in CI where no interactive editor is available", async () => {
process.env.CI = "1";
await expect(
redirectSendToDraft({ ctx: stubCtx, targetInput: "#general", text: "hi", options: {} }),
).rejects.toThrow("unavailable in CI");
});

test("opens a draft with the send text and thread context", async () => {
delete process.env.CI;
const draftCalls: unknown[] = [];
const payload = await redirectSendToDraft(
{
ctx: stubCtx,
targetInput: "#general",
text: "here's the report",
options: { workspace: "myteam", threadTs: "1770165109.628379" },
},
async (input) => {
draftCalls.push(input);
return { ok: true, sent: true };
},
);
expect(draftCalls).toEqual([
{
ctx: stubCtx,
targetInput: "#general",
initialText: "here's the report",
options: { workspace: "myteam", threadTs: "1770165109.628379" },
},
]);
expect(payload).toEqual({
safe_mode: true,
redirected_from: "send",
ok: true,
sent: true,
});
});
});
Loading