import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import type { GatewayClient } from "../gateway/client.js"; import { addAllowlistEntry, analyzeArgvCommand, evaluateExecAllowlist, evaluateShellAllowlist, recordAllowlistUse, resolveAllowAlwaysPatterns, resolveExecApprovals, type ExecAllowlistEntry, type ExecAsk, type ExecCommandSegment, type ExecSecurity, type SystemRunApprovalPlanV2, type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; import { sameFileIdentity } from "../infra/file-identity.js"; import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; import type { ExecEventPayload, RunResult, SkillBinsProvider, SystemRunParams, } from "./invoke-types.js"; type SystemRunInvokeResult = { ok: boolean; payloadJSON?: string | null; error?: { code?: string; message?: string } | null; }; type SystemRunDeniedReason = | "security=deny" | "approval-required" | "allowlist-miss" | "execution-plan-miss" | "companion-unavailable" | "permission:screenRecording"; type SystemRunExecutionContext = { sessionKey: string; runId: string; cmdText: string; }; type SystemRunAllowlistAnalysis = { analysisOk: boolean; allowlistMatches: ExecAllowlistEntry[]; allowlistSatisfied: boolean; segments: ExecCommandSegment[]; }; type ResolvedExecApprovals = ReturnType; type SystemRunParsePhase = { argv: string[]; shellCommand: string | null; cmdText: string; agentId: string | undefined; sessionKey: string; runId: string; execution: SystemRunExecutionContext; approvalDecision: ReturnType; envOverrides: Record | undefined; env: Record | undefined; cwd: string | undefined; timeoutMs: number | undefined; needsScreenRecording: boolean; approved: boolean; }; type SystemRunPolicyPhase = SystemRunParsePhase & { approvals: ResolvedExecApprovals; security: ExecSecurity; policy: ReturnType; allowlistMatches: ExecAllowlistEntry[]; analysisOk: boolean; allowlistSatisfied: boolean; segments: ExecCommandSegment[]; plannedAllowlistArgv: string[] | undefined; isWindows: boolean; }; const safeBinTrustedDirWarningCache = new Set(); function warnWritableTrustedDirOnce(message: string): void { if (safeBinTrustedDirWarningCache.has(message)) { return; } safeBinTrustedDirWarningCache.add(message); console.warn(message); } function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeniedReason { switch (reason) { case "security=deny": case "approval-required": case "allowlist-miss": case "execution-plan-miss": case "companion-unavailable": case "permission:screenRecording": return reason; default: return "approval-required"; } } function normalizeString(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; } function isPathLikeExecutableToken(value: string): boolean { if (!value) { return false; } if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) { return true; } if (value.includes("/") || value.includes("\\")) { return true; } if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) { return true; } return false; } function pathComponentsFromRootSync(targetPath: string): string[] { const absolute = path.resolve(targetPath); const parts: string[] = []; let cursor = absolute; while (true) { parts.unshift(cursor); const parent = path.dirname(cursor); if (parent === cursor) { return parts; } cursor = parent; } } function isWritableByCurrentProcessSync(candidate: string): boolean { try { fs.accessSync(candidate, fs.constants.W_OK); return true; } catch { return false; } } function hasMutableSymlinkPathComponentSync(targetPath: string): boolean { for (const component of pathComponentsFromRootSync(targetPath)) { try { if (!fs.lstatSync(component).isSymbolicLink()) { continue; } const parentDir = path.dirname(component); if (isWritableByCurrentProcessSync(parentDir)) { return true; } } catch { return true; } } return false; } function hardenApprovedExecutionPaths(params: { approvedByAsk: boolean; argv: string[]; shellCommand: string | null; cwd: string | undefined; }): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } { if (!params.approvedByAsk) { return { ok: true, argv: params.argv, cwd: params.cwd }; } let hardenedCwd = params.cwd; if (hardenedCwd) { const requestedCwd = path.resolve(hardenedCwd); let cwdLstat: fs.Stats; let cwdStat: fs.Stats; let cwdReal: string; let cwdRealStat: fs.Stats; try { cwdLstat = fs.lstatSync(requestedCwd); cwdStat = fs.statSync(requestedCwd); cwdReal = fs.realpathSync(requestedCwd); cwdRealStat = fs.statSync(cwdReal); } catch { return { ok: false, message: "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd", }; } if (!cwdStat.isDirectory()) { return { ok: false, message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory", }; } if (hasMutableSymlinkPathComponentSync(requestedCwd)) { return { ok: false, message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink path components)", }; } if (cwdLstat.isSymbolicLink()) { return { ok: false, message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink cwd)", }; } if ( !sameFileIdentity(cwdStat, cwdLstat) || !sameFileIdentity(cwdStat, cwdRealStat) || !sameFileIdentity(cwdLstat, cwdRealStat) ) { return { ok: false, message: "SYSTEM_RUN_DENIED: approval cwd identity mismatch", }; } hardenedCwd = cwdReal; } if (params.shellCommand !== null || params.argv.length === 0) { return { ok: true, argv: params.argv, cwd: hardenedCwd }; } const argv = [...params.argv]; const rawExecutable = argv[0] ?? ""; if (!isPathLikeExecutableToken(rawExecutable)) { return { ok: true, argv, cwd: hardenedCwd }; } const base = hardenedCwd ?? process.cwd(); const candidate = path.isAbsolute(rawExecutable) ? rawExecutable : path.resolve(base, rawExecutable); try { argv[0] = fs.realpathSync(candidate); } catch { return { ok: false, message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", }; } return { ok: true, argv, cwd: hardenedCwd }; } export function buildSystemRunApprovalPlanV2(params: { command?: unknown; rawCommand?: unknown; cwd?: unknown; agentId?: unknown; sessionKey?: unknown; }): { ok: true; plan: SystemRunApprovalPlanV2; cmdText: string } | { ok: false; message: string } { const command = resolveSystemRunCommand({ command: params.command, rawCommand: params.rawCommand, }); if (!command.ok) { return { ok: false, message: command.message }; } if (command.argv.length === 0) { return { ok: false, message: "command required" }; } const hardening = hardenApprovedExecutionPaths({ approvedByAsk: true, argv: command.argv, shellCommand: command.shellCommand, cwd: normalizeString(params.cwd) ?? undefined, }); if (!hardening.ok) { return { ok: false, message: hardening.message }; } return { ok: true, plan: { version: 2, argv: hardening.argv, cwd: hardening.cwd ?? null, rawCommand: command.cmdText.trim() || null, agentId: normalizeString(params.agentId), sessionKey: normalizeString(params.sessionKey), }, cmdText: command.cmdText, }; } export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams; skillBins: SkillBinsProvider; execHostEnforced: boolean; execHostFallbackAllowed: boolean; resolveExecSecurity: (value?: string) => ExecSecurity; resolveExecAsk: (value?: string) => ExecAsk; isCmdExeInvocation: (argv: string[]) => boolean; sanitizeEnv: (overrides?: Record | null) => Record | undefined; runCommand: ( argv: string[], cwd: string | undefined, env: Record | undefined, timeoutMs: number | undefined, ) => Promise; runViaMacAppExecHost: (params: { approvals: ReturnType; request: ExecHostRequest; }) => Promise; sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise; buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload; sendInvokeResult: (result: SystemRunInvokeResult) => Promise; sendExecFinishedEvent: (params: { sessionKey: string; runId: string; cmdText: string; result: { stdout?: string; stderr?: string; error?: string | null; exitCode?: number | null; timedOut?: boolean; success?: boolean; }; }) => Promise; preferMacAppExecHost: boolean; }; async function sendSystemRunDenied( opts: Pick< HandleSystemRunInvokeOptions, "client" | "sendNodeEvent" | "buildExecEventPayload" | "sendInvokeResult" >, execution: SystemRunExecutionContext, params: { reason: SystemRunDeniedReason; message: string; }, ) { await opts.sendNodeEvent( opts.client, "exec.denied", opts.buildExecEventPayload({ sessionKey: execution.sessionKey, runId: execution.runId, host: "node", command: execution.cmdText, reason: params.reason, }), ); await opts.sendInvokeResult({ ok: false, error: { code: "UNAVAILABLE", message: params.message }, }); } function evaluateSystemRunAllowlist(params: { shellCommand: string | null; argv: string[]; approvals: ReturnType; security: ExecSecurity; safeBins: ReturnType["safeBins"]; safeBinProfiles: ReturnType["safeBinProfiles"]; trustedSafeBinDirs: ReturnType["trustedSafeBinDirs"]; cwd: string | undefined; env: Record | undefined; skillBins: SkillBinTrustEntry[]; autoAllowSkills: boolean; }): SystemRunAllowlistAnalysis { if (params.shellCommand) { const allowlistEval = evaluateShellAllowlist({ command: params.shellCommand, allowlist: params.approvals.allowlist, safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, env: params.env, trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, platform: process.platform, }); return { analysisOk: allowlistEval.analysisOk, allowlistMatches: allowlistEval.allowlistMatches, allowlistSatisfied: params.security === "allowlist" && allowlistEval.analysisOk ? allowlistEval.allowlistSatisfied : false, segments: allowlistEval.segments, }; } const analysis = analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env }); const allowlistEval = evaluateExecAllowlist({ analysis, allowlist: params.approvals.allowlist, safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, trustedSafeBinDirs: params.trustedSafeBinDirs, skillBins: params.skillBins, autoAllowSkills: params.autoAllowSkills, }); return { analysisOk: analysis.ok, allowlistMatches: allowlistEval.allowlistMatches, allowlistSatisfied: params.security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false, segments: analysis.segments, }; } function resolvePlannedAllowlistArgv(params: { security: ExecSecurity; shellCommand: string | null; policy: { approvedByAsk: boolean; analysisOk: boolean; allowlistSatisfied: boolean; }; segments: ExecCommandSegment[]; }): string[] | undefined | null { if ( params.security !== "allowlist" || params.policy.approvedByAsk || params.shellCommand || !params.policy.analysisOk || !params.policy.allowlistSatisfied || params.segments.length !== 1 ) { return undefined; } const plannedAllowlistArgv = params.segments[0]?.resolution?.effectiveArgv; return plannedAllowlistArgv && plannedAllowlistArgv.length > 0 ? plannedAllowlistArgv : null; } function resolveSystemRunExecArgv(params: { plannedAllowlistArgv: string[] | undefined; argv: string[]; security: ExecSecurity; isWindows: boolean; policy: { approvedByAsk: boolean; analysisOk: boolean; allowlistSatisfied: boolean; }; shellCommand: string | null; segments: ExecCommandSegment[]; }): string[] { let execArgv = params.plannedAllowlistArgv ?? params.argv; if ( params.security === "allowlist" && params.isWindows && !params.policy.approvedByAsk && params.shellCommand && params.policy.analysisOk && params.policy.allowlistSatisfied && params.segments.length === 1 && params.segments[0]?.argv.length > 0 ) { execArgv = params.segments[0].argv; } return execArgv; } function applyOutputTruncation(result: RunResult) { if (!result.truncated) { return; } const suffix = "... (truncated)"; if (result.stderr.trim().length > 0) { result.stderr = `${result.stderr}\n${suffix}`; } else { result.stdout = `${result.stdout}\n${suffix}`; } } export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; async function parseSystemRunPhase( opts: HandleSystemRunInvokeOptions, ): Promise { const command = resolveSystemRunCommand({ command: opts.params.command, rawCommand: opts.params.rawCommand, }); if (!command.ok) { await opts.sendInvokeResult({ ok: false, error: { code: "INVALID_REQUEST", message: command.message }, }); return null; } if (command.argv.length === 0) { await opts.sendInvokeResult({ ok: false, error: { code: "INVALID_REQUEST", message: "command required" }, }); return null; } const shellCommand = command.shellCommand; const cmdText = command.cmdText; const agentId = opts.params.agentId?.trim() || undefined; const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); const envOverrides = sanitizeSystemRunEnvOverrides({ overrides: opts.params.env ?? undefined, shellWrapper: shellCommand !== null, }); return { argv: command.argv, shellCommand, cmdText, agentId, sessionKey, runId, execution: { sessionKey, runId, cmdText }, approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision), envOverrides, env: opts.sanitizeEnv(envOverrides), cwd: opts.params.cwd?.trim() || undefined, timeoutMs: opts.params.timeoutMs ?? undefined, needsScreenRecording: opts.params.needsScreenRecording === true, approved: opts.params.approved === true, }; } async function evaluateSystemRunPolicyPhase( opts: HandleSystemRunInvokeOptions, parsed: SystemRunParsePhase, ): Promise { const cfg = loadConfig(); const agentExec = parsed.agentId ? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec : undefined; const configuredSecurity = opts.resolveExecSecurity( agentExec?.security ?? cfg.tools?.exec?.security, ); const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); const approvals = resolveExecApprovals(parsed.agentId, { security: configuredSecurity, ask: configuredAsk, }); const security = approvals.agent.security; const ask = approvals.agent.ask; const autoAllowSkills = approvals.agent.autoAllowSkills; const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({ global: cfg.tools?.exec, local: agentExec, onWarning: warnWritableTrustedDirOnce, }); const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ shellCommand: parsed.shellCommand, argv: parsed.argv, approvals, security, safeBins, safeBinProfiles, trustedSafeBinDirs, cwd: parsed.cwd, env: parsed.env, skillBins: bins, autoAllowSkills, }); const isWindows = process.platform === "win32"; const cmdInvocation = parsed.shellCommand ? opts.isCmdExeInvocation(segments[0]?.argv ?? []) : opts.isCmdExeInvocation(parsed.argv); const policy = evaluateSystemRunPolicy({ security, ask, analysisOk, allowlistSatisfied, approvalDecision: parsed.approvalDecision, approved: parsed.approved, isWindows, cmdInvocation, shellWrapperInvocation: parsed.shellCommand !== null, }); analysisOk = policy.analysisOk; allowlistSatisfied = policy.allowlistSatisfied; if (!policy.allowed) { await sendSystemRunDenied(opts, parsed.execution, { reason: policy.eventReason, message: policy.errorMessage, }); return null; } // Fail closed if policy/runtime drift re-allows unapproved shell wrappers. if (security === "allowlist" && parsed.shellCommand && !policy.approvedByAsk) { await sendSystemRunDenied(opts, parsed.execution, { reason: "approval-required", message: "SYSTEM_RUN_DENIED: approval required", }); return null; } const hardenedPaths = hardenApprovedExecutionPaths({ approvedByAsk: policy.approvedByAsk, argv: parsed.argv, shellCommand: parsed.shellCommand, cwd: parsed.cwd, }); if (!hardenedPaths.ok) { await sendSystemRunDenied(opts, parsed.execution, { reason: "approval-required", message: hardenedPaths.message, }); return null; } const plannedAllowlistArgv = resolvePlannedAllowlistArgv({ security, shellCommand: parsed.shellCommand, policy, segments, }); if (plannedAllowlistArgv === null) { await sendSystemRunDenied(opts, parsed.execution, { reason: "execution-plan-miss", message: "SYSTEM_RUN_DENIED: execution plan mismatch", }); return null; } return { ...parsed, argv: hardenedPaths.argv, cwd: hardenedPaths.cwd, approvals, security, policy, allowlistMatches, analysisOk, allowlistSatisfied, segments, plannedAllowlistArgv: plannedAllowlistArgv ?? undefined, isWindows, }; } async function executeSystemRunPhase( opts: HandleSystemRunInvokeOptions, phase: SystemRunPolicyPhase, ): Promise { const useMacAppExec = opts.preferMacAppExecHost; if (useMacAppExec) { const execRequest: ExecHostRequest = { command: phase.plannedAllowlistArgv ?? phase.argv, // Forward canonical display text so companion approval/prompt surfaces bind to // the exact command context already validated on the node-host. rawCommand: phase.cmdText || null, cwd: phase.cwd ?? null, env: phase.envOverrides ?? null, timeoutMs: phase.timeoutMs ?? null, needsScreenRecording: phase.needsScreenRecording, agentId: phase.agentId ?? null, sessionKey: phase.sessionKey ?? null, approvalDecision: phase.approvalDecision, }; const response = await opts.runViaMacAppExecHost({ approvals: phase.approvals, request: execRequest, }); if (!response) { if (opts.execHostEnforced || !opts.execHostFallbackAllowed) { await sendSystemRunDenied(opts, phase.execution, { reason: "companion-unavailable", message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable", }); return; } } else if (!response.ok) { await sendSystemRunDenied(opts, phase.execution, { reason: normalizeDeniedReason(response.error.reason), message: response.error.message, }); return; } else { const result: ExecHostRunResult = response.payload; await opts.sendExecFinishedEvent({ sessionKey: phase.sessionKey, runId: phase.runId, cmdText: phase.cmdText, result, }); await opts.sendInvokeResult({ ok: true, payloadJSON: JSON.stringify(result), }); return; } } if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") { if (phase.policy.analysisOk) { const patterns = resolveAllowAlwaysPatterns({ segments: phase.segments, cwd: phase.cwd, env: phase.env, platform: process.platform, }); for (const pattern of patterns) { if (pattern) { addAllowlistEntry(phase.approvals.file, phase.agentId, pattern); } } } } if (phase.allowlistMatches.length > 0) { const seen = new Set(); for (const match of phase.allowlistMatches) { if (!match?.pattern || seen.has(match.pattern)) { continue; } seen.add(match.pattern); recordAllowlistUse( phase.approvals.file, phase.agentId, match, phase.cmdText, phase.segments[0]?.resolution?.resolvedPath, ); } } if (phase.needsScreenRecording) { await sendSystemRunDenied(opts, phase.execution, { reason: "permission:screenRecording", message: "PERMISSION_MISSING: screenRecording", }); return; } const execArgv = resolveSystemRunExecArgv({ plannedAllowlistArgv: phase.plannedAllowlistArgv, argv: phase.argv, security: phase.security, isWindows: phase.isWindows, policy: phase.policy, shellCommand: phase.shellCommand, segments: phase.segments, }); const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs); applyOutputTruncation(result); await opts.sendExecFinishedEvent({ sessionKey: phase.sessionKey, runId: phase.runId, cmdText: phase.cmdText, result, }); await opts.sendInvokeResult({ ok: true, payloadJSON: JSON.stringify({ exitCode: result.exitCode, timedOut: result.timedOut, success: result.success, stdout: result.stdout, stderr: result.stderr, error: result.error ?? null, }), }); } export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { const parsed = await parseSystemRunPhase(opts); if (!parsed) { return; } const policyPhase = await evaluateSystemRunPolicyPhase(opts, parsed); if (!policyPhase) { return; } await executeSystemRunPhase(opts, policyPhase); }