diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index b4f1f04e004..7c8289b4769 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -401,16 +401,16 @@ originating chat can already send commands and receive replies, approval request separate channel-specific approval client just to stay pending. Discord and Telegram also support same-chat `/approve`, but those channels still use their -resolved approver list for authorization even when the richer approval client is disabled. +resolved approver list for authorization even when native approval delivery is disabled. -### Rich approval clients +### Native approval delivery -Discord and Telegram can also act as richer exec approval clients with channel-specific config. +Discord and Telegram can also act as native approval-delivery adapters with channel-specific config. - Discord: `channels.discord.execApprovals.*` - Telegram: `channels.telegram.execApprovals.*` -These richer clients are opt-in. They add native DM routing, channel fanout, and interactive UI on +These native delivery adapters are opt-in. They add DM routing, channel fanout, and interactive UI on top of the shared same-chat `/approve` flow. Shared behavior: diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 5ab617d6436..692a2f3f7f7 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -41,6 +41,7 @@ import { } from "./directory-config.js"; import { getDiscordExecApprovalApprovers, + isDiscordExecApprovalApprover, isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; @@ -492,6 +493,16 @@ export const discordPlugin: ChannelPlugin }, }, execApprovals: { + authorizeCommand: ({ cfg, accountId, senderId, kind }) => + isDiscordExecApprovalApprover({ cfg, accountId, senderId }) + ? { authorized: true } + : { + authorized: false, + reason: + kind === "plugin" + ? "❌ You are not authorized to approve plugin requests on Discord." + : "❌ You are not authorized to approve exec requests on Discord.", + }, getInitiatingSurfaceState: ({ cfg, accountId }) => getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0 ? { kind: "enabled" } diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 236cc95209f..79856f57379 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -53,6 +53,8 @@ import { } from "./exec-approval-forwarding.js"; import { getTelegramExecApprovalApprovers, + isTelegramExecApprovalApprover, + isTelegramExecApprovalAuthorizedSender, isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; @@ -458,6 +460,22 @@ export const telegramPlugin = createChatChannelPlugin({ }, }, execApprovals: { + authorizeCommand: ({ cfg, accountId, senderId, kind }) => { + const params = { cfg, accountId, senderId }; + const authorized = + kind === "plugin" + ? isTelegramExecApprovalApprover(params) + : isTelegramExecApprovalAuthorizedSender(params); + return authorized + ? { authorized: true } + : { + authorized: false, + reason: + kind === "plugin" + ? "❌ You are not authorized to approve plugin requests on Telegram." + : "❌ You are not authorized to approve exec requests on Telegram.", + }; + }, getInitiatingSurfaceState: ({ cfg, accountId }) => getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0 ? { kind: "enabled" } diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 4d949eea339..5e43385d886 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,11 +1,7 @@ import { callGateway } from "../../gateway/call.js"; import { ErrorCodes } from "../../gateway/protocol/index.js"; import { logVerbose } from "../../globals.js"; -import { isDiscordExecApprovalApprover } from "../../plugin-sdk/discord-surface.js"; -import { - isTelegramExecApprovalAuthorizedSender, - isTelegramExecApprovalApprover, -} from "../../plugin-sdk/telegram-runtime.js"; +import { resolveApprovalCommandAuthorization } from "../../infra/channel-approval-auth.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; @@ -127,61 +123,19 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { text: parsed.error } }; } const isPluginId = parsed.id.startsWith("plugin:"); - let isTelegramExplicitApprover = false; - - if (params.command.channel === "telegram") { - const telegramApproverContext = { - cfg: params.cfg, - accountId: params.ctx.AccountId, - senderId: params.command.senderId, - }; - isTelegramExplicitApprover = isTelegramExecApprovalApprover(telegramApproverContext); - - if (!isPluginId && !isTelegramExecApprovalAuthorizedSender(telegramApproverContext)) { - return { - shouldContinue: false, - reply: { text: "❌ You are not authorized to approve exec requests on Telegram." }, - }; - } - - if (isPluginId && !isTelegramExplicitApprover) { - return { - shouldContinue: false, - reply: { text: "❌ You are not authorized to approve plugin requests on Telegram." }, - }; - } - } - - if (params.command.channel === "discord" && !isPluginId) { - if ( - !isDiscordExecApprovalApprover({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - senderId: params.command.senderId, - }) - ) { - return { - shouldContinue: false, - reply: { text: "❌ You are not authorized to approve exec requests on Discord." }, - }; - } - } - - // Keep plugin-ID routing independent from exec approval client enablement so - // forwarded plugin approvals remain resolvable, but still require explicit - // Discord approver membership for security parity. - if ( - params.command.channel === "discord" && - isPluginId && - !isDiscordExecApprovalApprover({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - senderId: params.command.senderId, - }) - ) { + const approvalAuthorization = resolveApprovalCommandAuthorization({ + cfg: params.cfg, + channel: params.command.channel, + accountId: params.ctx.AccountId, + senderId: params.command.senderId, + kind: isPluginId ? "plugin" : "exec", + }); + if (!approvalAuthorization.authorized) { return { shouldContinue: false, - reply: { text: "❌ You are not authorized to approve plugin requests on Discord." }, + reply: { + text: approvalAuthorization.reason ?? "❌ You are not authorized to approve this request.", + }, }; } @@ -221,7 +175,14 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm await callApprovalMethod("exec.approval.resolve"); } catch (err) { if (isApprovalNotFoundError(err)) { - if (params.command.channel === "telegram" && !isTelegramExplicitApprover) { + const pluginFallbackAuthorization = resolveApprovalCommandAuthorization({ + cfg: params.cfg, + channel: params.command.channel, + accountId: params.ctx.AccountId, + senderId: params.command.senderId, + kind: "plugin", + }); + if (!pluginFallbackAuthorization.authorized) { return { shouldContinue: false, reply: { text: `❌ Failed to submit approval: ${String(err)}` }, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index fdbba1fdf7a..4858006d9d8 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -466,6 +466,15 @@ export type ChannelLifecycleAdapter = { }; export type ChannelExecApprovalAdapter = { + authorizeCommand?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; + kind: "exec" | "plugin"; + }) => { + authorized: boolean; + reason?: string; + }; getInitiatingSurfaceState?: (params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/infra/channel-approval-auth.test.ts b/src/infra/channel-approval-auth.test.ts new file mode 100644 index 00000000000..904b94ff8e8 --- /dev/null +++ b/src/infra/channel-approval-auth.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getChannelPluginMock = vi.hoisted(() => vi.fn()); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), +})); + +import { resolveApprovalCommandAuthorization } from "./channel-approval-auth.js"; + +describe("resolveApprovalCommandAuthorization", () => { + beforeEach(() => { + getChannelPluginMock.mockReset(); + }); + + it("allows commands by default when the channel has no approval override", () => { + expect( + resolveApprovalCommandAuthorization({ + cfg: {} as never, + channel: "slack", + senderId: "U123", + kind: "exec", + }), + ).toEqual({ authorized: true }); + }); + + it("delegates to the channel approval override when present", () => { + getChannelPluginMock.mockReturnValue({ + execApprovals: { + authorizeCommand: ({ kind }: { kind: "exec" | "plugin" }) => + kind === "plugin" ? { authorized: false, reason: "plugin denied" } : { authorized: true }, + }, + }); + + expect( + resolveApprovalCommandAuthorization({ + cfg: {} as never, + channel: "discord", + accountId: "work", + senderId: "123", + kind: "exec", + }), + ).toEqual({ authorized: true }); + + expect( + resolveApprovalCommandAuthorization({ + cfg: {} as never, + channel: "discord", + accountId: "work", + senderId: "123", + kind: "plugin", + }), + ).toEqual({ authorized: false, reason: "plugin denied" }); + }); +}); diff --git a/src/infra/channel-approval-auth.ts b/src/infra/channel-approval-auth.ts new file mode 100644 index 00000000000..7264ad3e7fc --- /dev/null +++ b/src/infra/channel-approval-auth.ts @@ -0,0 +1,24 @@ +import { getChannelPlugin } from "../channels/plugins/index.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeMessageChannel } from "../utils/message-channel.js"; + +export function resolveApprovalCommandAuthorization(params: { + cfg: OpenClawConfig; + channel?: string | null; + accountId?: string | null; + senderId?: string | null; + kind: "exec" | "plugin"; +}): { authorized: boolean; reason?: string } { + const channel = normalizeMessageChannel(params.channel); + if (!channel) { + return { authorized: true }; + } + return ( + getChannelPlugin(channel)?.execApprovals?.authorizeCommand?.({ + cfg: params.cfg, + accountId: params.accountId, + senderId: params.senderId, + kind: params.kind, + }) ?? { authorized: true } + ); +}