From efe2a464af9fcaa2da13873742003d21408a3f9d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 2 Feb 2026 11:51:42 +0100 Subject: [PATCH] fix(approvals): gate /approve by gateway scopes --- src/auto-reply/reply/commands-approve.test.ts | 19 ++++++++++++++++++ src/auto-reply/reply/commands-approve.ts | 20 ++++++++++++++++++- src/auto-reply/templating.ts | 2 ++ src/gateway/server-methods/chat.ts | 1 + 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts index a4e2301b7..6add5a19c 100644 --- a/src/auto-reply/reply/commands-approve.test.ts +++ b/src/auto-reply/reply/commands-approve.test.ts @@ -79,4 +79,23 @@ describe("/approve command", () => { }), ); }); + + it("rejects gateway clients without approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.write"], + }); + + const mockCallGateway = vi.mocked(callGateway); + mockCallGateway.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.approvals"); + expect(mockCallGateway).not.toHaveBeenCalled(); + }); }); diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 30695ab7b..12bca57de 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,7 +1,11 @@ import type { CommandHandler } from "./commands-types.js"; import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + isInternalMessageChannel, +} from "../../utils/message-channel.js"; const COMMAND = "/approve"; @@ -82,6 +86,20 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { text: parsed.error } }; } + if (isInternalMessageChannel(params.command.channel)) { + const scopes = params.ctx.GatewayClientScopes ?? []; + const hasApprovals = scopes.includes("operator.approvals") || scopes.includes("operator.admin"); + if (!hasApprovals) { + logVerbose("Ignoring /approve from gateway client missing operator.approvals."); + return { + shouldContinue: false, + reply: { + text: "❌ /approve requires operator.approvals for gateway clients.", + }, + }; + } + } + const resolvedBy = buildResolvedByLabel(params); try { await callGateway({ diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 9cc89087d..b374ac7a7 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -101,6 +101,8 @@ export type MsgContext = { CommandAuthorized?: boolean; CommandSource?: "text" | "native"; CommandTargetSessionKey?: string; + /** Gateway client scopes when the message originates from the gateway. */ + GatewayClientScopes?: string[]; /** Thread identifier (Telegram topic id or Matrix thread event id). */ MessageThreadId?: string | number; /** Telegram forum supergroup marker. */ diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index ba5347dc3..b49af56de 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -470,6 +470,7 @@ export const chatHandlers: GatewayRequestHandlers = { SenderId: clientInfo?.id, SenderName: clientInfo?.displayName, SenderUsername: clientInfo?.displayName, + GatewayClientScopes: client?.connect?.scopes, }; const agentId = resolveSessionAgentId({