diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts new file mode 100644 index 00000000000..9fd591458a9 --- /dev/null +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -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; + pty: boolean; + timeoutSec?: number; + defaultTimeoutSec: number; + security: ExecSecurity; + ask: ExecAsk; + safeBins: Set; + agentId?: string; + sessionKey?: string; + scopeKey?: string; + warnings: string[]; + notifySessionKey?: string; + approvalRunningNoticeMs: number; + maxOutput: number; + pendingMaxOutput: number; + trustedSafeBinDirs?: ReadonlySet; +}; + +export type ProcessGatewayAllowlistResult = { + execCommandOverride?: string; + pendingResult?: AgentToolResult; +}; + +export async function processGatewayAllowlist( + params: ProcessGatewayAllowlistParams, +): Promise { + 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(); + 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> | 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(); + 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 }; +} diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts new file mode 100644 index 00000000000..7023473cdb8 --- /dev/null +++ b/src/agents/bash-tools.exec-host-node.ts @@ -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; + requestedEnv?: Record; + requestedNode?: string; + boundNode?: string; + sessionKey?: string; + agentId?: string; + security: ExecSecurity; + ask: ExecAsk; + timeoutSec?: number; + defaultTimeoutSec: number; + approvalRunningNoticeMs: number; + warnings: string[]; + notifySessionKey?: string; + trustedSafeBinDirs?: ReadonlySet; +}; + +export async function executeNodeHostCommand( + params: ExecuteNodeHostCommandParams, +): Promise> { + 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; + + 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) : {}; + 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, + }; +} diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index c23b9e8c534..bc602255529 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -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"; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts new file mode 100644 index 00000000000..9a94f45543d --- /dev/null +++ b/src/agents/bash-tools.exec-types.ts @@ -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; + }; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 52de4249ecc..01136090c0b 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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, + 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, }); - 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, - }); - 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; - - 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) : {}; - 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, + 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, }); - 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(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(); - 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(); - 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 = diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 4ebe6bbd3a2..249a885be55 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -357,6 +357,7 @@ function evaluateSegments( allowlist: ExecAllowlistEntry[]; safeBins: Set; cwd?: string; + trustedSafeBinDirs?: ReadonlySet; skillBins?: Set; 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; cwd?: string; + trustedSafeBinDirs?: ReadonlySet; skillBins?: Set; 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; cwd?: string; env?: NodeJS.ProcessEnv; + trustedSafeBinDirs?: ReadonlySet; skillBins?: Set; 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, }); diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index e3a3914efeb..9acedf9c44b 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -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", () => { diff --git a/src/infra/exec-safe-bin-trust.test.ts b/src/infra/exec-safe-bin-trust.test.ts index 70d4a7795d2..c370b8122a9 100644 --- a/src/infra/exec-safe-bin-trust.test.ts +++ b/src/infra/exec-safe-bin-trust.test.ts @@ -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; + } + }); }); diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index b5f27c5b0d3..dfdffbc6bb0 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -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 { 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) { diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index fdd9e744497..b5cbec1263d 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -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(); 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, });