guard: cli gate propagator fail-closed on signal-kill (null status -> exit 2) - #725
Conversation
… exit 2)
bin/cli.js propagated child exit codes with `result.status || 0`. spawnSync
reports `status: null` (and leaves `error` undefined) whenever the child was
terminated by a signal rather than exiting on its own -- SIGTERM/SIGKILL from
an operator, an OOM kill, or a CI job cancellation. `null || 0` collapsed that
to 0, so a signal-killed child reported SUCCESS.
This propagator is shared by every `aesop <namespace> <verb>` dispatch (~50
gate/lint/verify subcommands), so a killed `aesop gate secret-scan` was
indistinguishable from a passing secret gate: a fail-OPEN security gate.
Fix: route all three spawnSync sites (Python namespace dispatch, `init`, and
`init`'s python fallback) through a single `exitCodeFromSpawnResult()` that
fails CLOSED -- real numeric status verbatim, but spawn error or a non-numeric
status (null = signal-killed, undefined = unknown) exits 2. The namespace
dispatch also prints why it failed instead of exiting silently.
`init`'s fallback additionally collapsed a signal-killed child to 0 via
`fallback.status || (fallback.error ? 1 : 0)`; same fix applies.
Requiring bin/cli.js as a module now exports the helper instead of running the
scaffolder, which is the seam the tests use.
TDD: tests/cli-dispatch.test.mjs gains 6 assertions -- real codes propagate
unchanged, {status:null,signal:'SIGTERM',error:undefined} must exit nonzero
(2), SIGKILL/undefined status fail closed, spawn errors exit 2, and the
fixture is cross-checked against a real self-SIGTERM child on POSIX so it is
not an invented shape. Verified RED: the old expression yields 0 for that
fixture, the new one yields 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Evicted from the merge queue: batch #737 (integrate/q-1785731578) red with every member individually green |
|
Re-admitting to the merge queue. This PR was evicted as collateral damage from a batch dissolution (see state/merge-queue/exceptions.jsonl, kind=batch_red_dissolved) whose stated cause was 'all members individually green but the batch was red'. The batch's actual failure is owned by #712, which wires |
|
Evicted from the merge queue: batch #741 (integrate/q-1785745378) red with every member individually green |
Problem (verified latent P1)
bin/cli.jspropagated child exit codes withprocess.exit(result.status || 0).spawnSyncreportsstatus: null— witherrorleft undefined — whenever the child is terminated by a signal rather than exiting on its own: SIGTERM/SIGKILL from an operator, an OOM kill, or a CI job cancellation.null || 0collapses that to 0.This is the shared propagator for every
aesop <namespace> <verb>dispatch (~50 gate/lint/verify subcommands). So a signal-killedaesop gate secret-scanwas indistinguishable from a passing secret gate — a fail-OPEN security gate.Verified in Node:
Other
|| 0rc sites swept in bin/All three spawnSync exit sites had the same defect and are all fixed:
process.exit(result.status || 0)process.exit(fallback.status || (fallback.error ? 1 : 0))initpython fallbackprocess.exit(result.status || 0)initprimaryNo other
|| 0/status ||/code ||rc patterns remain inbin/.Fix
A single
exitCodeFromSpawnResult(result)that fails closed:status→ propagated verbatim (0, 1, 2, 42 … unchanged)result.error→ 2status(null= signal-killed,undefined= unknown) → 2A command that never ran to completion produced no verdict; the only safe verdict is failure. The namespace dispatch also now prints why it failed instead of exiting silently.
Requiring
bin/cli.jsas a module (require.main !== module) exports the helper instead of running the scaffolder — the seam the tests use. Running it as a CLI is unchanged.TDD evidence
Failing test written first in
tests/cli-dispatch.test.mjs(6 new assertions):{status:null, signal:'SIGTERM', error:undefined}must exit nonzero (2) — this is the regression guardstatus: undefinedfail closederrorexits 2, including when a status is also presentspawnSync(node, ['-e','process.kill(process.pid,"SIGTERM")'])really does yieldstatus: nullwith no error), so the shape is observed, not inventedRED → GREEN proof:
Verification
tests/cli-dispatch.test.mjs: 42/42 pass (was 36)npm run test:node: 314/315 pass. The one failure (tests/fleet-cli.test.mjs— "Unable to deserialize cloned data", a Node test-runner IPC artifact) reproduces identically on a pristineorigin/mainworktree (309/310) and passes when the file is run alone. Pre-existing, unrelated to this change.tools/claudemd_lint.py,tools/encoding_lint.py,tools/verify_test_suite_count.py: OKtools/secret_scan.py --staged: CLEANbin/CLAUDE.mddocuments the fail-closed invariant in the same PR as the code.🤖 Generated with Claude Code