Root cause
Injection & unsafe execution introduced
feat(discord): add exec approval forwarding to DMs (#1621)
How AI contributed: Direct introductionPublished Feb 17, 2026 in
openclaw/openclawAll 35 findings in this repository
A gateway client holding only the operator.write scope could approve or deny pending exec approvals through the /approve chat command in OpenClaw. The exec.approval.resolve method was scoped correctly for direct RPC, but handleApproveCommand in src/auto-reply/reply/commands-approve.ts reached the same operation through an internal privileged gateway client and checked only isAuthorizedSender. The fix carries the originating scopes into command handling and rejects gateway-originated approval requests unless the client holds operator.approvals or operator.admin.
handleApproveCommand in src/auto-reply/reply/commands-approve.ts resolves exec approvals through an internal privileged client and verifies only isAuthorizedSender, so the caller's gateway scope is never compared with the operator.approvals or operator.admin requirement that exec.approval.resolve enforces on the direct RPC path.
Root cause
feat(discord): add exec approval forwarding to DMs (#1621)
Fix
fix(approvals): gate /approve by gateway scopes
Fix by Armin Ronacher · no AI marker found
Case facts
@@ -1,4 +1,5 @@ import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";+import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { ErrorCodes,@@ -9,7 +10,10 @@ import { } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; -export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers {+export function createExecApprovalHandlers(+ manager: ExecApprovalManager,+ opts?: { forwarder?: ExecApprovalForwarder },+): GatewayRequestHandlers { return { "exec.approval.request": async ({ params, respond, context }) => { if (!validateExecApprovalRequestParams(params)) {@@ -69,6 +73,16 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa }, { dropIfSlow: true }, );+ void opts?.forwarder+ ?.handleRequested({+ id: record.id,+ request: record.request,+ createdAtMs: record.createdAtMs,+ expiresAtMs: record.expiresAtMs,+ })+ .catch((err) => {+ context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);+ }); const decision = await decisionPromise; respond( true,@@ -112,6 +126,11 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa { id: p.id, decision, resolvedBy, ts: Date.now() }, { dropIfSlow: true }, );+ void opts?.forwarder+ ?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })+ .catch((err) => {+ context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);+ }); respond(true, { ok: true }, undefined); }, };@@ -0,0 +1,549 @@+import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";+import { ButtonStyle, Routes } from "discord-api-types/v10";+import type { ClawdbotConfig } from "../../config/config.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 { createDiscordClient } from "../send.shared.js";+import { logDebug, logError } from "../../logger.js";+import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";+import type { RuntimeEnv } from "../../runtime.js";++const EXEC_APPROVAL_KEY = "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 = {+ discordMessageId: string;+ discordChannelId: string;+ timeoutId: NodeJS.Timeout;+};++function encodeCustomIdValue(value: string): string {+ return encodeURIComponent(value);+}++function decodeCustomIdValue(value: string): string {+ try {+ return decodeURIComponent(value);+ } catch {+ return value;+ }+}++export function buildExecApprovalCustomId(+ approvalId: string,+ action: ExecApprovalDecision,+): string {+ return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(+ ";",+ );+}++export function parseExecApprovalData(+ data: ComponentData,+): { approvalId: string; action: ExecApprovalDecision } | null {+ if (!data || typeof data !== "object") return null;+ const coerce = (value: unknown) =>+ typeof value === "string" || typeof value === "number" ? String(value) : "";+ const rawId = coerce(data.id);+ const rawAction = coerce(data.action);+ if (!rawId || !rawAction) return null;+ const action = rawAction as ExecApprovalDecision;+ if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {+ return null;+ }+ return {+ approvalId: decodeCustomIdValue(rawId),+ action,+ };+}++function formatExecApprovalEmbed(request: ExecApprovalRequest) {+ const commandText = request.request.command;+ const commandPreview =+ commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;+ const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));+@@ -0,0 +1,101 @@+import { callGateway } from "../../gateway/call.js";+import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";+import { logVerbose } from "../../globals.js";+import type { CommandHandler } from "./commands-types.js";++const COMMAND = "/approve";++const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {+ allow: "allow-once",+ once: "allow-once",+ "allow-once": "allow-once",+ allowonce: "allow-once",+ always: "allow-always",+ "allow-always": "allow-always",+ allowalways: "allow-always",+ deny: "deny",+ reject: "deny",+ block: "deny",+};++type ParsedApproveCommand =+ | { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" }+ | { ok: false; error: string };++function parseApproveCommand(raw: string): ParsedApproveCommand | null {+ const trimmed = raw.trim();+ if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;+ const rest = trimmed.slice(COMMAND.length).trim();+ if (!rest) {+ return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };+ }+ const tokens = rest.split(/\s+/).filter(Boolean);+ if (tokens.length < 2) {+ return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };+ }++ const first = tokens[0].toLowerCase();+ const second = tokens[1].toLowerCase();++ if (DECISION_ALIASES[first]) {+ return {+ ok: true,+ decision: DECISION_ALIASES[first],+ id: tokens.slice(1).join(" ").trim(),+ };+ }+ if (DECISION_ALIASES[second]) {+ return {+ ok: true,+ decision: DECISION_ALIASES[second],+ id: tokens[0],+ };+ }+ return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };+}++function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {+ const channel = params.command.channel;+ const sender = params.command.senderId ?? "unknown";+ return `${channel}:${sender}`;+}++export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {+ if (!allowTextCommands) return null;+ const normalized = params.command.commandBodyNormalized;+ const parsed = parseApproveCommand(normalized);+ if (!parsed) return null;+ if (!params.command.isAuthorizedSender) {+ logVerbose(+ `Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,+ );+ return { shouldContinue: false };+ }++ if (!parsed.ok) {+ return { shouldContinue: false, reply: { text: parsed.error } };+ }++ const resolvedBy = buildResolvedByLabel(params);+ try {+ await callGateway({+ method: "exec.approval.resolve",+ params: { id: parsed.id, decision: parsed.decision },+ clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,+ clientDisplayName: `Chat approval (${resolvedBy})`,+ mode: GATEWAY_CLIENT_MODES.BACKEND,+ });+ } catch (err) {+ return {@@ -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. */@@ -470,6 +470,7 @@ export const chatHandlers: GatewayRequestHandlers = { SenderId: clientInfo?.id, SenderName: clientInfo?.displayName, SenderUsername: clientInfo?.displayName,+ GatewayClientScopes: client?.connect?.scopes, }; const agentId = resolveSessionAgentId({@@ -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({AI-assisted change f9d91baeb09c91f83b49f08d9a1871cad399fdd604b7dea71bdbc42b6d4f8a20 · Fix 5b181ef304eec61c0a4b47733876e3fdc6aec16cf3b5222862d11a5a8700b5e1