import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { guardedCommand, SHELL_SYNTAX } from "../security/permissions.js"; import { GovernanceViolation } from "../security/worker-scope.js"; const execFileAsync = promisify(execFile); export interface ParsedArgv { file: string; args: string[] } export function parseArgv(command: string): ParsedArgv { if (SHELL_SYNTAX.test(command)) throw new Error("Verification command must be a single argv command without shell operators"); const parts: string[] = []; let token = ""; let quote: "'" | '"' | undefined; for (const char of command.trim()) { if (quote) { if (char === quote) quote = undefined; else token += char; } else if (char === "'" || char === '"') quote = char; else if (/\s/.test(char)) { if (token) { parts.push(token); token = ""; } } else token += char; } if (quote || token === "" && parts.length === 0) throw new Error("Invalid verification command"); if (token) parts.push(token); const [file, ...args] = parts; if (!file) throw new Error("Missing verification executable"); return { file, args }; } export interface GuardedExecResult { exitCode: number | null; stdout: string; stderr: string; durationMs: number; timedOut: boolean } export async function guardedExec(command: string, cwd: string, timeoutMs: number, validate?: (argv: ParsedArgv) => void | Promise): Promise { const decision = guardedCommand(command); if (decision === "block") throw new GovernanceViolation("blocked-command"); if (decision !== "allow") throw new Error(`Guarded execution ${decision}s: ${command}`); const argv = parseArgv(command); // The decision above read the command as written. This one reads what execFile will // actually be handed, so the text that was judged and the argv that runs cannot be two // different things -- which is precisely how a quoted `--pre` used to pass as safe. if (guardedCommand([argv.file, ...argv.args].join(" ")) !== "allow") throw new GovernanceViolation("blocked-command"); await validate?.(argv); const { file, args } = argv; const startedAt = Date.now(); const env = { ...process.env }; delete env.NODE_TEST_CONTEXT; try { const { stdout, stderr } = await execFileAsync(file, args, { cwd, env, timeout: timeoutMs, maxBuffer: 256 * 1024, encoding: "utf8" }); return { exitCode: 0, stdout, stderr, durationMs: Date.now() - startedAt, timedOut: false }; } catch (error) { const failed = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string; killed?: boolean }; return { exitCode: typeof failed.code === "number" ? failed.code : null, stdout: failed.stdout ?? "", stderr: failed.stderr ?? failed.message, durationMs: Date.now() - startedAt, timedOut: Boolean(failed.killed) }; } }