OpenClaw < 2026.2.26 - Approval Bypass via Parent Symlink Current Working Directory Rebind
Description
OpenClaw versions prior to 2026.2.26 contain an approval bypass vulnerability in system.run execution that allows attackers to execute commands from unintended filesystem locations by rebinding writable parent symlinks in the current working directory after approval. An attacker can modify mutable parent symlink path components between approval and execution time to redirect command execution to a different location while preserving the visible working directory string.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.26 | 2026.2.26 |
Affected products
1Patches
54b4718c8dfcerefactor(cli): decompose nodes run approval flow
1 file changed · +252 −174
src/cli/nodes-cli/register.invoke.ts+252 −174 modified@@ -7,14 +7,13 @@ import { type ExecApprovalsFile, type ExecAsk, type ExecSecurity, - type SystemRunApprovalPlanV2, maxAsk, minSecurity, resolveExecApprovalsFromFile, } from "../../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../../infra/node-shell.js"; import { applyPathPrepend } from "../../infra/path-prepend.js"; -import { normalizeSystemRunApprovalPlanV2 } from "../../infra/system-run-approval-binding.js"; +import { parsePreparedSystemRunPayload } from "../../infra/system-run-approval-context.js"; import { defaultRuntime } from "../../runtime.js"; import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -44,22 +43,6 @@ type ExecDefaults = { safeBins?: string[]; }; -function parsePreparedRunPlan(payload: unknown): { - cmdText: string; - plan: SystemRunApprovalPlanV2; -} { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - throw new Error("invalid system.run.prepare response"); - } - const raw = payload as { cmdText?: unknown; plan?: unknown }; - const cmdText = typeof raw.cmdText === "string" ? raw.cmdText.trim() : ""; - const plan = normalizeSystemRunApprovalPlanV2(raw.plan); - if (!cmdText || !plan) { - throw new Error("invalid system.run.prepare response"); - } - return { cmdText, plan }; -} - function normalizeExecSecurity(value?: string | null): ExecSecurity | null { const normalized = value?.trim().toLowerCase(); if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { @@ -113,6 +96,221 @@ async function resolveNodePlatform(opts: NodesRpcOpts, nodeId: string): Promise< } } +function requirePreparedRunPayload(payload: unknown) { + const prepared = parsePreparedSystemRunPayload(payload); + if (!prepared) { + throw new Error("invalid system.run.prepare response"); + } + return prepared; +} + +function resolveNodesRunPolicy(opts: NodesRunOpts, execDefaults: ExecDefaults | undefined) { + const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist"; + const requestedSecurity = normalizeExecSecurity(opts.security); + if (opts.security && !requestedSecurity) { + throw new Error("invalid --security (use deny|allowlist|full)"); + } + const configuredAsk = normalizeExecAsk(execDefaults?.ask) ?? "on-miss"; + const requestedAsk = normalizeExecAsk(opts.ask); + if (opts.ask && !requestedAsk) { + throw new Error("invalid --ask (use off|on-miss|always)"); + } + return { + security: minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity), + ask: maxAsk(configuredAsk, requestedAsk ?? configuredAsk), + }; +} + +async function prepareNodesRunContext(params: { + opts: NodesRunOpts; + command: string[]; + raw: string; + nodeId: string; + agentId: string | undefined; + execDefaults: ExecDefaults | undefined; +}) { + const env = parseEnvPairs(params.opts.env); + const timeoutMs = parseTimeoutMs(params.opts.commandTimeout); + const invokeTimeout = parseTimeoutMs(params.opts.invokeTimeout); + + let argv = Array.isArray(params.command) ? params.command : []; + let rawCommand: string | undefined; + if (params.raw) { + rawCommand = params.raw; + const platform = await resolveNodePlatform(params.opts, params.nodeId); + argv = buildNodeShellCommand(rawCommand, platform ?? undefined); + } + + const nodeEnv = env ? { ...env } : undefined; + if (nodeEnv) { + applyPathPrepend(nodeEnv, params.execDefaults?.pathPrepend, { requireExisting: true }); + } + + const prepareResponse = (await callGatewayCli("node.invoke", params.opts, { + nodeId: params.nodeId, + command: "system.run.prepare", + params: { + command: argv, + rawCommand, + cwd: params.opts.cwd, + agentId: params.agentId, + }, + idempotencyKey: `prepare-${randomIdempotencyKey()}`, + })) as { payload?: unknown } | null; + + return { + prepared: requirePreparedRunPayload(prepareResponse?.payload), + nodeEnv, + timeoutMs, + invokeTimeout, + }; +} + +async function resolveNodeApprovals(params: { + opts: NodesRunOpts; + nodeId: string; + agentId: string | undefined; + security: ExecSecurity; + ask: ExecAsk; +}) { + const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", params.opts, { + nodeId: params.nodeId, + })) as { + file?: unknown; + } | null; + const approvalsFile = + approvalsSnapshot && typeof approvalsSnapshot === "object" ? approvalsSnapshot.file : undefined; + if (!approvalsFile || typeof approvalsFile !== "object") { + throw new Error("exec approvals unavailable"); + } + const approvals = resolveExecApprovalsFromFile({ + file: approvalsFile as ExecApprovalsFile, + agentId: params.agentId, + overrides: { security: params.security, ask: params.ask }, + }); + return { + approvals, + hostSecurity: minSecurity(params.security, approvals.agent.security), + hostAsk: maxAsk(params.ask, approvals.agent.ask), + askFallback: approvals.agent.askFallback, + }; +} + +async function maybeRequestNodesRunApproval(params: { + opts: NodesRunOpts; + nodeId: string; + agentId: string | undefined; + preparedCmdText: string; + approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"]; + hostSecurity: ExecSecurity; + hostAsk: ExecAsk; + askFallback: ExecSecurity; +}) { + let approvedByAsk = false; + let approvalDecision: "allow-once" | "allow-always" | null = null; + let approvalId: string | null = null; + const requiresAsk = params.hostAsk === "always" || params.hostAsk === "on-miss"; + if (!requiresAsk) { + return { approvedByAsk, approvalDecision, approvalId }; + } + + approvalId = crypto.randomUUID(); + const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS; + // Keep client transport alive while the approver decides. + const transportTimeoutMs = Math.max( + parseTimeoutMs(params.opts.timeout) ?? 0, + approvalTimeoutMs + 10_000, + ); + const decisionResult = (await callGatewayCli( + "exec.approval.request", + params.opts, + { + id: approvalId, + command: params.preparedCmdText, + commandArgv: params.approvalPlan.argv, + systemRunPlanV2: params.approvalPlan, + cwd: params.approvalPlan.cwd, + nodeId: params.nodeId, + host: "node", + security: params.hostSecurity, + ask: params.hostAsk, + agentId: params.approvalPlan.agentId ?? params.agentId, + resolvedPath: undefined, + sessionKey: params.approvalPlan.sessionKey ?? undefined, + timeoutMs: approvalTimeoutMs, + }, + { transportTimeoutMs }, + )) as { decision?: string } | null; + const decision = + decisionResult && typeof decisionResult === "object" ? (decisionResult.decision ?? null) : null; + if (decision === "deny") { + throw new Error("exec denied: user denied"); + } + if (!decision) { + if (params.askFallback === "full") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } else if (params.askFallback !== "allowlist") { + throw new Error("exec denied: approval required (approval UI not available)"); + } + } + if (decision === "allow-once") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } + if (decision === "allow-always") { + approvedByAsk = true; + approvalDecision = "allow-always"; + } + return { approvedByAsk, approvalDecision, approvalId }; +} + +function buildSystemRunInvokeParams(params: { + nodeId: string; + approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"]; + nodeEnv: Record<string, string> | undefined; + timeoutMs: number | undefined; + invokeTimeout: number | undefined; + approvedByAsk: boolean; + approvalDecision: "allow-once" | "allow-always" | null; + approvalId: string | null; + idempotencyKey: string | undefined; + fallbackAgentId: string | undefined; + needsScreenRecording: boolean; +}) { + const invokeParams: Record<string, unknown> = { + nodeId: params.nodeId, + command: "system.run", + params: { + command: params.approvalPlan.argv, + rawCommand: params.approvalPlan.rawCommand, + cwd: params.approvalPlan.cwd, + env: params.nodeEnv, + timeoutMs: params.timeoutMs, + needsScreenRecording: params.needsScreenRecording, + }, + idempotencyKey: String(params.idempotencyKey ?? randomIdempotencyKey()), + }; + if (params.approvalPlan.agentId ?? params.fallbackAgentId) { + (invokeParams.params as Record<string, unknown>).agentId = + params.approvalPlan.agentId ?? params.fallbackAgentId; + } + if (params.approvalPlan.sessionKey) { + (invokeParams.params as Record<string, unknown>).sessionKey = params.approvalPlan.sessionKey; + } + (invokeParams.params as Record<string, unknown>).approved = params.approvedByAsk; + if (params.approvalDecision) { + (invokeParams.params as Record<string, unknown>).approvalDecision = params.approvalDecision; + } + if (params.approvedByAsk && params.approvalId) { + (invokeParams.params as Record<string, unknown>).runId = params.approvalId; + } + if (params.invokeTimeout !== undefined) { + invokeParams.timeoutMs = params.invokeTimeout; + } + return invokeParams; +} + export function registerNodesInvokeCommands(nodes: Command) { nodesCallOpts( nodes @@ -192,169 +390,49 @@ export function registerNodesInvokeCommands(nodes: Command) { throw new Error("node required (set --node or tools.exec.node)"); } const nodeId = await resolveNodeId(opts, nodeQuery); - - const env = parseEnvPairs(opts.env); - const timeoutMs = parseTimeoutMs(opts.commandTimeout); - const invokeTimeout = parseTimeoutMs(opts.invokeTimeout); - - let argv = Array.isArray(command) ? command : []; - let rawCommand: string | undefined; - if (raw) { - rawCommand = raw; - const platform = await resolveNodePlatform(opts, nodeId); - argv = buildNodeShellCommand(rawCommand, platform ?? undefined); - } - - const nodeEnv = env ? { ...env } : undefined; - if (nodeEnv) { - applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true }); - } - - const prepareResponse = (await callGatewayCli("node.invoke", opts, { + const preparedContext = await prepareNodesRunContext({ + opts, + command, + raw, nodeId, - command: "system.run.prepare", - params: { - command: argv, - rawCommand, - cwd: opts.cwd, - agentId, - }, - idempotencyKey: `prepare-${randomIdempotencyKey()}`, - })) as { payload?: unknown } | null; - const prepared = parsePreparedRunPlan(prepareResponse?.payload); - const approvalPlan = prepared.plan; - - let approvedByAsk = false; - let approvalDecision: "allow-once" | "allow-always" | null = null; - const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist"; - const requestedSecurity = normalizeExecSecurity(opts.security); - if (opts.security && !requestedSecurity) { - throw new Error("invalid --security (use deny|allowlist|full)"); - } - const configuredAsk = normalizeExecAsk(execDefaults?.ask) ?? "on-miss"; - const requestedAsk = normalizeExecAsk(opts.ask); - if (opts.ask && !requestedAsk) { - throw new Error("invalid --ask (use off|on-miss|always)"); - } - const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); - const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); - - const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, { + agentId, + execDefaults, + }); + const approvalPlan = preparedContext.prepared.plan; + const policy = resolveNodesRunPolicy(opts, execDefaults); + const approvals = await resolveNodeApprovals({ + opts, nodeId, - })) as { - file?: unknown; - } | null; - const approvalsFile = - approvalsSnapshot && typeof approvalsSnapshot === "object" - ? approvalsSnapshot.file - : undefined; - if (!approvalsFile || typeof approvalsFile !== "object") { - throw new Error("exec approvals unavailable"); - } - const approvals = resolveExecApprovalsFromFile({ - file: approvalsFile as ExecApprovalsFile, agentId, - overrides: { security, ask }, + security: policy.security, + ask: policy.ask, }); - const hostSecurity = minSecurity(security, approvals.agent.security); - const hostAsk = maxAsk(ask, approvals.agent.ask); - const askFallback = approvals.agent.askFallback; - - if (hostSecurity === "deny") { + if (approvals.hostSecurity === "deny") { throw new Error("exec denied: host=node security=deny"); } - - const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; - let approvalId: string | null = null; - if (requiresAsk) { - approvalId = crypto.randomUUID(); - const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS; - // The CLI transport timeout (opts.timeout) must be longer than the - // gateway-side approval wait so the connection stays alive while the - // user decides. Without this override the default 35 s transport - // timeout races — and always loses — against the 120 s approval - // timeout, causing "gateway timeout after 35000ms" (#12098). - const transportTimeoutMs = Math.max( - parseTimeoutMs(opts.timeout) ?? 0, - approvalTimeoutMs + 10_000, - ); - const decisionResult = (await callGatewayCli( - "exec.approval.request", - opts, - { - id: approvalId, - command: prepared.cmdText, - commandArgv: approvalPlan.argv, - systemRunPlanV2: approvalPlan, - cwd: approvalPlan.cwd, - nodeId, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId: approvalPlan.agentId ?? agentId, - resolvedPath: undefined, - sessionKey: approvalPlan.sessionKey ?? undefined, - timeoutMs: approvalTimeoutMs, - }, - { transportTimeoutMs }, - )) as { decision?: string } | null; - const decision = - decisionResult && typeof decisionResult === "object" - ? (decisionResult.decision ?? null) - : null; - if (decision === "deny") { - throw new Error("exec denied: user denied"); - } - if (!decision) { - if (askFallback === "full") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } else if (askFallback === "allowlist") { - // defer allowlist enforcement to node host - } else { - throw new Error("exec denied: approval required (approval UI not available)"); - } - } - if (decision === "allow-once") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } - if (decision === "allow-always") { - approvedByAsk = true; - approvalDecision = "allow-always"; - } - } - - const invokeParams: Record<string, unknown> = { + const approvalResult = await maybeRequestNodesRunApproval({ + opts, nodeId, - command: "system.run", - params: { - command: approvalPlan.argv, - rawCommand: approvalPlan.rawCommand, - cwd: approvalPlan.cwd, - env: nodeEnv, - timeoutMs, - needsScreenRecording: opts.needsScreenRecording === true, - }, - idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()), - }; - if (approvalPlan.agentId ?? agentId) { - (invokeParams.params as Record<string, unknown>).agentId = - approvalPlan.agentId ?? agentId; - } - if (approvalPlan.sessionKey) { - (invokeParams.params as Record<string, unknown>).sessionKey = approvalPlan.sessionKey; - } - (invokeParams.params as Record<string, unknown>).approved = approvedByAsk; - if (approvalDecision) { - (invokeParams.params as Record<string, unknown>).approvalDecision = approvalDecision; - } - if (approvedByAsk && approvalId) { - (invokeParams.params as Record<string, unknown>).runId = approvalId; - } - if (invokeTimeout !== undefined) { - invokeParams.timeoutMs = invokeTimeout; - } + agentId, + preparedCmdText: preparedContext.prepared.cmdText, + approvalPlan, + hostSecurity: approvals.hostSecurity, + hostAsk: approvals.hostAsk, + askFallback: approvals.askFallback, + }); + const invokeParams = buildSystemRunInvokeParams({ + nodeId, + approvalPlan, + nodeEnv: preparedContext.nodeEnv, + timeoutMs: preparedContext.timeoutMs, + invokeTimeout: preparedContext.invokeTimeout, + approvedByAsk: approvalResult.approvedByAsk, + approvalDecision: approvalResult.approvalDecision, + approvalId: approvalResult.approvalId, + idempotencyKey: opts.idempotencyKey, + fallbackAgentId: agentId, + needsScreenRecording: opts.needsScreenRecording === true, + }); const result = await callGatewayCli("node.invoke", opts, invokeParams); if (opts.json) {
4e690e09c746refactor(gateway): centralize system.run approval context and errors
4 files changed · +238 −106
src/gateway/node-invoke-system-run-approval-errors.ts+29 −0 added@@ -0,0 +1,29 @@ +export type SystemRunApprovalGuardError = { + ok: false; + message: string; + details: Record<string, unknown>; +}; + +export function systemRunApprovalGuardError(params: { + code: string; + message: string; + details?: Record<string, unknown>; +}): SystemRunApprovalGuardError { + const details = params.details ? { ...params.details } : {}; + return { + ok: false, + message: params.message, + details: { + code: params.code, + ...details, + }, + }; +} + +export function systemRunApprovalRequired(runId: string): SystemRunApprovalGuardError { + return systemRunApprovalGuardError({ + code: "APPROVAL_REQUIRED", + message: "approval required", + details: { runId }, + }); +}
src/gateway/node-invoke-system-run-approval.ts+69 −85 modified@@ -1,6 +1,10 @@ -import { normalizeSystemRunApprovalPlanV2 } from "../infra/system-run-approval-binding.js"; +import { resolveSystemRunApprovalRuntimeContext } from "../infra/system-run-approval-context.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import type { ExecApprovalRecord } from "./exec-approval-manager.js"; +import { + systemRunApprovalGuardError, + systemRunApprovalRequired, +} from "./node-invoke-system-run-approval-errors.js"; import { evaluateSystemRunApprovalMatch, toSystemRunApprovalMismatchError, @@ -125,62 +129,60 @@ export function sanitizeSystemRunParamsForForwarding(opts: { const runId = normalizeString(p.runId); if (!runId) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "MISSING_RUN_ID", message: "approval override requires params.runId", - details: { code: "MISSING_RUN_ID" }, - }; + }); } const manager = opts.execApprovalManager; if (!manager) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "APPROVALS_UNAVAILABLE", message: "exec approvals unavailable", - details: { code: "APPROVALS_UNAVAILABLE" }, - }; + }); } const snapshot = manager.getSnapshot(runId); if (!snapshot) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "UNKNOWN_APPROVAL_ID", message: "unknown or expired approval id", - details: { code: "UNKNOWN_APPROVAL_ID", runId }, - }; + details: { runId }, + }); } const nowMs = typeof opts.nowMs === "number" ? opts.nowMs : Date.now(); if (nowMs > snapshot.expiresAtMs) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "APPROVAL_EXPIRED", message: "approval expired", - details: { code: "APPROVAL_EXPIRED", runId }, - }; + details: { runId }, + }); } const targetNodeId = normalizeString(opts.nodeId); if (!targetNodeId) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "MISSING_NODE_ID", message: "node.invoke requires nodeId", - details: { code: "MISSING_NODE_ID", runId }, - }; + details: { runId }, + }); } const approvalNodeId = normalizeString(snapshot.request.nodeId); if (!approvalNodeId) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "APPROVAL_NODE_BINDING_MISSING", message: "approval id missing node binding", - details: { code: "APPROVAL_NODE_BINDING_MISSING", runId }, - }; + details: { runId }, + }); } if (approvalNodeId !== targetNodeId) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "APPROVAL_NODE_MISMATCH", message: "approval id not valid for this node", - details: { code: "APPROVAL_NODE_MISMATCH", runId }, - }; + details: { runId }, + }); } // Prefer binding by device identity (stable across reconnects / per-call clients like callGateway()). @@ -189,79 +191,69 @@ export function sanitizeSystemRunParamsForForwarding(opts: { const clientDeviceId = opts.client?.connect?.device?.id ?? null; if (snapshotDeviceId) { if (snapshotDeviceId !== clientDeviceId) { - return { - ok: false, + return systemRunApprovalGuardError({ + code: "APPROVAL_DEVICE_MISMATCH", message: "approval id not valid for this device", - details: { code: "APPROVAL_DEVICE_MISMATCH", runId }, - }; + details: { runId }, + }); } } else if ( snapshot.requestedByConnId && snapshot.requestedByConnId !== (opts.client?.connId ?? null) ) { + return systemRunApprovalGuardError({ + code: "APPROVAL_CLIENT_MISMATCH", + message: "approval id not valid for this client", + details: { runId }, + }); + } + + const runtimeContext = resolveSystemRunApprovalRuntimeContext({ + planV2: snapshot.request.systemRunPlanV2 ?? null, + command: p.command, + rawCommand: p.rawCommand, + cwd: p.cwd, + agentId: p.agentId, + sessionKey: p.sessionKey, + }); + if (!runtimeContext.ok) { return { ok: false, - message: "approval id not valid for this client", - details: { code: "APPROVAL_CLIENT_MISMATCH", runId }, + message: runtimeContext.message, + details: runtimeContext.details, }; } - - const planV2 = normalizeSystemRunApprovalPlanV2(snapshot.request.systemRunPlanV2 ?? null); - let approvalArgv: string[]; - let approvalCwd: string | null; - let approvalAgentId: string | null; - let approvalSessionKey: string | null; - if (planV2) { - approvalArgv = [...planV2.argv]; - approvalCwd = planV2.cwd; - approvalAgentId = planV2.agentId; - approvalSessionKey = planV2.sessionKey; - next.command = [...planV2.argv]; - if (planV2.rawCommand) { - next.rawCommand = planV2.rawCommand; + if (runtimeContext.planV2) { + next.command = [...runtimeContext.planV2.argv]; + if (runtimeContext.rawCommand) { + next.rawCommand = runtimeContext.rawCommand; } else { delete next.rawCommand; } - if (planV2.cwd) { - next.cwd = planV2.cwd; + if (runtimeContext.cwd) { + next.cwd = runtimeContext.cwd; } else { delete next.cwd; } - if (planV2.agentId) { - next.agentId = planV2.agentId; + if (runtimeContext.agentId) { + next.agentId = runtimeContext.agentId; } else { delete next.agentId; } - if (planV2.sessionKey) { - next.sessionKey = planV2.sessionKey; + if (runtimeContext.sessionKey) { + next.sessionKey = runtimeContext.sessionKey; } else { delete next.sessionKey; } - } else { - const cmdTextResolution = resolveSystemRunCommand({ - command: p.command, - rawCommand: p.rawCommand, - }); - if (!cmdTextResolution.ok) { - return { - ok: false, - message: cmdTextResolution.message, - details: cmdTextResolution.details, - }; - } - approvalArgv = cmdTextResolution.argv; - approvalCwd = normalizeString(p.cwd) ?? null; - approvalAgentId = normalizeString(p.agentId) ?? null; - approvalSessionKey = normalizeString(p.sessionKey) ?? null; } const approvalMatch = evaluateSystemRunApprovalMatch({ - argv: approvalArgv, + argv: runtimeContext.argv, request: snapshot.request, binding: { - cwd: approvalCwd, - agentId: approvalAgentId, - sessionKey: approvalSessionKey, + cwd: runtimeContext.cwd, + agentId: runtimeContext.agentId, + sessionKey: runtimeContext.sessionKey, env: p.env, }, }); @@ -272,11 +264,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: { // Normal path: enforce the decision recorded by the gateway. if (snapshot.decision === "allow-once") { if (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) { - return { - ok: false, - message: "approval required", - details: { code: "APPROVAL_REQUIRED", runId }, - }; + return systemRunApprovalRequired(runId); } next.approved = true; next.approvalDecision = "allow-once"; @@ -306,9 +294,5 @@ export function sanitizeSystemRunParamsForForwarding(opts: { return { ok: true, params: next }; } - return { - ok: false, - message: "approval required", - details: { code: "APPROVAL_REQUIRED", runId }, - }; + return systemRunApprovalRequired(runId); }
src/gateway/server-methods/exec-approval.ts+17 −21 modified@@ -3,11 +3,8 @@ import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, type ExecApprovalDecision, } from "../../infra/exec-approvals.js"; -import { - buildSystemRunApprovalBindingV1, - normalizeSystemRunApprovalPlanV2, -} from "../../infra/system-run-approval-binding.js"; -import { formatExecCommand } from "../../infra/system-run-command.js"; +import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; +import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { ErrorCodes, @@ -72,21 +69,20 @@ export function createExecApprovalHandlers( const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null; const host = typeof p.host === "string" ? p.host.trim() : ""; const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; - const commandArgv = Array.isArray(p.commandArgv) - ? p.commandArgv.map((entry) => String(entry)) - : undefined; - const systemRunPlanV2 = - host === "node" ? normalizeSystemRunApprovalPlanV2(p.systemRunPlanV2) : null; - const effectiveCommandArgv = systemRunPlanV2?.argv ?? commandArgv; - const effectiveCwd = systemRunPlanV2?.cwd ?? p.cwd; - const effectiveAgentId = systemRunPlanV2?.agentId ?? p.agentId; - const effectiveSessionKey = systemRunPlanV2?.sessionKey ?? p.sessionKey; - const effectiveCommandText = (() => { - if (!systemRunPlanV2) { - return p.command; - } - return systemRunPlanV2.rawCommand ?? formatExecCommand(systemRunPlanV2.argv); - })(); + const approvalContext = resolveSystemRunApprovalRequestContext({ + host, + command: p.command, + commandArgv: p.commandArgv, + systemRunPlanV2: p.systemRunPlanV2, + cwd: p.cwd, + agentId: p.agentId, + sessionKey: p.sessionKey, + }); + const effectiveCommandArgv = approvalContext.commandArgv; + const effectiveCwd = approvalContext.cwd; + const effectiveAgentId = approvalContext.agentId; + const effectiveSessionKey = approvalContext.sessionKey; + const effectiveCommandText = approvalContext.commandText; if (host === "node" && !nodeId) { respond( false, @@ -129,7 +125,7 @@ export function createExecApprovalHandlers( commandArgv: effectiveCommandArgv, envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined, systemRunBindingV1: systemRunBindingV1?.binding ?? null, - systemRunPlanV2: systemRunPlanV2, + systemRunPlanV2: approvalContext.planV2, cwd: effectiveCwd ?? null, nodeId: host === "node" ? nodeId : null, host: host || null,
src/infra/system-run-approval-context.ts+123 −0 added@@ -0,0 +1,123 @@ +import type { SystemRunApprovalPlanV2 } from "./exec-approvals.js"; +import { normalizeSystemRunApprovalPlanV2 } from "./system-run-approval-binding.js"; +import { formatExecCommand, resolveSystemRunCommand } from "./system-run-command.js"; + +type PreparedRunPayload = { + cmdText: string; + plan: SystemRunApprovalPlanV2; +}; + +type SystemRunApprovalRequestContext = { + planV2: SystemRunApprovalPlanV2 | null; + commandArgv: string[] | undefined; + commandText: string; + cwd: string | null; + agentId: string | null; + sessionKey: string | null; +}; + +type SystemRunApprovalRuntimeContext = + | { + ok: true; + planV2: SystemRunApprovalPlanV2 | null; + argv: string[]; + cwd: string | null; + agentId: string | null; + sessionKey: string | null; + rawCommand: string | null; + } + | { + ok: false; + message: string; + details?: Record<string, unknown>; + }; + +function normalizeString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizeStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((entry) => String(entry)) : []; +} + +function normalizeCommandText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function parsePreparedSystemRunPayload(payload: unknown): PreparedRunPayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + const raw = payload as { cmdText?: unknown; plan?: unknown }; + const cmdText = normalizeString(raw.cmdText); + const plan = normalizeSystemRunApprovalPlanV2(raw.plan); + if (!cmdText || !plan) { + return null; + } + return { cmdText, plan }; +} + +export function resolveSystemRunApprovalRequestContext(params: { + host?: unknown; + command?: unknown; + commandArgv?: unknown; + systemRunPlanV2?: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; +}): SystemRunApprovalRequestContext { + const host = normalizeString(params.host) ?? ""; + const planV2 = host === "node" ? normalizeSystemRunApprovalPlanV2(params.systemRunPlanV2) : null; + const fallbackArgv = normalizeStringArray(params.commandArgv); + const fallbackCommand = normalizeCommandText(params.command); + return { + planV2, + commandArgv: planV2?.argv ?? (fallbackArgv.length > 0 ? fallbackArgv : undefined), + commandText: planV2 ? (planV2.rawCommand ?? formatExecCommand(planV2.argv)) : fallbackCommand, + cwd: planV2?.cwd ?? normalizeString(params.cwd), + agentId: planV2?.agentId ?? normalizeString(params.agentId), + sessionKey: planV2?.sessionKey ?? normalizeString(params.sessionKey), + }; +} + +export function resolveSystemRunApprovalRuntimeContext(params: { + planV2?: unknown; + command?: unknown; + rawCommand?: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; +}): SystemRunApprovalRuntimeContext { + const normalizedPlan = normalizeSystemRunApprovalPlanV2(params.planV2 ?? null); + if (normalizedPlan) { + return { + ok: true, + planV2: normalizedPlan, + argv: [...normalizedPlan.argv], + cwd: normalizedPlan.cwd, + agentId: normalizedPlan.agentId, + sessionKey: normalizedPlan.sessionKey, + rawCommand: normalizedPlan.rawCommand, + }; + } + const command = resolveSystemRunCommand({ + command: params.command, + rawCommand: params.rawCommand, + }); + if (!command.ok) { + return { ok: false, message: command.message, details: command.details }; + } + return { + ok: true, + planV2: null, + argv: command.argv, + cwd: normalizeString(params.cwd), + agentId: normalizeString(params.agentId), + sessionKey: normalizeString(params.sessionKey), + rawCommand: normalizeString(params.rawCommand), + }; +}
d06632ba45a8refactor(gateway): share node command catalog
3 files changed · +30 −12
src/gateway/node-command-policy.ts+9 −6 modified@@ -1,4 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; +import { + NODE_BROWSER_PROXY_COMMAND, + NODE_SYSTEM_NOTIFY_COMMAND, + NODE_SYSTEM_RUN_COMMANDS, +} from "../infra/node-commands.js"; import type { NodeSession } from "./node-registry.js"; const CANVAS_COMMANDS = [ @@ -38,14 +43,12 @@ const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; 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 IOS_SYSTEM_COMMANDS = [NODE_SYSTEM_NOTIFY_COMMAND]; const SYSTEM_COMMANDS = [ - "system.run.prepare", - "system.run", - "system.which", - "system.notify", - "browser.proxy", + ...NODE_SYSTEM_RUN_COMMANDS, + NODE_SYSTEM_NOTIFY_COMMAND, + NODE_BROWSER_PROXY_COMMAND, ]; // "High risk" node commands. These can be enabled by explicitly adding them to
src/infra/node-commands.ts+13 −0 added@@ -0,0 +1,13 @@ +export const NODE_SYSTEM_RUN_COMMANDS = [ + "system.run.prepare", + "system.run", + "system.which", +] as const; + +export const NODE_SYSTEM_NOTIFY_COMMAND = "system.notify"; +export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy"; + +export const NODE_EXEC_APPROVALS_COMMANDS = [ + "system.execApprovals.get", + "system.execApprovals.set", +] as const;
src/node-host/runner.ts+8 −6 modified@@ -6,6 +6,11 @@ import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; +import { + NODE_BROWSER_PROXY_COMMAND, + NODE_EXEC_APPROVALS_COMMANDS, + NODE_SYSTEM_RUN_COMMANDS, +} from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; @@ -189,12 +194,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> { scopes: [], caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])], commands: [ - "system.run.prepare", - "system.run", - "system.which", - "system.execApprovals.get", - "system.execApprovals.set", - ...(browserProxyEnabled ? ["browser.proxy"] : []), + ...NODE_SYSTEM_RUN_COMMANDS, + ...NODE_EXEC_APPROVALS_COMMANDS, + ...(browserProxyEnabled ? [NODE_BROWSER_PROXY_COMMAND] : []), ], pathEnv, permissions: undefined,
d82c042b0972refactor(node-host): split system.run plan and allowlist internals
3 files changed · +342 −325
src/node-host/invoke-system-run-allowlist.ts+141 −0 added@@ -0,0 +1,141 @@ +import { + analyzeArgvCommand, + evaluateExecAllowlist, + evaluateShellAllowlist, + resolveExecApprovals, + type ExecAllowlistEntry, + type ExecCommandSegment, + type ExecSecurity, + type SkillBinTrustEntry, +} from "../infra/exec-approvals.js"; +import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; +import type { RunResult } from "./invoke-types.js"; + +export type SystemRunAllowlistAnalysis = { + analysisOk: boolean; + allowlistMatches: ExecAllowlistEntry[]; + allowlistSatisfied: boolean; + segments: ExecCommandSegment[]; +}; + +export function evaluateSystemRunAllowlist(params: { + shellCommand: string | null; + argv: string[]; + approvals: ReturnType<typeof resolveExecApprovals>; + security: ExecSecurity; + safeBins: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBins"]; + safeBinProfiles: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBinProfiles"]; + trustedSafeBinDirs: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["trustedSafeBinDirs"]; + cwd: string | undefined; + env: Record<string, string> | undefined; + skillBins: SkillBinTrustEntry[]; + autoAllowSkills: boolean; +}): SystemRunAllowlistAnalysis { + if (params.shellCommand) { + const allowlistEval = evaluateShellAllowlist({ + command: params.shellCommand, + allowlist: params.approvals.allowlist, + safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, + cwd: params.cwd, + env: params.env, + trustedSafeBinDirs: params.trustedSafeBinDirs, + skillBins: params.skillBins, + autoAllowSkills: params.autoAllowSkills, + platform: process.platform, + }); + return { + analysisOk: allowlistEval.analysisOk, + allowlistMatches: allowlistEval.allowlistMatches, + allowlistSatisfied: + params.security === "allowlist" && allowlistEval.analysisOk + ? allowlistEval.allowlistSatisfied + : false, + segments: allowlistEval.segments, + }; + } + + const analysis = analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env }); + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: params.approvals.allowlist, + safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, + cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, + skillBins: params.skillBins, + autoAllowSkills: params.autoAllowSkills, + }); + return { + analysisOk: analysis.ok, + allowlistMatches: allowlistEval.allowlistMatches, + allowlistSatisfied: + params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false, + segments: analysis.segments, + }; +} + +export function resolvePlannedAllowlistArgv(params: { + security: ExecSecurity; + shellCommand: string | null; + policy: { + approvedByAsk: boolean; + analysisOk: boolean; + allowlistSatisfied: boolean; + }; + segments: ExecCommandSegment[]; +}): string[] | undefined | null { + if ( + params.security !== "allowlist" || + params.policy.approvedByAsk || + params.shellCommand || + !params.policy.analysisOk || + !params.policy.allowlistSatisfied || + params.segments.length !== 1 + ) { + return undefined; + } + const plannedAllowlistArgv = params.segments[0]?.resolution?.effectiveArgv; + return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null; +} + +export function resolveSystemRunExecArgv(params: { + plannedAllowlistArgv: string[] | undefined; + argv: string[]; + security: ExecSecurity; + isWindows: boolean; + policy: { + approvedByAsk: boolean; + analysisOk: boolean; + allowlistSatisfied: boolean; + }; + shellCommand: string | null; + segments: ExecCommandSegment[]; +}): string[] { + let execArgv = params.plannedAllowlistArgv ?? params.argv; + if ( + params.security === "allowlist" && + params.isWindows && + !params.policy.approvedByAsk && + params.shellCommand && + params.policy.analysisOk && + params.policy.allowlistSatisfied && + params.segments.length === 1 && + params.segments[0]?.argv.length > 0 + ) { + execArgv = params.segments[0].argv; + } + return execArgv; +} + +export function applyOutputTruncation(result: RunResult): void { + if (!result.truncated) { + return; + } + const suffix = "... (truncated)"; + if (result.stderr.trim().length > 0) { + result.stderr = `${result.stderr}\n${suffix}`; + } else { + result.stdout = `${result.stdout}\n${suffix}`; + } +}
src/node-host/invoke-system-run-plan.ts+193 −0 added@@ -0,0 +1,193 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { SystemRunApprovalPlanV2 } from "../infra/exec-approvals.js"; +import { sameFileIdentity } from "../infra/file-identity.js"; +import { resolveSystemRunCommand } from "../infra/system-run-command.js"; + +function normalizeString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function isPathLikeExecutableToken(value: string): boolean { + if (!value) { + return false; + } + if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) { + return true; + } + if (value.includes("/") || value.includes("\\")) { + return true; + } + if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) { + return true; + } + return false; +} + +function pathComponentsFromRootSync(targetPath: string): string[] { + const absolute = path.resolve(targetPath); + const parts: string[] = []; + let cursor = absolute; + while (true) { + parts.unshift(cursor); + const parent = path.dirname(cursor); + if (parent === cursor) { + return parts; + } + cursor = parent; + } +} + +function isWritableByCurrentProcessSync(candidate: string): boolean { + try { + fs.accessSync(candidate, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +function hasMutableSymlinkPathComponentSync(targetPath: string): boolean { + for (const component of pathComponentsFromRootSync(targetPath)) { + try { + if (!fs.lstatSync(component).isSymbolicLink()) { + continue; + } + const parentDir = path.dirname(component); + if (isWritableByCurrentProcessSync(parentDir)) { + return true; + } + } catch { + return true; + } + } + return false; +} + +export function hardenApprovedExecutionPaths(params: { + approvedByAsk: boolean; + argv: string[]; + shellCommand: string | null; + cwd: string | undefined; +}): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } { + if (!params.approvedByAsk) { + return { ok: true, argv: params.argv, cwd: params.cwd }; + } + + let hardenedCwd = params.cwd; + if (hardenedCwd) { + const requestedCwd = path.resolve(hardenedCwd); + let cwdLstat: fs.Stats; + let cwdStat: fs.Stats; + let cwdReal: string; + let cwdRealStat: fs.Stats; + try { + cwdLstat = fs.lstatSync(requestedCwd); + cwdStat = fs.statSync(requestedCwd); + cwdReal = fs.realpathSync(requestedCwd); + cwdRealStat = fs.statSync(cwdReal); + } catch { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd", + }; + } + if (!cwdStat.isDirectory()) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory", + }; + } + if (hasMutableSymlinkPathComponentSync(requestedCwd)) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink path components)", + }; + } + if (cwdLstat.isSymbolicLink()) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink cwd)", + }; + } + if ( + !sameFileIdentity(cwdStat, cwdLstat) || + !sameFileIdentity(cwdStat, cwdRealStat) || + !sameFileIdentity(cwdLstat, cwdRealStat) + ) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval cwd identity mismatch", + }; + } + hardenedCwd = cwdReal; + } + + if (params.shellCommand !== null || params.argv.length === 0) { + return { ok: true, argv: params.argv, cwd: hardenedCwd }; + } + + const argv = [...params.argv]; + const rawExecutable = argv[0] ?? ""; + if (!isPathLikeExecutableToken(rawExecutable)) { + return { ok: true, argv, cwd: hardenedCwd }; + } + + const base = hardenedCwd ?? process.cwd(); + const candidate = path.isAbsolute(rawExecutable) + ? rawExecutable + : path.resolve(base, rawExecutable); + try { + argv[0] = fs.realpathSync(candidate); + } catch { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", + }; + } + return { ok: true, argv, cwd: hardenedCwd }; +} + +export function buildSystemRunApprovalPlanV2(params: { + command?: unknown; + rawCommand?: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; +}): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } { + const command = resolveSystemRunCommand({ + command: params.command, + rawCommand: params.rawCommand, + }); + if (!command.ok) { + return { ok: false, message: command.message }; + } + if (command.argv.length === 0) { + return { ok: false, message: "command required" }; + } + const hardening = hardenApprovedExecutionPaths({ + approvedByAsk: true, + argv: command.argv, + shellCommand: command.shellCommand, + cwd: normalizeString(params.cwd) ?? undefined, + }); + if (!hardening.ok) { + return { ok: false, message: hardening.message }; + } + return { + ok: true, + plan: { + version: 2, + argv: hardening.argv, + cwd: hardening.cwd ?? null, + rawCommand: command.cmdText.trim() || null, + agentId: normalizeString(params.agentId), + sessionKey: normalizeString(params.sessionKey), + }, + cmdText: command.cmdText, + }; +}
src/node-host/invoke-system-run.ts+8 −325 modified@@ -1,30 +1,29 @@ import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import type { GatewayClient } from "../gateway/client.js"; import { addAllowlistEntry, - analyzeArgvCommand, - evaluateExecAllowlist, - evaluateShellAllowlist, recordAllowlistUse, resolveAllowAlwaysPatterns, resolveExecApprovals, type ExecAllowlistEntry, type ExecAsk, type ExecCommandSegment, type ExecSecurity, - type SystemRunApprovalPlanV2, - type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; -import { sameFileIdentity } from "../infra/file-identity.js"; import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; +import { + applyOutputTruncation, + evaluateSystemRunAllowlist, + resolvePlannedAllowlistArgv, + resolveSystemRunExecArgv, +} from "./invoke-system-run-allowlist.js"; +import { hardenApprovedExecutionPaths } from "./invoke-system-run-plan.js"; import type { ExecEventPayload, RunResult, @@ -52,13 +51,6 @@ type SystemRunExecutionContext = { cmdText: string; }; -type SystemRunAllowlistAnalysis = { - analysisOk: boolean; - allowlistMatches: ExecAllowlistEntry[]; - allowlistSatisfied: boolean; - segments: ExecCommandSegment[]; -}; - type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>; type SystemRunParsePhase = { @@ -114,194 +106,6 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni } } -function normalizeString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -function isPathLikeExecutableToken(value: string): boolean { - if (!value) { - return false; - } - if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) { - return true; - } - if (value.includes("/") || value.includes("\\")) { - return true; - } - if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) { - return true; - } - return false; -} - -function pathComponentsFromRootSync(targetPath: string): string[] { - const absolute = path.resolve(targetPath); - const parts: string[] = []; - let cursor = absolute; - while (true) { - parts.unshift(cursor); - const parent = path.dirname(cursor); - if (parent === cursor) { - return parts; - } - cursor = parent; - } -} - -function isWritableByCurrentProcessSync(candidate: string): boolean { - try { - fs.accessSync(candidate, fs.constants.W_OK); - return true; - } catch { - return false; - } -} - -function hasMutableSymlinkPathComponentSync(targetPath: string): boolean { - for (const component of pathComponentsFromRootSync(targetPath)) { - try { - if (!fs.lstatSync(component).isSymbolicLink()) { - continue; - } - const parentDir = path.dirname(component); - if (isWritableByCurrentProcessSync(parentDir)) { - return true; - } - } catch { - return true; - } - } - return false; -} - -function hardenApprovedExecutionPaths(params: { - approvedByAsk: boolean; - argv: string[]; - shellCommand: string | null; - cwd: string | undefined; -}): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } { - if (!params.approvedByAsk) { - return { ok: true, argv: params.argv, cwd: params.cwd }; - } - - let hardenedCwd = params.cwd; - if (hardenedCwd) { - const requestedCwd = path.resolve(hardenedCwd); - let cwdLstat: fs.Stats; - let cwdStat: fs.Stats; - let cwdReal: string; - let cwdRealStat: fs.Stats; - try { - cwdLstat = fs.lstatSync(requestedCwd); - cwdStat = fs.statSync(requestedCwd); - cwdReal = fs.realpathSync(requestedCwd); - cwdRealStat = fs.statSync(cwdReal); - } catch { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd", - }; - } - if (!cwdStat.isDirectory()) { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory", - }; - } - if (hasMutableSymlinkPathComponentSync(requestedCwd)) { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink path components)", - }; - } - if (cwdLstat.isSymbolicLink()) { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink cwd)", - }; - } - if ( - !sameFileIdentity(cwdStat, cwdLstat) || - !sameFileIdentity(cwdStat, cwdRealStat) || - !sameFileIdentity(cwdLstat, cwdRealStat) - ) { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval cwd identity mismatch", - }; - } - hardenedCwd = cwdReal; - } - - if (params.shellCommand !== null || params.argv.length === 0) { - return { ok: true, argv: params.argv, cwd: hardenedCwd }; - } - - const argv = [...params.argv]; - const rawExecutable = argv[0] ?? ""; - if (!isPathLikeExecutableToken(rawExecutable)) { - return { ok: true, argv, cwd: hardenedCwd }; - } - - const base = hardenedCwd ?? process.cwd(); - const candidate = path.isAbsolute(rawExecutable) - ? rawExecutable - : path.resolve(base, rawExecutable); - try { - argv[0] = fs.realpathSync(candidate); - } catch { - return { - ok: false, - message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", - }; - } - return { ok: true, argv, cwd: hardenedCwd }; -} - -export function buildSystemRunApprovalPlanV2(params: { - command?: unknown; - rawCommand?: unknown; - cwd?: unknown; - agentId?: unknown; - sessionKey?: unknown; -}): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } { - const command = resolveSystemRunCommand({ - command: params.command, - rawCommand: params.rawCommand, - }); - if (!command.ok) { - return { ok: false, message: command.message }; - } - if (command.argv.length === 0) { - return { ok: false, message: "command required" }; - } - const hardening = hardenApprovedExecutionPaths({ - approvedByAsk: true, - argv: command.argv, - shellCommand: command.shellCommand, - cwd: normalizeString(params.cwd) ?? undefined, - }); - if (!hardening.ok) { - return { ok: false, message: hardening.message }; - } - return { - ok: true, - plan: { - version: 2, - argv: hardening.argv, - cwd: hardening.cwd ?? null, - rawCommand: command.cmdText.trim() || null, - agentId: normalizeString(params.agentId), - sessionKey: normalizeString(params.sessionKey), - }, - cmdText: command.cmdText, - }; -} - export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams; @@ -369,129 +173,8 @@ async function sendSystemRunDenied( }); } -function evaluateSystemRunAllowlist(params: { - shellCommand: string | null; - argv: string[]; - approvals: ReturnType<typeof resolveExecApprovals>; - security: ExecSecurity; - safeBins: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBins"]; - safeBinProfiles: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["safeBinProfiles"]; - trustedSafeBinDirs: ReturnType<typeof resolveExecSafeBinRuntimePolicy>["trustedSafeBinDirs"]; - cwd: string | undefined; - env: Record<string, string> | undefined; - skillBins: SkillBinTrustEntry[]; - autoAllowSkills: boolean; -}): SystemRunAllowlistAnalysis { - if (params.shellCommand) { - const allowlistEval = evaluateShellAllowlist({ - command: params.shellCommand, - allowlist: params.approvals.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - env: params.env, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - platform: process.platform, - }); - return { - analysisOk: allowlistEval.analysisOk, - allowlistMatches: allowlistEval.allowlistMatches, - allowlistSatisfied: - params.security === "allowlist" && allowlistEval.analysisOk - ? allowlistEval.allowlistSatisfied - : false, - segments: allowlistEval.segments, - }; - } - - const analysis = analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env }); - const allowlistEval = evaluateExecAllowlist({ - analysis, - allowlist: params.approvals.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - return { - analysisOk: analysis.ok, - allowlistMatches: allowlistEval.allowlistMatches, - allowlistSatisfied: - params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false, - segments: analysis.segments, - }; -} - -function resolvePlannedAllowlistArgv(params: { - security: ExecSecurity; - shellCommand: string | null; - policy: { - approvedByAsk: boolean; - analysisOk: boolean; - allowlistSatisfied: boolean; - }; - segments: ExecCommandSegment[]; -}): string[] | undefined | null { - if ( - params.security !== "allowlist" || - params.policy.approvedByAsk || - params.shellCommand || - !params.policy.analysisOk || - !params.policy.allowlistSatisfied || - params.segments.length !== 1 - ) { - return undefined; - } - const plannedAllowlistArgv = params.segments[0]?.resolution?.effectiveArgv; - return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null; -} - -function resolveSystemRunExecArgv(params: { - plannedAllowlistArgv: string[] | undefined; - argv: string[]; - security: ExecSecurity; - isWindows: boolean; - policy: { - approvedByAsk: boolean; - analysisOk: boolean; - allowlistSatisfied: boolean; - }; - shellCommand: string | null; - segments: ExecCommandSegment[]; -}): string[] { - let execArgv = params.plannedAllowlistArgv ?? params.argv; - if ( - params.security === "allowlist" && - params.isWindows && - !params.policy.approvedByAsk && - params.shellCommand && - params.policy.analysisOk && - params.policy.allowlistSatisfied && - params.segments.length === 1 && - params.segments[0]?.argv.length > 0 - ) { - execArgv = params.segments[0].argv; - } - return execArgv; -} - -function applyOutputTruncation(result: RunResult) { - if (!result.truncated) { - return; - } - const suffix = "... (truncated)"; - if (result.stderr.trim().length > 0) { - result.stderr = `${result.stderr}\n${suffix}`; - } else { - result.stdout = `${result.stdout}\n${suffix}`; - } -} - export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; +export { buildSystemRunApprovalPlanV2 } from "./invoke-system-run-plan.js"; async function parseSystemRunPhase( opts: HandleSystemRunInvokeOptions,
78a7ff2d50fbfix(security): harden node exec approvals against symlink rebind
15 files changed · +489 −43
CHANGELOG.md+1 −0 modified@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Node exec approvals hardening: freeze immutable approval-time execution plans (`argv`/`cwd`/`agentId`/`sessionKey`) via `system.run.prepare`, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.
src/cli/nodes-cli.coverage.test.ts+56 −2 modified@@ -28,6 +28,35 @@ const callGateway = vi.fn(async (opts: NodeInvokeCall) => { }; } if (opts.method === "node.invoke") { + const command = opts.params?.command; + if (command === "system.run.prepare") { + const params = (opts.params?.params ?? {}) as { + command?: unknown[]; + rawCommand?: unknown; + cwd?: unknown; + agentId?: unknown; + }; + const argv = Array.isArray(params.command) + ? params.command.map((entry) => String(entry)) + : []; + const rawCommand = + typeof params.rawCommand === "string" && params.rawCommand.trim().length > 0 + ? params.rawCommand + : null; + return { + payload: { + cmdText: rawCommand ?? argv.join(" "), + plan: { + version: 2, + argv, + cwd: typeof params.cwd === "string" ? params.cwd : null, + rawCommand, + agentId: typeof params.agentId === "string" ? params.agentId : null, + sessionKey: null, + }, + }, + }; + } return { payload: { stdout: "", @@ -80,8 +109,16 @@ vi.mock("../config/config.js", () => ({ describe("nodes-cli coverage", () => { let registerNodesCli: (program: Command) => void; - const getNodeInvokeCall = () => - callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall; + const getNodeInvokeCall = () => { + const nodeInvokeCalls = callGateway.mock.calls + .map((call) => call[0]) + .filter((entry): entry is NodeInvokeCall => entry?.method === "node.invoke"); + const last = nodeInvokeCalls.at(-1); + if (!last) { + throw new Error("expected node.invoke call"); + } + return last; + }; const getApprovalRequestCall = () => callGateway.mock.calls.find((call) => call[0]?.method === "exec.approval.request")?.[0] as { @@ -135,6 +172,7 @@ describe("nodes-cli coverage", () => { expect(invoke?.params?.command).toBe("system.run"); expect(invoke?.params?.params).toEqual({ command: ["echo", "hi"], + rawCommand: null, cwd: "/tmp", env: { FOO: "bar" }, timeoutMs: 1200, @@ -147,6 +185,14 @@ describe("nodes-cli coverage", () => { expect(invoke?.params?.timeoutMs).toBe(5000); const approval = getApprovalRequestCall(); expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]); + expect(approval?.params?.["systemRunPlanV2"]).toEqual({ + version: 2, + argv: ["echo", "hi"], + cwd: "/tmp", + rawCommand: null, + agentId: "main", + sessionKey: null, + }); }); it("invokes system.run with raw command", async () => { @@ -174,6 +220,14 @@ describe("nodes-cli coverage", () => { }); const approval = getApprovalRequestCall(); expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]); + expect(approval?.params?.["systemRunPlanV2"]).toEqual({ + version: 2, + argv: ["/bin/sh", "-lc", "echo hi"], + cwd: null, + rawCommand: "echo hi", + agentId: "main", + sessionKey: null, + }); }); it("invokes system.notify with provided fields", async () => {
src/cli/nodes-cli/register.invoke.ts+46 −11 modified@@ -7,12 +7,14 @@ import { type ExecApprovalsFile, type ExecAsk, type ExecSecurity, + type SystemRunApprovalPlanV2, maxAsk, minSecurity, resolveExecApprovalsFromFile, } from "../../infra/exec-approvals.js"; import { buildNodeShellCommand } from "../../infra/node-shell.js"; import { applyPathPrepend } from "../../infra/path-prepend.js"; +import { normalizeSystemRunApprovalPlanV2 } from "../../infra/system-run-approval-binding.js"; import { defaultRuntime } from "../../runtime.js"; import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -42,6 +44,22 @@ type ExecDefaults = { safeBins?: string[]; }; +function parsePreparedRunPlan(payload: unknown): { + cmdText: string; + plan: SystemRunApprovalPlanV2; +} { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("invalid system.run.prepare response"); + } + const raw = payload as { cmdText?: unknown; plan?: unknown }; + const cmdText = typeof raw.cmdText === "string" ? raw.cmdText.trim() : ""; + const plan = normalizeSystemRunApprovalPlanV2(raw.plan); + if (!cmdText || !plan) { + throw new Error("invalid system.run.prepare response"); + } + return { cmdText, plan }; +} + function normalizeExecSecurity(value?: string | null): ExecSecurity | null { const normalized = value?.trim().toLowerCase(); if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { @@ -192,6 +210,20 @@ export function registerNodesInvokeCommands(nodes: Command) { applyPathPrepend(nodeEnv, execDefaults?.pathPrepend, { requireExisting: true }); } + const prepareResponse = (await callGatewayCli("node.invoke", opts, { + nodeId, + command: "system.run.prepare", + params: { + command: argv, + rawCommand, + cwd: opts.cwd, + agentId, + }, + idempotencyKey: `prepare-${randomIdempotencyKey()}`, + })) as { payload?: unknown } | null; + const prepared = parsePreparedRunPlan(prepareResponse?.payload); + const approvalPlan = prepared.plan; + let approvedByAsk = false; let approvalDecision: "allow-once" | "allow-always" | null = null; const configuredSecurity = normalizeExecSecurity(execDefaults?.security) ?? "allowlist"; @@ -251,16 +283,17 @@ export function registerNodesInvokeCommands(nodes: Command) { opts, { id: approvalId, - command: rawCommand ?? argv.join(" "), - commandArgv: argv, - cwd: opts.cwd, + command: prepared.cmdText, + commandArgv: approvalPlan.argv, + systemRunPlanV2: approvalPlan, + cwd: approvalPlan.cwd, nodeId, host: "node", security: hostSecurity, ask: hostAsk, - agentId, + agentId: approvalPlan.agentId ?? agentId, resolvedPath: undefined, - sessionKey: undefined, + sessionKey: approvalPlan.sessionKey ?? undefined, timeoutMs: approvalTimeoutMs, }, { transportTimeoutMs }, @@ -296,19 +329,21 @@ export function registerNodesInvokeCommands(nodes: Command) { nodeId, command: "system.run", params: { - command: argv, - cwd: opts.cwd, + command: approvalPlan.argv, + rawCommand: approvalPlan.rawCommand, + cwd: approvalPlan.cwd, env: nodeEnv, timeoutMs, needsScreenRecording: opts.needsScreenRecording === true, }, idempotencyKey: String(opts.idempotencyKey ?? randomIdempotencyKey()), }; - if (agentId) { - (invokeParams.params as Record<string, unknown>).agentId = agentId; + if (approvalPlan.agentId ?? agentId) { + (invokeParams.params as Record<string, unknown>).agentId = + approvalPlan.agentId ?? agentId; } - if (rawCommand) { - (invokeParams.params as Record<string, unknown>).rawCommand = rawCommand; + if (approvalPlan.sessionKey) { + (invokeParams.params as Record<string, unknown>).sessionKey = approvalPlan.sessionKey; } (invokeParams.params as Record<string, unknown>).approved = approvedByAsk; if (approvalDecision) {
src/gateway/node-command-policy.ts+7 −1 modified@@ -40,7 +40,13 @@ 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", "browser.proxy"]; +const SYSTEM_COMMANDS = [ + "system.run.prepare", + "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).
src/gateway/node-invoke-system-run-approval.test.ts+44 −0 modified@@ -229,6 +229,50 @@ describe("sanitizeSystemRunParamsForForwarding", () => { expectAllowOnceForwardingResult(result); }); + test("uses systemRunPlanV2 for forwarded command context and ignores caller tampering", () => { + const record = makeRecord("echo SAFE", ["echo", "SAFE"]); + record.request.systemRunPlanV2 = { + version: 2, + argv: ["/usr/bin/echo", "SAFE"], + cwd: "/real/cwd", + rawCommand: "/usr/bin/echo SAFE", + agentId: "main", + sessionKey: "agent:main:main", + }; + record.request.systemRunBindingV1 = buildSystemRunApprovalBindingV1({ + argv: ["/usr/bin/echo", "SAFE"], + cwd: "/real/cwd", + agentId: "main", + sessionKey: "agent:main:main", + }).binding; + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "PWNED"], + rawCommand: "echo PWNED", + cwd: "/tmp/attacker-link/sub", + agentId: "attacker", + sessionKey: "agent:attacker:main", + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(record), + nowMs: now, + }); + expectAllowOnceForwardingResult(result); + if (!result.ok) { + throw new Error("unreachable"); + } + const forwarded = result.params as Record<string, unknown>; + expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]); + expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE"); + expect(forwarded.cwd).toBe("/real/cwd"); + expect(forwarded.agentId).toBe("main"); + expect(forwarded.sessionKey).toBe("agent:main:main"); + }); + test("rejects env overrides when approval record lacks env binding", () => { const result = sanitizeSystemRunParamsForForwarding({ rawParams: {
src/gateway/node-invoke-system-run-approval.ts+65 −16 modified@@ -1,3 +1,4 @@ +import { normalizeSystemRunApprovalPlanV2 } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import type { ExecApprovalRecord } from "./exec-approval-manager.js"; import { @@ -99,18 +100,6 @@ export function sanitizeSystemRunParamsForForwarding(opts: { } const p = obj as SystemRunParamsLike; - const cmdTextResolution = resolveSystemRunCommand({ - command: p.command, - rawCommand: p.rawCommand, - }); - if (!cmdTextResolution.ok) { - return { - ok: false, - message: cmdTextResolution.message, - details: cmdTextResolution.details, - }; - } - const approved = p.approved === true; const requestedDecision = normalizeApprovalDecision(p.approvalDecision); const wantsApprovalOverride = approved || requestedDecision !== null; @@ -120,6 +109,17 @@ export function sanitizeSystemRunParamsForForwarding(opts: { const next: Record<string, unknown> = pickSystemRunParams(obj); if (!wantsApprovalOverride) { + const cmdTextResolution = resolveSystemRunCommand({ + command: p.command, + rawCommand: p.rawCommand, + }); + if (!cmdTextResolution.ok) { + return { + ok: false, + message: cmdTextResolution.message, + details: cmdTextResolution.details, + }; + } return { ok: true, params: next }; } @@ -206,13 +206,62 @@ export function sanitizeSystemRunParamsForForwarding(opts: { }; } + const planV2 = normalizeSystemRunApprovalPlanV2(snapshot.request.systemRunPlanV2 ?? null); + let approvalArgv: string[]; + let approvalCwd: string | null; + let approvalAgentId: string | null; + let approvalSessionKey: string | null; + if (planV2) { + approvalArgv = [...planV2.argv]; + approvalCwd = planV2.cwd; + approvalAgentId = planV2.agentId; + approvalSessionKey = planV2.sessionKey; + next.command = [...planV2.argv]; + if (planV2.rawCommand) { + next.rawCommand = planV2.rawCommand; + } else { + delete next.rawCommand; + } + if (planV2.cwd) { + next.cwd = planV2.cwd; + } else { + delete next.cwd; + } + if (planV2.agentId) { + next.agentId = planV2.agentId; + } else { + delete next.agentId; + } + if (planV2.sessionKey) { + next.sessionKey = planV2.sessionKey; + } else { + delete next.sessionKey; + } + } else { + const cmdTextResolution = resolveSystemRunCommand({ + command: p.command, + rawCommand: p.rawCommand, + }); + if (!cmdTextResolution.ok) { + return { + ok: false, + message: cmdTextResolution.message, + details: cmdTextResolution.details, + }; + } + approvalArgv = cmdTextResolution.argv; + approvalCwd = normalizeString(p.cwd) ?? null; + approvalAgentId = normalizeString(p.agentId) ?? null; + approvalSessionKey = normalizeString(p.sessionKey) ?? null; + } + const approvalMatch = evaluateSystemRunApprovalMatch({ - argv: cmdTextResolution.argv, + argv: approvalArgv, request: snapshot.request, binding: { - cwd: normalizeString(p.cwd) ?? null, - agentId: normalizeString(p.agentId) ?? null, - sessionKey: normalizeString(p.sessionKey) ?? null, + cwd: approvalCwd, + agentId: approvalAgentId, + sessionKey: approvalSessionKey, env: p.env, }, });
src/gateway/protocol/schema/exec-approvals.ts+13 −0 modified@@ -90,6 +90,19 @@ export const ExecApprovalRequestParamsSchema = Type.Object( id: Type.Optional(NonEmptyString), command: NonEmptyString, commandArgv: Type.Optional(Type.Array(Type.String())), + systemRunPlanV2: Type.Optional( + Type.Object( + { + version: Type.Literal(2), + argv: Type.Array(Type.String()), + cwd: Type.Union([Type.String(), Type.Null()]), + rawCommand: Type.Union([Type.String(), Type.Null()]), + agentId: Type.Union([Type.String(), Type.Null()]), + sessionKey: Type.Union([Type.String(), Type.Null()]), + }, + { additionalProperties: false }, + ), + ), env: Type.Optional(Type.Record(NonEmptyString, Type.String())), cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
src/gateway/server-methods/exec-approval.ts+32 −11 modified@@ -3,7 +3,11 @@ import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, type ExecApprovalDecision, } from "../../infra/exec-approvals.js"; -import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js"; +import { + buildSystemRunApprovalBindingV1, + normalizeSystemRunApprovalPlanV2, +} from "../../infra/system-run-approval-binding.js"; +import { formatExecCommand } from "../../infra/system-run-command.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { ErrorCodes, @@ -47,6 +51,7 @@ export function createExecApprovalHandlers( commandArgv?: string[]; env?: Record<string, string>; cwd?: string; + systemRunPlanV2?: unknown; nodeId?: string; host?: string; security?: string; @@ -70,6 +75,18 @@ export function createExecApprovalHandlers( const commandArgv = Array.isArray(p.commandArgv) ? p.commandArgv.map((entry) => String(entry)) : undefined; + const systemRunPlanV2 = + host === "node" ? normalizeSystemRunApprovalPlanV2(p.systemRunPlanV2) : null; + const effectiveCommandArgv = systemRunPlanV2?.argv ?? commandArgv; + const effectiveCwd = systemRunPlanV2?.cwd ?? p.cwd; + const effectiveAgentId = systemRunPlanV2?.agentId ?? p.agentId; + const effectiveSessionKey = systemRunPlanV2?.sessionKey ?? p.sessionKey; + const effectiveCommandText = (() => { + if (!systemRunPlanV2) { + return p.command; + } + return systemRunPlanV2.rawCommand ?? formatExecCommand(systemRunPlanV2.argv); + })(); if (host === "node" && !nodeId) { respond( false, @@ -78,7 +95,10 @@ export function createExecApprovalHandlers( ); return; } - if (host === "node" && (!Array.isArray(commandArgv) || commandArgv.length === 0)) { + if ( + host === "node" && + (!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0) + ) { respond( false, undefined, @@ -89,10 +109,10 @@ export function createExecApprovalHandlers( const systemRunBindingV1 = host === "node" ? buildSystemRunApprovalBindingV1({ - argv: commandArgv, - cwd: p.cwd, - agentId: p.agentId, - sessionKey: p.sessionKey, + argv: effectiveCommandArgv, + cwd: effectiveCwd, + agentId: effectiveAgentId, + sessionKey: effectiveSessionKey, env: p.env, }) : null; @@ -105,18 +125,19 @@ export function createExecApprovalHandlers( return; } const request = { - command: p.command, - commandArgv, + command: effectiveCommandText, + commandArgv: effectiveCommandArgv, envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined, systemRunBindingV1: systemRunBindingV1?.binding ?? null, - cwd: p.cwd ?? null, + systemRunPlanV2: systemRunPlanV2, + cwd: effectiveCwd ?? null, nodeId: host === "node" ? nodeId : null, host: host || null, security: p.security ?? null, ask: p.ask ?? null, - agentId: p.agentId ?? null, + agentId: effectiveAgentId ?? null, resolvedPath: p.resolvedPath ?? null, - sessionKey: p.sessionKey ?? null, + sessionKey: effectiveSessionKey ?? null, turnSourceChannel: typeof p.turnSourceChannel === "string" ? p.turnSourceChannel.trim() || null : null, turnSourceTo: typeof p.turnSourceTo === "string" ? p.turnSourceTo.trim() || null : null,
src/gateway/server-methods/server-methods.test.ts+38 −0 modified@@ -471,6 +471,44 @@ describe("exec approval handlers", () => { ); }); + it("prefers systemRunPlanV2 canonical command/cwd when present", async () => { + const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + command: "echo stale", + commandArgv: ["echo", "stale"], + cwd: "/tmp/link/sub", + systemRunPlanV2: { + version: 2, + argv: ["/usr/bin/echo", "ok"], + cwd: "/real/cwd", + rawCommand: "/usr/bin/echo ok", + agentId: "main", + sessionKey: "agent:main:main", + }, + }, + }); + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {}; + expect(request["command"]).toBe("/usr/bin/echo ok"); + expect(request["commandArgv"]).toEqual(["/usr/bin/echo", "ok"]); + expect(request["cwd"]).toBe("/real/cwd"); + expect(request["agentId"]).toBe("main"); + expect(request["sessionKey"]).toBe("agent:main:main"); + expect(request["systemRunPlanV2"]).toEqual({ + version: 2, + argv: ["/usr/bin/echo", "ok"], + cwd: "/real/cwd", + rawCommand: "/usr/bin/echo ok", + agentId: "main", + sessionKey: "agent:main:main", + }); + }); + it("accepts resolve during broadcast", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager);
src/infra/exec-approvals.ts+10 −0 modified@@ -20,12 +20,22 @@ export type SystemRunApprovalBindingV1 = { envHash: string | null; }; +export type SystemRunApprovalPlanV2 = { + version: 2; + argv: string[]; + cwd: string | null; + rawCommand: string | null; + agentId: string | null; + sessionKey: string | null; +}; + export type ExecApprovalRequestPayload = { command: string; commandArgv?: string[]; // Optional UI-safe env key preview for approval prompts. envKeys?: string[]; systemRunBindingV1?: SystemRunApprovalBindingV1 | null; + systemRunPlanV2?: SystemRunApprovalPlanV2 | null; cwd?: string | null; nodeId?: string | null; host?: string | null;
src/infra/system-run-approval-binding.ts+23 −1 modified@@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import type { SystemRunApprovalBindingV1 } from "./exec-approvals.js"; +import type { SystemRunApprovalBindingV1, SystemRunApprovalPlanV2 } from "./exec-approvals.js"; import { normalizeEnvVarKey } from "./host-env-security.js"; type NormalizedSystemRunEnvEntry = [key: string, value: string]; @@ -16,6 +16,28 @@ function normalizeStringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((entry) => String(entry)) : []; } +export function normalizeSystemRunApprovalPlanV2(value: unknown): SystemRunApprovalPlanV2 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const candidate = value as Record<string, unknown>; + if (candidate.version !== 2) { + return null; + } + const argv = normalizeStringArray(candidate.argv); + if (argv.length === 0) { + return null; + } + return { + version: 2, + argv, + cwd: normalizeString(candidate.cwd), + rawCommand: normalizeString(candidate.rawCommand), + agentId: normalizeString(candidate.agentId), + sessionKey: normalizeString(candidate.sessionKey), + }; +} + function normalizeSystemRunEnvEntries(env: unknown): NormalizedSystemRunEnvEntry[] { if (!env || typeof env !== "object" || Array.isArray(env)) { return [];
src/node-host/invoke-system-run.test.ts+33 −0 modified@@ -252,6 +252,39 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }, ); + it.runIf(process.platform !== "win32")( + "denies approval-based execution when cwd contains a symlink parent component", + async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-parent-link-")); + const safeRoot = path.join(tmp, "safe-root"); + const safeSub = path.join(safeRoot, "sub"); + const linkRoot = path.join(tmp, "approved-link"); + fs.mkdirSync(safeSub, { recursive: true }); + fs.symlinkSync(safeRoot, linkRoot, "dir"); + try { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: ["./run.sh"], + cwd: path.join(linkRoot, "sub"), + approved: true, + security: "full", + ask: "off", + }); + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("no symlink path components"), + }), + }), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + it("uses canonical executable path for approval-based relative command execution", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-real-")); const script = path.join(tmp, "run.sh");
src/node-host/invoke-system-run.ts+95 −0 modified@@ -16,6 +16,7 @@ import { type ExecAsk, type ExecCommandSegment, type ExecSecurity, + type SystemRunApprovalPlanV2, type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; @@ -113,6 +114,14 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni } } +function normalizeString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + function isPathLikeExecutableToken(value: string): boolean { if (!value) { return false; @@ -129,6 +138,46 @@ function isPathLikeExecutableToken(value: string): boolean { return false; } +function pathComponentsFromRootSync(targetPath: string): string[] { + const absolute = path.resolve(targetPath); + const parts: string[] = []; + let cursor = absolute; + while (true) { + parts.unshift(cursor); + const parent = path.dirname(cursor); + if (parent === cursor) { + return parts; + } + cursor = parent; + } +} + +function isWritableByCurrentProcessSync(candidate: string): boolean { + try { + fs.accessSync(candidate, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +function hasMutableSymlinkPathComponentSync(targetPath: string): boolean { + for (const component of pathComponentsFromRootSync(targetPath)) { + try { + if (!fs.lstatSync(component).isSymbolicLink()) { + continue; + } + const parentDir = path.dirname(component); + if (isWritableByCurrentProcessSync(parentDir)) { + return true; + } + } catch { + return true; + } + } + return false; +} + function hardenApprovedExecutionPaths(params: { approvedByAsk: boolean; argv: string[]; @@ -163,6 +212,12 @@ function hardenApprovedExecutionPaths(params: { message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory", }; } + if (hasMutableSymlinkPathComponentSync(requestedCwd)) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink path components)", + }; + } if (cwdLstat.isSymbolicLink()) { return { ok: false, @@ -207,6 +262,46 @@ function hardenApprovedExecutionPaths(params: { return { ok: true, argv, cwd: hardenedCwd }; } +export function buildSystemRunApprovalPlanV2(params: { + command?: unknown; + rawCommand?: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; +}): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } { + const command = resolveSystemRunCommand({ + command: params.command, + rawCommand: params.rawCommand, + }); + if (!command.ok) { + return { ok: false, message: command.message }; + } + if (command.argv.length === 0) { + return { ok: false, message: "command required" }; + } + const hardening = hardenApprovedExecutionPaths({ + approvedByAsk: true, + argv: command.argv, + shellCommand: command.shellCommand, + cwd: normalizeString(params.cwd) ?? undefined, + }); + if (!hardening.ok) { + return { ok: false, message: hardening.message }; + } + return { + ok: true, + plan: { + version: 2, + argv: hardening.argv, + cwd: hardening.cwd ?? null, + rawCommand: command.cmdText.trim() || null, + agentId: normalizeString(params.agentId), + sessionKey: normalizeString(params.sessionKey), + }, + cmdText: command.cmdText, + }; +} + export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams;
src/node-host/invoke.ts+25 −1 modified@@ -20,7 +20,7 @@ import { } from "../infra/exec-host.js"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { runBrowserProxyCommand } from "./invoke-browser.js"; -import { handleSystemRunInvoke } from "./invoke-system-run.js"; +import { buildSystemRunApprovalPlanV2, handleSystemRunInvoke } from "./invoke-system-run.js"; import type { ExecEventPayload, RunResult, @@ -420,6 +420,30 @@ export async function handleInvoke( return; } + if (command === "system.run.prepare") { + try { + const params = decodeParams<{ + command?: unknown; + rawCommand?: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; + }>(frame.paramsJSON); + const prepared = buildSystemRunApprovalPlanV2(params); + if (!prepared.ok) { + await sendErrorResult(client, frame, "INVALID_REQUEST", prepared.message); + return; + } + await sendJsonPayloadResult(client, frame, { + cmdText: prepared.cmdText, + plan: prepared.plan, + }); + } catch (err) { + await sendInvalidRequestResult(client, frame, err); + } + return; + } + if (command !== "system.run") { await sendErrorResult(client, frame, "UNAVAILABLE", "command not supported"); return;
src/node-host/runner.ts+1 −0 modified@@ -189,6 +189,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> { scopes: [], caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])], commands: [ + "system.run.prepare", "system.run", "system.which", "system.execApprovals.get",
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/openclaw/openclaw/commit/4b4718c8dfce2e2c48404aa5088af7c013bed60bghsapatchWEB
- github.com/openclaw/openclaw/commit/4e690e09c746408b5e27617a20cb3fdc5190dbdaghsapatchWEB
- github.com/openclaw/openclaw/commit/78a7ff2d50fb3bcef351571cb5a0f21430a340c1ghsapatchWEB
- github.com/openclaw/openclaw/commit/d06632ba45a8482192792c55d5ff0b2e21abb0a7ghsapatchWEB
- github.com/openclaw/openclaw/commit/d82c042b09727a6148f3ca651b254c4a677aff26ghsapatchWEB
- github.com/advisories/GHSA-f7ww-2725-qvw2ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-f7ww-2725-qvw2ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-27545ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-approval-bypass-via-parent-symlink-current-working-directory-rebindghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.