@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
TypeScript
/**
* 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