fix(gateway): block node.invoke exec approvals

This commit is contained in:
Peter Steinberger
2026-02-14 19:21:31 +01:00
parent d0f64c955e
commit 01b3226ecb
4 changed files with 46 additions and 8 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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);

View File

@@ -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<string, unknown> | null = null;
const node = await connectLinuxNode((payload) => {