diff --git a/CHANGELOG.md b/CHANGELOG.md index cff84218d4..ab71058a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x. - Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. +- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth. - CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command. - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index ca2ad13cbe..ec829b0c5f 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -39,14 +39,7 @@ const SMS_DANGEROUS_COMMANDS = ["sms.send"]; // iOS nodes don't implement system.run/which, but they do support notifications. const IOS_SYSTEM_COMMANDS = ["system.notify"]; -const SYSTEM_COMMANDS = [ - "system.run", - "system.which", - "system.notify", - "system.execApprovals.get", - "system.execApprovals.set", - "browser.proxy", -]; +const SYSTEM_COMMANDS = ["system.run", "system.which", "system.notify", "browser.proxy"]; // "High risk" node commands. These can be enabled by explicitly adding them to // `gateway.nodes.allowCommands` (and ensuring they're not blocked by denyCommands). diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index fd2b4e3d82..f86a94490d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -388,6 +388,18 @@ export const nodeHandlers: GatewayRequestHandlers = { ); return; } + if (command === "system.execApprovals.get" || command === "system.execApprovals.set") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "node.invoke does not allow system.execApprovals.*; use exec.approvals.node.*", + { details: { command } }, + ), + ); + return; + } await respondUnavailableOnThrow(respond, async () => { const nodeSession = context.nodeRegistry.get(nodeId); diff --git a/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts b/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts index e8103d0e55..1cf4352294 100644 --- a/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.e2e.test.ts @@ -197,6 +197,38 @@ describe("node.invoke approval bypass", () => { node.stop(); }); + test("rejects invoking system.execApprovals.set via node.invoke", async () => { + let sawInvoke = false; + const node = await connectLinuxNode(() => { + sawInvoke = true; + }); + const ws = await connectOperator(["operator.write"]); + + const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>( + ws, + "node.list", + {}, + ); + expect(nodes.ok).toBe(true); + const nodeId = nodes.payload?.nodes?.find((n) => n.connected)?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + + const res = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.execApprovals.set", + params: { file: { version: 1, agents: {} }, baseHash: "nope" }, + idempotencyKey: crypto.randomUUID(), + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("exec.approvals.node"); + + await sleep(50); + expect(sawInvoke).toBe(false); + + ws.close(); + node.stop(); + }); + test("binds system.run approval flags to exec.approval decision (ignores caller escalation)", async () => { let lastInvokeParams: Record | null = null; const node = await connectLinuxNode((payload) => {