UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

134 lines 6.59 kB
/** * Shared, consent-checked, allow-listed, timeout-bounded command executor. * * Every dashboard route that needs to spawn a local process must go through * this helper. Callers declare an explicit per-call-site allow-list; commands * not in that list are rejected synchronously without a spawn. Arguments are * passed positionally to `child_process.spawn` with `shell: false` so shell * metacharacters in `args` cannot be interpreted. * * Pitfalls: * - Do NOT pass `shell: true`. Ever. * - Do NOT accept user-supplied `env`. Callers must scrub secrets first. * - Always use positional `args: string[]`, never a single command string. * - The `allowList` is the security boundary, not `command -v`. */ import { type SpawnSyncReturns } from "node:child_process"; import { type EvidenceEnvelopeWriteOptions, type EvidenceEventKind } from "../evidence/envelope.js"; /** Spawn request accepted by `execSafely` after the caller has validated route inputs. */ export interface ExecOptions { /** The binary to spawn. Must exactly match an entry in `allowList`. */ command: string; /** Positional argv after `command`. No shell interpolation; metacharacters * in any arg cause a synchronous rejection before spawn. */ args: string[]; /** Working directory. Callers validate this with `validateLocalPath`. */ cwd: string; /** Hard wall-clock cap; the process is killed (SIGTERM → SIGKILL) on expiry. */ timeoutMs?: number; /** Explicit per-call-site whitelist. The command must be a member, regardless * of whether `command -v` would resolve it. */ allowList: readonly string[]; /** Optional environment. Defaults to a minimal PATH/temp env; callers that * pass env must scrub secrets first. */ env?: Record<string, string>; /** Optional cap on captured stdout bytes. Defaults to 1 MB. */ stdoutCapBytes?: number; /** Optional cap on captured stderr bytes. Defaults to 1 MB. */ stderrCapBytes?: number; /** Optional local evidence event for spawned command completion. */ evidence?: { projectPath: string; eventKind?: EvidenceEventKind; producer?: string; onWarning?: EvidenceEnvelopeWriteOptions["onWarning"]; }; } /** Stable result flags: `ok` means clean exit; `truncated` means an output cap fired. */ type ExecResultBooleanFields = Record<"ok" | "truncated", boolean>; /** Completed process result with captured output bounded to the configured byte caps. */ export interface ExecResult extends ExecResultBooleanFields { /** Exit code; `null` if the process was killed by signal. */ exitCode: number | null; /** Signal that terminated the process, if any. */ signal: NodeJS.Signals | null; /** Captured stdout, truncated with a marker line if the cap fired. */ stdout: string; /** Captured stderr, truncated with a marker line if the cap fired. */ stderr: string; /** Whether the timeout fired. */ timedOut: boolean; /** Wall-clock duration in milliseconds. */ durationMs: number; /** Basename of the spawned command, for telemetry. */ commandBasename: string; } /** Rejected safety check before any child process is spawned. */ declare class SafeExecRejection extends Error { readonly reason: "command-not-in-allow-list" | "args-contain-metacharacters" | "args-not-array"; constructor(reason: "command-not-in-allow-list" | "args-contain-metacharacters" | "args-not-array", message: string); } export { SafeExecRejection }; /** * Write one file atomically inside a project root. * * The temp file lives beside the destination so `rename` stays atomic on the * same filesystem. Existing destination content is replaced only after the * temp file is flushed and closed. * * @param targetPath - destination path to replace atomically * @param content - complete file contents to write * @param projectRoot - project boundary that targetPath must stay within */ export declare function writeFileAtomic(targetPath: string, content: string, projectRoot: string): void; /** * Spawns one allow-listed command without a shell and reports bounded output. * * The control flow stays explicit because each branch owns a different safety * invariant: pre-spawn rejection, timeout cleanup, output capping, spawn-error * recovery, and optional evidence writes. * * @param opts Spawn request plus allow-list, cwd, caps, and optional evidence settings. * @returns A promise that resolves with the process result or rejects with `SafeExecRejection`. */ export declare function execSafely(opts: ExecOptions): Promise<ExecResult>; /** * Spawn request accepted by `spawnInheritedSync` for interactive CLI children. * * Contract: `allowedBasenames` matches the command's lowercased basename rather * than the full path, because interactive callers pass resolved absolute * binaries (for example a discovered Windows Git Bash path). */ export interface InheritedSpawnOptions { /** Resolved binary to spawn; its basename must appear in `allowedBasenames`. */ command: string; /** Positional argv; rejected on shell metacharacters like `execSafely` args. */ args: string[]; /** Lowercase command basenames this call site permits (e.g. ["bash", "bash.exe"]). */ allowedBasenames: readonly string[]; /** Optional environment passed through to the child unchanged. */ env?: NodeJS.ProcessEnv; } /** * Spawn an allow-listed command with inherited stdio for interactive CLI flows. * * Unlike `execSafely`, output is not captured or capped: stdin/stdout/stderr stay * attached to the caller's terminal, which suits long-running interactive children * such as the bundled installer. The same pre-spawn gates apply - basename * allow-list, metacharacter-free string args - and the child always runs with * `shell: false`. Throws `SafeExecRejection` before any process is spawned when a * gate fails. * * @param opts - command, argv, allowed basenames, and optional child environment * @returns the raw `spawnSync` result; callers read `status`, `signal`, and `error` */ export declare function spawnInheritedSync(opts: InheritedSpawnOptions): SpawnSyncReturns<Buffer>; /** * Build the canonical key for side-effectful API route allow-lists. * * @param method HTTP method as received from the server. * @param path Normalised route path. * @returns Uppercase-method route key used by exact-match allow-lists. */ export declare function sideEffectfulRouteKey(method: string, path: string): string; //# sourceMappingURL=safe-exec.d.ts.map