perf(disk-hygiene): cut the destructive guard's per-call spawns 4 to 1, and stop its watchdog blocking commands it never judged - #3523
Conversation
…very call The `Bash|PowerShell` PreToolUse guard launcher re-derived its Python interpreter on every single tool call: a `sed` read of the engine to recover `MIN_PYTHON`, a whole extra Python spawned solely to evaluate a version predicate, a `dirname`, and on the `py` branch a third spawn. Process creation is the dominant cost on Windows, and it is the term that explodes under concurrent load — which is exactly when the guard's own watchdog was firing and denying benign read-only commands. Measured spawn census for one warm invocation, counted through a PATH shim: 4 spawns (dirname, sed, 2x python3) -> 1 spawn (the guard itself). The resolution logic is unchanged and still runs whenever the cache does not answer; it exists for real reasons (WindowsApps App Execution Alias stubs, the `py` launcher fallback, the version floor). It just no longer runs every time. - the floor is recovered inside the candidate interpreter rather than by `sed`, so the cold path spends one spawn per candidate instead of two, and `hygiene.MIN_PYTHON` stays the single origin (#1028); - the resolved interpreter is cached under `$HOME/.cache/disk-hygiene`, keyed on the launcher's own directory (already version-pinned) and validated against `PATH` verbatim, executability, non-emptiness, an mtime newer-than check for in-place upgrades, and a TTL backstop. Every hot-path check is a bash builtin; command substitution is avoided throughout because a subshell on Windows is a real process spawn; - any miss, corrupt record, or unwritable cache location falls back to full resolution, never to "no interpreter" — that is the guard's silent fail-open. The launcher's previous one-read/stops-at-the-match contract described a reader that no longer exists, so it is replaced rather than deleted: the suite now asserts the engine is never `sed`-read, that the floor is still sourced from the engine and still enforced (behaviorally, against fixture engines whose last line is a decoy floor), and that every cache invalidation path re-resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
…er judged The watchdog's deadline is WALL-CLOCK, so it measures contention as readily as it measures a stall, and this hook fires on every Bash/PowerShell call in every session. Under concurrent subagent load an ordinary read-only command could cross the 10s deadline while the guard was still inside `_engine_gate_relevant`'s marker-free fallback, and the blanket `os._exit(2)` then BLOCKED it. Observed roughly six times in one session, every one of them succeeding on an identical retry — the signature of contention, not of a command that deserved denying. "The guard could not decide" and "the guard decided deny" are not the same event and must not produce the same result. The split, at expiry: - a command carrying the engine marker — the only shape whose completed verdict could have been `deny` — still exits 2 with the same diagnostic. No protection is given up; - a command that provably carries no marker cannot be an engine invocation by name, and its completed verdict would have been the plugin-level defer. It now emits `ask`. That is strictly MORE protective than the defer it replaces (which emits no decision at all and lets the command run) and strictly less blocking than the exit 2 it replaces; - no command seen yet (the stdin-stall shape) still denies, unchanged. `_watchdog_marker_present` is deliberately syscall-free. That is a requirement, not an optimisation: it runs on the timer thread precisely because the main thread is presumed wedged in a filesystem call, so a classifier that touched the filesystem could wedge identically and the watchdog would never fire at all. A test asserts it by making every `os.path` probe raise. Fail-closed is preserved in the sense that matters: every path still DELIVERS a decision. The fail-open this guard exists to close is the harness killing a hook that produced no `permissionDecision` at all (ADR 0004), and no branch does that. Two hazards this change had to close in itself: - two threads writing stdout would splice a MALFORMED JSON object, which PreToolUse reads as no decision at all — the exact fail-open the watchdog exists to prevent, reintroduced by the watchdog. Emission is now serialized and latched to exactly one object, and the watchdog takes the lock under a bounded wait so a main thread wedged while HOLDING it cannot deadlock the timer thread into never firing; - `os._exit` skips the interpreter's shutdown flush, so exiting on the already-decided path without flushing discarded the decision the main thread had just buffered. Exit 0 carrying no JSON is read as no decision at all. Both hard-exit paths now flush first. Deny-parity is proven rather than asserted: a differential harness runs the pre-change and post-change guards over the same corpus in the same directory and requires byte-identical stdout and exit codes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
The launcher's hot path `exec`s a value read from a predictable file, which makes that record an input to a security control. It is now treated as untrusted: only a basename a resolution would itself have produced (`python3`, `python`, `py`, `python3.*`, with an optional `.exe`) is accepted. Pure parameter expansion, so it adds no spawn to the path whose whole purpose is spending none. This is defence in depth rather than a boundary, and is commented as such: anything able to write that file can already edit the hook registrations that name this launcher. The `chmod 700` on the cache directory is likewise softened to what it actually delivers — MSYS `chmod` is close to a no-op against Windows ACLs, so the read path validates the record rather than trusting its location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 5m 11s —— View job Code review completeReviewed the diff against Findings1. Possible TOCTOU race lets the watchdog override an already-delivered decision (posted inline) 2. Not flagged (verified, judged fine)
Author-claimed spawn counts, timing measurements, and differential-guard parity results in the PR description are unverified by me (I did not re-run the harnesses described there) — noting per review policy rather than treating them as independently confirmed. |
|
Claude finished @kyle-sexton's task in 7m 35s —— View job Security review (
|
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3a8deb86bb
ℹ️ 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".
|
Last security-reviewed head: |
…d keep every expiry outcome self-carrying Two defects found by adversarial review of the previous commit. Both were invisible to the 138-test suite and to a fully green CI run, because both live on paths only reachable when the watchdog fires. MODE GATE. The `ask` downgrade rested on "the completed verdict would have been the plugin-level defer". That holds only in `engine-gate` mode. The skill-frontmatter registration passes no `--mode` and `resolve_mode()` falls back to `belt` — so belt is the DEFAULT, not an exotic surface — and in belt mode `_engine_gate_relevant` is never consulted at all: Bash is deny-by-default. A marker-free `rm -rf /some/dir` would therefore have been DENIED there, and the previous commit downgraded it to a prompt the operator is invited to approve, on nothing more than "the host was slow". The emitted reason made it worse by asserting "it is not a disk-hygiene invocation this guard governs", which is false in belt mode. The downgrade is now gated on engine-gate; belt keeps the pre-change deny, and the reason text is true where it is now reachable. SELF-CARRYING OUTCOMES. `exit 2` needs nothing on stdout — the deny rides the exit status and the diagnostic goes to stderr. Adding `exit 0` outcomes to this callback therefore introduced a failure mode a deny-only callback could not have: exit 0 asserts that a JSON object reached the host, and `os._exit` skips the interpreter's shutdown flush, so any exit 0 taken while a write is buffered or still in progress discards it. Exit 0 carrying no JSON is read as NO DECISION, and the command proceeds unguarded. The lock-acquisition-failure branch is where that bit hardest. Failing to take `_EMIT_LOCK` means the main thread is inside `_emit_decision`, wedged between `json.dumps` and `print` returning — and that branch cannot be repaired by flushing: CPython's `BufferedWriter` takes a per-object lock across both `write` and `flush`, so a flush from the timer thread blocks on the very writer it is trying to rescue. Measured directly against a real `os.pipe()` with no reader: flush from a second thread blocked indefinitely and never returned. `contextlib.suppress` catches exceptions; it does not bound a block. The process would then hang past the declared hook timeout and be killed, and a killed PreToolUse hook yields no `permissionDecision` — the same ADR-0004 fail-open by another road. That branch now denies at exit 2, touching stdout not at all. The remaining `exit 0` branches run entirely inside the lock, which is what makes their flush safe: `_emit_decision` holds the lock across its `print`, so having acquired it proves no thread is inside a stdout write. Also: every `os._exit` is followed by an explicit `return`, dead in production but required by the suite's existing mocked-exit contract, which observes one call rather than the tail of the function running on. The differential harness now covers all three mode surfaces (`engine-gate`, `belt`, and no `--mode`). Running engine-gate alone is exactly why the previous commit's parity claim missed the belt downgrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 5m 18s —— View job Code review complete (
|
|
Claude finished @kyle-sexton's task in 4m 55s —— View job Security review (
|
…laims review disproved
Follow-up to adversarial review. One behavior change, three comments that
asserted more than the code delivered, and one new regression test for a limit
that is real and now stated rather than implied.
TTL is compiled in, not an environment override. A widened
`DISK_HYGIENE_INTERPRETER_CACHE_TTL` makes the launcher accept a record it would
otherwise reject as stale — demonstrated: a record ten days old was refused
under the default and accepted under a widened value, reaching the guard's
silent fail-open. That is an env-borne input to a security control, the exact
shape `resolve_disk_hygiene_enabled` refuses on the Python side because "a repo
`settings.json` `env` block reaches hook subprocesses and carries no provenance
a hook could check". A tunable staleness window does not earn a new channel of
that kind; an operator who wants re-resolution can delete the record. The
contract test now forges staleness in the RECORD rather than reaching for a knob
the launcher deliberately does not have.
The cache's residual exposure is now written down instead of left to inference.
The hot path validates SHAPE only (`-x`, `-s`, an interpreter basename) because
proving a binary is really Python costs the spawn the cache exists to remove, so
an executable merely NAMED `python3` is exec'd and the guard never runs.
Reaching it requires writing the record, whose location derives from `$HOME` —
so `HOME` is an input to this control that the pre-cache launcher did not have.
Both readings are recorded: unreachable if nothing untrusted can set `HOME` for
hook subprocesses; otherwise a new instance of an existing exposure, since a
hostile `PATH` fails open on the PRE-change launcher too (the version probe only
checks an exit status, which any script satisfies). Not a new kind of channel,
and the "anyone who can write here can already edit the hook registration"
argument does not cover an env vector, so it is called out rather than waved
through. A new test pins the boundary that IS enforced, so widening the basename
allowlist fails.
Three corrected claims:
- the watchdog's residual was described as the "linked-alias shape". Measured,
it is the whole filesystem-IDENTITY class: `_watchdog_marker_present` diverges
from `_engine_gate_relevant` on exactly {no token's basename is the marker}
intersected with {some candidate is samefile with the bundled engine}. That
includes the Win32 filename-alias spellings (a trailing dot, and the
alternate-data-stream suffix) which open the engine while having no marker
basename — the very class `_engine_gate_relevant`'s docstring says identity is
what closes. It cannot be closed on the timer thread, since settling identity
means a filesystem call and this classifier must stay syscall-free. Recorded
as accepted, with why. Also recorded: the overbreadth shapes are NOT in the
set, verified rather than assumed;
- "holding the lock means there is no writer to contend with" was too strong. It
proves the main thread is not inside `_emit_decision`'s `print`, which is the
only stdout write during `main`'s lifetime — but the `__main__` block's flush
and its `_discard_stream` fallback run after `main` returns, outside the lock,
and a daemon timer can still be live because `cancel()` is a no-op on an
already-fired one. Both overlaps are benign; the comment now says what is
actually guaranteed;
- "clear the latch AFTER cancelling, so no live timer can observe the cleared
state" stated the opposite of what the code guarantees: cancelling cannot stop
a running callback, so a timer CAN observe it, read "no decision yet" about a
call that has one, and route to the non-flushing exit 2 — discarding a
buffered allow. Fail-closed and microsecond-wide, but avoidable: `main` now
flushes before resetting, so the decision is durable before any timer can see
the cleared state. The flush is the mechanism, not the ordering.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 5m 47s —— View job Code review complete (
|
|
Claude finished @kyle-sexton's task in 5m 8s —— View job Security review (
|
…nup race direction Addresses both automated review lanes on this PR. Both reviewed commit 3a8deb8, two commits before the current head, so one of their three findings was already fixed; the other two are handled here. README hook-budget section was stale, and the hook-budget convention requires the plugin README to state its measured always-on share. It still described the launcher's `sed` read of the engine as costing about 24 ms, roughly 13% of the hook, and contrasted it with a two-full-pass predecessor. Neither mechanism exists after this PR. The section now leads with the spawn census (four to one on a warm invocation) rather than a duration, because the census is deterministic while the wall-clock share was measured on a host whose process-creation cost was later observed varying more than tenfold within one hour under contention. The interleaved before/after numbers are quoted with an explicit warning that they are contention-specific and not comparable to the earlier quiet-host figures, and the section asks for a fresh measurement on a quiet host before anyone cites a new share. The cleanup race is now pinned by a test rather than argued about. The security lane read `main`'s finally-block reset as a route back to two JSON objects on one stdout, which would be the malformed-output fail-open the emit lock exists to close. Against the current head it is not: the reset clears the command to None as well as the latch, and the marker classifier reports None as marker-present by construction, so both arms of the mode gate route to the non-printing exit 2. The residual is a deny overriding an already-delivered decision, which is fail-closed and the opposite direction from a fail-open. The new test drives a real timer against a real main() return and asserts that at most one JSON object reaches stdout and that exit 1 is never reachable, because the ordering is precisely what the review disputed and an assertion about ordering would not have settled it. The third finding, a TOCTOU window where the marker-present branch read the latch, released the lock, then worked before an unconditional exit 2, was already closed: the lock is now held from acquisition through whichever exit runs, so the classification and the diagnostic both happen under it and the main thread cannot deliver a decision in that window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 6m 22s —— View job Code review complete (
|
|
Claude finished @kyle-sexton's task in 2m 1s —— View job Security review (
|
Review responses (both lanes)Both lanes reviewed 1. TOCTOU: watchdog overrides an already-delivered decision — already fixed in
|
…se the cleanup race Four findings from the review lanes, all correct, all fixed. Two were bugs I introduced; two were claims of mine that did not hold. P1, the warm cache could never hit on py-launcher-only Windows hosts. The basename check stripped only a POSIX slash. The `py -3` fallback resolves through `print(sys.executable)`, which on Windows returns a native backslash path, and that fallback exists precisely for hosts where neither python3 nor python is on PATH. So the allowlist compared a whole backslash path against bare interpreter names, never matched, and the record was rejected on every invocation: the warm path was dead on exactly the host class the branch was written for, silently and with no error. The check now splits on both separators and is case-insensitive, since Windows filenames are. The main-cleanup race is now actually closed, by an invocation token rather than by a comment. Timer.cancel cannot stop a dispatched callback and the cleanup does not join, so a callback could acquire the emit lock after the reset, read "no decision yet" about a call that had one, and re-decide it: a cleared command is marker-present by construction, so both arms of the mode gate reached the non-flushing exit 2 and overrode a delivered verdict with a deny. main now publishes a token, arms the timer with it (carried on the Timer object, because a pre-existing test pins the Timer construction exactly), and clears it during cleanup; a callback whose token is no longer current stands down at exit 0. A token rather than a join because a join would deadlock against a callback blocked on the lock the resetter holds. A directly invoked _watchdog_fire runs on a thread carrying no token attribute, which keeps the existing unit-test path on the full decision route. Two claims of mine that were wrong, and are corrected rather than restated: - successive comments claimed first the cancel-before-reset ordering and then the flush closed that race. Neither did. cancel cannot stop a dispatched callback, and a flush only makes an already-printed decision durable; it does nothing about a callback about to override it. The flush stays for the narrower thing it does do, since a hard exit skips the shutdown flush; - the test added for this race exercised nothing. It padded the cleanup and hoped the timer would fire inside the window, but cancel runs before the padding and always won the deadline, so it passed with the stand-down disabled. Reproducing the STATE is what makes it discriminating; reproducing the timing is what made it vacuous. It is replaced by a deterministic test that drives the exact post-reset callback, plus a live-token control so a stand-down keyed on something always stale cannot pass for the wrong reason. Verified red with the stand-down disabled and green with it, restoring the source byte-identical afterward. CHANGELOG entry for the expiry behavior was stale: it said "a provably marker-free one emits ask" unqualified, which the mode gate narrowed to engine-gate only. It now states that belt always denies, and why belt is the default the frontmatter registration runs under. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 5m 32s —— View job Code review complete (
|
|
Claude finished @kyle-sexton's task in 2m 31s —— View job Security review (
|
…he cache The py-launcher basename bug shipped because every interpreter path this suite produced was POSIX, so nothing exercised the branch that broke. This writes a cache record holding a native backslash path (what `py -3` emits via `print(sys.executable)`) and asserts the warm path accepts it and runs the target under it. Verified discriminating: FAIL with the backslash split disabled, PASS with it, launcher restored clean against HEAD. Two earlier attempts at that verification silently ran nothing in BOTH arms, because a `D:/...` path handed to bash resolves nowhere under MSYS — the same drive-letter trap that would have made a spawn census read zero. The check now uses MSYS paths and asserts its own patch applied before drawing any conclusion, since a check that cannot fail proves nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 2m 21s —— View job Security review (
|
…hygiene gates CI's hygiene lane failed two checks on the previous commit. shellcheck SC2221/SC2222: the interpreter allowlist spelled `python3.exe` and `python3.*.exe` alongside `python3.*`, which already covers both, so those were dead patterns that could never match. Removed; the remaining set is `python3`, `python`, `py`, `python3.*`, `python.exe`, `py.exe`, with no overlap. machine-specific-paths: a comment illustrating what `py -3` emits used a literal `C:\Users\...` path. The repo's existing Windows examples use placeholders (`<user>`, `<current-drive>`) for exactly this reason, so it now reads `<drive>:\<path>\python.exe`. The example loses nothing: the point is the backslash separator, not the location. Both re-verified locally against LF content rather than the working tree, since this checkout has core.autocrlf=true and shellcheck reports SC1017 on every line of a CRLF file — a local artifact CI never sees, and one that would otherwise mask a real finding behind hundreds of spurious ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 3m 18s —— View job Security review complete (
|
… not exit 0 The token stand-down added in 9d46879 closed the fail-CLOSED override it targeted and opened a fail-OPEN on the one `main` path that carries its verdict on the exit status alone. Found by review; the trace is exact. `main`'s outer `except BaseException` returns 2 having emitted NOTHING — the deny rides the exit code, which is this module's own stated contract for exit 2 ("exit 2 needs nothing on stdout"). On that path `_DECISION_EMITTED` stays False. If a watchdog was already dispatched and blocked on `_EMIT_LOCK` when the cleanup ran, it then read a stale token and took the stand-down's unconditional `os._exit(0)`. That is a hard, whole-process kill: it runs BEFORE the main thread can reach `__main__`'s own `os._exit(result)`, so the deny never happened and the host saw exit 0 with no JSON — no decision at all, command proceeds unguarded. The mechanism added to stop the watchdog overriding decisions had become a way to erase one. The stand-down now exits with the result `main` actually reached. `_reset_decision_state` takes that result and publishes it in the SAME lock hold that clears the token, so no callback can observe a stale token without also observing the outcome that made it stale. An unknown outcome defaults to 2: absence of a recorded result is not evidence of an allow. Three tests, each checked for discrimination rather than assumed: - a stand-down after a `main` that reached 2 exits 2, and after one that reached 0 exits 0; - an unknown outcome denies; - the existing stale-token test now models the cleanup as it really is, passing the result through, rather than calling the reset bare. Deny parity re-verified across all three mode surfaces after the token work: 360 invocations, 720 runs, byte-identical stdout and exit code, 272 denials on each side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDkZJ87i7h8J9KzDNruvuS
|
Claude finished @kyle-sexton's task in 1m 41s —— View job Security review complete (
|
## Summary Adds the `performance` plugin: a measurement-first optimization workflow for an arbitrary target, built around refusing to report what the data does not support. Generalized from one end-to-end run of that workflow done by hand against the `disk-hygiene` destructive-guard hook (#3523). That session had a competent operator and a strong prompt, and it still produced **five verification harnesses that each returned a confident wrong answer rather than an error**. Four of the five were checks written *specifically* to avoid being fooled. That disproportion is the plugin's whole reason for existing: a workflow that measures without enforcing harness-integrity rules mostly generates confident numbers, which is worse than generating none. The design was settled by the `/planning:interview` #3530 requires. Nine questions answered by the user, three deferred to planning, all recorded in `docs/topics/performance-plugin/PLAN.md` and [summarized on the issue](#3530 (comment)). ## Fix **Four skills, each naming its successor** rather than routing through a hub, the way the planning pipeline already chains: | Skill | Owns | |---|---| | `target` | Ranks candidates by **evidence quality** (E1 attributed measurement to E4 suspicion). Nothing above E3 means the recommendation is "instrument this first", not a ranked guess. | | `goal` | **Human-gated always.** Computes the irreducible **floor** before the target is agreed, and stops when the target sits below it. | | `snapshot` | Qualifies the host, then captures. Interleaved or duet A/B, counter ranked above duration, and the refusal. | | `verify` | Fresh-context re-derivation that does not inherit the implementer's numbers, plus a report that never rounds a miss into a win. | Named `snapshot`, **not** `measure`: Q2 locked "depend + route" on `/verification:measure`, and two skills called `measure` is that routing line failing to route. `measure` keeps baseline capture, storage and compare mechanics, and gains one gotcha pointing here for hosts a noise-floor warning cannot describe. **The shared lib becomes a registered cluster.** A cross-plugin runtime import was never available, since plugins install independently. So `lib/spawn_noise.py` is carried as a byte-identical copy with `scripts/sync-spawn-noise.sh`, a registry entry, and the `spawn-noise-sync` CI lane, the mechanism this repo already uses for six clusters. The canonical gains `is_measurable()` (the refusal verdict) and `percentile_floor()` (the `1/(1-p)` sample floor). ### Two places the research contradicted the issue, and the code follows the evidence - **#3530's Phase 4 says to suppress the paired ratio under concurrency.** Bulej, Horký, Tůma, Farquet & Prokopec, ["Duet Benchmarking"](https://arxiv.org/abs/2001.05811) (ICPE 2020) measured accuracy gains of **5.03x** (ScalaBench/DaCapo) and **37.4x** (SPEC CPU 2017) from running arms in *parallel* on shared machines, because both arms absorb the same interference. Both modes ship, the suppression rule is scoped to the sequential form, and the reconciliation is labelled as this plugin's reading rather than a sourced claim. - **The unmeasurable-host refusal ships as a house rule, not consensus.** No surveyed tool refuses above a variance threshold: pyperf, Criterion, JMH and benchstat all warn and print anyway. ### Three claims the literature does not ground, labelled rather than dressed up - **Sample count.** No benchmarking-community figure exists beyond the derivable `1/(1-p)` floor. The p50/p95-over-20 default is a labelled house convention; only the arithmetic floor is *enforced*. - **p95 itself.** "Median plus a high-order percentile" is grounded (SRE Book ch. 4), but the percentiles that chapter names are the 99th and 99.9th. - **Counts over wall clock** is grounded only for *instruction* counts. Extending it to process spawns is this plugin's own generalization, and it is load-bearing here because spawn count is the headline metric and Valgrind does not run on Windows. Two citation traps the skill bodies avoid on purpose: `benchstat` is **unpaired** (it recommends interleaved collection but analyzes with Mann-Whitney U), and coordinated omission is a **load-generator** problem, so citing Tene for a synchronous harness would miscite the field's best-known source. ## Verification - **All four skills PASS `check-skill.sh`** with 0 errors and 0 warnings. - `scripts/sync-spawn-noise.test.sh` — 7 assertions, passing. - `plugins/claude-ops/lib/spawn_noise.test.sh` — 9 assertions, passing. - `audit_performance.test.sh` — 45 tests, passing, unmodified. - `scripts/check-lane-coverage.sh --check` — all 50 lanes reachable from `ci-status.needs`, including the new one. - `scripts/check-cross-plugin-source-drift.sh --check` — no unregistered or drifted clusters. - `run-ruff.sh` clean on both lib copies; markdownlint clean; **no em dashes** in any new surface. **The gates were proven to discriminate, not assumed to.** This is the plugin's own doctrine applied to its own code, and it matters because four of the five catalogued harness failures were checks that exited identically in *both* arms and reported a confident verdict: - **The sync gate.** Its suite drifts a copy's `BIMODAL_SPREAD_RATIO` and asserts the clean and drifted arms return **different verdicts**, not merely that each printed its expected string. - **The two-part bimodal predicate.** Deleted the `high >= SLOW_SPAWN_FLOOR_MS` clause by hand; the suite failed with the assertion it was written to produce; restored; confirmed with an empty `git diff` rather than trusting the restore. Done **after** committing, because harness defect #5 in the catalogue was a `git checkout --` restore over uncommitted work that destroyed it. - **The refusal itself.** `test_a_quiet_host_is_measurable_and_a_contended_one_is_not` runs both a low-variance and a high-variance host and asserts the verdicts differ, because a refusal that fires on every host refuses nothing. The `snapshot` eval suite carries the same positive/negative pair. ## The harnesses Nine scripts under `plugins/performance/scripts/`, each with a co-located suite, 200 assertions total. Ported from the source run's scratch tree, which lived on local disk only and would have died with that directory. `spawn-census.sh` / `run-spawn-census.sh` use a **stable** shim dir, closing the defect where a `mktemp -d` shim put a fresh path on `PATH` every run against a `PATH`-keyed cache, so the census measured its own randomization and reported "no improvement". `ab.sh` + `summarize.py` + `ratio.py` interleave the arms and flip order per iteration. `differential.py` proves behavior over an argv matrix. `discriminate.py` consolidates five variants, four of which were broken. **The verifiers found seven real defects between them, all fixed.** The two that matter most: - **`discriminate.py` scored a check that never ran.** With no `signal` configured the signal is the exit code, so any shared non-zero rc reported `NOT DISCRIMINATING` with an affirmatively false explanation. It now splits identical-failing (`HARNESS BROKEN`, exit 2) from identical-passing (`NOT DISCRIMINATING`, exit 1). The four original harness failures that exited 127 in both arms would now be caught rather than reported clean. - **The sample floor guarded one statistic out of three.** Two identical `true` arms produced `median_paired_ratio=1.06x` beside `ratio_of_p50=12.08x`, so a reader could quote a 12x speedup between `true` and `true`. All three are gated now. Also fixed: a `126` subject censused as `spawns=0` exit 0; a spliced row `ratio.py` accepted that `summarize.py` rejects; `printf | subject` under `pipefail` fabricating exit 141 intermittently on a pipe-buffer race; and an `os` reference with no import on a line no test reached, which is exactly the "check that never ran" shape that harness exists to detect. **Rule 4 is proven behaviorally, not asserted:** the target is committed, the fix applied and left uncommitted, and the fix is still present after the run. A `git checkout --` restore would have destroyed it, which is what defect #5 in the source catalogue actually did. ## Related Closes #3530. Depends on #3553 (merged), which promoted the lib. Every acceptance criterion on that issue is met: the manifest validates, all four skills pass `check-skill.sh` with zero warnings, the refusal is asserted with both a high-variance and a low-variance arm shown to differ, the drift-immune counter is ranked above any duration in the emitted report, the design questions were answered by the user in a `/planning:interview` [linked from the issue](#3530 (comment)), and every normative claim carries a source tier with the ungrounded ones labelled as house rules. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3781) <!-- CURSOR_AGENT_PR_BODY_BEGIN --> No linked issue ## Summary Phase 1 of a measurement-first Bash performance program: the always-on guardrails dispatcher was still paying 7 `dirname` execs and 1 `sed` exec on every Bash tool call. Those are gone. Spawn census **13 → 5**. ## Discovery (why this, not all 766 scripts) This marketplace has already run a large hook-performance program (#3623, disk-hygiene #3523, hook-utils spawn cuts). The remaining per-tool-call budget miss is still guardrails (#3685). A PATH-shim census of the current Bash dispatcher on a benign `git status --short` showed: ``` spawns=13 rc=0 [7 dirname 3 git 2 jq 1 sed] ``` The 7 `dirname` were `source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"` in each enabled guard (the default-off `flag-commit-pr-skill-bypass` exits before `source`). The `sed` was `eval "$(declare -f hook::jq_fields | sed …)"` in `run-guards.sh`. The dispatcher had a builtins-only `script_dir` helper, then wrapped it in `$(…)`, which GNU Bash still forks (Command Substitution, Bash Reference Manual; https://mywiki.wooledge.org/CommandSubstitution). The house pattern for this is already in `markdown-format` / `typos-format` / `disk-hygiene`: `${BASH_SOURCE[0]%/*}`. What this phase does **not** do, because it was already measured and refused or is a later phase: - Per-guard `$(source …)` isolation forks (~7 spawn-eq floor while `exit`/`trap` stay standalone) — #3685, high correctness risk - `typos-format` / `eol-normalizer` `if:` gates — closed as won't-do in #3751: they have work to do on every file type - Fleet `bash script.sh` shebang hop (#3684) — shell-form `bash` is a WSL-relay trap in exec form (PLUGIN-PHILOSOPHY) - Formatter / skill / CI scripts — lower frequency than this always-on Bash path ## Fix - Every always-on guard (and `workflow-resilience-check`) locates `hook-utils.sh` with `${BASH_SOURCE[0]%/*}` plus the bare-filename fallback, matching the formatter plugins. - `run-guards.sh` uses the same expansion and only `cd && pwd` for a relative spelling. It does not define a function named `dirname` (a dispatched guard must still see the real external command). - The jq-cache function copy is `declare -f` plus parameter expansion, not a `sed` pipeline. - `run-guards.test.sh` pins an empty dirname/sed shim log on the benign Bash lane, pins the source shape so the `$(dirname …)` form cannot return, and invokes from `hooks/` as `./run-guards.sh`, as a bare `run-guards.sh`, and as a bare `block-no-verify.sh` so the relative `cd && pwd` arm and the `_HOOK_SELF=.` fallback are covered (review follow-up on `afc8f3bb`). Guard decisions are unchanged. A dispatched guard still sees the real `dirname` command (the existing shadow test). ## Verification Host qualified with `plugins/performance/lib/spawn_noise.py`: measurable (min 0.5 ms, spread 1.42×). `HOOK_TELEMETRY_SINK` unset. | Counter | before | after | |---|---|---| | PATH-shim spawns | 13 (`7 dirname`, `3 git`, `2 jq`, `1 sed`) | 5 (`3 git`, `2 jq`) | | Wall p50 / p95 (n=20, Linux) | 70.0 / 73.5 ms | 60.7 / 62.1 ms | The milliseconds are context on this cheap-spawn host. The durable claim is the eight PATH-visible execs. `git commit --no-verify` still exits 2. - `plugins/guardrails/hooks/run-guards.test.sh` PASS=99 FAIL=0 (dirname/sed shim pin plus the three relative-path cases) - All `plugins/guardrails/hooks/*.test.sh` and `lib/git-hooks/*.test.sh`: every suite FAIL=0 - `scripts/check-killswitch-hoist.sh` clean - `scripts/check-changelog-parity.sh --check` and `--check-bump origin/main` clean An over-selected `claude-observability.test.sh` failed because `clean` walked `/workspace/.observability/claude` instead of its tmpdir. That suite is not in this plugin; the failure is isolation against the workspace log root, not this change. ## Remaining phases (not this PR) 1. **Same pattern on remaining always-on/entry hooks** that still `source "$(dirname …)"` (formatters that are `if:`-gated, source-control, claude-ops audits). Lower frequency than this Bash path. 2. **#3685 isolation forks** — convert `exit` to `return` (or an equivalent) so guards can source in-process. Needs a byte-identical differential over every guard mode. 3. **#3684 shebang hop** — only in shell form, never a bare `"command": "bash"` (WSL relay). 4. **CI/test-script spawn tax** (#3716) — developer-time, not hook latency. ## Related Refs #3685 Refs #3523 Refs #3623 <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-13a78d56-95ab-4fd8-aea2-b74c586ecfed?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-13a78d56-95ab-4fd8-aea2-b74c586ecfed&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Summary
The disk-hygiene destructive guard runs on every
BashandPowerShelltool call in every session, so its cost is a tax on every command. This PR cuts its warm-path process spawns from 4 to 1, and stops its watchdog issuing a blocking deny for commands it never actually judged.No linked issue.
Problem
Two defects, one performance and one correctness:
Both were reproduced on this host. The watchdog false-deny reproduced live during this work — the installed 0.20.34 guard blocked an ordinary read-only
grepmid-session withdestructive_guard: internal deadline of 10s exceeded; denying by default.Root cause
Confirmed before changing anything. Per invocation the launcher ran:
sedread of the 3,500-line engine to recoverMIN_PYTHON;sys.version_info >= MIN_PYTHON;dirname, plus a possible third spawn viapy -3.The guard's own Python was never the bottleneck. Process creation is the dominant cost on Windows, and it is the term that explodes under concurrent load — which is exactly when the watchdog was firing.
Fix
Resolve the interpreter once and cache it; split the watchdog's expiry outcome by whether the command could possibly be an engine invocation. Details of each are in the sections below.
Verification
The headline number is a spawn count, not a duration
This host's process-creation cost drifts several-fold within an hour at ~10% CPU (bare
bash -c truemeasured at 1825 ms and 283 ms in the same session). Wall-clock numbers taken at different times are therefore not comparable, so the primary evidence is a deterministic spawn census, counted through aPATHshim that logs each external command and delegates to the real one:dirname,sed,python3×2mkdir,chmod,mv,python3×2python3— the guard itselfOne interpreter spawn is irreducible. The warm path now reaches
exechaving spawned nothing else.Timings
Measured with an interleaved harness — old and new alternating within a single run, order flipped each iteration — because two separate passes on this box would attribute drift to the change.
Serial, 24 alternating pairs:
Median paired ratio 3.71x; ratio of p50 3.84x; ratio of p95 2.16x.
Under concurrent load (8-way parallel, 24 samples per arm) — the condition under which the watchdog actually fired:
The paired ratio is deliberately omitted for the concurrent run: the arms are not load-matched there, so pairing by index would compare samples that never shared conditions. Per-arm percentiles are the honest statistic.
On the p50 ≤ 250 ms / p95 ≤ 500 ms targets
Not met on this host, and not reachable here by any code change. One Python spawn is irreducible, and this machine charges a highly variable 0.2–2.8 s for a process launch. An independent sampling of
cmd.exe /c exit(native Windows, no MSYS involved) on the same box gives min 180.5 ms / median 1107.7 ms / max 2841.3 ms — a spread ratio of 15.74 at 501 concurrent processes.usertime for 10 bash spawns is 0.090 s, so ~99% of the wall clock is process creation rather than work.A bimodal spread across identical no-op spawns is itself the diagnosis: this is host contention, not a fixed per-spawn tax. An earlier reading of the same box attributed it to WDAC code-integrity enforcement (
Win32_DeviceGuarddoes report kernel-mode policy enforced) and was retracted — a fixed policy check cannot produce a 15x spread. WDAC remains present and may contribute; it is no longer the leading explanation.The
aftermin of 124 ms is the evidence that matters: when spawn cost is briefly healthy, the whole hook completes inside the 250 ms target. What remains is one spawn × whatever this host charges for it. Getting under the target on this machine is a WDAC/EDR exclusion, not a code change.Deny-parity: proven, not asserted
A passing suite proves nothing broke that was asserted; it does not prove the guard still denies what it denied. So there is a differential harness: the pre-change and post-change guards run over the same corpus, in the same directory, with identical argv and environment, requiring byte-identical stdout and exit codes.
Placing the baseline copy in the same directory is what makes byte-exactness the right bar — the guard resolves its bundled engine with
Path(__file__).resolve().with_name(...), which depends on the directory, not the file's own name, so both copies disclose identical paths in their denial text.Corpus: every command literal harvested from the suite's own guard-invoking helpers, plus adversarial shapes aimed at the touched branch (alias spellings
hygiene.py./"hygiene.py "/hygiene.py::$DATA, 8.3 names,cd-then-relative, wrapper wordsenv/sudo/nohup/timeout, expansions, operators, marker-lookalikestest_hygiene.py/myhygiene.py, and the mere-mention forms).The harness runs all three mode surfaces:
engine-gate,belt, and no--modeat all (what the skill frontmatter passes, whichresolve_moderesolves tobelt).An earlier revision of this harness ran
engine-gateonly. That gap is precisely why the first version of this PR shipped a belt-mode regression, recorded below.Independently re-derived by a fresh-context reviewer, on a wider matrix than mine:
Its nine axes cover the two I had plus: no
--plugin-root/--authorized-data-root(which exercises a different denial text), an unexpanded${CLAUDE_PLUGIN_ROOT}, the--flag=valuespelling, a mismatched data root, a bogus--modevalue, and no argv at all. Its conclusion on my original harness is worth repeating: the gap was the invocation matrix, not corpus size — thirty more command strings on a single axis would not have found the belt regression.One deliberate divergence, which is the reported bug
The differential covers non-expiry paths. There is exactly one intended behavioral change, and it is the fix itself:
On watchdog expiry with a provably marker-free command, the guard now emits
askwhere it previously exited 2. That is a removed denial, stated plainly. It is justified because:deny— still exits 2, unchanged;askis therefore strictly more protective than the outcome the guard would have reached had it finished, and strictly less blocking than theexit 2it replaces;Fail-closed is preserved in the sense that matters: every path still delivers a decision. The fail-open ADR 0004 documents is the harness killing a hook that produced no
permissionDecisionat all; no branch here does that.The residual not covered was measured rather than estimated.
_watchdog_marker_presentdiverges from_engine_gate_relevanton exactly one set:That is the whole filesystem-identity class, and it is wider than hard links. It includes the Win32 filename-alias spellings —
hygiene.py.(Win32 discards the trailing dot;_carries_marker'srstrip("/\\")does not) andhygiene.py::$DATA(splitting on[/\\:]and taking the last part yields the empty string). Both open the bundled engine while having no marker basename, and both are the exact class_engine_gate_relevant's own docstring says identity is what closes:Confirmed on real watchdog expiry:
hygiene.py.,hygiene.py...,./hygiene.py.,hygiene.py::$DATAand hardlink/symlink variants were all baseline DENY →askin engine-gate mode. The mode gate does not close this; it only removes belt from the population._engine_gate_relevantsettles it by asking the filesystem, which is unavailable to the watchdog by construction, and it is already an accepted residual class in that function's own contract. A plain byte copy is a different file and is relevant to neither.Two fail-opens this change had to close in itself
Both found by testing the change rather than by reading it:
os._exitskips the interpreter's shutdown flush, so exiting on the already-decided path discarded the decision the main thread had just buffered. Exit 0 carrying no JSON is read as no decision at all. Both hard-exit paths now flush first._watchdog_marker_presentis deliberately syscall-free. That is a requirement, not an optimisation: it runs on the timer thread precisely because the main thread is presumed wedged in a filesystem call, so a classifier that touched the filesystem could wedge identically and the watchdog would never fire. A test asserts it by making everyos.pathprobe raise.Cache design
Keyed on the launcher's own directory (already version-pinned, so a plugin upgrade cannot read a stale entry) and stored under
$HOME/.cache/disk-hygiene. The location is derived from$HOMEandSCRIPT_DIRand nothing else — a hard constraint, since the skill-frontmatter registration may substitute only${CLAUDE_PLUGIN_ROOT}(#1014).Invalidation, all checked with bash builtins so the hit path spawns nothing:
python3may now winPATHcompared verbatim[[ -x ]],[[ -s ]][[ ! "$interp" -nt "$cache" ]]Any miss, corrupt record, or unwritable cache location falls back to full resolution — never to "no interpreter", which is the guard's silent fail-open.
Two Windows-specific traps worth flagging for reviewers, both of which cost a round here:
x="$(f)"is a spawn. Command substitution forks a subshell, and on MSYS a subshell is a real process creation. Every hot-path helper reports through a global instead of stdout for this reason.${var: -N}returns the empty string when the string is shorter thanN, which silently collapsed the per-plugin cache key onto one shared file.Tests
The resolution logic is unchanged and still runs on a miss; it exists for real reasons (WindowsApps stubs,
pylauncher fallback, version floor). It just no longer runs every time.The launcher's previous one-read/stops-at-the-match contract described a reader that no longer exists, so it is replaced, not deleted — deleting a cost must not quietly delete the property that cost was buying. The suite now asserts the engine is never
sed-read, and that the floor is still sourced fromhygiene.MIN_PYTHONand still enforced, proven behaviorally against fixture engines whose last line is a decoy floor (so a reader that scanned to EOF would fail).test_hygiene: 337 tests, up from 317. Same 2 failures before and after, both pre-existing and Windows-only, both named as known Windows failures inci.yml(test_preview_allows_root_children_os_managed_snapshot,test_stash_must_exist_in_an_independent_checkout). 6 skipped.GuardTests: 147, up from 127 — 20 new watchdog tests, including one per finding below.run-python-hook.test.sh: 32 assertions, 19 new (floor enforcement + every cache invalidation path + fallback-never-to-no-interpreter).ruff check: clean.shellcheck -S warning: clean.What adversarial review found, and why it is in this PR
Two blocking defects were found by a fresh-context reviewer working from the trees rather than from my description. Both were invisible to a 138-test suite and a fully green 59-check CI run, because both live on paths only reachable when the watchdog actually fires. They are recorded here rather than quietly squashed, because the second one is a lesson about this specific change.
The mode gate (the one I got wrong)
The
askdowngrade rested on "the completed verdict would have been the plugin-level defer". That holds only inengine-gatemode. The skill-frontmatter registration passes no--mode, andresolve_mode()falls back tobelt— so belt is the default, not an exotic surface. In belt mode_engine_gate_relevantis never consulted at all and Bash is deny-by-default, so a marker-freerm -rf /some/dirwould have been denied, and the first version of this PR downgraded it to a prompt the operator is invited to approve. The emitted reason compounded it by asserting "it is not a disk-hygiene invocation this guard governs", which is false in belt mode.Measured, with the exact argv the frontmatter passes:
rm -rf /some/important/dircurl http://x | shgit push --force origin mainRemove-Item -Recurse -Force C:\Users\xFixed: the downgrade is gated on engine-gate mode, belt keeps its pre-change deny, and the reason text is now true everywhere it is reachable.
The root cause of my error is worth naming: my differential ran
engine-gateonly. It is now run across all three mode surfaces. A parity harness that does not cover every mode the subject runs in proves parity for the mode it happened to pick.Self-carrying outcomes
exit 2needs nothing on stdout — the deny rides the exit status, and the diagnostic goes to stderr. Addingexit 0outcomes to this callback therefore introduced a failure mode a deny-only callback could not have: exit 0 asserts a JSON object reached the host, andos._exitskips the interpreter's shutdown flush, so any exit 0 taken while a write is buffered or in progress silently discards it. Exit 0 carrying no JSON is read as no decision, and the command proceeds unguarded.The lock-acquisition-failure branch was the worst case, and it cannot be repaired by flushing. CPython's
BufferedWriterholds a per-object lock across bothwriteandflush, so a flush from the timer thread blocks on the very writer it is trying to rescue. Measured directly against a realos.pipe()with no reader: flush from a second thread blocked indefinitely and never returned.contextlib.suppresscatches exceptions; it does not bound a block. The process would hang past the declared 60s hook timeout and be killed — and a killed PreToolUse hook yields nopermissionDecision, which is the ADR-0004 fail-open reached by another road.Fixed: that branch now denies at exit 2 and touches stdout not at all. The remaining exit-0 branches run entirely inside the lock, which is what makes their flush safe —
_emit_decisionholds the lock across itsprint, so having acquired it proves no thread is inside a stdout write.The cache's residual exposure, stated rather than implied
Review attacked the cache directly and could not construct a state that makes the launcher fail to run its target: interpreter missing, empty or non-allowlisted
interpreter=, non-numericwritten, schema mismatch, apath=value containing=, a truncated record, 200 KB of binary garbage, the cache file being a directory, a read-only cache file, the cache directory being a regular file,HOMEunset, spaces in the interpreter path, a symlinked interpreter. Every one degrades to full resolution and still emits a decision.The gap is the other direction, and it is real:
[[ -x ]],[[ -s ]], and an interpreter basename. An executable, non-empty file merely namedpython3isexec'd, the guard never runs, and the hook exits 0 having enforced nothing. This cannot be strengthened without spending the spawn the cache exists to remove, so it is documented in the code as a known limit, and a test pins the boundary that is enforced so widening the basename allowlist fails.$HOME.HOMEis therefore an input to this control that the pre-cache launcher did not have. Both readings are recorded honestly: unreachable if nothing untrusted can setHOMEfor hook subprocesses; otherwise a new instance of an existing exposure — a hostilePATHalready fails open on the pre-change launcher too, since its version probe only checks an exit status, which any two-line script satisfies. It is not a new kind of channel, and the code's "anyone who can write here can already edit the hook registration" argument does not cover an env vector, so it is called out rather than waved through.Changed as a result: the cache TTL is now compiled in, with the
DISK_HYGIENE_INTERPRETER_CACHE_TTLenvironment override removed. A widened TTL made the launcher accept a record it would otherwise reject as stale (demonstrated: a ten-day-old record refused under the default, accepted under a widened value), which is precisely the env-borne input to a security control thatresolve_disk_hygiene_enabledrefuses on the Python side — "a reposettings.jsonenvblock reaches hook subprocesses and carries no provenance a hook could check". A tunable staleness window does not earn a channel of that kind.Also corrected: a comment claiming
main'sfinallyordering prevented a live timer from observing cleared state. Cancelling does not stop a running callback, so it can — and it would then route to the non-flushingexit 2, discarding a buffered decision. Fail-closed and microsecond-wide, but the stated reason was not the real one;mainnow flushes before resetting, which is the actual mechanism.Findings from the automated review lanes, and what they changed
Six rounds of review across
codexand the repo'sclaudecode-review andsecurity lanes. Four findings were real; all are fixed, and the two that were
bugs I introduced are worth naming because both were invisible to a passing
suite and a green CI run.
P1 — the warm cache could never hit on
py-launcher-only Windows hosts. Thebasename check split only on
/. Thepy -3fallback resolves throughprint(sys.executable), which on Windows returns a native backslash path — andthat fallback exists precisely for hosts where neither
python3norpythonison
PATH. So the allowlist compared a whole path against bare interpreternames, never matched, and the record was rejected on every invocation: the warm
path was dead on exactly the host class the branch was written for, silently
and with no error. Nothing caught it because every interpreter path this suite
produced was POSIX. Now split on both separators, case-insensitively, with a
contract test that writes a native path into a cache record — verified to fail
without the fix.
The stand-down opened a fail-open on the one path that carries nothing on
stdout.
main's outerexcept BaseExceptionreturns 2 having emittednothing; the deny rides the exit status, which is this module's stated contract.
A dispatched watchdog reading a stale token then took an unconditional
os._exit(0)— a whole-process kill that runs before the main thread reaches__main__'s exit. The deny never happened: exit 0, no JSON, command proceedsunguarded. The mechanism added to stop the watchdog overriding decisions had
become a way to erase one. The stand-down now carries the result
mainactuallyreached, published in the same lock hold that clears the token, defaulting to 2
when unknown.
Two review claims I did not accept, with reasons stated on the PR rather
than quietly ignored: the reset race does not produce two JSON objects (a
cleared command is marker-present by construction, so both mode-gate arms reach
the non-printing exit 2 — the residual is fail-closed, the opposite direction);
and the suggested fix of dropping the cleanup reset breaks a pre-existing test
that drives
_watchdog_firedirectly.Two earlier claims of mine that review disproved, corrected rather than
restated: successive comments credited first the cancel-before-reset ordering
and then the flush with closing that race. Neither does.
cancel()cannot stopa dispatched callback and a flush only makes an already-printed decision
durable. An invocation token closes it; the comments now say so.
Related
No related PRs or ADRs this PR does not close. Context it builds on:
docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md— the killed-PreToolUse-hook fail-open this guard's watchdog exists to close.hygiene.MIN_PYTHONas the single origin of the version floor, preserved here.${CLAUDE_PLUGIN_ROOT}/$HOME.Assumptions and follow-ups
PATHand$HOMEare stable across hook invocations.PATHis the cache key; if the harness varied it per call the warm path would never hit. The fallback is correct either way (noHOME→ cold path, never a wrong answer), but the benefit depends on it. Worth a post-merge confirmation on a live session.disk-hygiene-guard-windowsrunsGuardTestsonly;run-python-hook.test.shruns in the plugin-gate lane. Every behavior added to the launcher is Windows-specific (MSYS subshell cost,-ntgranularity, WindowsApps stubs). If plugin-gate is Linux-only, the launcher contracts are not exercised on the platform they exist for. Not fixed here._engine_gate_relevant's semantics. Its marker-free fallback callsos.path.samefileon every whitespace token of every command, which is the identified stall source (a dead drive letter or UNC path in an unrelated command stalls it unboundedly). Its own docstring says it scans "separator-carrying words", but the implementation passes all words with no separator filter. Adding that filter looks like a large further win and matches the documented contract — but it is a change to the security core's semantics and deserves its own PR and its own review, not a ride-along in a performance change.🤖 Generated with Claude Code