OpenClaw < 2026.2.19 - Arbitrary File Write via Short-Option Bypass in exec Allowlist
Description
OpenClaw versions prior to 2026.2.19 contain an allowlist bypass vulnerability in the exec safeBins policy that allows attackers to write arbitrary files using short-option payloads. Attackers can bypass argument validation by attaching short options like -o to whitelisted binaries, enabling unauthorized file-write operations that should be denied by safeBins checks.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.19 | 2026.2.19 |
Affected products
1Patches
3fec48a5006earefactor(exec): split host flows and harden safe-bin trust
10 files changed · +834 −616
src/agents/bash-tools.exec-host-gateway.ts+333 −0 added@@ -0,0 +1,333 @@ +import crypto from "node:crypto"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + addAllowlistEntry, + type ExecAsk, + type ExecSecurity, + buildSafeBinsShellCommand, + buildSafeShellCommand, + evaluateShellAllowlist, + maxAsk, + minSecurity, + recordAllowlistUse, + requiresExecApproval, + resolveExecApprovals, +} from "../infra/exec-approvals.js"; +import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, + DEFAULT_NOTIFY_TAIL_CHARS, + createApprovalSlug, + emitExecSystemEvent, + normalizeNotifyOutput, + runExecProcess, +} from "./bash-tools.exec-runtime.js"; +import type { ExecToolDetails } from "./bash-tools.exec-types.js"; +import { callGatewayTool } from "./tools/gateway.js"; + +export type ProcessGatewayAllowlistParams = { + command: string; + workdir: string; + env: Record<string, string>; + pty: boolean; + timeoutSec?: number; + defaultTimeoutSec: number; + security: ExecSecurity; + ask: ExecAsk; + safeBins: Set<string>; + agentId?: string; + sessionKey?: string; + scopeKey?: string; + warnings: string[]; + notifySessionKey?: string; + approvalRunningNoticeMs: number; + maxOutput: number; + pendingMaxOutput: number; + trustedSafeBinDirs?: ReadonlySet<string>; +}; + +export type ProcessGatewayAllowlistResult = { + execCommandOverride?: string; + pendingResult?: AgentToolResult<ExecToolDetails>; +}; + +export async function processGatewayAllowlist( + params: ProcessGatewayAllowlistParams, +): Promise<ProcessGatewayAllowlistResult> { + const approvals = resolveExecApprovals(params.agentId, { + security: params.security, + ask: params.ask, + }); + const hostSecurity = minSecurity(params.security, approvals.agent.security); + const hostAsk = maxAsk(params.ask, approvals.agent.ask); + const askFallback = approvals.agent.askFallback; + if (hostSecurity === "deny") { + throw new Error("exec denied: host=gateway security=deny"); + } + const allowlistEval = evaluateShellAllowlist({ + command: params.command, + allowlist: approvals.allowlist, + safeBins: params.safeBins, + cwd: params.workdir, + env: params.env, + platform: process.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + }); + const allowlistMatches = allowlistEval.allowlistMatches; + const analysisOk = allowlistEval.analysisOk; + const allowlistSatisfied = + hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; + const requiresAsk = requiresExecApproval({ + ask: hostAsk, + security: hostSecurity, + analysisOk, + allowlistSatisfied, + }); + + if (requiresAsk) { + const approvalId = crypto.randomUUID(); + const approvalSlug = createApprovalSlug(approvalId); + const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + const contextKey = `exec:${approvalId}`; + const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; + const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); + const effectiveTimeout = + typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; + const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + + void (async () => { + let decision: string | null = null; + try { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + const decisionValue = + decisionResult && typeof decisionResult === "object" + ? (decisionResult as { decision?: unknown }).decision + : undefined; + decision = typeof decisionValue === "string" ? decisionValue : null; + } catch { + emitExecSystemEvent( + `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, + { + sessionKey: params.notifySessionKey, + contextKey, + }, + ); + return; + } + + let approvedByAsk = false; + let deniedReason: string | null = null; + + if (decision === "deny") { + deniedReason = "user-denied"; + } else if (!decision) { + if (askFallback === "full") { + approvedByAsk = true; + } else if (askFallback === "allowlist") { + if (!analysisOk || !allowlistSatisfied) { + deniedReason = "approval-timeout (allowlist-miss)"; + } else { + approvedByAsk = true; + } + } else { + deniedReason = "approval-timeout"; + } + } else if (decision === "allow-once") { + approvedByAsk = true; + } else if (decision === "allow-always") { + approvedByAsk = true; + if (hostSecurity === "allowlist") { + for (const segment of allowlistEval.segments) { + const pattern = segment.resolution?.resolvedPath ?? ""; + if (pattern) { + addAllowlistEntry(approvals.file, params.agentId, pattern); + } + } + } + } + + if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) { + deniedReason = deniedReason ?? "allowlist-miss"; + } + + if (deniedReason) { + emitExecSystemEvent( + `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, + { + sessionKey: params.notifySessionKey, + contextKey, + }, + ); + return; + } + + if (allowlistMatches.length > 0) { + const seen = new Set<string>(); + for (const match of allowlistMatches) { + if (seen.has(match.pattern)) { + continue; + } + seen.add(match.pattern); + recordAllowlistUse( + approvals.file, + params.agentId, + match, + params.command, + resolvedPath ?? undefined, + ); + } + } + + let run: Awaited<ReturnType<typeof runExecProcess>> | null = null; + try { + run = await runExecProcess({ + command: params.command, + workdir: params.workdir, + env: params.env, + sandbox: undefined, + containerWorkdir: null, + usePty: params.pty, + warnings: params.warnings, + maxOutput: params.maxOutput, + pendingMaxOutput: params.pendingMaxOutput, + notifyOnExit: false, + notifyOnExitEmptySuccess: false, + scopeKey: params.scopeKey, + sessionKey: params.notifySessionKey, + timeoutSec: effectiveTimeout, + }); + } catch { + emitExecSystemEvent( + `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, + { + sessionKey: params.notifySessionKey, + contextKey, + }, + ); + return; + } + + markBackgrounded(run.session); + + let runningTimer: NodeJS.Timeout | null = null; + if (params.approvalRunningNoticeMs > 0) { + runningTimer = setTimeout(() => { + emitExecSystemEvent( + `Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`, + { sessionKey: params.notifySessionKey, contextKey }, + ); + }, params.approvalRunningNoticeMs); + } + + const outcome = await run.promise; + if (runningTimer) { + clearTimeout(runningTimer); + } + const output = normalizeNotifyOutput( + tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), + ); + const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; + const summary = output + ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` + : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; + emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey }); + })(); + + return { + pendingResult: { + content: [ + { + type: "text", + text: + `${warningText}Approval required (id ${approvalSlug}). ` + + "Approve to run; updates will arrive after completion.", + }, + ], + details: { + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "gateway", + command: params.command, + cwd: params.workdir, + }, + }, + }; + } + + if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) { + throw new Error("exec denied: allowlist miss"); + } + + let execCommandOverride: string | undefined; + // If allowlist uses safeBins, sanitize only those stdin-only segments: + // disable glob/var expansion by forcing argv tokens to be literal via single-quoting. + if ( + hostSecurity === "allowlist" && + analysisOk && + allowlistSatisfied && + allowlistEval.segmentSatisfiedBy.some((by) => by === "safeBins") + ) { + const safe = buildSafeBinsShellCommand({ + command: params.command, + segments: allowlistEval.segments, + segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy, + platform: process.platform, + }); + if (!safe.ok || !safe.command) { + // Fallback: quote everything (safe, but may change glob behavior). + const fallback = buildSafeShellCommand({ + command: params.command, + platform: process.platform, + }); + if (!fallback.ok || !fallback.command) { + throw new Error(`exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`); + } + params.warnings.push( + "Warning: safeBins hardening used fallback quoting due to parser mismatch.", + ); + execCommandOverride = fallback.command; + } else { + params.warnings.push( + "Warning: safeBins hardening disabled glob/variable expansion for stdin-only segments.", + ); + execCommandOverride = safe.command; + } + } + + if (allowlistMatches.length > 0) { + const seen = new Set<string>(); + for (const match of allowlistMatches) { + if (seen.has(match.pattern)) { + continue; + } + seen.add(match.pattern); + recordAllowlistUse( + approvals.file, + params.agentId, + match, + params.command, + allowlistEval.segments[0]?.resolution?.resolvedPath, + ); + } + } + + return { execCommandOverride }; +}
src/agents/bash-tools.exec-host-node.ts+327 −0 added@@ -0,0 +1,327 @@ +import crypto from "node:crypto"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + type ExecApprovalsFile, + type ExecAsk, + type ExecSecurity, + evaluateShellAllowlist, + maxAsk, + minSecurity, + requiresExecApproval, + resolveExecApprovals, + resolveExecApprovalsFromFile, +} from "../infra/exec-approvals.js"; +import { buildNodeShellCommand } from "../infra/node-shell.js"; +import { + DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, + DEFAULT_APPROVAL_TIMEOUT_MS, + createApprovalSlug, + emitExecSystemEvent, +} from "./bash-tools.exec-runtime.js"; +import type { ExecToolDetails } from "./bash-tools.exec-types.js"; +import { callGatewayTool } from "./tools/gateway.js"; +import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; + +export type ExecuteNodeHostCommandParams = { + command: string; + workdir: string; + env: Record<string, string>; + requestedEnv?: Record<string, string>; + requestedNode?: string; + boundNode?: string; + sessionKey?: string; + agentId?: string; + security: ExecSecurity; + ask: ExecAsk; + timeoutSec?: number; + defaultTimeoutSec: number; + approvalRunningNoticeMs: number; + warnings: string[]; + notifySessionKey?: string; + trustedSafeBinDirs?: ReadonlySet<string>; +}; + +export async function executeNodeHostCommand( + params: ExecuteNodeHostCommandParams, +): Promise<AgentToolResult<ExecToolDetails>> { + const approvals = resolveExecApprovals(params.agentId, { + security: params.security, + ask: params.ask, + }); + const hostSecurity = minSecurity(params.security, approvals.agent.security); + const hostAsk = maxAsk(params.ask, approvals.agent.ask); + const askFallback = approvals.agent.askFallback; + if (hostSecurity === "deny") { + throw new Error("exec denied: host=node security=deny"); + } + if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) { + throw new Error(`exec node not allowed (bound to ${params.boundNode})`); + } + const nodeQuery = params.boundNode || params.requestedNode; + const nodes = await listNodes({}); + if (nodes.length === 0) { + throw new Error( + "exec host=node requires a paired node (none available). This requires a companion app or node host.", + ); + } + let nodeId: string; + try { + nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery); + } catch (err) { + if (!nodeQuery && String(err).includes("node required")) { + throw new Error( + "exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).", + { cause: err }, + ); + } + throw err; + } + const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId); + const supportsSystemRun = Array.isArray(nodeInfo?.commands) + ? nodeInfo?.commands?.includes("system.run") + : false; + if (!supportsSystemRun) { + throw new Error( + "exec host=node requires a node that supports system.run (companion app or node host).", + ); + } + const argv = buildNodeShellCommand(params.command, nodeInfo?.platform); + + const nodeEnv = params.requestedEnv ? { ...params.requestedEnv } : undefined; + const baseAllowlistEval = evaluateShellAllowlist({ + command: params.command, + allowlist: [], + safeBins: new Set(), + cwd: params.workdir, + env: params.env, + platform: nodeInfo?.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + }); + let analysisOk = baseAllowlistEval.analysisOk; + let allowlistSatisfied = false; + if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) { + try { + const approvalsSnapshot = await callGatewayTool<{ file: string }>( + "exec.approvals.node.get", + { timeoutMs: 10_000 }, + { nodeId }, + ); + const approvalsFile = + approvalsSnapshot && typeof approvalsSnapshot === "object" + ? approvalsSnapshot.file + : undefined; + if (approvalsFile && typeof approvalsFile === "object") { + const resolved = resolveExecApprovalsFromFile({ + file: approvalsFile as ExecApprovalsFile, + agentId: params.agentId, + overrides: { security: "allowlist" }, + }); + // Allowlist-only precheck; safe bins are node-local and may diverge. + const allowlistEval = evaluateShellAllowlist({ + command: params.command, + allowlist: resolved.allowlist, + safeBins: new Set(), + cwd: params.workdir, + env: params.env, + platform: nodeInfo?.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + }); + allowlistSatisfied = allowlistEval.allowlistSatisfied; + analysisOk = allowlistEval.analysisOk; + } + } catch { + // Fall back to requiring approval if node approvals cannot be fetched. + } + } + const requiresAsk = requiresExecApproval({ + ask: hostAsk, + security: hostSecurity, + analysisOk, + allowlistSatisfied, + }); + const invokeTimeoutMs = Math.max( + 10_000, + (typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec) * 1000 + + 5_000, + ); + const buildInvokeParams = ( + approvedByAsk: boolean, + approvalDecision: "allow-once" | "allow-always" | null, + runId?: string, + ) => + ({ + nodeId, + command: "system.run", + params: { + command: argv, + rawCommand: params.command, + cwd: params.workdir, + env: nodeEnv, + timeoutMs: typeof params.timeoutSec === "number" ? params.timeoutSec * 1000 : undefined, + agentId: params.agentId, + sessionKey: params.sessionKey, + approved: approvedByAsk, + approvalDecision: approvalDecision ?? undefined, + runId: runId ?? undefined, + }, + idempotencyKey: crypto.randomUUID(), + }) satisfies Record<string, unknown>; + + if (requiresAsk) { + const approvalId = crypto.randomUUID(); + const approvalSlug = createApprovalSlug(approvalId); + const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + const contextKey = `exec:${approvalId}`; + const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); + const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + + void (async () => { + let decision: string | null = null; + try { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.request", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { + id: approvalId, + command: params.command, + cwd: params.workdir, + host: "node", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath: undefined, + sessionKey: params.sessionKey, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }, + ); + const decisionValue = + decisionResult && typeof decisionResult === "object" + ? (decisionResult as { decision?: unknown }).decision + : undefined; + decision = typeof decisionValue === "string" ? decisionValue : null; + } catch { + emitExecSystemEvent( + `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, + { sessionKey: params.notifySessionKey, contextKey }, + ); + return; + } + + let approvedByAsk = false; + let approvalDecision: "allow-once" | "allow-always" | null = null; + let deniedReason: string | null = null; + + if (decision === "deny") { + deniedReason = "user-denied"; + } else if (!decision) { + if (askFallback === "full") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } else if (askFallback === "allowlist") { + // Defer allowlist enforcement to the node host. + } else { + deniedReason = "approval-timeout"; + } + } else if (decision === "allow-once") { + approvedByAsk = true; + approvalDecision = "allow-once"; + } else if (decision === "allow-always") { + approvedByAsk = true; + approvalDecision = "allow-always"; + } + + if (deniedReason) { + emitExecSystemEvent( + `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, + { + sessionKey: params.notifySessionKey, + contextKey, + }, + ); + return; + } + + let runningTimer: NodeJS.Timeout | null = null; + if (params.approvalRunningNoticeMs > 0) { + runningTimer = setTimeout(() => { + emitExecSystemEvent( + `Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`, + { sessionKey: params.notifySessionKey, contextKey }, + ); + }, params.approvalRunningNoticeMs); + } + + try { + await callGatewayTool( + "node.invoke", + { timeoutMs: invokeTimeoutMs }, + buildInvokeParams(approvedByAsk, approvalDecision, approvalId), + ); + } catch { + emitExecSystemEvent( + `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, + { + sessionKey: params.notifySessionKey, + contextKey, + }, + ); + } finally { + if (runningTimer) { + clearTimeout(runningTimer); + } + } + })(); + + return { + content: [ + { + type: "text", + text: + `${warningText}Approval required (id ${approvalSlug}). ` + + "Approve to run; updates will arrive after completion.", + }, + ], + details: { + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "node", + command: params.command, + cwd: params.workdir, + nodeId, + }, + }; + } + + const startedAt = Date.now(); + const raw = await callGatewayTool( + "node.invoke", + { timeoutMs: invokeTimeoutMs }, + buildInvokeParams(false, null), + ); + const payload = + raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined; + const payloadObj = + payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}; + const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : ""; + const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : ""; + const errorText = typeof payloadObj.error === "string" ? payloadObj.error : ""; + const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false; + const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null; + return { + content: [ + { + type: "text", + text: stdout || stderr || errorText || "", + }, + ], + details: { + status: success ? "completed" : "failed", + exitCode, + durationMs: Date.now() - startedAt, + aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"), + cwd: params.workdir, + } satisfies ExecToolDetails, + }; +}
src/agents/bash-tools.exec-runtime.ts+1 −1 modified@@ -6,7 +6,7 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import type { ProcessSession } from "./bash-process-registry.js"; -import type { ExecToolDetails } from "./bash-tools.exec.js"; +import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export { applyPathPrepend, normalizePathPrepend } from "../infra/path-prepend.js"; import { logWarn } from "../logger.js";
src/agents/bash-tools.exec.ts+51 −613 modified@@ -1,56 +1,38 @@ -import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - type ExecAsk, - type ExecHost, - type ExecSecurity, - type ExecApprovalsFile, - addAllowlistEntry, - evaluateShellAllowlist, - maxAsk, - minSecurity, - requiresExecApproval, - resolveSafeBins, - recordAllowlistUse, - resolveExecApprovals, - resolveExecApprovalsFromFile, - buildSafeShellCommand, - buildSafeBinsShellCommand, -} from "../infra/exec-approvals.js"; -import { buildNodeShellCommand } from "../infra/node-shell.js"; +import { type ExecHost, maxAsk, minSecurity, resolveSafeBins } from "../infra/exec-approvals.js"; +import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, } from "../infra/shell-env.js"; import { logInfo } from "../logger.js"; import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { markBackgrounded } from "./bash-process-registry.js"; +import { processGatewayAllowlist } from "./bash-tools.exec-host-gateway.js"; +import { executeNodeHostCommand } from "./bash-tools.exec-host-node.js"; import { - DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, - DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_MAX_OUTPUT, - DEFAULT_NOTIFY_TAIL_CHARS, DEFAULT_PATH, DEFAULT_PENDING_MAX_OUTPUT, applyPathPrepend, applyShellPath, - createApprovalSlug, - emitExecSystemEvent, normalizeExecAsk, normalizeExecHost, normalizeExecSecurity, - normalizeNotifyOutput, normalizePathPrepend, renderExecHostLabel, resolveApprovalRunningNoticeMs, runExecProcess, execSchema, - type ExecProcessHandle, validateHostEnv, } from "./bash-tools.exec-runtime.js"; -import type { BashSandboxConfig } from "./bash-tools.shared.js"; +import type { + ExecElevatedDefaults, + ExecToolDefaults, + ExecToolDetails, +} from "./bash-tools.exec-types.js"; import { buildSandboxEnv, clampWithDefault, @@ -60,65 +42,13 @@ import { resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; -import { callGatewayTool } from "./tools/gateway.js"; -import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; - -export type ExecToolDefaults = { - host?: ExecHost; - security?: ExecSecurity; - ask?: ExecAsk; - node?: string; - pathPrepend?: string[]; - safeBins?: string[]; - agentId?: string; - backgroundMs?: number; - timeoutSec?: number; - approvalRunningNoticeMs?: number; - sandbox?: BashSandboxConfig; - elevated?: ExecElevatedDefaults; - allowBackground?: boolean; - scopeKey?: string; - sessionKey?: string; - messageProvider?: string; - notifyOnExit?: boolean; - notifyOnExitEmptySuccess?: boolean; - cwd?: string; -}; export type { BashSandboxConfig } from "./bash-tools.shared.js"; - -export type ExecElevatedDefaults = { - enabled: boolean; - allowed: boolean; - defaultLevel: "on" | "off" | "ask" | "full"; -}; - -export type ExecToolDetails = - | { - status: "running"; - sessionId: string; - pid?: number; - startedAt: number; - cwd?: string; - tail?: string; - } - | { - status: "completed" | "failed"; - exitCode: number | null; - durationMs: number; - aggregated: string; - cwd?: string; - } - | { - status: "approval-pending"; - approvalId: string; - approvalSlug: string; - expiresAtMs: number; - host: ExecHost; - command: string; - cwd?: string; - nodeId?: string; - }; +export type { + ExecElevatedDefaults, + ExecToolDefaults, + ExecToolDetails, +} from "./bash-tools.exec-types.js"; function extractScriptTargetFromCommand( command: string, @@ -228,6 +158,7 @@ export function createExecTool( : 1800; const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend); const safeBins = resolveSafeBins(defaults?.safeBins); + const trustedSafeBinDirs = getTrustedSafeBinDirs(); const notifyOnExit = defaults?.notifyOnExit !== false; const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true; const notifySessionKey = defaults?.sessionKey?.trim() || undefined; @@ -423,544 +354,51 @@ export function createExecTool( } if (host === "node") { - const approvals = resolveExecApprovals(agentId, { security, ask }); - const hostSecurity = minSecurity(security, approvals.agent.security); - const hostAsk = maxAsk(ask, approvals.agent.ask); - const askFallback = approvals.agent.askFallback; - if (hostSecurity === "deny") { - throw new Error("exec denied: host=node security=deny"); - } - const boundNode = defaults?.node?.trim(); - const requestedNode = params.node?.trim(); - if (boundNode && requestedNode && boundNode !== requestedNode) { - throw new Error(`exec node not allowed (bound to ${boundNode})`); - } - const nodeQuery = boundNode || requestedNode; - const nodes = await listNodes({}); - if (nodes.length === 0) { - throw new Error( - "exec host=node requires a paired node (none available). This requires a companion app or node host.", - ); - } - let nodeId: string; - try { - nodeId = resolveNodeIdFromList(nodes, nodeQuery, !nodeQuery); - } catch (err) { - if (!nodeQuery && String(err).includes("node required")) { - throw new Error( - "exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).", - { cause: err }, - ); - } - throw err; - } - const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId); - const supportsSystemRun = Array.isArray(nodeInfo?.commands) - ? nodeInfo?.commands?.includes("system.run") - : false; - if (!supportsSystemRun) { - throw new Error( - "exec host=node requires a node that supports system.run (companion app or node host).", - ); - } - const argv = buildNodeShellCommand(params.command, nodeInfo?.platform); - - const nodeEnv = params.env ? { ...params.env } : undefined; - const baseAllowlistEval = evaluateShellAllowlist({ + return executeNodeHostCommand({ command: params.command, - allowlist: [], - safeBins: new Set(), - cwd: workdir, + workdir, env, - platform: nodeInfo?.platform, - }); - let analysisOk = baseAllowlistEval.analysisOk; - let allowlistSatisfied = false; - if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) { - try { - const approvalsSnapshot = await callGatewayTool<{ file: string }>( - "exec.approvals.node.get", - { timeoutMs: 10_000 }, - { nodeId }, - ); - const approvalsFile = - approvalsSnapshot && typeof approvalsSnapshot === "object" - ? approvalsSnapshot.file - : undefined; - if (approvalsFile && typeof approvalsFile === "object") { - const resolved = resolveExecApprovalsFromFile({ - file: approvalsFile as ExecApprovalsFile, - agentId, - overrides: { security: "allowlist" }, - }); - // Allowlist-only precheck; safe bins are node-local and may diverge. - const allowlistEval = evaluateShellAllowlist({ - command: params.command, - allowlist: resolved.allowlist, - safeBins: new Set(), - cwd: workdir, - env, - platform: nodeInfo?.platform, - }); - allowlistSatisfied = allowlistEval.allowlistSatisfied; - analysisOk = allowlistEval.analysisOk; - } - } catch { - // Fall back to requiring approval if node approvals cannot be fetched. - } - } - const requiresAsk = requiresExecApproval({ - ask: hostAsk, - security: hostSecurity, - analysisOk, - allowlistSatisfied, + requestedEnv: params.env, + requestedNode: params.node?.trim(), + boundNode: defaults?.node?.trim(), + sessionKey: defaults?.sessionKey, + agentId, + security, + ask, + timeoutSec: params.timeout, + defaultTimeoutSec, + approvalRunningNoticeMs, + warnings, + notifySessionKey, + trustedSafeBinDirs, }); - const commandText = params.command; - const invokeTimeoutMs = Math.max( - 10_000, - (typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec) * 1000 + 5_000, - ); - const buildInvokeParams = ( - approvedByAsk: boolean, - approvalDecision: "allow-once" | "allow-always" | null, - runId?: string, - ) => - ({ - nodeId, - command: "system.run", - params: { - command: argv, - rawCommand: params.command, - cwd: workdir, - env: nodeEnv, - timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, - agentId, - sessionKey: defaults?.sessionKey, - approved: approvedByAsk, - approvalDecision: approvalDecision ?? undefined, - runId: runId ?? undefined, - }, - idempotencyKey: crypto.randomUUID(), - }) satisfies Record<string, unknown>; - - if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - const contextKey = `exec:${approvalId}`; - const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); - const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; - - void (async () => { - let decision: string | null = null; - try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: commandText, - cwd: workdir, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId, - resolvedPath: undefined, - sessionKey: defaults?.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; - } catch { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - return; - } - - let approvedByAsk = false; - let approvalDecision: "allow-once" | "allow-always" | null = null; - let deniedReason: string | null = null; - - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (askFallback === "full") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } else if (askFallback === "allowlist") { - // Defer allowlist enforcement to the node host. - } else { - deniedReason = "approval-timeout"; - } - } else if (decision === "allow-once") { - approvedByAsk = true; - approvalDecision = "allow-once"; - } else if (decision === "allow-always") { - approvedByAsk = true; - approvalDecision = "allow-always"; - } - - if (deniedReason) { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - return; - } - - let runningTimer: NodeJS.Timeout | null = null; - if (approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - }, approvalRunningNoticeMs); - } - - try { - await callGatewayTool( - "node.invoke", - { timeoutMs: invokeTimeoutMs }, - buildInvokeParams(approvedByAsk, approvalDecision, approvalId), - ); - } catch { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - } finally { - if (runningTimer) { - clearTimeout(runningTimer); - } - } - })(); - - return { - content: [ - { - type: "text", - text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", - }, - ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "node", - command: commandText, - cwd: workdir, - nodeId, - }, - }; - } - - const startedAt = Date.now(); - const raw = await callGatewayTool( - "node.invoke", - { timeoutMs: invokeTimeoutMs }, - buildInvokeParams(false, null), - ); - const payload = - raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined; - const payloadObj = - payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}; - const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : ""; - const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : ""; - const errorText = typeof payloadObj.error === "string" ? payloadObj.error : ""; - const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false; - const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null; - return { - content: [ - { - type: "text", - text: stdout || stderr || errorText || "", - }, - ], - details: { - status: success ? "completed" : "failed", - exitCode, - durationMs: Date.now() - startedAt, - aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"), - cwd: workdir, - } satisfies ExecToolDetails, - }; } if (host === "gateway" && !bypassApprovals) { - const approvals = resolveExecApprovals(agentId, { security, ask }); - const hostSecurity = minSecurity(security, approvals.agent.security); - const hostAsk = maxAsk(ask, approvals.agent.ask); - const askFallback = approvals.agent.askFallback; - if (hostSecurity === "deny") { - throw new Error("exec denied: host=gateway security=deny"); - } - const allowlistEval = evaluateShellAllowlist({ + const gatewayResult = await processGatewayAllowlist({ command: params.command, - allowlist: approvals.allowlist, - safeBins, - cwd: workdir, + workdir, env, - platform: process.platform, - }); - const allowlistMatches = allowlistEval.allowlistMatches; - const analysisOk = allowlistEval.analysisOk; - const allowlistSatisfied = - hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; - const requiresAsk = requiresExecApproval({ - ask: hostAsk, - security: hostSecurity, - analysisOk, - allowlistSatisfied, + pty: params.pty === true && !sandbox, + timeoutSec: params.timeout, + defaultTimeoutSec, + security, + ask, + safeBins, + agentId, + sessionKey: defaults?.sessionKey, + scopeKey: defaults?.scopeKey, + warnings, + notifySessionKey, + approvalRunningNoticeMs, + maxOutput, + pendingMaxOutput, + trustedSafeBinDirs, }); - - if (requiresAsk) { - const approvalId = crypto.randomUUID(); - const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; - const contextKey = `exec:${approvalId}`; - const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; - const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); - const commandText = params.command; - const effectiveTimeout = - typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; - const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : ""; - - void (async () => { - let decision: string | null = null; - try { - const decisionResult = await callGatewayTool<{ decision: string }>( - "exec.approval.request", - { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: approvalId, - command: commandText, - cwd: workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId, - resolvedPath, - sessionKey: defaults?.sessionKey, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - }, - ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - decision = typeof decisionValue === "string" ? decisionValue : null; - } catch { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, approval-request-failed): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - return; - } - - let approvedByAsk = false; - let deniedReason: string | null = null; - - if (decision === "deny") { - deniedReason = "user-denied"; - } else if (!decision) { - if (askFallback === "full") { - approvedByAsk = true; - } else if (askFallback === "allowlist") { - if (!analysisOk || !allowlistSatisfied) { - deniedReason = "approval-timeout (allowlist-miss)"; - } else { - approvedByAsk = true; - } - } else { - deniedReason = "approval-timeout"; - } - } else if (decision === "allow-once") { - approvedByAsk = true; - } else if (decision === "allow-always") { - approvedByAsk = true; - if (hostSecurity === "allowlist") { - for (const segment of allowlistEval.segments) { - const pattern = segment.resolution?.resolvedPath ?? ""; - if (pattern) { - addAllowlistEntry(approvals.file, agentId, pattern); - } - } - } - } - - if ( - hostSecurity === "allowlist" && - (!analysisOk || !allowlistSatisfied) && - !approvedByAsk - ) { - deniedReason = deniedReason ?? "allowlist-miss"; - } - - if (deniedReason) { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - return; - } - - if (allowlistMatches.length > 0) { - const seen = new Set<string>(); - for (const match of allowlistMatches) { - if (seen.has(match.pattern)) { - continue; - } - seen.add(match.pattern); - recordAllowlistUse( - approvals.file, - agentId, - match, - commandText, - resolvedPath ?? undefined, - ); - } - } - - let run: ExecProcessHandle | null = null; - try { - run = await runExecProcess({ - command: commandText, - workdir, - env, - sandbox: undefined, - containerWorkdir: null, - usePty: params.pty === true && !sandbox, - warnings, - maxOutput, - pendingMaxOutput, - notifyOnExit: false, - notifyOnExitEmptySuccess: false, - scopeKey: defaults?.scopeKey, - sessionKey: notifySessionKey, - timeoutSec: effectiveTimeout, - }); - } catch { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, spawn-failed): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - return; - } - - markBackgrounded(run.session); - - let runningTimer: NodeJS.Timeout | null = null; - if (approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${commandText}`, - { sessionKey: notifySessionKey, contextKey }, - ); - }, approvalRunningNoticeMs); - } - - const outcome = await run.promise; - if (runningTimer) { - clearTimeout(runningTimer); - } - const output = normalizeNotifyOutput( - tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), - ); - const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; - const summary = output - ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` - : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; - emitExecSystemEvent(summary, { sessionKey: notifySessionKey, contextKey }); - })(); - - return { - content: [ - { - type: "text", - text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", - }, - ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "gateway", - command: params.command, - cwd: workdir, - }, - }; - } - - if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) { - throw new Error("exec denied: allowlist miss"); - } - - // If allowlist uses safeBins, sanitize only those stdin-only segments: - // disable glob/var expansion by forcing argv tokens to be literal via single-quoting. - if ( - hostSecurity === "allowlist" && - analysisOk && - allowlistSatisfied && - allowlistEval.segmentSatisfiedBy.some((by) => by === "safeBins") - ) { - const safe = buildSafeBinsShellCommand({ - command: params.command, - segments: allowlistEval.segments, - segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy, - platform: process.platform, - }); - if (!safe.ok || !safe.command) { - // Fallback: quote everything (safe, but may change glob behavior). - const fallback = buildSafeShellCommand({ - command: params.command, - platform: process.platform, - }); - if (!fallback.ok || !fallback.command) { - throw new Error( - `exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`, - ); - } - warnings.push( - "Warning: safeBins hardening used fallback quoting due to parser mismatch.", - ); - execCommandOverride = fallback.command; - } else { - warnings.push( - "Warning: safeBins hardening disabled glob/variable expansion for stdin-only segments.", - ); - execCommandOverride = safe.command; - } - } - - if (allowlistMatches.length > 0) { - const seen = new Set<string>(); - for (const match of allowlistMatches) { - if (seen.has(match.pattern)) { - continue; - } - seen.add(match.pattern); - recordAllowlistUse( - approvals.file, - agentId, - match, - params.command, - allowlistEval.segments[0]?.resolution?.resolvedPath, - ); - } + if (gatewayResult.pendingResult) { + return gatewayResult.pendingResult; } + execCommandOverride = gatewayResult.execCommandOverride; } const effectiveTimeout =
src/agents/bash-tools.exec-types.ts+57 −0 added@@ -0,0 +1,57 @@ +import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; +import type { BashSandboxConfig } from "./bash-tools.shared.js"; + +export type ExecToolDefaults = { + host?: ExecHost; + security?: ExecSecurity; + ask?: ExecAsk; + node?: string; + pathPrepend?: string[]; + safeBins?: string[]; + agentId?: string; + backgroundMs?: number; + timeoutSec?: number; + approvalRunningNoticeMs?: number; + sandbox?: BashSandboxConfig; + elevated?: ExecElevatedDefaults; + allowBackground?: boolean; + scopeKey?: string; + sessionKey?: string; + messageProvider?: string; + notifyOnExit?: boolean; + notifyOnExitEmptySuccess?: boolean; + cwd?: string; +}; + +export type ExecElevatedDefaults = { + enabled: boolean; + allowed: boolean; + defaultLevel: "on" | "off" | "ask" | "full"; +}; + +export type ExecToolDetails = + | { + status: "running"; + sessionId: string; + pid?: number; + startedAt: number; + cwd?: string; + tail?: string; + } + | { + status: "completed" | "failed"; + exitCode: number | null; + durationMs: number; + aggregated: string; + cwd?: string; + } + | { + status: "approval-pending"; + approvalId: string; + approvalSlug: string; + expiresAtMs: number; + host: ExecHost; + command: string; + cwd?: string; + nodeId?: string; + };
src/infra/exec-approvals-allowlist.ts+8 −0 modified@@ -357,6 +357,7 @@ function evaluateSegments( allowlist: ExecAllowlistEntry[]; safeBins: Set<string>; cwd?: string; + trustedSafeBinDirs?: ReadonlySet<string>; skillBins?: Set<string>; autoAllowSkills?: boolean; }, @@ -384,6 +385,7 @@ function evaluateSegments( resolution: segment.resolution, safeBins: params.safeBins, cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, }); const skillAllow = allowSkills && segment.resolution?.executableName @@ -408,6 +410,7 @@ export function evaluateExecAllowlist(params: { allowlist: ExecAllowlistEntry[]; safeBins: Set<string>; cwd?: string; + trustedSafeBinDirs?: ReadonlySet<string>; skillBins?: Set<string>; autoAllowSkills?: boolean; }): ExecAllowlistEvaluation { @@ -424,6 +427,7 @@ export function evaluateExecAllowlist(params: { allowlist: params.allowlist, safeBins: params.safeBins, cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, }); @@ -441,6 +445,7 @@ export function evaluateExecAllowlist(params: { allowlist: params.allowlist, safeBins: params.safeBins, cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, }); @@ -468,6 +473,7 @@ export function evaluateShellAllowlist(params: { safeBins: Set<string>; cwd?: string; env?: NodeJS.ProcessEnv; + trustedSafeBinDirs?: ReadonlySet<string>; skillBins?: Set<string>; autoAllowSkills?: boolean; platform?: string | null; @@ -496,6 +502,7 @@ export function evaluateShellAllowlist(params: { allowlist: params.allowlist, safeBins: params.safeBins, cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, }); @@ -529,6 +536,7 @@ export function evaluateShellAllowlist(params: { allowlist: params.allowlist, safeBins: params.safeBins, cwd: params.cwd, + trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, });
src/infra/exec-approvals.test.ts+37 −1 modified@@ -517,7 +517,6 @@ describe("exec approvals safe bins", () => { }); expect(ok).toBe(true); }); - it("does not include sort/grep in default safeBins", () => { const defaults = resolveSafeBins(undefined); expect(defaults.has("jq")).toBe(true); @@ -582,6 +581,43 @@ describe("exec approvals safe bins", () => { expect(ok).toBe(false); expect(checkedExists).toBe(false); }); + + it("threads trusted safe-bin dirs through allowlist evaluation", () => { + if (process.platform === "win32") { + return; + } + const analysis = { + ok: true as const, + segments: [ + { + raw: "jq .foo", + argv: ["jq", ".foo"], + resolution: { + rawExecutable: "jq", + resolvedPath: "/custom/bin/jq", + executableName: "jq", + }, + }, + ], + }; + const denied = evaluateExecAllowlist({ + analysis, + allowlist: [], + safeBins: normalizeSafeBins(["jq"]), + trustedSafeBinDirs: new Set(["/usr/bin"]), + cwd: "/tmp", + }); + expect(denied.allowlistSatisfied).toBe(false); + + const allowed = evaluateExecAllowlist({ + analysis, + allowlist: [], + safeBins: normalizeSafeBins(["jq"]), + trustedSafeBinDirs: new Set(["/custom/bin"]), + cwd: "/tmp", + }); + expect(allowed.allowlistSatisfied).toBe(true); + }); }); describe("exec approvals allowlist evaluation", () => {
src/infra/exec-safe-bin-trust.test.ts+14 −0 modified@@ -54,4 +54,18 @@ describe("exec safe bin trust", () => { }), ).toBe(false); }); + + it("uses startup PATH snapshot when pathEnv is omitted", () => { + const originalPath = process.env.PATH; + const injected = `/tmp/openclaw-path-injected-${Date.now()}`; + const initial = getTrustedSafeBinDirs({ refresh: true }); + try { + process.env.PATH = `${injected}${path.delimiter}${originalPath ?? ""}`; + const refreshed = getTrustedSafeBinDirs({ refresh: true }); + expect(refreshed.has(path.resolve(injected))).toBe(false); + expect([...refreshed].toSorted()).toEqual([...initial].toSorted()); + } finally { + process.env.PATH = originalPath; + } + }); });
src/infra/exec-safe-bin-trust.ts+2 −1 modified@@ -29,6 +29,7 @@ type TrustedSafeBinCache = { }; let trustedSafeBinCache: TrustedSafeBinCache | null = null; +const STARTUP_PATH_ENV = process.env.PATH ?? process.env.Path ?? ""; function normalizeTrustedDir(value: string): string | null { const trimmed = value.trim(); @@ -74,7 +75,7 @@ export function getTrustedSafeBinDirs( } = {}, ): Set<string> { const delimiter = params.delimiter ?? path.delimiter; - const pathEnv = params.pathEnv ?? process.env.PATH ?? process.env.Path ?? ""; + const pathEnv = params.pathEnv ?? STARTUP_PATH_ENV; const key = buildTrustedSafeBinCacheKey(pathEnv, delimiter); if (!params.refresh && trustedSafeBinCache?.key === key) {
src/node-host/invoke.ts+4 −0 modified@@ -31,6 +31,7 @@ import { type ExecHostResponse, type ExecHostRunResult, } from "../infra/exec-host.js"; +import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { validateSystemRunCommandConsistency } from "../infra/system-run-command.js"; import { runBrowserProxyCommand } from "./invoke-browser.js"; @@ -546,6 +547,7 @@ export async function handleInvoke( const runId = params.runId?.trim() || crypto.randomUUID(); const env = sanitizeEnv(params.env ?? undefined); const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); + const trustedSafeBinDirs = getTrustedSafeBinDirs(); const bins = autoAllowSkills ? await skillBins.current() : new Set<string>(); let analysisOk = false; let allowlistMatches: ExecAllowlistEntry[] = []; @@ -558,6 +560,7 @@ export async function handleInvoke( safeBins, cwd: params.cwd ?? undefined, env, + trustedSafeBinDirs, skillBins: bins, autoAllowSkills, platform: process.platform, @@ -574,6 +577,7 @@ export async function handleInvoke( allowlist: approvals.allowlist, safeBins, cwd: params.cwd ?? undefined, + trustedSafeBinDirs, skillBins: bins, autoAllowSkills, });
bafdbb6f1124fix(security): eliminate safeBins file-existence oracle
5 files changed · +347 −92
CHANGELOG.md+1 −0 modified@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. - Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. +- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. This ships in the next npm release. Thanks @nedlir for reporting. - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code.
docs/tools/exec-approvals.md+4 −0 modified@@ -124,6 +124,10 @@ are treated as allowlisted on nodes (macOS node or headless node host). This use `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject positional file args and path-like tokens, so they can only operate on the incoming stream. +Validation is deterministic from argv shape only (no host filesystem existence checks), which +prevents file-existence oracle behavior from allow/deny differences. +File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, +`sort --files0-from`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`). Safe bins also enforce explicit per-binary flag policy for options that break stdin-only behavior (for example `sort -o/--output` and grep recursive flags). Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
src/agents/pi-tools.safe-bins.e2e.test.ts+50 −10 modified@@ -123,8 +123,7 @@ describe("createOpenClawCodingTools safeBins", () => { const { createOpenClawCodingTools } = await import("./pi-tools.js"); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-safe-bins-expand-")); - const secret = `TOP_SECRET_${Date.now()}`; - fs.writeFileSync(path.join(tmpDir, "secret.txt"), `${secret}\n`, "utf8"); + fs.writeFileSync(path.join(tmpDir, "secret.txt"), "TOP_SECRET\n", "utf8"); const cfg: OpenClawConfig = { tools: { @@ -146,16 +145,57 @@ describe("createOpenClawCodingTools safeBins", () => { const execTool = tools.find((tool) => tool.name === "exec"); expect(execTool).toBeDefined(); - const result = await execTool!.execute("call1", { - command: "head $FOO ; wc -l", - workdir: tmpDir, - env: { FOO: "secret.txt" }, + await expect( + execTool!.execute("call1", { + command: "head $FOO ; wc -l", + workdir: tmpDir, + env: { FOO: "secret.txt" }, + }), + ).rejects.toThrow("exec denied: allowlist miss"); + }); + + it("does not leak file existence from sort output flags", async () => { + if (process.platform === "win32") { + return; + } + + const { createOpenClawCodingTools } = await import("./pi-tools.js"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-safe-bins-oracle-")); + fs.writeFileSync(path.join(tmpDir, "existing.txt"), "x\n", "utf8"); + + const cfg: OpenClawConfig = { + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "off", + safeBins: ["sort"], + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: tmpDir, + agentDir: path.join(tmpDir, "agent"), }); - const text = result.content.find((content) => content.type === "text")?.text ?? ""; + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const run = async (command: string) => { + try { + const result = await execTool!.execute("call-oracle", { command, workdir: tmpDir }); + const text = result.content.find((content) => content.type === "text")?.text ?? ""; + return { kind: "result" as const, status: result.details.status, text }; + } catch (err) { + return { kind: "error" as const, message: String(err) }; + } + }; - const blockedResultDetails = result.details as { status?: string }; - expect(blockedResultDetails.status).toBe("completed"); - expect(text).not.toContain(secret); + const existing = await run("sort -o existing.txt"); + const missing = await run("sort -o missing.txt"); + expect(existing).toEqual(missing); }); it("blocks sort output flags from writing files via safeBins", async () => {
src/infra/exec-approvals-allowlist.ts+234 −82 modified@@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { @@ -31,14 +30,6 @@ function isPathLikeToken(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(trimmed); } -function defaultFileExists(filePath: string): boolean { - try { - return fs.existsSync(filePath); - } catch { - return false; - } -} - export function normalizeSafeBins(entries?: string[]): Set<string> { if (!Array.isArray(entries)) { return new Set(); @@ -62,57 +53,253 @@ function hasGlobToken(value: string): boolean { return /[*?[\]]/.test(value); } -type SafeBinOptionPolicy = { - blockedShort?: ReadonlySet<string>; - blockedLong?: ReadonlySet<string>; +type SafeBinProfile = { + minPositional?: number; + maxPositional?: number; + valueFlags?: ReadonlySet<string>; + blockedFlags?: ReadonlySet<string>; }; -const SAFE_BIN_OPTION_POLICIES: Readonly<Record<string, SafeBinOptionPolicy>> = { - // sort can write arbitrary output paths via -o/--output, which breaks stdin-only guarantees. - sort: { - blockedShort: new Set(["o"]), - blockedLong: new Set(["output"]), +const NO_FLAGS = new Set<string>(); +const SAFE_BIN_GENERIC_PROFILE: SafeBinProfile = {}; +const SAFE_BIN_PROFILES: Record<string, SafeBinProfile> = { + jq: { + maxPositional: 1, + valueFlags: new Set([ + "--arg", + "--argjson", + "--argstr", + "--argfile", + "--rawfile", + "--slurpfile", + "--from-file", + "--library-path", + "-L", + "-f", + ]), + blockedFlags: new Set([ + "--argfile", + "--rawfile", + "--slurpfile", + "--from-file", + "--library-path", + "-L", + "-f", + ]), }, - // grep recursion flags read from cwd (or provided roots), so they are not stdin-only. grep: { - blockedShort: new Set(["d", "r"]), - blockedLong: new Set(["dereference-recursive", "directories", "recursive"]), + maxPositional: 1, + valueFlags: new Set([ + "--regexp", + "--file", + "--max-count", + "--after-context", + "--before-context", + "--context", + "--devices", + "--directories", + "--binary-files", + "--exclude", + "--exclude-from", + "--include", + "--label", + "-e", + "-f", + "-m", + "-A", + "-B", + "-C", + "-D", + "-d", + ]), + blockedFlags: new Set([ + "--file", + "--exclude-from", + "--dereference-recursive", + "--directories", + "--recursive", + "-f", + "-d", + "-r", + "-R", + ]), + }, + cut: { + maxPositional: 0, + valueFlags: new Set([ + "--bytes", + "--characters", + "--fields", + "--delimiter", + "--output-delimiter", + "-b", + "-c", + "-f", + "-d", + ]), + }, + sort: { + maxPositional: 0, + valueFlags: new Set([ + "--key", + "--field-separator", + "--buffer-size", + "--temporary-directory", + "--compress-program", + "--parallel", + "--batch-size", + "--random-source", + "--files0-from", + "--output", + "-k", + "-t", + "-S", + "-T", + "-o", + ]), + blockedFlags: new Set(["--files0-from", "--output", "-o"]), + }, + uniq: { + maxPositional: 0, + valueFlags: new Set([ + "--skip-fields", + "--skip-chars", + "--check-chars", + "--group", + "-f", + "-s", + "-w", + ]), + }, + head: { + maxPositional: 0, + valueFlags: new Set(["--lines", "--bytes", "-n", "-c"]), + }, + tail: { + maxPositional: 0, + valueFlags: new Set([ + "--lines", + "--bytes", + "--sleep-interval", + "--max-unchanged-stats", + "--pid", + "-n", + "-c", + ]), + }, + tr: { + minPositional: 1, + maxPositional: 2, + }, + wc: { + maxPositional: 0, + valueFlags: new Set(["--files0-from"]), + blockedFlags: new Set(["--files0-from"]), }, }; -function parseLongOptionName(token: string): string | null { - if (!token.startsWith("--") || token === "--") { - return null; - } - const body = token.slice(2); - if (!body) { - return null; +function isSafeLiteralToken(value: string): boolean { + if (!value || value === "-") { + return true; } - const eqIndex = body.indexOf("="); - const name = (eqIndex >= 0 ? body.slice(0, eqIndex) : body).trim().toLowerCase(); - return name.length > 0 ? name : null; + return !hasGlobToken(value) && !isPathLikeToken(value); } -function hasBlockedSafeBinOption(execName: string, token: string): boolean { - const policy = SAFE_BIN_OPTION_POLICIES[execName]; - if (!policy || !token.startsWith("-")) { - return false; - } - const longName = parseLongOptionName(token); - if (longName) { - return policy.blockedLong?.has(longName) ?? false; +function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { + const valueFlags = profile.valueFlags ?? NO_FLAGS; + const blockedFlags = profile.blockedFlags ?? NO_FLAGS; + const positional: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + if (!token) { + continue; + } + if (token === "--") { + for (let j = i + 1; j < args.length; j += 1) { + const rest = args[j]; + if (!rest || rest === "-") { + continue; + } + if (!isSafeLiteralToken(rest)) { + return false; + } + positional.push(rest); + } + break; + } + if (token === "-") { + continue; + } + if (!token.startsWith("-")) { + if (!isSafeLiteralToken(token)) { + return false; + } + positional.push(token); + continue; + } + + if (token.startsWith("--")) { + const eqIndex = token.indexOf("="); + const flag = eqIndex > 0 ? token.slice(0, eqIndex) : token; + if (blockedFlags.has(flag)) { + return false; + } + if (eqIndex > 0) { + if (!isSafeLiteralToken(token.slice(eqIndex + 1))) { + return false; + } + continue; + } + if (!valueFlags.has(flag)) { + continue; + } + const value = args[i + 1]; + if (!value || !isSafeLiteralToken(value)) { + return false; + } + i += 1; + continue; + } + + let consumedValue = false; + for (let j = 1; j < token.length; j += 1) { + const flag = `-${token[j]}`; + if (blockedFlags.has(flag)) { + return false; + } + if (!valueFlags.has(flag)) { + continue; + } + const inlineValue = token.slice(j + 1); + if (inlineValue) { + if (!isSafeLiteralToken(inlineValue)) { + return false; + } + } else { + const value = args[i + 1]; + if (!value || !isSafeLiteralToken(value)) { + return false; + } + i += 1; + } + consumedValue = true; + break; + } + if (!consumedValue && hasGlobToken(token)) { + return false; + } } - if (token === "-" || token === "--") { + + const minPositional = profile.minPositional ?? 0; + if (positional.length < minPositional) { return false; } - for (const ch of token.slice(1)) { - if (policy.blockedShort?.has(ch.toLowerCase())) { - return true; - } + if (typeof profile.maxPositional === "number" && positional.length > profile.maxPositional) { + return false; } - return false; + return true; } - export function isSafeBinUsage(params: { argv: string[]; resolution: CommandResolution | null; @@ -151,44 +338,9 @@ export function isSafeBinUsage(params: { ) { return false; } - const cwd = params.cwd ?? process.cwd(); - const exists = params.fileExists ?? defaultFileExists; const argv = params.argv.slice(1); - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i]; - if (!token) { - continue; - } - if (token === "-") { - continue; - } - if (token.startsWith("-")) { - if (hasBlockedSafeBinOption(execName, token)) { - return false; - } - const eqIndex = token.indexOf("="); - if (eqIndex > 0) { - const value = token.slice(eqIndex + 1); - if (value && hasGlobToken(value)) { - return false; - } - if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) { - return false; - } - } - continue; - } - if (hasGlobToken(token)) { - return false; - } - if (isPathLikeToken(token)) { - return false; - } - if (exists(path.resolve(cwd, token))) { - return false; - } - } - return true; + const profile = SAFE_BIN_PROFILES[execName] ?? SAFE_BIN_GENERIC_PROFILE; + return validateSafeBinArgv(argv, profile); } export type ExecAllowlistEvaluation = {
src/infra/exec-approvals.test.ts+58 −0 modified@@ -524,6 +524,64 @@ describe("exec approvals safe bins", () => { expect(defaults.has("sort")).toBe(false); expect(defaults.has("grep")).toBe(false); }); + + it("blocks sort output flags independent of file existence", () => { + if (process.platform === "win32") { + return; + } + const cwd = makeTempDir(); + fs.writeFileSync(path.join(cwd, "existing.txt"), "x"); + const resolution = { + rawExecutable: "sort", + resolvedPath: "/usr/bin/sort", + executableName: "sort", + }; + const safeBins = normalizeSafeBins(["sort"]); + const existing = isSafeBinUsage({ + argv: ["sort", "-o", "existing.txt"], + resolution, + safeBins, + cwd, + }); + const missing = isSafeBinUsage({ + argv: ["sort", "-o", "missing.txt"], + resolution, + safeBins, + cwd, + }); + const longFlag = isSafeBinUsage({ + argv: ["sort", "--output=missing.txt"], + resolution, + safeBins, + cwd, + }); + expect(existing).toBe(false); + expect(missing).toBe(false); + expect(longFlag).toBe(false); + }); + + it("does not consult file existence callbacks for safe-bin decisions", () => { + if (process.platform === "win32") { + return; + } + let checkedExists = false; + const ok = isSafeBinUsage({ + argv: ["sort", "-o", "target.txt"], + resolution: { + rawExecutable: "sort", + resolvedPath: "/usr/bin/sort", + executableName: "sort", + }, + safeBins: normalizeSafeBins(["sort"]), + cwd: "/tmp", + fileExists: () => { + checkedExists = true; + return true; + }, + }); + expect(ok).toBe(false); + expect(checkedExists).toBe(false); + }); }); describe("exec approvals allowlist evaluation", () => {
cfe8457a0f4afix(security): harden safeBins stdin-only enforcement
6 files changed · +200 −7
CHANGELOG.md+1 −0 modified@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting. +- Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. - Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax.
docs/tools/exec-approvals.md+6 −1 modified@@ -124,6 +124,8 @@ are treated as allowlisted on nodes (macOS node or headless node host). This use `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject positional file args and path-like tokens, so they can only operate on the incoming stream. +Safe bins also enforce explicit per-binary flag policy for options that break stdin-only +behavior (for example `sort -o/--output` and grep recursive flags). Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be used to smuggle file reads. @@ -136,7 +138,10 @@ Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfi Command substitution (`$()` / backticks) is rejected during allowlist parsing, including inside double quotes; use single quotes if you need literal `$()` text. -Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`. +Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. + +`grep` and `sort` are not in the default list. If you opt in, keep explicit allowlist entries for +their non-stdin workflows. ## Control UI editing
src/agents/pi-tools.safe-bins.e2e.test.ts+88 −0 modified@@ -157,4 +157,92 @@ describe("createOpenClawCodingTools safeBins", () => { expect(blockedResultDetails.status).toBe("completed"); expect(text).not.toContain(secret); }); + + it("blocks sort output flags from writing files via safeBins", async () => { + if (process.platform === "win32") { + return; + } + + const { createOpenClawCodingTools } = await import("./pi-tools.js"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-safe-bins-sort-")); + + const cfg: OpenClawConfig = { + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "off", + safeBins: ["sort"], + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: tmpDir, + agentDir: path.join(tmpDir, "agent"), + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const shortTarget = path.join(tmpDir, "blocked-short.txt"); + await expect( + execTool!.execute("call1", { + command: "sort -oblocked-short.txt", + workdir: tmpDir, + }), + ).rejects.toThrow("exec denied: allowlist miss"); + expect(fs.existsSync(shortTarget)).toBe(false); + + const longTarget = path.join(tmpDir, "blocked-long.txt"); + await expect( + execTool!.execute("call2", { + command: "sort --output=blocked-long.txt", + workdir: tmpDir, + }), + ).rejects.toThrow("exec denied: allowlist miss"); + expect(fs.existsSync(longTarget)).toBe(false); + }); + + it("blocks grep recursive flags from reading cwd via safeBins", async () => { + if (process.platform === "win32") { + return; + } + + const { createOpenClawCodingTools } = await import("./pi-tools.js"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-safe-bins-grep-")); + fs.writeFileSync( + path.join(tmpDir, "secret.txt"), + "SAFE_BINS_RECURSIVE_SHOULD_NOT_LEAK\n", + "utf8", + ); + + const cfg: OpenClawConfig = { + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "off", + safeBins: ["grep"], + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: tmpDir, + agentDir: path.join(tmpDir, "agent"), + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + await expect( + execTool!.execute("call1", { + command: "grep -R SAFE_BINS_RECURSIVE_SHOULD_NOT_LEAK", + workdir: tmpDir, + }), + ).rejects.toThrow("exec denied: allowlist miss"); + }); });
src/infra/exec-approvals-allowlist.ts+55 −1 modified@@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { DEFAULT_SAFE_BINS, analyzeShellCommand, @@ -11,7 +12,6 @@ import { type CommandResolution, type ExecCommandSegment, } from "./exec-approvals-analysis.js"; -import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js"; function isPathLikeToken(value: string): boolean { @@ -62,6 +62,57 @@ function hasGlobToken(value: string): boolean { return /[*?[\]]/.test(value); } +type SafeBinOptionPolicy = { + blockedShort?: ReadonlySet<string>; + blockedLong?: ReadonlySet<string>; +}; + +const SAFE_BIN_OPTION_POLICIES: Readonly<Record<string, SafeBinOptionPolicy>> = { + // sort can write arbitrary output paths via -o/--output, which breaks stdin-only guarantees. + sort: { + blockedShort: new Set(["o"]), + blockedLong: new Set(["output"]), + }, + // grep recursion flags read from cwd (or provided roots), so they are not stdin-only. + grep: { + blockedShort: new Set(["d", "r"]), + blockedLong: new Set(["dereference-recursive", "directories", "recursive"]), + }, +}; + +function parseLongOptionName(token: string): string | null { + if (!token.startsWith("--") || token === "--") { + return null; + } + const body = token.slice(2); + if (!body) { + return null; + } + const eqIndex = body.indexOf("="); + const name = (eqIndex >= 0 ? body.slice(0, eqIndex) : body).trim().toLowerCase(); + return name.length > 0 ? name : null; +} + +function hasBlockedSafeBinOption(execName: string, token: string): boolean { + const policy = SAFE_BIN_OPTION_POLICIES[execName]; + if (!policy || !token.startsWith("-")) { + return false; + } + const longName = parseLongOptionName(token); + if (longName) { + return policy.blockedLong?.has(longName) ?? false; + } + if (token === "-" || token === "--") { + return false; + } + for (const ch of token.slice(1)) { + if (policy.blockedShort?.has(ch.toLowerCase())) { + return true; + } + } + return false; +} + export function isSafeBinUsage(params: { argv: string[]; resolution: CommandResolution | null; @@ -112,6 +163,9 @@ export function isSafeBinUsage(params: { continue; } if (token.startsWith("-")) { + if (hasBlockedSafeBinOption(execName, token)) { + return false; + } const eqIndex = token.indexOf("="); if (eqIndex > 0) { const value = token.slice(eqIndex + 1);
src/infra/exec-approvals-analysis.ts+2 −2 modified@@ -1,10 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { splitShellArgs } from "../utils/shell-argv.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; +import { splitShellArgs } from "../utils/shell-argv.js"; import { expandHomePrefix } from "./home-dir.js"; -export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"]; +export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"]; export type CommandResolution = { rawExecutable: string;
src/infra/exec-approvals.test.ts+48 −3 modified@@ -21,6 +21,7 @@ import { resolveExecApprovalsFromFile, resolveExecApprovalsPath, resolveExecApprovalsSocketPath, + resolveSafeBins, type ExecAllowlistEntry, type ExecApprovalsFile, } from "./exec-approvals.js"; @@ -414,6 +415,9 @@ describe("exec approvals safe bins", () => { argv: string[]; resolvedPath: string; expected: boolean; + safeBins?: string[]; + executableName?: string; + rawExecutable?: string; cwd?: string; setup?: (cwd: string) => void; }; @@ -439,6 +443,38 @@ describe("exec approvals safe bins", () => { expected: false, cwd: "/tmp", }, + { + name: "blocks sort output path via -o <file>", + argv: ["sort", "-o", "malicious.sh"], + resolvedPath: "/usr/bin/sort", + expected: false, + safeBins: ["sort"], + executableName: "sort", + }, + { + name: "blocks sort output path via attached short option (-ofile)", + argv: ["sort", "-omalicious.sh"], + resolvedPath: "/usr/bin/sort", + expected: false, + safeBins: ["sort"], + executableName: "sort", + }, + { + name: "blocks sort output path via --output=file", + argv: ["sort", "--output=malicious.sh"], + resolvedPath: "/usr/bin/sort", + expected: false, + safeBins: ["sort"], + executableName: "sort", + }, + { + name: "blocks grep recursive flags that read cwd", + argv: ["grep", "-R", "needle"], + resolvedPath: "/usr/bin/grep", + expected: false, + safeBins: ["grep"], + executableName: "grep", + }, ]; for (const testCase of cases) { @@ -448,14 +484,16 @@ describe("exec approvals safe bins", () => { } const cwd = testCase.cwd ?? makeTempDir(); testCase.setup?.(cwd); + const executableName = testCase.executableName ?? "jq"; + const rawExecutable = testCase.rawExecutable ?? executableName; const ok = isSafeBinUsage({ argv: testCase.argv, resolution: { - rawExecutable: "jq", + rawExecutable, resolvedPath: testCase.resolvedPath, - executableName: "jq", + executableName, }, - safeBins: normalizeSafeBins(["jq"]), + safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]), cwd, }); expect(ok).toBe(testCase.expected); @@ -479,6 +517,13 @@ describe("exec approvals safe bins", () => { }); expect(ok).toBe(true); }); + + it("does not include sort/grep in default safeBins", () => { + const defaults = resolveSafeBins(undefined); + expect(defaults.has("jq")).toBe(true); + expect(defaults.has("sort")).toBe(false); + expect(defaults.has("grep")).toBe(false); + }); }); describe("exec approvals allowlist evaluation", () => {
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
7- github.com/openclaw/openclaw/commit/bafdbb6f112409a65decd3d4e7350fbd637c7754ghsapatchWEB
- github.com/openclaw/openclaw/commit/cfe8457a0f4aae5324daec261d3b0aad1461a4bcghsapatchWEB
- github.com/openclaw/openclaw/commit/fec48a5006eab37c6a5821726ccaeec886486b13ghsapatchWEB
- github.com/advisories/GHSA-3x3x-h76w-hp98ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-3x3x-h76w-hp98ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32017ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-write-via-short-option-bypass-in-exec-allowlistghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.