@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.
349 lines • 14 kB
JavaScript
/**
* 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 { spawn, spawnSync } from "node:child_process";
import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
import { basename as pathBasename, dirname, resolve, sep } from "node:path";
import { performance } from "node:perf_hooks";
import { StringDecoder } from "node:string_decoder";
import { recordEvidenceEvent, } from "../evidence/envelope.js";
const DEFAULT_TIMEOUT_MS = 30_000; // Timeout budget: dashboard commands must return before the UI feels stuck.
const DEFAULT_STDOUT_CAP_BYTES = 1_048_576; // 1 MB
const KILL_GRACE_MS = 2_000;
const DEFAULT_ENV_KEYS = [
"PATH",
"Path",
"PATHEXT",
"SystemRoot",
"WINDIR",
"TEMP",
"TMP",
"TMPDIR",
];
/**
* Shell metacharacters rejected in args. Because we always spawn with
* `shell: false`, redirection / glob characters like `>` `<` `*` are inert at
* execve time. We still reject the four genuinely-dangerous tokens because
* any hostile callee shelling out internally would re-interpret them.
*/
const SHELL_METACHARACTER = /[;|\n\r\0]/u;
const COMMAND_SUBSTITUTION = /\$\(|`/u;
/** Rejected safety check before any child process is spawned. */
class SafeExecRejection extends Error {
reason;
constructor(reason, message) {
super(message);
this.name = "SafeExecRejection";
this.reason = reason;
}
}
export { SafeExecRejection };
/** Rejected local file write before any content is written. */
class SafeFileWriteRejection extends Error {
reason = "target-outside-project";
/** Report the rejected destination and project root without writing any file content. */
constructor(targetPath, projectRoot) {
super(`Refusing to write ${JSON.stringify(targetPath)} outside project root ${JSON.stringify(projectRoot)}`);
this.name = "SafeFileWriteRejection";
}
}
/** Extract telemetry-safe command names from POSIX or Windows-style command paths. */
function basename(path) {
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return slash === -1 ? path : path.slice(slash + 1);
}
/** Confirm a target path resolves to the project root or one of its descendants. */
function isWithinProject(projectRoot, targetPath) {
const root = resolve(projectRoot);
const target = resolve(targetPath);
return target === root || target.startsWith(`${root}${sep}`);
}
/**
* 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 function writeFileAtomic(targetPath, content, projectRoot) {
if (!isWithinProject(projectRoot, targetPath)) {
throw new SafeFileWriteRejection(targetPath, projectRoot);
}
const dir = dirname(targetPath);
mkdirSync(dir, { recursive: true });
const tempPath = resolve(dir, `.${pathBasename(targetPath)}.${process.pid}.${Date.now()}.tmp`);
if (!isWithinProject(projectRoot, tempPath)) {
throw new SafeFileWriteRejection(tempPath, projectRoot);
}
let fd = null;
try {
fd = openSync(tempPath, "w", 0o600);
writeFileSync(fd, content, "utf-8");
fsyncSync(fd);
closeSync(fd);
fd = null;
renameSync(tempPath, targetPath);
}
catch (err) {
if (fd !== null)
closeSync(fd);
try {
unlinkSync(tempPath);
}
catch {
/* temp file may be missing — best-effort cleanup before rethrow */
}
throw err;
}
}
/** Build a minimal inherited environment so spawned commands keep PATH but not secrets. */
function defaultSafeEnv() {
const env = {};
for (const key of DEFAULT_ENV_KEYS) {
const envValue = process.env[key];
if (typeof envValue === "string" && envValue !== "")
env[key] = envValue;
}
return env;
}
/** Throws `SafeExecRejection` for argv shapes that could become dangerous if a callee shells out. */
function rejectIfUnsafeArgs(args) {
if (!Array.isArray(args)) {
throw new SafeExecRejection("args-not-array", "args must be an array");
}
for (const [index, arg] of args.entries()) {
if (typeof arg !== "string") {
throw new SafeExecRejection("args-not-array", `args[${index}] must be a string`);
}
if (SHELL_METACHARACTER.test(arg) || COMMAND_SUBSTITUTION.test(arg)) {
throw new SafeExecRejection("args-contain-metacharacters", `args[${index}] contains shell metacharacters: ${JSON.stringify(arg)}`);
}
}
}
function capBuffer(buffers, totalBytes, capBytes) {
const truncated = totalBytes > capBytes;
const joined = Buffer.concat(buffers);
if (!truncated)
return { text: joined.toString("utf-8"), truncated: false };
const decoder = new StringDecoder("utf8");
const head = decoder.write(joined.subarray(0, Math.max(0, capBytes)));
return {
text: `${head}\n…[output truncated at ${capBytes} bytes]`,
truncated: true,
};
}
/**
* Validate command and argv before spawn.
*
* @throws SafeExecRejection when the command is not explicitly allowed or argv is unsafe.
*/
function validateExecRequest(opts) {
if (!opts.allowList.includes(opts.command)) {
throw new SafeExecRejection("command-not-in-allow-list", `command ${JSON.stringify(opts.command)} is not in the allow-list`);
}
rejectIfUnsafeArgs(opts.args);
}
/** Resolve timeout, caps, command display name, and environment after validation. */
function buildExecRuntimeConfig(opts) {
return {
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
stdoutCap: opts.stdoutCapBytes ?? DEFAULT_STDOUT_CAP_BYTES,
stderrCap: opts.stderrCapBytes ?? DEFAULT_STDOUT_CAP_BYTES,
commandBasename: basename(opts.command),
env: opts.env ?? defaultSafeEnv(),
};
}
/** Create an output accumulator for stdout or stderr. */
function createOutputCapture() {
return { chunks: [], bytes: 0 };
}
/** Append child-process data while retaining only enough bytes to produce a capped response. */
function appendOutputChunk(capture, chunk, capBytes) {
capture.bytes += chunk.length;
if (capture.bytes <= capBytes * 2)
capture.chunks.push(chunk);
}
/** Start the timeout that first sends SIGTERM, then SIGKILL after the grace period. */
function startTimeoutGuard(child, timeoutMs, onTimeout) {
const timer = setTimeout(() => {
onTimeout();
try {
child.kill("SIGTERM");
}
catch {
/* already gone */
}
setTimeout(() => {
try {
child.kill("SIGKILL");
}
catch {
/* already gone */
}
}, KILL_GRACE_MS).unref();
}, timeoutMs);
timer.unref();
return timer;
}
/**
* Build the externally visible execution result from collected process state.
*
* @param runtime Runtime config derived from validated options.
* @param output Captured stdout and stderr accumulators.
* @param status Process close status and timeout state.
* @returns Redacted, capped execution result for API responses and evidence logs.
*/
function buildExecResult(runtime, output, status) {
const out = capBuffer(output.stdout.chunks, output.stdout.bytes, runtime.stdoutCap);
const err = capBuffer(output.stderr.chunks, output.stderr.bytes, runtime.stderrCap);
return {
ok: !status.hasTimedOut && status.exitCode === 0,
exitCode: status.exitCode,
signal: status.signal,
stdout: out.text,
stderr: err.text,
timedOut: status.hasTimedOut,
truncated: out.truncated || err.truncated,
durationMs: Number((performance.now() - status.start).toFixed(2)),
commandBasename: runtime.commandBasename,
};
}
/** Writes redacted command-completion evidence only when a route opts in. */
function recordExecEvidence(opts, result) {
if (!opts.evidence)
return;
recordEvidenceEvent({
actor: "server",
eventType: opts.evidence.eventKind ?? "audit.exec",
producer: opts.evidence.producer ?? "safe-exec",
projectRoot: opts.evidence.projectPath,
payload: {
command: result.commandBasename,
ok: result.ok,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
truncated: result.truncated,
durationMs: result.durationMs,
},
provenance: {
framework_evidence_paths: ["src/cli/server/safe-exec.ts"],
reason: "safe-exec records command completion without args, stdout, or stderr",
},
}, { onWarning: opts.evidence.onWarning });
}
/**
* 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 function execSafely(opts) {
try {
validateExecRequest(opts);
}
catch (err) {
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
}
const runtime = buildExecRuntimeConfig(opts);
return new Promise((resolveExec) => {
const start = performance.now();
const stdout = createOutputCapture();
const stderr = createOutputCapture();
let hasTimedOut = false;
let hasSettled = false;
const child = spawn(opts.command, opts.args, {
cwd: opts.cwd,
env: runtime.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
const timer = startTimeoutGuard(child, runtime.timeoutMs, () => {
hasTimedOut = true;
});
child.stdout.on("data", (chunk) => {
appendOutputChunk(stdout, chunk, runtime.stdoutCap);
});
child.stderr.on("data", (chunk) => {
appendOutputChunk(stderr, chunk, runtime.stderrCap);
});
/** Writes evidence and resolves once because both spawn error and close can fire. */
function finish(exitCode, signal) {
if (hasSettled)
return;
hasSettled = true;
clearTimeout(timer);
const result = buildExecResult(runtime, { stdout, stderr }, { exitCode, signal, hasTimedOut, start });
recordExecEvidence(opts, result);
resolveExec(result);
}
child.on("error", (e) => {
appendOutputChunk(stderr, Buffer.from(`spawn error: ${e.message}`, "utf-8"), runtime.stderrCap);
finish(null, null);
});
child.on("close", (code, signal) => {
finish(code, signal);
});
});
}
/**
* 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 function spawnInheritedSync(opts) {
const commandBasename = pathBasename(opts.command).toLowerCase();
// Normalise the allow-list to lowercase too, matching the documented
// "lowercase basenames" contract: the command side is already lowercased, so
// comparing against verbatim entries would silently reject a correct command
// whenever a caller passed a mixed-case allow-list entry.
const allowedBasenames = opts.allowedBasenames.map((name) => name.toLowerCase());
if (!allowedBasenames.includes(commandBasename)) {
throw new SafeExecRejection("command-not-in-allow-list", `command ${JSON.stringify(opts.command)} is not in the allow-list`);
}
rejectIfUnsafeArgs(opts.args);
return spawnSync(opts.command, opts.args, {
env: opts.env,
stdio: "inherit",
shell: false,
});
}
/**
* 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 function sideEffectfulRouteKey(method, path) {
return `${method.toUpperCase()} ${path}`;
}
//# sourceMappingURL=safe-exec.js.map