/** * Hook command execution — the only file in extensions/hooks that touches * child_process. Everything upstream (matching, payload, envelope, decision) * is pure and tested with a fake executor; this file is tested against real * /bin/sh children. * * pi.exec is deliberately not used: it hardcodes shell:false and gives the * child no stdin, while Claude Code hooks are shell command strings that read * a JSON payload from stdin. (Ported from pi-code's runHookCommand, MIT — see * docs/decisions.md.) * * Hardening carried over: * - absolute /bin/sh, so a repo-local `sh` on PATH can't hijack the hook * - detached:true makes the shell a process-group leader; timeout SIGKILLs * the negative pid so grandchildren holding the stdio pipes die too — * otherwise `close` never fires and the promise hangs past the timeout * - setEncoding("utf8") so multi-byte characters can't be split across chunks * - output capped at 1MB per stream * - stdin errors ignored (a hook that exits without reading stdin — `exit 2` * — would otherwise EPIPE-crash the write) * - stdin JSON is newline-terminated: without a trailing "\n" a hook doing * `read -r line` sees EOF-before-delimiter and `read` exits 1, so the * `if read -r line; then …` branch is silently skipped even though the * variable was populated (Claude Code carries the same fix — bug CC-161) * - timeout clamped under Node's 2^31-1 ms timer overflow, timer unref'd (it * still fires while the process lives; it must not be what keeps it alive) * - the child process handle stays REF'd while the hook runs, unless the caller * passes `detached` (SessionEnd at shutdown, fire-and-forget). Until * 2026-09-05 every hook child was `unref()`d, and in a one-shot run * (`pi -p` / `--mode json`) a PreToolUse hook was often the only pending * work: the provider's keep-alive socket is idle between the response and * the tool's spawn, so nothing ref'd remained but the hook's stdio pipes. * When the child exited, the pipe-close callbacks could run before its * SIGCHLD-driven exit callback; with the process handle unref'd the loop * counted itself empty and node exited 0 mid-tool, silently, before the * hook's `close` ever fired — measured at ~55% of runs with a trivial * `exit 0` hook (docs/one-shot-lsp-event-loop-drain.md has the same * mechanism for the LSP client). A ref'd child holds the loop until `close`. */ import { spawn } from "node:child_process"; import { killProcessTree } from "../lib/process-tree.ts"; export interface HookRunResult { /** * null when the process was killed (timeout) or never spawned. Normalized on * the timeout path rather than taken from `waitpid`: a group SIGKILL is not * atomic, so the shell can be scheduled after its foreground child is killed * and before its own signal lands, reap the child and exit(128+9) itself — * `close` then reports a normal exit of 137 instead of death by signal * (roughly 1% of timeouts under load, and never when the shell had exec'd * away, leaving no shell to reap; findings §10.20). Callers should still * prefer `timedOut`, which says what happened rather than what it looked * like, but they no longer have to. */ exitCode: number | null; timedOut: boolean; /** Set when the child could not be spawned at all. */ spawnError?: string; stdout: string; stderr: string; durationMs: number; } export interface HookRunOptions { cwd: string; /** Seconds, Claude Code convention. Clamped to [1, MAX_TIMEOUT_S]. */ timeoutSeconds?: number; /** Exposed to the hook as CLAUDE_PROJECT_DIR; defaults to cwd. */ projectDir?: string; /** * Let the process exit without waiting for this hook. Only for fire-and-forget * dispatches at shutdown; an awaited hook must keep the event loop alive or a * one-shot run drains mid-await (header). The child is `unref()`d AND spawned * without stdout/stderr pipes: `unref` releases the process handle only, and * each inherited pipe is a ref'd handle of its own that stays open until the * child exits — a `sleep 20` SessionEnd hook held a `-p` run open for the * full 20 s (LIFECYCLE-REVIEW-2026-09-06 M4, measured). stdin is still piped * for the payload, written and closed at once, and unref'd too. The result's * stdout/stderr are therefore empty for a detached hook — nothing reads them. */ detached?: boolean; } const MAX_OUTPUT_BYTES = 1_000_000; const DEFAULT_TIMEOUT_S = 60; /** Node timers silently fire immediately above 2^31-1 ms. */ const MAX_TIMEOUT_S = 2_147_483; export function runHookCommand(command: string, stdinJson: string, opts: HookRunOptions): Promise { const timeoutMs = Math.min(Math.max(opts.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 1), MAX_TIMEOUT_S) * 1000; const started = Date.now(); return new Promise((resolve) => { let child: ReturnType; try { child = spawn("/bin/sh", ["-c", command], { cwd: opts.cwd, detached: true, stdio: opts.detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"], env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd }, }); } catch (error) { resolve({ exitCode: null, timedOut: false, spawnError: error instanceof Error ? error.message : String(error), stdout: "", stderr: "", durationMs: Date.now() - started, }); return; } if (opts.detached) { child.unref(); // The stdin pipe is the one handle left; it closes as soon as the // payload is flushed below, and must not hold the loop meanwhile. (child.stdin as { unref?: () => void } | null)?.unref?.(); } let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const capture = (sink: "stdout" | "stderr") => (chunk: string) => { const current = sink === "stdout" ? stdout : stderr; if (current.length >= MAX_OUTPUT_BYTES) return; const next = current + chunk.slice(0, MAX_OUTPUT_BYTES - current.length); if (sink === "stdout") stdout = next; else stderr = next; }; child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); child.stdout?.on("data", capture("stdout")); child.stderr?.on("data", capture("stderr")); const timer = setTimeout(() => { timedOut = true; // The whole process group the detached shell leads (lib/process-tree.ts). killProcessTree(child, "SIGKILL"); }, timeoutMs); timer.unref(); const finish = (result: Omit) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ ...result, durationMs: Date.now() - started }); }; child.on("error", (error) => { finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr }); }); child.on("close", (code) => { // A killed process reports null here, EXCEPT when the shell outlived the // group kill just long enough to reap its child and exit 128+9 itself // (see exitCode's contract). Normalizing keeps "we killed it" from ever // looking like an ordinary non-zero exit, which fails OPEN downstream. finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr }); }); // A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe // early; the resulting EPIPE must not take the extension down. child.stdin?.on("error", () => {}); child.stdin?.write(stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`); child.stdin?.end(); }); }