Skip to content

guard: cli gate propagator fail-closed on signal-kill (null status -> exit 2) - #725

Merged
matt82198 merged 1 commit into
mainfrom
guard/cli-null-status-failclosed
Aug 3, 2026
Merged

matt82198 merged 1 commit into
mainfrom
guard/cli-null-status-failclosed

Conversation

@matt82198

Copy link
Copy Markdown
Owner

Problem (verified latent P1)

bin/cli.js propagated child exit codes with process.exit(result.status || 0).

spawnSync reports status: null — with error left 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 || 0 collapses that to 0.

This is the shared propagator for every aesop <namespace> <verb> dispatch (~50 gate/lint/verify subcommands). So a signal-killed aesop gate secret-scan was indistinguishable from a passing secret gate — a fail-OPEN security gate.

Verified in Node:

$ node -e "const r={status:null,signal:'SIGTERM',error:undefined}; console.log(r.status||0)"
0

Other || 0 rc sites swept in bin/

All three spawnSync exit sites had the same defect and are all fixed:

line old note
169 process.exit(result.status || 0) Python namespace dispatch (~50 subcommands)
210 process.exit(fallback.status || (fallback.error ? 1 : 0)) init python fallback
212 process.exit(result.status || 0) init primary

No other || 0 / status || / code || rc patterns remain in bin/.

Fix

A single exitCodeFromSpawnResult(result) that fails closed:

  • numeric status → propagated verbatim (0, 1, 2, 42 … unchanged)
  • result.error2
  • non-numeric status (null = signal-killed, undefined = unknown) → 2

A 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.js as 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):

  • real exit codes (0/1/2/42) propagate unchanged
  • {status:null, signal:'SIGTERM', error:undefined} must exit nonzero (2) — this is the regression guard
  • SIGKILL and status: undefined fail closed
  • spawn error exits 2, including when a status is also present
  • the fixture is cross-checked against a real self-SIGTERM child on POSIX (spawnSync(node, ['-e','process.kill(process.pid,"SIGTERM")']) really does yield status: null with no error), so the shape is observed, not invented

RED → GREEN proof:

OLD rc = 0
NEW rc = 2

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 pristine origin/main worktree (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: OK
  • tools/secret_scan.py --staged: CLEAN

bin/CLAUDE.md documents the fail-closed invariant in the same PR as the code.

🤖 Generated with Claude Code

… 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>
@matt82198 matt82198 added the merge-queue Queued for the merge-queue advancer daemon label Aug 3, 2026
@matt82198 matt82198 added queue-rejected Evicted from the merge queue (red or culprit) and removed merge-queue Queued for the merge-queue advancer daemon labels Aug 3, 2026
@matt82198

Copy link
Copy Markdown
Owner Author

Evicted from the merge queue: batch #737 (integrate/q-1785731578) red with every member individually green

@matt82198

Copy link
Copy Markdown
Owner Author

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 import_resolution_check into ci.yml as a hard 'Sibling import guard' step while the checker on main still flags the sanctioned sys.path idiom in tools/merge_queue.py:68-69 -- the fix for that is #724, which is not yet landed. #712 stays queue-rejected until #724 merges; this PR is individually green and goes back in the line.

@matt82198 matt82198 added merge-queue Queued for the merge-queue advancer daemon and removed queue-rejected Evicted from the merge queue (red or culprit) labels Aug 3, 2026
@matt82198 matt82198 added queue-rejected Evicted from the merge queue (red or culprit) and removed merge-queue Queued for the merge-queue advancer daemon labels Aug 3, 2026
@matt82198

Copy link
Copy Markdown
Owner Author

Evicted from the merge queue: batch #741 (integrate/q-1785745378) red with every member individually green

@matt82198

Copy link
Copy Markdown
Owner Author

Re-admitting: evicted by the premature dissolution of batch #741, whose only fault was being evaluated seconds after creation while GitHub had not yet created its windows check run. Fixed in #742 (merged) -- dissolution now requires positive evidence of failure, not mere absence.

@matt82198 matt82198 added merge-queue Queued for the merge-queue advancer daemon and removed queue-rejected Evicted from the merge queue (red or culprit) labels Aug 3, 2026
@matt82198
matt82198 merged commit 10c2fd0 into main Aug 3, 2026
12 checks passed
@matt82198
matt82198 deleted the guard/cli-null-status-failclosed branch August 3, 2026 08:52
@matt82198 matt82198 mentioned this pull request Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-queue Queued for the merge-queue advancer daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant