Root cause
Injection & unsafe execution
fix(slack): resolve native approval buttons
How AI contributed: Flawed AI-written codePublished Jul 2, 2026 in
openclaw/openclawAll 35 findings in this repository
Slack native plugin-approval buttons reused isSlackExecApprovalApprover (execApprovals.approvers / ownerAllowFrom) instead of isSlackApprovalAuthorizedSender (channel allowFrom), so an exec-only Slack approver could resolve plugin: approvals through the exec gate.
Root cause
fix(slack): resolve native approval buttons
Fix
Enforce Slack plugin approval button authorization [AI] (#80899)
AI-assisted fix: ChatGPT/Codex · Pavan Kumar Gondhi
Case facts
@@ -10,11 +10,22 @@ const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => ); const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn(() => "Binding updated."));+const resolveApprovalOverGatewayMock = vi.hoisted(() =>+ vi.fn<(arg: unknown) => Promise<void>>(async () => undefined),+); let registerSlackInteractionEvents: typeof import("./interactions.js").registerSlackInteractionEvents; -vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({- enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),+vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {+ const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();+ return {+ ...actual,+ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),+ };+});++vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({+ resolveApprovalOverGateway: (arg: unknown) => resolveApprovalOverGatewayMock(arg), })); vi.mock("openclaw/plugin-sdk/security-runtime", () => ({@@ -161,6 +172,7 @@ function createContext(overrides?: { allowFrom?: string[]; allowNameMatching?: boolean; channelsConfig?: Record<string, { users?: string[] }>;+ cfg?: Record<string, unknown>; shouldDropMismatchedSlackEvent?: (body: unknown) => boolean; isChannelAllowed?: (params: { channelId?: string;@@ -221,6 +233,17 @@ function createContext(overrides?: { const ctx = { app, accountId: "default",+ cfg: overrides?.cfg ?? {+ channels: {+ slack: {+ execApprovals: {+ enabled: true,+ approvers: ["U123"],+ target: "both",+ },+ },+ },+ }, runtime: { log: runtimeLog }, dmEnabled: overrides?.dmEnabled ?? true, dmPolicy: overrides?.dmPolicy ?? ("open" as const),@@ -263,6 +286,8 @@ describe("registerSlackInteractionEvents", () => { resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" }); buildPluginBindingResolvedTextMock.mockClear(); buildPluginBindingResolvedTextMock.mockReturnValue("Binding updated.");+ resolveApprovalOverGatewayMock.mockClear();+ resolveApprovalOverGatewayMock.mockResolvedValue(undefined); dispatchPluginInteractiveHandlerMock.mockResolvedValue({ matched: false, handled: false,@@ -644,6 +669,168 @@ describe("registerSlackInteractionEvents", () => { expect(enqueueSystemEventMock).not.toHaveBeenCalled(); }); + it("resolves exec approvals from shared interactive Slack actions", async () => {+ const { ctx, app, getHandler } = createContext({ allowFrom: ["U999"] });+ registerSlackInteractionEvents({ ctx: ctx as never });++ const handler = getHandler();+ expect(handler).toBeTruthy();++ const ack = vi.fn().mockResolvedValue(undefined);+ const respond = vi.fn().mockResolvedValue(undefined);+ await handler!({+ ack,+ respond,+ body: {+ user: { id: "U123" },+ channel: { id: "C1" },+ container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },+ message: {+ ts: "100.200",+ text: "Exec approval required",+ blocks: [+ {+ type: "actions",+ block_id: "exec_actions",+ elements: [{ type: "button", action_id: "openclaw:reply_button" }],+ },+ ],@@ -1,7 +1,15 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api";-import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";+import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";+import {+ enqueueSystemEvent,+ parseExecApprovalCommandText,+} from "openclaw/plugin-sdk/infra-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";+import {+ isSlackExecApprovalApprover,+ isSlackExecApprovalAuthorizedSender,+} from "../../exec-approvals.js"; import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js"; import { SLACK_REPLY_BUTTON_ACTION_ID,@@ -521,6 +529,68 @@ async function handleSlackPluginBindingApproval(params: { return true; } +async function handleSlackExecApprovalInteraction(params: {+ ctx: SlackMonitorContext;+ parsed: ParsedSlackBlockAction;+ pluginInteractionData: string;+ respond?: SlackBlockActionRespond;+}): Promise<boolean> {+ const approval = parseExecApprovalCommandText(params.pluginInteractionData);+ if (!approval) {+ return false;+ }+ const pluginApprovalAuthorizedSender = isSlackExecApprovalApprover({+ cfg: params.ctx.cfg,+ accountId: params.ctx.accountId,+ senderId: params.parsed.userId,+ });+ const execApprovalAuthorizedSender = isSlackExecApprovalAuthorizedSender({+ cfg: params.ctx.cfg,+ accountId: params.ctx.accountId,+ senderId: params.parsed.userId,+ });+ const isPluginApproval = approval.approvalId.startsWith("plugin:");+ const authorized = isPluginApproval+ ? pluginApprovalAuthorizedSender+ : execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;+ if (!authorized) {+ params.ctx.runtime.log?.(+ `slack:interaction drop exec approval user=${params.parsed.userId} (not authorized)`,+ );+ await respondEphemeral(params.respond, "You are not authorized to approve this request.");+ return true;+ }++ try {+ await resolveApprovalOverGateway({+ cfg: params.ctx.cfg,+ approvalId: approval.approvalId,+ decision: approval.decision,+ senderId: params.parsed.userId,+ allowPluginFallback: pluginApprovalAuthorizedSender,+ clientDisplayName: `Slack approval (${params.parsed.userId.trim() || "unknown"})`,+ });+ } catch (error) {+ params.ctx.runtime.log?.(+ `slack:interaction exec approval resolve failed id=${approval.approvalId}: ${String(error)}`,+ );+ throw error;+ }++ try {+ await updateSlackInteractionMessage({+ ctx: params.ctx,+ channelId: params.parsed.channelId,+ messageTs: params.parsed.messageTs,+ text: params.parsed.typedBody.message?.text ?? "",+ blocks: [],+ });+ } catch {+ // Best-effort cleanup only.+ }+ return true;+}+ async function dispatchSlackPluginInteraction(params: { ctx: SlackMonitorContext; parsed: ParsedSlackBlockAction;@@ -742,6 +812,21 @@ async function handleSlackBlockAction(params: { return; } params.trackEvent?.();@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/exec approvals: resolve native approval button clicks over the Gateway instead of delivering `/approve ...` as plain agent text, preserving retry buttons if Gateway resolution fails. Fixes #71023. (#71025) Thanks @marusan03. - Slack/files: return non-image `download-file` results as local file paths instead of image payloads, and include Slack file IDs in inbound file placeholders so agents can call `download-file`. Fixes #71212. Thanks @teamrazo. - Discord/replies: run `message_sending` plugin hooks for Discord reply delivery, including DM targets, so plugins can transform or cancel outbound Discord replies consistently with other channels. Fixes #59350. (#71094) Thanks @wei840222. - Control UI/commands: carry provider-owned thinking option ids/labels in session rows and defaults so fresh sessions show and accept dynamic modes such as `adaptive`, `xhigh`, and `max`. Fixes #71269. Thanks @Young-Khalil.@@ -952,7 +952,7 @@ describe("registerSlackInteractionEvents", () => { approvalId: "req-123", decision: "allow-once", senderId: "U123",- allowPluginFallback: true,+ allowPluginFallback: false, clientDisplayName: "Slack approval (U123)", }); expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();@@ -967,6 +967,220 @@ describe("registerSlackInteractionEvents", () => { expect(respond).not.toHaveBeenCalled(); }); + it("resolves plugin approval buttons from plugin approvers", async () => {+ const { ctx, app, getHandler } = createContext({+ cfg: {+ channels: {+ slack: {+ accounts: {+ default: {+ allowFrom: ["U123OWNER"],+ execApprovals: {+ enabled: true,+ approvers: ["U999EXEC"],+ target: "both",+ },+ },+ },+ },+ },+ },+ });+ registerSlackInteractionEvents({ ctx: ctx as never });++ const handler = getHandler();++ const ack = vi.fn().mockResolvedValue(undefined);+ const respond = vi.fn().mockResolvedValue(undefined);+ await handler({+ ack,+ respond,+ body: {+ user: { id: "U123OWNER" },+ channel: { id: "C1" },+ container: { channel_id: "C1", message_ts: "100.200" },+ message: {+ ts: "100.200",+ text: "Plugin approval required",+ blocks: [+ {+ type: "actions",+ block_id: "plugin_actions",+ elements: [{ type: "button", action_id: "openclaw:reply_button" }],+ },+ ],+ },+ },+ action: {+ type: "button",+ action_id: "openclaw:reply_button",+ block_id: "plugin_actions",+ value: "/approve plugin:req-123 allow-always",+ text: { type: "plain_text", text: "Always allow" },+ },+ });++ expect(ack).toHaveBeenCalled();+ expect(resolveApprovalOverGatewayMock).toHaveBeenCalledWith({+ cfg: ctx.cfg,+ approvalId: "plugin:req-123",+ decision: "allow-always",+ senderId: "U123OWNER",+ allowPluginFallback: false,+ clientDisplayName: "Slack approval (U123OWNER)",+ });+ expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();+ expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();+ expectRecordFields(chatUpdateCall(app), {+ channel: "C1",+ ts: "100.200",+ text: "Plugin approval required",+ blocks: [],+ });+ expect(respond).not.toHaveBeenCalled();+ });++ it("allows unprefixed plugin approval fallback from plugin approvers", async () => {+ const { ctx, app, getHandler } = createContext({+ cfg: {@@ -6,10 +6,8 @@ import { resolveCommandAuthorization } from "openclaw/plugin-sdk/command-auth-na import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";-import {- isSlackExecApprovalApprover,- isSlackExecApprovalAuthorizedSender,-} from "../../exec-approvals.js";+import { isSlackApprovalAuthorizedSender } from "../../approval-auth.js";+import { isSlackExecApprovalAuthorizedSender } from "../../exec-approvals.js"; import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js"; import { SLACK_REPLY_BUTTON_ACTION_ID,@@ -544,7 +542,7 @@ async function handleSlackExecApprovalInteraction(params: { if (!approval) { return false; }- const pluginApprovalAuthorizedSender = isSlackExecApprovalApprover({+ const pluginApprovalAuthorizedSender = isSlackApprovalAuthorizedSender({ cfg: params.ctx.cfg, accountId: params.ctx.accountId, senderId: params.parsed.userId,@@ -555,9 +553,13 @@ async function handleSlackExecApprovalInteraction(params: { senderId: params.parsed.userId, }); const isPluginApproval = approval.approvalId.startsWith("plugin:");+ const resolveUnprefixedAsPlugin =+ !isPluginApproval && !execApprovalAuthorizedSender && pluginApprovalAuthorizedSender; const authorized = isPluginApproval ? pluginApprovalAuthorizedSender- : execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;+ : execApprovalAuthorizedSender || resolveUnprefixedAsPlugin;+ const allowPluginFallback =+ !isPluginApproval && execApprovalAuthorizedSender && pluginApprovalAuthorizedSender; if (!authorized) { params.ctx.runtime.log?.( `slack:interaction drop exec approval user=${params.parsed.userId} (not authorized)`,@@ -572,7 +574,8 @@ async function handleSlackExecApprovalInteraction(params: { approvalId: approval.approvalId, decision: approval.decision, senderId: params.parsed.userId,- allowPluginFallback: pluginApprovalAuthorizedSender,+ allowPluginFallback,+ ...(resolveUnprefixedAsPlugin ? { resolveMethod: "plugin" as const } : {}), clientDisplayName: `Slack approval (${params.parsed.userId.trim() || "unknown"})`, }); } catch (error) {@@ -9,6 +9,7 @@ type ResolveApprovalOverGatewayParams = { decision: ExecApprovalDecision; senderId?: string | null; allowPluginFallback?: boolean;+ resolveMethod?: "plugin"; gatewayUrl?: string; clientDisplayName?: string; };@@ -32,7 +33,7 @@ export async function resolveApprovalOverGateway( decision: params.decision, }); };- if (params.approvalId.startsWith("plugin:")) {+ if (params.resolveMethod === "plugin" || params.approvalId.startsWith("plugin:")) { await requestResolve("plugin.approval.resolve"); return; }AI-assisted change 2f5449ed0b2a082957a2a2a4dbfd2daa020fc160062f86773afda4c4f5bae46e · Fix a78f7e2bf1192a1c52d6e467a49111efcf13f1d0466859044195fa9be3f48cc2