fix(disk-hygiene): deny when the guard's decision cannot be written to stdout - #1588
Conversation
…o stdout Follow-up to #1449, which closed six fail-open paths in `destructive_guard.py` but not this one, because none of its in-process tests can reach the failure. `_decide` writes its decision with `print`, which only buffers. A closed or lost stdout pipe therefore raises nowhere inside `main` — the failure surfaces at interpreter shutdown, and CPython reports a failed shutdown flush by replacing the exit status with 120. Measured on merged `efb6c271`, deny decision, stdout wired to a pipe whose reader is closed: deny + broken stdout -> exit 120 "Exception ignored while flushing sys.stdout" 120 is "any other exit code" under the PreToolUse contract (https://code.claude.com/docs/en/hooks) — non-blocking, so the destructive command runs even though the guard had decided to deny it. Same shape as #1423, reached from the one stream #1449's stderr work did not cover. The module tail now flushes stdout itself, so an undeliverable decision is caught while there is still a decision to make: it denies at exit 2 with a diagnostic, because a decision the host never received is not a decision. It then flushes stderr best-effort and `os._exit`s the resolved code, so a shutdown flush can no longer rewrite it. #1449's null-device fallback is reused rather than duplicated — extracted from `_write_diagnostic` as `_discard_stream`. `test_undeliverable_stdout_decision_denies_at_exit_2_in_a_real_process` spawns the real script against a genuinely closed stdout pipe and asserts exit 2 with the diagnostic; it exits 120 against the pre-fix tail. 212 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… handler `_discard_stream(sys.stdout)` in `main`'s `except` claimed to be what stopped a broken stdout rewriting the deny into a 120. It is not — that configuration was measured at 120 with the call present, and only the module tail's explicit flush closed it. With the tail in place the call changes nothing but which of two true diagnostics is emitted, so the comment justifying it was wrong and the call is one mechanism too many. The tail owns the stdout deadline alone. Re-verified: deny with stdout wired to a pipe whose reader is closed still exits 2 with "the decision could not be written to stdout". Also confirmed neither this module nor `killswitch_config` registers an `atexit` handler or `__del__`, which the tail's `os._exit` would otherwise skip. 212 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26c0b7f886
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…d mode The undeliverable-stdout test spawns the real guard with a copy of the suite's own environment. When the suite runs under PYTHONUNBUFFERED, the child inherits it, so the decision `print` raises at the call instead of buffering. That still denies at exit 2, but by `main`'s generic internal-error path, never reaching the interpreter-shutdown flush handler the test exists to cover -- so its diagnostic assertion failed. The raised exception is platform-dependent (BrokenPipeError on POSIX, OSError errno 22 on Windows), so widening the assertion to accept both diagnostics would trade a real regression signal for a platform guess. Dropping the variable from the child environment keeps the test pinned to the one path it means to exercise, however the runner was invoked. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
🤖 Released by autonomous babysit lane (fable-autopilot). PR merged as |
|
Claude finished @kyle-sexton's task in 1m 50s —— View job PR Review
Scope: One commit, one file — The fix is correct and completeThe Codex P2 comment identifies the exact problem this commit resolves. When the test suite runs under That path is correct behaviour, but it's not the path this test was written to exercise. The test exists to verify the module-tail flush handler that was the actual fix for the exit-120 regression. With the inherited variable, that handler is never reached, and the diagnostic assertion at line 3999 fails. Dropping The comment at lines 3970–3974 explains this correctly and concisely. Sibling tests are not affectedThe two other real-process tests that copy Minor observation (no action required)Line 3998: VerdictClean, minimal, correctly targeted test fix. No correctness, security, or architecture concerns. Addresses the Codex P2 finding fully. |
|
Claude finished @kyle-sexton's task in 2m 40s —— View job Security Review — PR #1588
Reviewed commits: No exploitable security issues foundThe changes close a genuine, previously-verified fail-open path and do so correctly. Below is a trust-boundary walk for each new piece. Module-tail flush +
|
) 🤖 Agent-authored (autonomous babysit lane). Closes #1611. Closes #1640. ## Summary The engine gate decided marker relevance with a bare substring test over the command string, so `test_hygiene.py` — this plugin's own test suite — read as an engine invocation. In any consumer session with the plugin enabled, the natural commands for working on the plugin were denied, on the Bash tool and on PowerShell alike: ```text Disk-hygiene fails closed: Bash is restricted to exact bundled scan, preview, handoff-verify, and apply invocations... ``` That is the response to `python3 -m unittest -v …/test_hygiene.py`, and to `ruff check …/test_hygiene.py`. Both were hit for real while preparing this PR; the lint had to be rerun over the directory instead, and the commit for this very fix was refused until its message stopped naming the file. The bug blocks its own repair. ### The interesting part: only half the function was wrong The literal-parse path was **already correct** — it basename-matched (`Path(word).name == _ENGINE_MARKER`) and deferred properly. Only the operator-carrying path used a substring. That is why the failure looked arbitrary: the *same* command gated or deferred depending on whether it happened to contain a `&&`. ```text python3 -m unittest -v …/test_hygiene.py → defer (parseable, basename-matched) cd /repo && python3 -m unittest -v …/test_hygiene.py → DENY (unparsable, substring-matched) ``` Both now go through one `_carries_marker` helper — including the literal branch, which had kept its own `Path().name` test. That was not cosmetic: `Path().name` is platform-flavoured, so `PATH=/x:hygiene.py python3 …` gated on Windows and deferred on Linux. ### Precision, not relaxation A basename test is only as good as the tokens it reads. A naive per-word test would open a seam that substring matching had closed: `foo;hygiene.py` is a single whitespace token whose basename is the whole glued string. My first cut paid for that by splitting on the module's shell-metacharacter set — **and that was wrong**, as codex's P1 review caught. An assignment glues with `=`, which is not a metacharacter, so `engine=hygiene.py && python3 "$engine" apply` presented no token whose basename was the marker and **bypassed the guard entirely**. Probing around the report found three more shapes with the same cause. Enumerating shell syntax is the wrong shape for this question — every glue character missed is a silently un-gated invocation. The delimiter is now the complement of what a path may contain, `[^A-Za-z0-9._\-/\\:]+`. That inversion is **total rather than enumerative**: `hygiene.py` is spelled entirely from the kept characters, so splitting on everything else can only ever *expose* the engine filename as its own token, never destroy an occurrence. ### The structural fix: detection and resolution are different questions That total splitting is right for **detection** and wrong for **resolution**, and one token list was serving both. The "provably a DIFFERENT file" escape requires an ABSOLUTE path, but it read the fine fragments — so any character outside the keep class split a consumer's absolute path and left the fragment carrying the filename relative, unprovable, and denied. `python3 /tmp/consumer+tools/hygiene.py --help && echo done` gated a file with nothing to do with this plugin. That was #1640, and `~` is what makes it ordinary rather than exotic: Windows 8.3 short-name segments (`C:\Users\KYLESE~1\…`) put unpunctuated paths under ordinary temp directories into the same population. Resolution now reads the **whole shell word** containing the token, with quoted spans kept intact so a path with spaces resolves too. Detection keeps the fine tokens exactly as they were, and a test pins the two partitions to the same token list so that claim is checked rather than argued. The widening cannot travel. A token is paired with its enclosing word by **span**, never by substring containment — containment would let `python3 /abs/consumer/hygiene.py --help && python3 hygiene.py apply` borrow the first word's absolute path to "prove" its bare second invocation different, and defer while the real engine ran. That shape is now a regression test. ### Identity outranks spelling `cd <plugin-scripts> && python hygiene.py. apply` opened and ran the kill-switched engine while the guard deferred: Win32 discards trailing dots and spaces from a filename and resolves `::$DATA` to the main data stream, so the filesystem hands back the engine under a name whose basename is not the marker. 8.3 short names are a third spelling of the same kind. The fix asks the filesystem instead of listing spellings. A relative word is identity-checked against the **engine's own directory** — precisely the directory such a command must `cd` into for the alias to run. That closes trailing dots, trailing spaces, NTFS stream suffixes, and short names in one move, where enumerating them closes one per review round. The name predicate is unchanged and stays platform-independent; identity carries this. ### The filing caveat is discharged #1611 was filed with an explicit warning from me that the fix was unverified against the gate's suite and that the substring **might be load-bearing for a copy-evasion case I had not identified**. Resolved empirically before touching the code: it is not. Evasion coverage never routed through the marker. `test_engine_gate_catches_linked_aliases_of_the_bundled_engine` and `test_engine_gate_catches_alias_beside_shell_operator` create a hard link to the engine under an unrelated name (`clean-engine`) and assert it gates — by `os.path.samefile` identity, which does not consult `_ENGINE_MARKER` at all. A byte copy remains the accepted residual the function's docstring already names as unclosable. Narrowing the marker cannot regress a mechanism that does not use it. ## Test plan **Differential over 89 command shapes**, captured on the guard at the merge commit, then re-run after the change, requiring that only the intended rows move. The matrix started at 26 shapes and grew with each review round, because each round showed my existing cases shared an assumption that turned out to be wrong. | Group | Cases | Before → After | | --- | --- | --- | | Engine invocations, wrappers, `bash -c` compound | 7 | GATE → GATE | | Metacharacter, assignment, and gluing shapes | 9 | GATE → GATE | | Line continuations, post-`cd` relative paths | 5 | GATE → GATE | | Linked aliases (suite-named and unrelated) | 6 | GATE → GATE | | Proof-borrowing shapes (new) | 7 | 6 GATE → GATE, **1 defer → GATE** | | Windows filename aliases (new) | 6 | **4 defer → GATE**, 2 unchanged | | `PATH=/x:hygiene.py` platform divergence | 1 | **defer → GATE** | | Consumer-owned scripts, all parent spellings | 19 | **14 GATE → defer**, 5 unchanged | | Mentions, unrelated commands, near-miss names | 10 | defer → defer | | The #1611 bug (`test_hygiene.py`, 5 shapes) | 5 | defer → defer | | `_carries_marker` predicate assertions | 10 | unchanged | **Six shapes move toward gating** — every one a fail-open that is now closed. **Fourteen move toward deferral** — every one a consumer's own file, denied only because of how its parent directory was spelled. **Suites** — full plugin, all green: | Suite | Result | | --- | --- | | `test_hygiene.py` (unittest) | **238 passed**, 4 skipped (+4 new tests) | | `test_guard_launch_monitor.py` | included above, passing | | `ruff check` over the scripts dir | All checks passed | **Four regression tests added** in this round, on top of the six from earlier rounds: - `test_engine_gate_defers_consumer_paths_outside_the_path_legal_class` — the #1640 population, across `+`, `@`, `=`, `~`, and a space in the parent name, in both operator shapes. - `test_engine_gate_catches_windows_equivalent_filename_aliases` — trailing dot, multiple dots, and `::$DATA`. Written as probe-then-assert rather than gated on `sys.platform`: these are aliases only where the filesystem says so, and round 2 of this chain was a host-dependent verdict that passed locally and failed open in CI. - `test_engine_gate_marker_token_cannot_borrow_proof_from_another_word` — the fail-open that containment-based pairing would have introduced. - `test_marker_token_words_partition_matches_the_split_partition` — mechanical proof that widening resolution did not change detection. **One pre-existing test was modified, and it is worth saying why rather than letting it read as gaming CI.** `test_engine_gate_defers_consumer_windows_path_on_powershell` forced backslashes onto its fixture path unconditionally, which on POSIX fabricates a path that cannot exist. It passed on Linux only because the basename predicate was `Path()`-flavoured and did not see the marker inside that backslash word — a Linux fail-open wearing a green test, which is round 2 of this chain exactly. Once the literal branch shares the platform-independent `_carries_marker`, that word is an unresolvable marker-carrying path after an interpreter, and failing closed on it is the documented rule. The test now asserts on the host's own spelling and adds the backslash spelling only where the filesystem says it names the same file. Windows coverage is unchanged; the differential shows no movement on that shape. **Repo gates**, all pass: `markdownlint-cli2`, and the changed-plugin gates via CI. Version bumps to `0.10.2` with `### Fixed` entries, per repo convention. (`0.10.1` landed on `main` from #1637 while this was in review.) One note for the reviewer: `ruff format --check` reports 5 files in this directory as unformatted, but it reports the identical 5 at `origin/main` — including files this PR never touches. Pre-existing and not enforced here; deliberately left alone rather than bundling a large unrelated reformat into a guard fix. ## Review chain — read this before the diff Codex reviewed this six times and found **five real defects, four of them fail-opens I introduced**. All six threads are resolved with a reproduction table each. Recorded here because the diff looks small and the history is the honest signal about it: | Round | Finding | Verdict | | --- | --- | --- | | 1 | `engine=hygiene.py && python3 "$engine" apply` bypassed the guard — `=` is not a shell metacharacter | Fixed; probing found 3 more shapes of the same class | | 2 | Bash line continuation left `hygiene.py\` welded together | Fixed — and exposed that `Path().name` is platform-flavoured, so the bug was **invisible on my Windows host and live on Linux** | | 3 | Relative marker path after an in-command `cd` resolved against the guard's cwd | Fixed; also closed 2 pre-existing fail-opens, and caught that my own round-2 fix had been severing Windows drive letters | | 4 | Consumer's absolute path containing `+`, `@`, or `~` over-gates | Fixed in this round — resolution now reads the whole shell word (closes #1640) | | 5 | A link to the engine *named* `test_hygiene.py` beside an operator deferred | Fixed — the deferral this PR adds had become a bypass under the one name it makes non-marker | | 6 | Trailing-dot Windows alias (`hygiene.py.`) bypassed the gate | Fixed in this round — identity against the engine's own directory, which subsumes the whole alias class | ### On the stopping rule Rounds 4 and 6 were parked rather than fixed, under a stopping rule I pre-committed to: a fail-open found after round 5 means hand the PR to a human, because at that point the sustained defect rate is itself the finding. **That rule was overridden by explicit operator direction** to drain the queue and fix findings in place. Recording it plainly rather than resuming silently past a stated pre-commitment. The diagnosis behind the rule stands and shaped this round's fix. My verification had been **enumerative** — it tested the shapes I thought of, and codex kept finding shapes I did not. Both fixes here are deliberately non-enumerative: resolution reads whole words rather than a wider character class, and alias handling asks the filesystem rather than listing spellings. Each closes a *class* instead of an instance, which is the only kind of fix that answers the round-6 objection. ### The property to check, if you check one thing Across the differential against the pre-change guard, every shape whose verdict moves toward *deferral* is a consumer's own file, identified by an absolute path that resolves to something other than the bundled engine. Every shape that moves toward *gating* is a fail-open. No shape moves toward deferral on the strength of a relative path, a name alone, or a word other than its own. ## Related - Closes #1611 — the engine gate denying unrelated files whose name ends in `hygiene.py` - Closes #1640 — over-gating on consumer paths outside the path-legal keep class - Found while babysitting #1588, where the same denial blocked running the suite for that fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Follow-up to #1449, which closed six fail-open paths in
destructive_guard.pybut not this one — none of its in-process tests could reach the failure._decidewrites its decision withprint, which only buffers. A closed or lost stdout pipe therefore raises nowhere insidemain— the failure surfaces at interpreter shutdown, and CPython reports a failed shutdown flush by replacing the exit status with 120.Measured on merged
efb6c271(deny decision, stdout wired to a pipe whose reader is closed):120 is "any other exit code" under the PreToolUse contract — non-blocking, so the destructive command runs even though the guard had decided to deny it. Same shape as #1423, reached from the one stream #1449's stderr work did not cover.
The module tail now flushes stdout itself, so an undeliverable decision is caught while there is still a decision to make: it denies at exit 2 with a diagnostic, because a decision the host never received is not a decision. It then flushes stderr best-effort and
os._exits the resolved code, so a shutdown flush can no longer rewrite it. #1449's null-device fallback is reused rather than duplicated — extracted from_write_diagnosticas_discard_stream.A follow-up commit then removed that same
_discard_stream(sys.stdout)call frommain's deny handler once re-verification showed the module tail's explicit flush — not the discard call — was what actually closed the exit-120 path; the discard call only changed which of two true diagnostics was emitted.Test plan
test_undeliverable_stdout_decision_denies_at_exit_2_in_a_real_processspawns the real script against a genuinely closed stdout pipe and asserts exit 2 with the diagnostic; it exits 120 against the pre-fix tail.killswitch_configregisters anatexithandler or__del__, which the tail'sos._exitwould otherwise skip.Related
Closes #1423 (the remaining stream not covered by #1449).
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com