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
```
This commit is contained in:
Kieran Klukas 2026-01-26 01:54:50 -05:00
parent 6859e1e6a6
commit 26cd21e73b
No known key found for this signature in database
5 changed files with 741 additions and 0 deletions

View File

@ -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<string | number>;
/** 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<string, SlackChannelConfig>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding with inline buttons. */
execApprovals?: SlackExecApprovalConfig;
};
export type SlackConfig = {

View File

@ -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();

View File

@ -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["request"]> = {},
): 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);
});
});

View File

@ -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<string, PendingApproval>();
private requestCache = new Map<string, ExecApprovalRequest>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof formatExpiredBlocks>,
): Promise<void> {
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<boolean> {
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;
}
}
}

View File

@ -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<void>;
body: { user?: { id?: string } };
action: { value?: string };
respond: (payload: { text: string; response_type?: string }) => Promise<void>;
}) => Promise<void>,
) => 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<void>((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);
}
}