import { type TreeHelper, type TreeTerminationReport } from './process-tree'; /** * argv spawn whose timeout kills the WHOLE process tree and CONFIRMS it (#211, * ES-P0-ACTION-CREDENTIAL-SCOPE RH-20260820-003V finding 1 and the 2026-08-21 * re-verification), and which never goes through a shell. * * `spawnSync(cmd, { shell: true, timeout })` kills only the shell on timeout: * the runner the shell spawned (the grandchild — the process actually doing * the work) survives as an orphan until it exits naturally. The first fix was * one `taskkill /PID /T /F` (Windows) or one process-group SIGKILL * (POSIX): both act on a snapshot, and a grandchild created a moment later — * on Windows, one the parent spawned but had not yet been assigned to its job * object when the parent was terminated — survives. The second fix struck and * then CONFIRMED by re-enumerating the process table; independent * verification showed that an enumeration is an observation, not a boundary: * the root died before the first snapshot, its pid was reused, the stranger's * creation time was pinned as the root's, and a living detached grandchild was * rejected as "born before its parent" — `confirmed: true`, marker written. * * Now the BOUNDARY COMES FIRST and the kernel holds it (lib/process-tree): * * Windows: a JOB OBJECT (KILL_ON_JOB_CLOSE, no breakaway) exists BEFORE the * command does. The command is launched through a GATE — a tiny `node -e` * stub (our code) spawned detached from this process's libuv job (ours must * be the outermost job; nested under libuv's SILENT_BREAKAWAY_OK job a * detached grandchild walks out — measured) — and the gate does NOTHING * until this process has the kernel's confirmation that it is in the job; * only then is it told to exec the real command, with stdout/stderr * inherited. So every process the command creates, at any depth, at any * instant, detached or not, is in the job from its first instruction and * cannot leave (`CREATE_BREAKAWAY_FROM_JOB` is refused). On timeout: one * snapshot is captured before anything is killed, the kernel's member list * is read, `TerminateJobObject` kills every member atomically, and the * sweep confirms — the result is `ETIMEDOUT` only when the kernel's * ActiveProcesses count is ZERO and every known member (kernel-listed, * captured, or attributed by life interval) is observed dead in two * consecutive snapshots. A command that exits leaving background * descendants: they die when the job closes with the run. * * Containment is REQUIRED by default, fail closed: if the job cannot be * established (no PowerShell, helper not READY, the gate cannot be placed in * the job) the run is refused BEFORE any untrusted code executes — * `error.code === 'ECONTAIN'` with the reason. `containment: 'best-effort'` * is an explicit opt-in to run with attribution only * (`boundary === 'attribution'`). * * POSIX: own process group; on timeout the tree is FROZEN before anything * is killed — SIGSTOP the root and its group, `ps` until two consecutive * snapshots show every member stopped and no new descendant (each one * stopped as it is found) — then group SIGKILL + every frozen member + the * root through its handle, and the `ps` sweep confirms every known member * dead. A stopped parent keeps its children attributable; a killed one * hands them to init. The remaining gap is documented: a descendant whose * parent died BEFORE the timeout is already init's and is not attributable. * * A confirmed kill is `error.code === 'ETIMEDOUT'` as before; an UNCONFIRMED * one (survivors, kernel count not zero, no enumeration backend) is * `error.code === 'ETREEKILL'` naming the survivors — a distinct, fail-closed * outcome that no caller mistakes for a clean timeout. `result.tree` carries * the report (boundary, method, killed, survivors, diagnostics, detail). * * The command is an argv LIST — `spawn(file, args, { shell: false })`. The * previous string form (`shell: true`) let the customer's template reach a * shell that re-scanned it, and handed that shell the complete parent * environment. Callers tokenise with `parseCommandTemplate` and resolve argv[0] * with `resolveSpawnTarget` (lib/child-boundary) before reaching this. * * `env` is REQUIRED: a caller must decide what the child sees; there is no * "inherit everything" default left to reach for by accident. * * The result mirrors spawnSync's surface — `{ status, stdout, stderr, * error }` — so judgement code written against spawnSync keeps its shape. One * deliberate difference: output beyond `maxBuffer` is truncated rather than * turned into an ENOBUFS error, because every caller reads a small prefix for * diagnostics. */ export interface TreeSpawnResult { status: number | null; stdout: string; stderr: string; error?: NodeJS.ErrnoException; /** True when the timeout fired (whether or not the tree kill was confirmed). */ timedOut?: boolean; /** The tree-termination report when the timeout fired. */ tree?: TreeTerminationReport; /** Which boundary held this run's tree: 'job' (Windows kernel job object) or 'attribution' (sweep only). Set once the command was started. */ boundary?: 'job' | 'attribution'; /** The pid of the command itself (behind the gate on Windows; the direct child elsewhere), once it started. */ commandPid?: number; } export interface TreeSpawnOptions { cwd: string; timeoutMs: number; /** The complete child environment. Build it with buildChildEnv — never pass process.env. */ env: Record; maxBuffer?: number; /** How long the tree kill may take to confirm before it is reported unconfirmed (default 120s). */ treeKillDeadlineMs?: number; /** Test seam: supply the tree helper (or null for none) instead of starting the platform one. */ treeHelperFactory?: () => TreeHelper | null; /** * Windows containment policy. 'required' (default): the command runs ONLY * inside a kernel job object, entered through the gate before its first * instruction; if the job cannot be established the run is refused before * any execution with `error.code === 'ECONTAIN'` — fail closed. * 'best-effort': run anyway and rely on the attribution sweep, reported as * `boundary === 'attribution'`. POSIX has no job; the option is inert. */ containment?: 'required' | 'best-effort'; } export declare function spawnTree(argv: readonly string[], options: TreeSpawnOptions): Promise; //# sourceMappingURL=tree-spawn.d.ts.map