From 26cd21e73b42a7803dbb9f140998b4a3347de040 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 01:54:50 -0500 Subject: [PATCH 1/6] feat(slack): add inline button support for exec approvals Implements #2101: Adds Block Kit buttons for exec approval requests in Slack DMs, mirroring the existing Discord implementation. Changes: - Add SlackExecApprovalConfig type to config/types.slack.ts - Add Zod schema validation for execApprovals in config - Create SlackExecApprovalHandler class that: - Listens to gateway events for approval requests/resolutions - Sends Block Kit messages with Allow once/Always allow/Deny buttons - Updates messages when resolved or expired - Wires button clicks through to gateway approval resolution - Register action handler in Slack monitor provider - Add comprehensive tests for value parsing and handler filtering Config example: ```yaml channels: slack: execApprovals: enabled: true approvers: ["U12345678"] agentFilter: ["main"] # optional sessionFilter: ["agent:main"] # optional ``` --- src/config/types.slack.ts | 13 + src/config/zod-schema.providers-core.ts | 9 + src/slack/monitor/exec-approvals.test.ts | 195 ++++++++++ src/slack/monitor/exec-approvals.ts | 452 +++++++++++++++++++++++ src/slack/monitor/provider.ts | 72 ++++ 5 files changed, 741 insertions(+) create mode 100644 src/slack/monitor/exec-approvals.test.ts create mode 100644 src/slack/monitor/exec-approvals.ts diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 564248503..ddb5b0c45 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -74,6 +74,17 @@ export type SlackThreadConfig = { inheritParent?: boolean; }; +export type SlackExecApprovalConfig = { + /** Enable exec approval forwarding to Slack DMs with inline buttons. Default: false. */ + enabled?: boolean; + /** Slack user IDs to receive approval prompts. Required if enabled. */ + approvers?: Array; + /** Only forward approvals for these agent IDs. Omit = all agents. */ + agentFilter?: string[]; + /** Only forward approvals matching these session key patterns (substring or regex). */ + sessionFilter?: string[]; +}; + export type SlackAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -141,6 +152,8 @@ export type SlackAccountConfig = { channels?: Record; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Exec approval forwarding with inline buttons. */ + execApprovals?: SlackExecApprovalConfig; }; export type SlackConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4b1b9338a..e92e34971 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -439,6 +439,15 @@ export const SlackAccountSchema = z dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + execApprovals: z + .object({ + enabled: z.boolean().optional(), + approvers: z.array(z.union([z.string(), z.number()])).optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/slack/monitor/exec-approvals.test.ts b/src/slack/monitor/exec-approvals.test.ts new file mode 100644 index 000000000..d120e710d --- /dev/null +++ b/src/slack/monitor/exec-approvals.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { + parseApprovalValue, + getExecApprovalActionId, + SlackExecApprovalHandler, + type ExecApprovalRequest, +} from "./exec-approvals.js"; +import type { SlackExecApprovalConfig } from "../../config/types.slack.js"; + +// Helper to encode approval values (mirrors internal implementation) +function encodeApprovalValue( + approvalId: string, + action: "allow-once" | "allow-always" | "deny", +): string { + return ["execapproval", encodeURIComponent(approvalId), action].join("|"); +} + +describe("getExecApprovalActionId", () => { + it("returns the action ID", () => { + expect(getExecApprovalActionId()).toBe("clawdbot_execapproval"); + }); +}); + +describe("parseApprovalValue", () => { + it("parses valid value", () => { + const value = encodeApprovalValue("abc-123", "allow-once"); + const result = parseApprovalValue(value); + expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" }); + }); + + it("parses encoded approval id", () => { + const value = encodeApprovalValue("abc|123", "allow-always"); + const result = parseApprovalValue(value); + expect(result).toEqual({ approvalId: "abc|123", action: "allow-always" }); + }); + + it("handles special characters", () => { + const value = encodeApprovalValue("test=approval&id", "deny"); + const result = parseApprovalValue(value); + expect(result).toEqual({ approvalId: "test=approval&id", action: "deny" }); + }); + + it("rejects invalid action", () => { + const value = "execapproval|abc-123|invalid"; + const result = parseApprovalValue(value); + expect(result).toBeNull(); + }); + + it("rejects missing parts", () => { + expect(parseApprovalValue("execapproval|abc-123")).toBeNull(); + expect(parseApprovalValue("execapproval")).toBeNull(); + expect(parseApprovalValue("")).toBeNull(); + }); + + it("rejects undefined input", () => { + expect(parseApprovalValue(undefined)).toBeNull(); + }); + + it("rejects wrong prefix", () => { + const value = "wrongprefix|abc-123|allow-once"; + const result = parseApprovalValue(value); + expect(result).toBeNull(); + }); + + it("accepts all valid actions", () => { + expect(parseApprovalValue(encodeApprovalValue("x", "allow-once"))?.action).toBe("allow-once"); + expect(parseApprovalValue(encodeApprovalValue("x", "allow-always"))?.action).toBe( + "allow-always", + ); + expect(parseApprovalValue(encodeApprovalValue("x", "deny"))?.action).toBe("deny"); + }); +}); + +describe("roundtrip encoding", () => { + it("encodes and decodes correctly", () => { + const approvalId = "test-approval-with|special&chars"; + const action = "allow-always" as const; + const encoded = encodeApprovalValue(approvalId, action); + const result = parseApprovalValue(encoded); + expect(result).toEqual({ approvalId, action }); + }); +}); + +describe("SlackExecApprovalHandler.shouldHandle", () => { + function createHandler(config: SlackExecApprovalConfig) { + // Create a minimal mock WebClient + const mockClient = {} as any; + return new SlackExecApprovalHandler({ + client: mockClient, + accountId: "default", + config, + cfg: {}, + }); + } + + function createRequest( + overrides: Partial = {}, + ): ExecApprovalRequest { + return { + id: "test-id", + request: { + command: "echo hello", + cwd: "/home/user", + host: "gateway", + agentId: "test-agent", + sessionKey: "agent:test-agent:slack:123", + ...overrides, + }, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60000, + }; + } + + it("returns false when disabled", () => { + const handler = createHandler({ enabled: false, approvers: ["U123"] }); + expect(handler.shouldHandle(createRequest())).toBe(false); + }); + + it("returns false when no approvers", () => { + const handler = createHandler({ enabled: true, approvers: [] }); + expect(handler.shouldHandle(createRequest())).toBe(false); + }); + + it("returns true with minimal config", () => { + const handler = createHandler({ enabled: true, approvers: ["U123"] }); + expect(handler.shouldHandle(createRequest())).toBe(true); + }); + + it("filters by agent ID", () => { + const handler = createHandler({ + enabled: true, + approvers: ["U123"], + agentFilter: ["allowed-agent"], + }); + expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true); + expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false); + expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false); + }); + + it("filters by session key substring", () => { + const handler = createHandler({ + enabled: true, + approvers: ["U123"], + sessionFilter: ["slack"], + }); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:slack:123" }))).toBe(true); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe( + false, + ); + expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false); + }); + + it("filters by session key regex", () => { + const handler = createHandler({ + enabled: true, + approvers: ["U123"], + sessionFilter: ["^agent:.*:slack:"], + }); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:slack:123" }))).toBe(true); + expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:slack:123" }))).toBe(false); + }); + + it("combines agent and session filters", () => { + const handler = createHandler({ + enabled: true, + approvers: ["U123"], + agentFilter: ["my-agent"], + sessionFilter: ["slack"], + }); + expect( + handler.shouldHandle( + createRequest({ + agentId: "my-agent", + sessionKey: "agent:my-agent:slack:123", + }), + ), + ).toBe(true); + expect( + handler.shouldHandle( + createRequest({ + agentId: "other-agent", + sessionKey: "agent:other:slack:123", + }), + ), + ).toBe(false); + expect( + handler.shouldHandle( + createRequest({ + agentId: "my-agent", + sessionKey: "agent:my-agent:telegram:123", + }), + ), + ).toBe(false); + }); +}); diff --git a/src/slack/monitor/exec-approvals.ts b/src/slack/monitor/exec-approvals.ts new file mode 100644 index 000000000..38316eebf --- /dev/null +++ b/src/slack/monitor/exec-approvals.ts @@ -0,0 +1,452 @@ +import type { WebClient } from "@slack/web-api"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SlackExecApprovalConfig } from "../../config/types.slack.js"; +import { GatewayClient } from "../../gateway/client.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import type { EventFrame } from "../../gateway/protocol/index.js"; +import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; +import { logDebug, logError } from "../../logger.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const EXEC_APPROVAL_ACTION_ID = "clawdbot_execapproval"; +const EXEC_APPROVAL_VALUE_PREFIX = "execapproval"; + +export type ExecApprovalRequest = { + id: string; + request: { + command: string; + cwd?: string | null; + host?: string | null; + security?: string | null; + ask?: string | null; + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; + }; + createdAtMs: number; + expiresAtMs: number; +}; + +export type ExecApprovalResolved = { + id: string; + decision: ExecApprovalDecision; + resolvedBy?: string | null; + ts: number; +}; + +type PendingApproval = { + slackMessageTs: string; + slackChannelId: string; + timeoutId: NodeJS.Timeout; +}; + +function encodeApprovalValue(approvalId: string, action: ExecApprovalDecision): string { + return [EXEC_APPROVAL_VALUE_PREFIX, encodeURIComponent(approvalId), action].join("|"); +} + +export function parseApprovalValue( + value: string | undefined, +): { approvalId: string; action: ExecApprovalDecision } | null { + if (!value) return null; + const parts = value.split("|"); + if (parts.length !== 3 || parts[0] !== EXEC_APPROVAL_VALUE_PREFIX) return null; + const [, encodedId, action] = parts; + if (!encodedId || !action) return null; + if (action !== "allow-once" && action !== "allow-always" && action !== "deny") return null; + try { + return { approvalId: decodeURIComponent(encodedId), action }; + } catch { + return null; + } +} + +export function getExecApprovalActionId(): string { + return EXEC_APPROVAL_ACTION_ID; +} + +function formatApprovalBlocks(request: ExecApprovalRequest) { + const commandText = request.request.command; + const commandPreview = + commandText.length > 2000 ? `${commandText.slice(0, 2000)}...` : commandText; + const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); + + const contextParts: string[] = []; + if (request.request.cwd) contextParts.push(`*CWD:* ${request.request.cwd}`); + if (request.request.host) contextParts.push(`*Host:* ${request.request.host}`); + if (request.request.agentId) contextParts.push(`*Agent:* ${request.request.agentId}`); + + const blocks: Array<{ + type: string; + text?: { type: string; text: string }; + elements?: Array< + | { type: string; text: string } // context element (mrkdwn/plain_text) + | { + // button element + type: string; + text?: { type: string; text: string; emoji?: boolean }; + action_id?: string; + value?: string; + style?: string; + } + >; + }> = [ + { + type: "header", + text: { type: "plain_text", text: "🔒 Exec Approval Required" }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`\n${commandPreview}\n\`\`\``, + }, + }, + ]; + + if (contextParts.length > 0) { + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: contextParts.join(" | ") }], + }); + } + + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: `Expires in ${expiresIn}s | ID: \`${request.id}\`` }], + }); + + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "✓ Allow once", emoji: true }, + action_id: EXEC_APPROVAL_ACTION_ID, + value: encodeApprovalValue(request.id, "allow-once"), + style: "primary", + }, + { + type: "button", + text: { type: "plain_text", text: "✓✓ Always allow", emoji: true }, + action_id: EXEC_APPROVAL_ACTION_ID, + value: encodeApprovalValue(request.id, "allow-always"), + }, + { + type: "button", + text: { type: "plain_text", text: "✗ Deny", emoji: true }, + action_id: EXEC_APPROVAL_ACTION_ID, + value: encodeApprovalValue(request.id, "deny"), + style: "danger", + }, + ], + }); + + return blocks; +} + +function formatResolvedBlocks( + request: ExecApprovalRequest, + decision: ExecApprovalDecision, + resolvedBy?: string | null, +) { + const commandText = request.request.command; + const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; + + const decisionLabel = + decision === "allow-once" + ? "✅ Allowed (once)" + : decision === "allow-always" + ? "✅ Allowed (always)" + : "❌ Denied"; + + const resolvedByText = resolvedBy ? ` by ${resolvedBy}` : ""; + + return [ + { + type: "header", + text: { type: "plain_text", text: `Exec Approval: ${decisionLabel}` }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`\n${commandPreview}\n\`\`\``, + }, + }, + { + type: "context", + elements: [{ type: "mrkdwn", text: `Resolved${resolvedByText} | ID: \`${request.id}\`` }], + }, + ]; +} + +function formatExpiredBlocks(request: ExecApprovalRequest) { + const commandText = request.request.command; + const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; + + return [ + { + type: "header", + text: { type: "plain_text", text: "⏱️ Exec Approval: Expired" }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`\n${commandPreview}\n\`\`\``, + }, + }, + { + type: "context", + elements: [{ type: "mrkdwn", text: `ID: \`${request.id}\`` }], + }, + ]; +} + +export type SlackExecApprovalHandlerOpts = { + client: WebClient; + accountId: string; + config: SlackExecApprovalConfig; + gatewayUrl?: string; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; +}; + +export class SlackExecApprovalHandler { + private gatewayClient: GatewayClient | null = null; + private pending = new Map(); + private requestCache = new Map(); + private opts: SlackExecApprovalHandlerOpts; + private started = false; + + constructor(opts: SlackExecApprovalHandlerOpts) { + this.opts = opts; + } + + shouldHandle(request: ExecApprovalRequest): boolean { + const config = this.opts.config; + if (!config.enabled) return false; + if (!config.approvers || config.approvers.length === 0) return false; + + // Check agent filter + if (config.agentFilter?.length) { + if (!request.request.agentId) return false; + if (!config.agentFilter.includes(request.request.agentId)) return false; + } + + // Check session filter (substring match) + if (config.sessionFilter?.length) { + const session = request.request.sessionKey; + if (!session) return false; + const matches = config.sessionFilter.some((p) => { + try { + return session.includes(p) || new RegExp(p).test(session); + } catch { + return session.includes(p); + } + }); + if (!matches) return false; + } + + return true; + } + + async start(): Promise { + if (this.started) return; + this.started = true; + + const config = this.opts.config; + if (!config.enabled) { + logDebug("slack exec approvals: disabled"); + return; + } + + if (!config.approvers || config.approvers.length === 0) { + logDebug("slack exec approvals: no approvers configured"); + return; + } + + logDebug("slack exec approvals: starting handler"); + + this.gatewayClient = new GatewayClient({ + url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "Slack Exec Approvals", + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.approvals"], + onEvent: (evt) => this.handleGatewayEvent(evt), + onHelloOk: () => { + logDebug("slack exec approvals: connected to gateway"); + }, + onConnectError: (err) => { + logError(`slack exec approvals: connect error: ${err.message}`); + }, + onClose: (code, reason) => { + logDebug(`slack exec approvals: gateway closed: ${code} ${reason}`); + }, + }); + + this.gatewayClient.start(); + } + + async stop(): Promise { + if (!this.started) return; + this.started = false; + + // Clear all pending timeouts + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + } + this.pending.clear(); + this.requestCache.clear(); + + this.gatewayClient?.stop(); + this.gatewayClient = null; + + logDebug("slack exec approvals: stopped"); + } + + private handleGatewayEvent(evt: EventFrame): void { + if (evt.event === "exec.approval.requested") { + const request = evt.payload as ExecApprovalRequest; + void this.handleApprovalRequested(request); + } else if (evt.event === "exec.approval.resolved") { + const resolved = evt.payload as ExecApprovalResolved; + void this.handleApprovalResolved(resolved); + } + } + + private async handleApprovalRequested(request: ExecApprovalRequest): Promise { + if (!this.shouldHandle(request)) return; + + logDebug(`slack exec approvals: received request ${request.id}`); + + this.requestCache.set(request.id, request); + + const client = this.opts.client; + const blocks = formatApprovalBlocks(request); + const approvers = this.opts.config.approvers ?? []; + + for (const approver of approvers) { + const userId = String(approver); + try { + // Open DM channel + const dmResponse = await client.conversations.open({ users: userId }); + const channelId = dmResponse.channel?.id; + + if (!channelId) { + logError(`slack exec approvals: failed to open DM for user ${userId}`); + continue; + } + + // Send message with blocks + const msgResponse = await client.chat.postMessage({ + channel: channelId, + text: `🔒 Exec approval required for: ${request.request.command.slice(0, 100)}...`, + blocks, + }); + + const messageTs = msgResponse.ts; + if (!messageTs) { + logError(`slack exec approvals: failed to send message to user ${userId}`); + continue; + } + + // Set up timeout + const timeoutMs = Math.max(0, request.expiresAtMs - Date.now()); + const timeoutId = setTimeout(() => { + void this.handleApprovalTimeout(request.id); + }, timeoutMs); + + this.pending.set(request.id, { + slackMessageTs: messageTs, + slackChannelId: channelId, + timeoutId, + }); + + logDebug(`slack exec approvals: sent approval ${request.id} to user ${userId}`); + } catch (err) { + logError(`slack exec approvals: failed to notify user ${userId}: ${String(err)}`); + } + } + } + + private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise { + const pending = this.pending.get(resolved.id); + if (!pending) return; + + clearTimeout(pending.timeoutId); + this.pending.delete(resolved.id); + + const request = this.requestCache.get(resolved.id); + this.requestCache.delete(resolved.id); + + if (!request) return; + + logDebug(`slack exec approvals: resolved ${resolved.id} with ${resolved.decision}`); + + await this.updateMessage( + pending.slackChannelId, + pending.slackMessageTs, + formatResolvedBlocks(request, resolved.decision, resolved.resolvedBy), + ); + } + + private async handleApprovalTimeout(approvalId: string): Promise { + const pending = this.pending.get(approvalId); + if (!pending) return; + + this.pending.delete(approvalId); + + const request = this.requestCache.get(approvalId); + this.requestCache.delete(approvalId); + + if (!request) return; + + logDebug(`slack exec approvals: timeout for ${approvalId}`); + + await this.updateMessage( + pending.slackChannelId, + pending.slackMessageTs, + formatExpiredBlocks(request), + ); + } + + private async updateMessage( + channelId: string, + messageTs: string, + blocks: ReturnType, + ): Promise { + try { + await this.opts.client.chat.update({ + channel: channelId, + ts: messageTs, + text: "Exec approval resolved", + blocks, + }); + } catch (err) { + logError(`slack exec approvals: failed to update message: ${String(err)}`); + } + } + + async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise { + if (!this.gatewayClient) { + logError("slack exec approvals: gateway client not connected"); + return false; + } + + logDebug(`slack exec approvals: resolving ${approvalId} with ${decision}`); + + try { + await this.gatewayClient.request("exec.approval.resolve", { + id: approvalId, + decision, + }); + logDebug(`slack exec approvals: resolved ${approvalId} successfully`); + return true; + } catch (err) { + logError(`slack exec approvals: resolve failed: ${String(err)}`); + return false; + } + } +} diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 366a32a34..74cc2cead 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -26,6 +26,11 @@ import { registerSlackMonitorSlashCommands } from "./slash.js"; import { normalizeAllowList } from "./allow-list.js"; import type { MonitorSlackOpts } from "./types.js"; +import { + SlackExecApprovalHandler, + getExecApprovalActionId, + parseApprovalValue, +} from "./exec-approvals.js"; const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { default?: typeof import("@slack/bolt"); @@ -210,6 +215,65 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { registerSlackMonitorEvents({ ctx, account, handleSlackMessage }); registerSlackMonitorSlashCommands({ ctx, account }); + + // Exec approvals with inline buttons + let execApprovalHandler: SlackExecApprovalHandler | null = null; + const execApprovalConfig = slackCfg.execApprovals; + if (execApprovalConfig?.enabled && execApprovalConfig.approvers?.length) { + execApprovalHandler = new SlackExecApprovalHandler({ + client: app.client, + accountId: account.accountId, + config: execApprovalConfig, + cfg, + runtime, + }); + + // Register action handler for approval buttons + const supportsActions = typeof (app as { action?: unknown }).action === "function"; + if (supportsActions) { + ( + app as unknown as { + action: ( + id: string, + handler: (args: { + ack: () => Promise; + body: { user?: { id?: string } }; + action: { value?: string }; + respond: (payload: { text: string; response_type?: string }) => Promise; + }) => Promise, + ) => void; + } + ).action(getExecApprovalActionId(), async ({ ack, body, action, respond }) => { + await ack(); + const parsed = parseApprovalValue(action?.value); + if (!parsed) { + await respond({ + text: "This approval button is no longer valid.", + response_type: "ephemeral", + }); + return; + } + const decisionLabel = + parsed.action === "allow-once" + ? "Allowed (once)" + : parsed.action === "allow-always" + ? "Allowed (always)" + : "Denied"; + await respond({ + text: `Submitting decision: **${decisionLabel}**...`, + response_type: "ephemeral", + }); + const ok = await execApprovalHandler!.resolveApproval(parsed.approvalId, parsed.action); + if (!ok) { + await respond({ + text: "Failed to submit approval. The request may have expired or already been resolved.", + response_type: "ephemeral", + }); + } + }); + } + } + if (slackMode === "http" && slackHttpHandler) { unregisterHttpHandler = registerSlackHttpHandler({ path: slackWebhookPath, @@ -349,6 +413,10 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } else { runtime.log?.(`slack http mode listening at ${slackWebhookPath}`); } + // Start exec approval handler after app is running + if (execApprovalHandler) { + await execApprovalHandler.start(); + } if (opts.abortSignal?.aborted) return; await new Promise((resolve) => { opts.abortSignal?.addEventListener("abort", () => resolve(), { @@ -358,6 +426,10 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } finally { opts.abortSignal?.removeEventListener("abort", stopOnAbort); unregisterHttpHandler?.(); + // Stop exec approval handler before app stops + if (execApprovalHandler) { + await execApprovalHandler.stop(); + } await app.stop().catch(() => undefined); } } From 07ae26470431e7021dfde65ac090362543215c19 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 02:29:32 -0500 Subject: [PATCH 2/6] fix(slack): use unique action_ids for approval buttons Slack requires unique action_id per button in a block. Changed from single shared ID to per-action IDs (allow_once, allow_always, deny) and use regex matching in the action handler. --- src/slack/monitor/exec-approvals.ts | 16 +++++--- src/slack/monitor/provider.ts | 60 +++++++++++++++-------------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/slack/monitor/exec-approvals.ts b/src/slack/monitor/exec-approvals.ts index 38316eebf..5796fd8ae 100644 --- a/src/slack/monitor/exec-approvals.ts +++ b/src/slack/monitor/exec-approvals.ts @@ -8,7 +8,7 @@ import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; import { logDebug, logError } from "../../logger.js"; import type { RuntimeEnv } from "../../runtime.js"; -const EXEC_APPROVAL_ACTION_ID = "clawdbot_execapproval"; +const EXEC_APPROVAL_ACTION_ID_PREFIX = "clawdbot_execapproval"; const EXEC_APPROVAL_VALUE_PREFIX = "execapproval"; export type ExecApprovalRequest = { @@ -60,8 +60,12 @@ export function parseApprovalValue( } } -export function getExecApprovalActionId(): string { - return EXEC_APPROVAL_ACTION_ID; +export function getExecApprovalActionIdPrefix(): string { + return EXEC_APPROVAL_ACTION_ID_PREFIX; +} + +export function matchesExecApprovalActionId(actionId: string): boolean { + return actionId.startsWith(EXEC_APPROVAL_ACTION_ID_PREFIX); } function formatApprovalBlocks(request: ExecApprovalRequest) { @@ -121,20 +125,20 @@ function formatApprovalBlocks(request: ExecApprovalRequest) { { type: "button", text: { type: "plain_text", text: "✓ Allow once", emoji: true }, - action_id: EXEC_APPROVAL_ACTION_ID, + action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_allow_once`, value: encodeApprovalValue(request.id, "allow-once"), style: "primary", }, { type: "button", text: { type: "plain_text", text: "✓✓ Always allow", emoji: true }, - action_id: EXEC_APPROVAL_ACTION_ID, + action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_allow_always`, value: encodeApprovalValue(request.id, "allow-always"), }, { type: "button", text: { type: "plain_text", text: "✗ Deny", emoji: true }, - action_id: EXEC_APPROVAL_ACTION_ID, + action_id: `${EXEC_APPROVAL_ACTION_ID_PREFIX}_deny`, value: encodeApprovalValue(request.id, "deny"), style: "danger", }, diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 74cc2cead..d1927bcd3 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -28,7 +28,8 @@ import { normalizeAllowList } from "./allow-list.js"; import type { MonitorSlackOpts } from "./types.js"; import { SlackExecApprovalHandler, - getExecApprovalActionId, + getExecApprovalActionIdPrefix, + matchesExecApprovalActionId, parseApprovalValue, } from "./exec-approvals.js"; @@ -234,43 +235,46 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ( app as unknown as { action: ( - id: string, + id: string | RegExp, handler: (args: { ack: () => Promise; body: { user?: { id?: string } }; - action: { value?: string }; + action: { action_id?: string; value?: string }; respond: (payload: { text: string; response_type?: string }) => Promise; }) => Promise, ) => void; } - ).action(getExecApprovalActionId(), async ({ ack, body, action, respond }) => { - await ack(); - const parsed = parseApprovalValue(action?.value); - if (!parsed) { + ).action( + new RegExp(`^${getExecApprovalActionIdPrefix()}_`), + async ({ ack, body, action, respond }) => { + await ack(); + const parsed = parseApprovalValue(action?.value); + if (!parsed) { + await respond({ + text: "This approval button is no longer valid.", + response_type: "ephemeral", + }); + return; + } + const decisionLabel = + parsed.action === "allow-once" + ? "Allowed (once)" + : parsed.action === "allow-always" + ? "Allowed (always)" + : "Denied"; await respond({ - text: "This approval button is no longer valid.", + text: `Submitting decision: **${decisionLabel}**...`, response_type: "ephemeral", }); - return; - } - const decisionLabel = - parsed.action === "allow-once" - ? "Allowed (once)" - : parsed.action === "allow-always" - ? "Allowed (always)" - : "Denied"; - await respond({ - text: `Submitting decision: **${decisionLabel}**...`, - response_type: "ephemeral", - }); - const ok = await execApprovalHandler!.resolveApproval(parsed.approvalId, parsed.action); - if (!ok) { - await respond({ - text: "Failed to submit approval. The request may have expired or already been resolved.", - response_type: "ephemeral", - }); - } - }); + const ok = await execApprovalHandler!.resolveApproval(parsed.approvalId, parsed.action); + if (!ok) { + await respond({ + text: "Failed to submit approval. The request may have expired or already been resolved.", + response_type: "ephemeral", + }); + } + }, + ); } } From f6be8845b228ce318a375de56f1e67295a8ce09e Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 02:41:51 -0500 Subject: [PATCH 3/6] fix(slack): prevent duplicate messages and use Slack date formatting - Skip text forwarder for Slack when execApprovals buttons are enabled - Use for localized expiry time --- src/infra/exec-approval-forwarder.ts | 28 ++++++++++++++++++++++++++++ src/slack/monitor/exec-approvals.ts | 9 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 2fbf53ae6..c73c2e3eb 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -158,6 +158,30 @@ function defaultResolveSessionTarget(params: { }; } +function isSlackExecApprovalsEnabled(cfg: ClawdbotConfig, accountId?: string | null): boolean { + const slackCfg = cfg.channels?.slack as + | { + execApprovals?: { enabled?: boolean; approvers?: Array }; + accounts?: Record< + string, + { execApprovals?: { enabled?: boolean; approvers?: Array } } + >; + } + | undefined; + if (!slackCfg) return false; + // Check account-specific config first + if (accountId && slackCfg.accounts?.[accountId]?.execApprovals?.enabled) { + const approvers = slackCfg.accounts[accountId].execApprovals?.approvers; + return Array.isArray(approvers) && approvers.length > 0; + } + // Fall back to top-level config + if (slackCfg.execApprovals?.enabled) { + const approvers = slackCfg.execApprovals?.approvers; + return Array.isArray(approvers) && approvers.length > 0; + } + return false; +} + async function deliverToTargets(params: { cfg: ClawdbotConfig; targets: ForwardTarget[]; @@ -169,6 +193,10 @@ async function deliverToTargets(params: { if (params.shouldSend && !params.shouldSend()) return; const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (!isDeliverableMessageChannel(channel)) return; + // Skip Slack text messages when Slack exec approval buttons are enabled + if (channel === "slack" && isSlackExecApprovalsEnabled(params.cfg, target.accountId)) { + return; + } try { await params.deliver({ cfg: params.cfg, diff --git a/src/slack/monitor/exec-approvals.ts b/src/slack/monitor/exec-approvals.ts index 5796fd8ae..cba65c03e 100644 --- a/src/slack/monitor/exec-approvals.ts +++ b/src/slack/monitor/exec-approvals.ts @@ -72,7 +72,9 @@ function formatApprovalBlocks(request: ExecApprovalRequest) { const commandText = request.request.command; const commandPreview = commandText.length > 2000 ? `${commandText.slice(0, 2000)}...` : commandText; + const expiresAtUnix = Math.floor(request.expiresAtMs / 1000); const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); + const fallbackTime = new Date(request.expiresAtMs).toISOString(); const contextParts: string[] = []; if (request.request.cwd) contextParts.push(`*CWD:* ${request.request.cwd}`); @@ -116,7 +118,12 @@ function formatApprovalBlocks(request: ExecApprovalRequest) { blocks.push({ type: "context", - elements: [{ type: "mrkdwn", text: `Expires in ${expiresIn}s | ID: \`${request.id}\`` }], + elements: [ + { + type: "mrkdwn", + text: `Expires (${expiresIn}s) | ID: \`${request.id}\``, + }, + ], }); blocks.push({ From 3ff7c3733c9d258a73c274000e1336b3fbe25308 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 02:46:32 -0500 Subject: [PATCH 4/6] docs: add inline approval buttons section for Discord/Slack --- docs/tools/exec-approvals.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index ec350f9d9..73fa1163b 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -187,6 +187,40 @@ Reply in chat: /approve deny ``` +### Inline approval buttons + +For Discord and Slack, you can enable inline buttons instead of text commands: + +**Discord:** +```yaml +channels: + discord: + execApprovals: + enabled: true + approvers: ["123456789012345678"] # Discord user IDs + agentFilter: ["main"] # optional + sessionFilter: ["discord"] # optional +``` + +**Slack:** +```yaml +channels: + slack: + execApprovals: + enabled: true + approvers: ["U12345678"] # Slack user IDs + agentFilter: ["main"] # optional + sessionFilter: ["slack"] # optional +``` + +When enabled, approval requests show Block Kit (Slack) or component (Discord) buttons: +- **Allow once** → run now +- **Always allow** → add to allowlist + run +- **Deny** → block + +Messages update in-place when resolved or expired. The text forwarder is automatically +skipped for channels with inline buttons enabled. + ### macOS IPC flow ``` Gateway -> Node Service (WS) From 1b0526d9c4bb11147d392052e263e212cb94d59f Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 02:47:39 -0500 Subject: [PATCH 5/6] fix: remove unused imports and parameters (lint) --- src/slack/monitor/provider.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index d1927bcd3..a89ebd926 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -29,7 +29,6 @@ import type { MonitorSlackOpts } from "./types.js"; import { SlackExecApprovalHandler, getExecApprovalActionIdPrefix, - matchesExecApprovalActionId, parseApprovalValue, } from "./exec-approvals.js"; @@ -238,7 +237,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { id: string | RegExp, handler: (args: { ack: () => Promise; - body: { user?: { id?: string } }; action: { action_id?: string; value?: string }; respond: (payload: { text: string; response_type?: string }) => Promise; }) => Promise, @@ -246,7 +244,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } ).action( new RegExp(`^${getExecApprovalActionIdPrefix()}_`), - async ({ ack, body, action, respond }) => { + async ({ ack, action, respond }) => { await ack(); const parsed = parseApprovalValue(action?.value); if (!parsed) { From 044b60bf2d28beb7a5cfb08b788179f0748debf5 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 26 Jan 2026 12:30:16 -0500 Subject: [PATCH 6/6] fix(test): update test for renamed getExecApprovalActionIdPrefix --- src/slack/monitor/exec-approvals.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/slack/monitor/exec-approvals.test.ts b/src/slack/monitor/exec-approvals.test.ts index d120e710d..02f311296 100644 --- a/src/slack/monitor/exec-approvals.test.ts +++ b/src/slack/monitor/exec-approvals.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseApprovalValue, - getExecApprovalActionId, + getExecApprovalActionIdPrefix, SlackExecApprovalHandler, type ExecApprovalRequest, } from "./exec-approvals.js"; @@ -15,9 +15,9 @@ function encodeApprovalValue( return ["execapproval", encodeURIComponent(approvalId), action].join("|"); } -describe("getExecApprovalActionId", () => { - it("returns the action ID", () => { - expect(getExecApprovalActionId()).toBe("clawdbot_execapproval"); +describe("getExecApprovalActionIdPrefix", () => { + it("returns the action ID prefix", () => { + expect(getExecApprovalActionIdPrefix()).toBe("clawdbot_execapproval"); }); });