fix(guardrails): make the drive-root /tmp guard see a Write, not only a command - #3503
Conversation
… a command block-windows-drive-tmp exists to stop a Windows write landing at <drive>:\tmp (#2594). On 2026-08-30 it missed one: an empty C:\tmp\tmp.rSFIkHm5DO was created and nothing fired. Root cause: the hook read `.tool_input.command`. A Write payload carries `file_path` instead, so hook::jq_fields produced an empty COMMAND and the `[[ -n "$COMMAND" ]] || exit 0` early exit returned before any matcher ran. The guard covered command-shaped writes and not tool-shaped ones. Two edits, because either alone is inert. The script now also reads `.tool_input.file_path` and `.tool_input.notebook_path`, and the early exit tests both doors; hooks.json gains a SEPARATE PreToolUse group matching `Write|Edit|MultiEdit|NotebookEdit`, without which the hook would never receive those payloads. A separate group, not a widened matcher string: widening the existing Write|Edit|NotebookEdit group would hand MultiEdit to secret-pattern-detection and hardcoded-path-check, and widening Bash|PowerShell would attach seven command-lane guards to every file write. The file-path lane reuses the shipped has_drive_root_tmp() — no second matcher — and carries none of the command lane's write-shape inference, because on Write/Edit the path IS the write target. Fail-closed posture is unchanged and now covers the new field: NUL handling, buffer_stdin rc 2, jq absence and MAX_COMMAND_LEN all behave as before. Only PATH fields were added to the jq_fields call; content fields are deliberately excluded so a NUL in a file body cannot make this guard fire. No length ceiling on the path lane, by decision: that lane runs three EREs with no tokenization, so length creates no parse ambiguity and a ceiling would only add a false-positive class. Case folding moves from `printf | tr` to `${var,,}`, removing a fork and an exec (~280 ms on Windows Git Bash) from the shared normalizer — a cost the pre-existing per-Bash-call lane was already paying. ADR 0003 measurement, in the README: 204 distinct real Write/Edit/MultiEdit/ NotebookEdit target paths from 202 local session transcripts on a Windows host; 1 finding (0.49% firing) and it was the incident itself, so precision is 100%; 6/6 seeded spellings detected end to end. Hook-budget share is measured and recorded in the README, load-normalized against an interleaved `bash -c :` baseline because the measuring host was under heavy agent load. Repro-first per the hook-precision discipline: the four seeded Write payloads exit 0 against the unmodified hook and 2 against this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
…a one-letter parent Fresh-context verification of the file-path lane surfaced three defects in it. A FALSE POSITIVE the lane made reachable on every write. `D:\a\tmp\x` blocked: after slash-normalization the drive colon satisfied the left boundary of the MSYS `/<drive>/tmp` alternative, so `d:` + `/a/tmp` read as a drive root. The identical MSYS spelling `/d/a/tmp/x` was allowed the whole time, so one sink decided two ways — contradicting the guard's own premise that the spellings are one sink. The defect predates this branch (the command lane blocked `mkdir -p D:\a\tmp\x` too) but the file-path lane made it reachable from every Write/Edit, so it is fixed here rather than inherited. The boundary now excludes `:`. No true positive is lost: a real MSYS drive root has no path component before `/<drive>/tmp`. Pinned repro-first on both lanes per the hook-precision convention — the new stay-quiet cases exit 2 against the previous commit and 0 against this one, while `/c/tmp/x` and `C:\tmp\x` still exit 2 on both. THE REGISTRATION WAS UNASSERTED. Reverting the hooks.json half — the half without which the script change is inert — left all 142 assertions green. The suite now asserts the guard is registered on Bash|PowerShell and on each of Write, Edit, MultiEdit and NotebookEdit. CI-BLOCKING PORTABILITY FAILURE. Two fixture path literals this branch added carried `\s` and one carried `\b`, which the repo's shell-portability scanner reads as GNU-only regex classes; `scripts/check-shell-portability.sh` exited 1 on the test file. Resolved by renaming the fixture segments rather than suppressing the scanner — a fixture is not worth a `portability-ok` comment when a different letter says the same thing. Also from the same review, honesty corrections rather than defects in behavior: - The README's budget accounting no longer states a set-level delta. The n=12 table measured AFTER *lower* than BEFORE, and the paired A/B's per-trial ratios span 0.55x-1.82x with several trials putting AFTER faster, which is physically impossible and is host noise. The delta is below this host's noise floor and the doc now says so; what it states instead is the hook's own measured cost and the parallel-dispatch bound that follows from it. It also records that the convention's surface counts PostToolUse too, so the existing overage is deeper than the PreToolUse-only slice shows. - The README's ADR 0003 record no longer presents "100% precision" as a standalone result. Precision is 1/1; the corpus is one Windows host and one operator and contains no MultiEdit or NotebookEdit entries. Per ADR 0003 rule 3 it now names the ratio considered acceptable for this surface and justifies it from the asymmetric cost of a wrong block versus a silent miss. - The CHANGELOG claimed "a tool name matches one group". Claude Code fires every matching group; a Write now matches two guardrails groups. The conclusion holds because this hook sits in exactly one of them. - The README's "a prose mention of /tmp is never a block" was true only of the file-path lane; the Bash lane still sees a heredoc body. The telemetry subject now resolves inside emit_tel. hook::extract_bash_subject runs in a command substitution, and that fork was paid on every tool call with no sink wired, and would now be paid on every Write to obtain a constant. Closes #3501 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
… drive-colon fix Second-pass fresh-context verification of the previous commit found that excluding the colon from the MSYS drive-form left boundary — the fix for the `D:\a\tmp\x` false positive — took a real class out with it. Six colon-bound PowerShell write spellings regressed from blocked to allowed: Set-Content, New-Item and Add-Content with `-Path:`, Out-File with `-FilePath:`, and Copy-Item and Move-Item with `-Destination:`, each targeting the MSYS drive root. `-Parameter:Value` is valid PowerShell binding, and the space-bound twin of one of those is a pinned MUST-fire case — so the previous commit reproduced, on the PowerShell lane, the very "one sink decided two ways" inconsistency it claimed to remove. The earlier justification reasoned about path shapes, but this matcher also runs over whole command strings, where a colon sits next to a path for parameter-binding reasons that have nothing to do with a drive letter. The MSYS alternative is now two arms discriminating on what precedes the colon. A DRIVE SPEC is exactly one alphanumeric at a word boundary and no longer satisfies the boundary; every other colon still does — a non-alphanumeric immediately before it, a two-alphanumeric token such as `-Path:` or `host:`, or a single alphanumeric behind a flag dash. Swept rather than argued, since hand-picked cases plus a plausibility argument is what produced the regression. Both versions' has_drive_root_tmp() were lifted from their own files by sed and compared over 1,702 probes: every ASCII printable as the immediate left neighbour, every two-character context ending in a colon, nine drive letters in eight surrounding contexts, deeper subdirectory shapes, and the colon-bearing forms a sweep alone does not reach. All 266 changed verdicts are the drive-spec reading and the arithmetic closes: 192 non-alnum-then-alnum colon contexts, 72 explicit drive-letter subdirectory probes, the leading bare colon, and `D:\a\tmp` itself. Every real drive-root spelling still matches on both versions. Two accepted residuals, both unchanged from the shipped guard rather than introduced here: a PATH-style search list presents the token shape and still matches, and a remote spec with a single-letter host now reads as a drive spec and does not — it names a path on another machine, which this guard never governed. Also from the second round: - The `file-path` telemetry envelope was documented in the schema and executed by nothing; the existing telemetry case pipes a Bash payload. A file-path case now asserts the Write / Write / file-path envelope and that the target path never reaches it. - The registration assertions were partly vacuous. `Edit` could never fail while `MultiEdit` passed, and a matcher with the pipes removed routes nothing while satisfying every containment check. They now split the matcher on the pipe and compare the exact sorted alternative set, and check that the registered command names a file that exists. - `guardrails-test-helpers.sh`'s `command_json` omits MSYS_NO_PATHCONV, so on a Windows host Git Bash rewrites an MSYS drive path to the drive-letter form before jq sees it and every MSYS assertion silently exercises the wrong alternative. The MSYS cases now build payloads through local no-pathconv builders; the shared helper is duplicated across plugins under a source-drift gate and is not edited from here. - A README sentence added by the previous commit promised that a heredoc body carrying a drive-root path blocks on the Bash lane. It does not, on either version — segment_writes_drive_root_tmp requires a creator verb and `cat` is not one. Corrected. The ADR 0003 sweep is re-run against the FINAL matcher, not an earlier draft: 259 distinct real Write/Edit/MultiEdit/NotebookEdit target paths across 227 local transcripts, 1 finding (0.39%) and it is the incident write itself, 6/6 seeded spellings detected end to end. README updated with those figures and dated, since the corpus grows as the host accumulates sessions. Contract suite green at PASS=181 FAIL=0 against this tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
…rite-tool # Conflicts: # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 5m 5s —— View job Security Review
Skip gate: does not apply — this PR modifies a blocking security guard hook ( Scope reviewed (per skill instructions, changed files only):
What I checked:
Result: no security issues found. This is a well-scoped hardening fix for a pre-existing detection gap (a |
|
Last security-reviewed head: |
…the widened guard Two CI gates this branch tripped, both real and both fixed at the source rather than suppressed. plugin-options-docs-gate. The guardrails README's options table is GENERATED from plugin.json's userConfig, and I had hand-edited the block_windows_drive_tmp_enabled row to describe both lanes without updating the declaration it is generated from. The declaration now carries the two-lane wording; sync-plugin-options-docs.py --check is clean and regenerating produces no README change, so the two agree at the source. hygiene / machine-specific-paths. A fixture path in the contract test named a concrete Windows user directory. It now uses the <user> placeholder the gate's own message prescribes. That placeholder then tripped a THIRD gate: check-shell-portability reads `\<` as a GNU-only word boundary, and a backslash separator put one immediately before the placeholder. The fixture uses forward slashes instead, a valid Windows spelling the matcher slash-normalizes anyway, and the backslash form stays covered by the D:\repo\docs\tmp and D:\a\tmp cases beside it. None of this changes what the case tests: the matcher decides on the presence of a drive-root tmp component and this path has none. The three constraints are recorded in a comment so the next author does not rediscover them one CI round at a time. Contract suite PASS=181 FAIL=0, with the %TEMP% case still exiting 0. check-shell-portability, shellcheck -x and sync-plugin-options-docs --check all clean locally. The machine-paths gate is a ci-workflows composite action with no local entry point, so that one is verified on CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 40s —— View job Security Review
Skip gate applies. The prior review comment on this PR recorded
Neither change touches the guard's matching logic, No new security review needed; the prior review's "no security issues found" verdict stands. |
A round-2 verifier finding claimed guardrails-test-helpers.sh's command_json omits MSYS_NO_PATHCONV and therefore rendered every MSYS `/<drive>/tmp` assertion vacuous on Windows. That finding was wrong. MSYS argv rewriting converts an argument only when the argument is ENTIRELY a POSIX-absolute path. Measured on a Windows host: --arg cmd '/c/tmp/x' -> C:/tmp/x --arg cmd 'mkdir -p /c/tmp/x' -> unchanged --arg cmd 'Set-Content -Path:/c/tmp/x -Value hi' -> unchanged Every command fixture in every suite is multi-token, so nothing routed through command_json was ever mangled. Only a lone bare path is exposed, and those go through the shared write_json family, which already sets MSYS_NO_PATHCONV deliberately and documents why. There is no shared-helper defect and no cross-plugin blast radius. The local msys_command_json / msys_pwsh_command_json builders are kept: setting the variable explicitly stops a future lone-path fixture from silently becoming a drive-letter payload and ceasing to exercise the MSYS arm. Only their comment changes, to state that reason instead of the retracted one. Text only: every changed line in the test file is a comment line, and the suite still reports PASS=181 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 53s —— View job Security Review
Skip gate applies. The prior review comment on this PR recorded
Neither file touches the guard's matching logic ( No new security review needed; the prior review's "no security issues found" verdict stands. |
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 6m 28s —— View job Code Review
Skip gate: does not apply. This PR is open, not draft (the trigger was Scope reviewed (changed files only, correctness/maintainability — security is out of scope for this lane and already covered by the security-review lane's three passes):
What I checked:
Finding (posted inline):
Not re-litigated: the matcher-narrowing regex logic, MSYS pathconv behavior, and hook-budget measurements are already exhaustively self-verified in the PR body (1,702-probe sweep, two independent fresh-context rounds, security-review passes) — my own hand-trace agreed with those claims everywhere I checked, so I'm not restating that work as new findings. |
|
Claude finished @kyle-sexton's task in 36s —— View job Security Review
Skip gate applies. The most recent prior review on this PR already covered HEAD SHA That review covered the full security-relevant surface of this PR:
with the verdict: no security issues found (fail-closed posture preserved across the new fields, no injection surface, no ReDoS, no double-execution hazard, no new attack surface from the file-content-blind path lane). No new security review needed; the prior review's "no security issues found" verdict stands. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 158ca9eb36
ℹ️ 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".
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Widening block-windows-drive-tmp to Write/Edit/MultiEdit/NotebookEdit had a consequence the widening itself did not carry. The non-Windows host gate sat BELOW hook::buffer_stdin and hook::require_jq_blocking, so on a Linux or macOS host with no jq on PATH, require_jq_blocking's fail-closed exit 2 fired on EVERY file edit — on a platform where /tmp is the real POSIX temp and this guard can never find a violation. Before the widening the hook matched only Bash|PowerShell, where blocking on missing jq is the accepted #2146 posture, so the ordering was not previously reachable this way. The host gate now runs immediately after hook::check_enabled. Reading OSTYPE needs nothing from the payload, and check_enabled already exits without draining stdin, so exiting there is an established shape in this hook rather than a new one. The telemetry start stamp moves below the gate so it stays adjacent to the work it times. The Windows path is UNCHANGED. The case falls through and every fail-closed posture runs in the same order as before: buffer_stdin rc 2, jq absence, an unparseable payload, NUL bytes, MAX_COMMAND_LEN. Verified directly — a drive-root Write still exits 2 and a NUL-bearing path still exits 2 — and the pre-existing Windows-lane assertions would go red on an over-hoist. block-exported-msys-pathconv.sh keeps the opposite ordering deliberately: it matches only Bash|PowerShell, so the blast radius forcing the hoist here does not exist there. The comment says so, to stop a future author aligning them back. Regression test: removing jq from PATH is not portably simulable — an isolated bin dir without jq cannot host bash + coreutils across Git Bash and Linux, the constraint require-jq-notice-isolation.test.sh and secret-pattern-detection.test.sh both record. What decides the bug is whether the call is REACHED, so the test asserts the ordering from an xtrace of a real Linux-host run: neither buffer_stdin nor require_jq_blocking appears. Both appear before this fix and neither after. Also corrects the CHANGELOG's governance claim about the shared test helpers. They are duplicated per plugin BY CONVENTION per docs/conventions/shell-test-helpers/README.md, which places them explicitly outside check-cross-plugin-source-drift.sh's scope, with no entry in scripts/cross-plugin-source-registry.txt. Nothing gates them. Suite: PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 1m 45s —— View job Code Review
Skip gate: does not apply. The PR is open (ready for review), and this lane's last review on this PR covered head Scope reviewed: the single new commit,
This commit fixes the two outstanding findings on this PR: Codex's P1 finding (posted as a review comment on What I checked:
Not independently run: I don't have shell-execution approval in this environment to run Result: no new findings. This commit correctly fixes both outstanding issues without introducing new ones. |
|
Claude finished @kyle-sexton's task in 1m 4s —— View job Security Review
Skip gate does not fully apply, but the change is narrow. The prior review comment recorded What changed (
Security assessment of this commit:
No security issues found in the commits since the last review. This fix closes the gap the Codex bot identified cleanly, and does not introduce a new one. |
…n trade-off Round-3 verification falsified a claim in 205fd68's own prose. I wrote that removing jq from PATH is not portably simulable and told a future author not to re-attempt it. It is simulable wherever jq lives outside the directory holding bash and coreutils: pruning that one PATH entry leaves the shell intact without jq. Done that way, the motivation is confirmed directly rather than by proxy — the parent commit exits 2 on a Linux Write and 205fd68 exits 0 on a real jq-less PATH. The constraint the repo actually records is portability, not impossibility: where jq sits in /usr/bin beside bash, pruning takes the shell with it. So the committed assertion still uses the portable xtrace form, and the comment now says why rather than overstating it. Also records two things the verification surfaced: - The non-Windows gate exits without draining stdin, so a payload past roughly 64KB is left undrained on Linux and macOS (measured: writer rc 141 at 256KB and 1MB; Windows lane unaffected). The shape was established by hook::check_enabled, but that is a kill switch firing only when a guard is disabled, whereas this gate fires on every non-Windows tool call — so the exposure is new even though the shape is not. Accepted rather than fixed: a naive builtin drain blocks until EOF and can hang a tool call, and draining via buffer_stdin first would put its rc-2 fail-closed exit back ahead of the host gate, which is the bug 205fd68 fixed. Filed as #3504. - On a non-Windows host this guard no longer fails closed on jq's absence for the Bash lane either. That posture is kept in full on Windows and dropped only where every exit 2 it produced was a false positive by construction. Stated as its own CHANGELOG bullet rather than left implicit. Comment and prose only: every changed line in both shell files is a comment line. Suite unchanged at PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 1m 9s —— View job Security Review
Skip gate applies. The prior review comment on this PR recorded Its full diff, verified line-by-line:
No line of executable logic changed in this commit — this is condition 2 of the skip gate (no security-relevant surface in the new commit) combined with condition 3 (the prior review of the guard's actual control flow, unchanged since On the newly-disclosed trade-off itself (not new, but since it's freshly documented, worth confirming it doesn't change the verdict): an unbuffered large stdin payload on a non-Windows host causing No new security review needed; the prior review's "no security issues found" verdict stands. |
|
Claude finished @kyle-sexton's task in 1m 58s —— View job Code Review
Skip gate: condition 3 applies to the delta under review. The PR is open (not draft), and this lane's last review covered head What I checked anyway, since comment accuracy is itself a maintainability concern:
Result: no findings. This commit is a documentation correction (retracting an overstated "not portably simulable" claim and recording a known/accepted stdin-drain trade-off as its own filed issue, #3504) with no logic change. Everything it asserts about the code matches the code as I read it. Not re-litigated: the matcher logic, |
The hygiene lane's typos check rejects `unparseable`. Prose only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 1m 25s —— View job Code Review
Skip gate: does not apply outright (HEAD moved since the last code-review-lane pass on Scope reviewed: everything since the last code-review-lane head
What I checked on the merge specifically, since a merge into a long-running branch is where content can silently get clobbered:
Result: no findings. This delta is a one-word comment/CHANGELOG spelling correction plus a clean merge from Not re-litigated: the matcher logic, |
|
Claude finished @kyle-sexton's task in 51s —— View job Security Review
Skip gate applies. The prior review comment on this PR recorded
Neither commit touches the guard's matching logic, fail-closed posture, telemetry, or the No new security review needed; the prior review's "no security issues found" verdict stands. |
…ture The machine-specific-paths hygiene gate rejects `/home/u/x.txt` as a Linux user path. The assertion only needs a path that is not a drive-root temp target on a Linux host, so it needs no home directory at all: `/srv/app/ notes.txt` satisfies the gate without a placeholder. Preferred over the `<user>` placeholder spelling used by the %TEMP% fixture. That fixture is exercising a real platform temp path and has to look like one; this one is not, so the simpler literal is better than templating a directory the test never needed. Suite unchanged at PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV
|
Claude finished @kyle-sexton's task in 38s —— View job Security Review
Skip gate applies. The prior review comment on this PR recorded Its full diff, verified directly: - trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/home/u/x.txt' 'body')" 2>&1 >/dev/null)
+ trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/srv/app/notes.txt' 'body')" 2>&1 >/dev/null)One line, in No change to No new security review needed; the prior review's "no security issues found" verdict stands. Branch · |
|
Claude finished @kyle-sexton's task in 49s —— View job Code Review
Skip gate: condition 3 applies to the delta under review. PR is open (ready for review, not draft). This lane's last review covered head Scope reviewed: the single new commit,
What I checked:
Result: no findings. This is a correct, minimal test-fixture fix for a CI hygiene-gate false positive, consistent with the PR body's own account of this exact issue ("Three CI gate interactions worth recording"). Not re-litigated: the matcher logic, Branch · |
Summary
block-windows-drive-tmpexists to stop a write landing at a Windows drive root (<drive>:\tmp) instead of%TEMP%(#2594). On 2026-08-30 it missed one: an emptyC:\tmp\tmp.rSFIkHm5DOwas created and nothing fired.Root cause. The hook read
.tool_input.command. AWritepayload carriesfile_pathand nocommand, sohook::jq_fieldsproduced an emptyCOMMANDand the[[ -n "$COMMAND" ]] || exit 0early exit returned before any matcher ran. The guard covered command-shaped writes and not tool-shaped ones. Confirmed from the local transcript, not inferred: aWritewithfile_path: /tmp/tmp.rSFIkHm5DO/worktree-rootat 18:15:54, then a record namingrealParentDir: C:\tmp\tmp.rSFIkHm5DOat 18:15:56.884 — matching that directory's birth time to the millisecond.Opened as a draft: the brief says do not merge, and
.claude/source-control.mdsetsbabysit_loop_merge: c3-autonomous, so prose is not enforcement and draft state is.Fix
Host gate hoisted ahead of stdin and the jq requirement (P1 from review). Widening the matcher to
Write/Edit/MultiEdit/NotebookEdithad a consequence the widening itself did not carry: the non-WindowsOSTYPEgate sat belowhook::buffer_stdinandhook::require_jq_blocking, so on a Linux or macOS host with nojqonPATH, the fail-closedexit 2fired on every file edit — on a platform where/tmpis the real POSIX temp and this guard can never find a violation. Before the widening the hook matched onlyBash|PowerShell, where blocking on missingjqis the accepted #2146 posture, so the ordering was not reachable this way.The gate now runs immediately after
hook::check_enabled. ReadingOSTYPEneeds nothing from the payload. The Windows path is unchanged — the case falls through andbuffer_stdinrc 2,jqabsence, an unparseable payload, NUL bytes andMAX_COMMAND_LENall still fail closed in the same order, verified verdict-by-verdict against the parent commit.One posture change is deliberate and stated rather than silent: on a non-Windows host this guard no longer fails closed on
jq's absence for the Bash lane either. That posture is kept in full on Windows and dropped only where everyexit 2it produced was a false positive by construction.block-exported-msys-pathconv.shkeeps the opposite ordering on purpose — it matches onlyBash|PowerShell, so the blast radius forcing the hoist here does not exist there. The comment says so, to stop a future author aligning them back.Two edits, because either alone is inert.
plugins/guardrails/hooks/block-windows-drive-tmp.shalso reads.tool_input.file_pathand.tool_input.notebook_path, and the early exit tests both doors.plugins/guardrails/hooks/hooks.jsongains a separatePreToolUsegroup matchingWrite|Edit|MultiEdit|NotebookEdit. Without it the hook never receives those payloads and edit 1 is dead code.Why a separate group rather than a widened matcher string. Widening the existing
Write|Edit|NotebookEditgroup would handMultiEdittosecret-pattern-detectionandhardcoded-path-checkas a side effect; wideningBash|PowerShellwould attach seven command-lane guards to every file write and blow the per-Writebudget several times over. Claude Code fires every matching group, so aWritenow matches two guardrails groups — but this hook sits in exactly one, so it still fires once per tool call.One matcher, not two. The file-path lane calls the shipped
has_drive_root_tmp(). It carries none of the command lane's write-shape inference — no redirect parsing, no producer-utility whitelist, no segment splitting — because onWrite/Editthe path is the write target.Fail-closed posture preserved, and extended to the new field. NUL handling,
hook::buffer_stdinrc 2, jq absence andMAX_COMMAND_LENbehave exactly as before, verified by execution againstorigin/main. Only PATH fields were added to thehook::jq_fieldscall:HOOK_JQ_FIELDS_NULis computed across every requested field, so pullingcontent/new_string/new_sourcein would have made this guard block on a NUL anywhere in a file body — a new false-positive class belonging tohardcoded-path-check, not here.No length ceiling on the path lane, by decision.
MAX_COMMAND_LENexists because the command lane walks its string character by character twice before matching; the path lane runs EREs with no tokenization, detection does not degrade with length, and a blocking ceiling would only refuse legitimate long paths. The payload stays bounded byhook::buffer_stdin, whose stall path fails closed.A pre-existing false positive, fixed because this change made it reachable — and then corrected again after it over-reached.
D:\a\tmp\xblocked: after slash-normalization the drive colon satisfied the left boundary of the MSYS/<drive>/tmpalternative, sod:+/a/tmpread as a drive root, while the identical MSYS spelling/d/a/tmp/xwas allowed — one sink deciding two ways. The first attempt excluded:from that boundary outright, which regressed six colon-bound PowerShell write spellings (Set-Content -Path:/c/tmp/x,New-Item -Path:,Out-File -FilePath:,Add-Content -Path:,Copy-Item -Destination:,Move-Item -Destination:) — one of whose space-bound twins the suite pins as MUST-fire. The matcher is now two arms, discriminating on what sits before the colon: a drive spec is exactly one alphanumeric at a word boundary and no longer satisfies it; every other colon, including a parameter colon, still does.Budget housekeeping in passing. Case folding moves from
printf | trto${var,,}, and the telemetry subject resolves insideemit_tel— removing a fork-and-exec pair and a fork that the pre-existing per-Bash-call lane was already paying on every call with no sink wired.Verification
The matcher narrowing was swept exhaustively, not argued. Both versions'
has_drive_root_tmp()were lifted from their own files bysed(no retyped copy) and compared over 1,702 probes: every ASCII printable as the immediate left neighbour of/c/tmp/x, every two-character context ending in a colon, nine drive letters in eight surrounding contexts, deeper subdirectory shapes, and the colon-bearing forms a sweep alone does not reach.All 266 changed verdicts are the drive-spec reading — 192
<non-alnum><alnum>:contexts, 72 explicitX:\a\tmpprobes,D:\a\tmpitself, and a leading bare:. Every real drive-root spelling still matches on both versions:/tmp/x,/c/tmp/x,C:/tmp/x,C:\tmp\x,\tmp\x,D:\tmp\x, the redirect andmkdirforms, and all six colon-bound PowerShell writers. Two accepted residuals, both unchanged from the shipped guard rather than introduced:PATH=/usr/bin:/c/tmp cmdpresents the multi-character-token shape and still matches, andssh u@h:/c/tmp/xnow reads as a drive spec and does not — it names a path on another machine, which this guard never governed.Hook budget — measured, per
.claude/rules/hook-budget.mdanddocs/conventions/hook-budget/README.md, recorded in the guardrails README. Method is the convention's:EPOCHREALTIMEaround direct invocation with a benign representative payload, sets launched concurrently. The measuring host was under heavy concurrent agent load — itsbash -c :spawn baseline measured 4,498 ms against the convention's reference-host ≈ 80 ms, ~56x slower — so every figure re-measuresbash -c :interleaved with each trial and is reported as a load-normalized spawn-equivalent, converted back at 80 ms.WritepayloadWritePreToolUse set BEFORE (2 hooks)WritePreToolUse set AFTER (3 hooks)The hook's own ≈ 505 ms is the figure that holds; the set-level delta is below this host's noise floor and the README states no delta.
AFTERmeasuring lower thanBEFOREis impossible, and a paired A/B (n=15, arms alternated within each trial) spans 0.55x–1.82x with several trials putting AFTER faster. Against the convention's ≤ 2 s worst-case per-tool-call ceiling — which countsPostToolUsetoo, so the ≈ 1.9 s per-Writeoverage recorded in the convention is deeper than the PreToolUse slice above shows — ≈ 505 ms is ≈ 25% of the ceiling as an upper bound on this widening's contribution, less in practice because matching hooks dispatch in parallel. Per rule 2 the budget does not relax: remediation stays guardrails' spawn-reduction work (#1403), which this change pays part of.ADR 0003 decision: ships BLOCKING and default-on, and here is why that is not a dodge. ADR 0003 is a pre-ship gate on whether a verification guard earns default-on; it hands an in-tree guard to
docs/conventions/hook-precision/thereafter. ADR 0002's advisory-first promotion governs the GitHub Actions AI review lanes, not plugin hooks. This guard is already blocking and default-on; this is a matcher widening, and the new lane's oracle is strictly less inferential than the shipped lane's — on aWritethe path is the write target, so the redirect parsing and utility whitelist that create the command lane's ambiguity are structurally absent, and precision on the new lane is bounded below by the shipped lane's. Shipping advisory-on-Writebeside blocking-on-Bashfor one hazard class would also be a posture no operator can reason about. The widening nonetheless opted into 0003's evidentiary discipline:file_path/notebook_pathvalues that a realWrite/Edit/MultiEdit/NotebookEditactually carried, across 227 local Claude Code session transcripts on a Windows host — absolute Windows and MSYS paths, not repo-relative ones a drive-root matcher could never match. Re-run against the final matcher, not an earlier draft of it./tmp/tmp.rSFIkHm5DO/worktree-root, the incident write itself. 6/6 seeded spellings detected end to end.MultiEditorNotebookEditentries — those two are covered by the contract suite and the shared matcher, not by the sweep. Per rule 3 the README names the acceptable ratio for this surface (a false-positive rate near zero) and justifies it from the asymmetry: a wrong block costs one stderr line and a reissued write, a missed one is silent by construction and was found only as litter on a volume root. Compare the ADR's shipped reference guard at 0.51% firing, 57% precision.Tests.
plugins/guardrails/hooks/block-windows-drive-tmp.test.shgreen, PASS=181 FAIL=0 (was 142 before this branch). New coverage: the incidentWritepayload and every drive-root spelling onWrite/Edit/MultiEdit/NotebookEdit(bothfile_pathandnotebook_path); legitimate%TEMP%,/var/tmp,docs/tmp,./tmp,foo/tmp,/tmpdir,C:/tmp2, UNC and single-letter-parenttmptargets in both spellings; the six colon-bound PowerShell writers as MUST-fire beside-Path:D:/a/tmp/xas MUST-stay-quiet;Writecontent mentioning/tmp; the Linux-host exit forWriteandEdit; NUL infile_path; the kill switch on the new lane; thefile-pathtelemetry envelope; and thehooks.jsonregistration itself. Every pre-existing Bash and PowerShell case is unchanged — the test diff is insertions only.Sibling suite
block-exported-msys-pathconv.test.shgreen (PASS=127 FAIL=0);scripts/check-drive-root-litter.test.shexit 0.scripts/affected-tests.sh --explainexits 0 with three suites selected and no changed file mapping to zero suites.check-changelog-parity.sh--check,--check-bump origin/mainand--check-preserved origin/mainall exit 0.check-shell-portability.shexits 0 — two fixture literals this branch introduced carried\s/\band failed it; resolved by renaming the fixture segments rather than suppressing the scanner.shellcheck -xandmarkdownlint-cli2clean.Repro-first, per the hook-precision convention. The new MUST-fire cases exit 0 against the unmodified hook and 2 against this one. The MUST-stay-quiet cases for the single-letter-parent false positive exit 2 against the first commit and 0 now, while
/c/tmp/xandC:\tmp\xstay at 2 throughout. The six colon-bound PowerShell cases exit 2 / 0 / 2 acrossorigin/main, the over-reaching first fix, and HEAD.Round 3 (on the host-gate fix), nine criteria, reasoning withheld. PASS on all of them, with zero Windows-lane divergence across 60+ payloads compared verdict-by-verdict against the parent commit — both lanes, both hosts, including the colon-bound PowerShell spellings — and every fail-closed posture checked independently (NUL, malformed JSON, MAX_COMMAND_LEN, empty stdin, jq absence). Suite 183/0. The new regression assertions were confirmed red against the parent and green against the fix, so the test guards the change rather than merely passing.
It also falsified a claim in my own commit prose: I wrote that removing
jqfromPATHis not portably simulable and told a future author not to re-attempt it. The verifier did it — pruning the singlejqPATH entry on this host leavesbashand coreutils intact — and got direct evidence for the motivation: on a real jq-lessPATH, the parent exits 2 on a LinuxWriteand this commit exits 0. The simulation is host-dependent (it needsjqoutside the directory holdingbash), which is the actual constraint the repo records, so the committed test still uses the portable xtrace assertion. The prose is corrected in both the hook comment and the CHANGELOG.Two fresh-context verification rounds before that, reasoning withheld, diff and criteria only. Round 1 (on the first commit) independently confirmed the root cause from the transcript, executed every payload against both hook versions, and found the portability failure, the single-letter-parent false positive, the unasserted registration, and README figures stated beyond their evidence. Round 2 (on the fix commit) found that the false-positive fix had regressed the six colon-bound PowerShell spellings, and that a new README sentence promised a
cat >heredoc block that has never existed on either version. Both are fixed: the two-arm matcher and the corrected sentence.Round 2 also raised a suspected gap in the shared
guardrails-test-helpers.sh— thatcommand_jsonomitsMSYS_NO_PATHCONVand so rendered every MSYS assertion vacuous on Windows. That finding was wrong and is retracted here rather than left implied by the diff. MSYS argv rewriting converts an argument only when the argument is entirely a POSIX-absolute path: measured on a Windows host,--arg cmd '/c/tmp/x'becomesC:/tmp/x, while--arg cmd 'mkdir -p /c/tmp/x'and--arg cmd 'Set-Content -Path:/c/tmp/x -Value hi'pass through untouched. Every command fixture in every suite is multi-token, so nothing routed throughcommand_jsonwas ever mangled; only a lone bare path is exposed, and those go through the sharedwrite_jsonfamily, which already setsMSYS_NO_PATHCONVdeliberately and says so in its own comment. There is no shared-helper defect and no cross-plugin blast radius. The local command builders in this suite are kept and set the variable explicitly, so that a future lone-path fixture cannot silently become a drive-letter payload; their comment now states that reason instead of the retracted one.Related
hook::check_enabled's kill-switch path, a naive builtin drain can hang a tool call, and draining throughbuffer_stdinfirst would put its rc-2 fail-closed exit back ahead of the host gate — the exact bug this PR fixes. Whetherhook-utils.shshould grow a shared bounded-drain helper for early-exit paths is a cross-hook decision, not a single-guard one./usr/bin/mkdir -p /tmp/x) is not blocked, becausesegment_writes_drive_root_tmpanchors its verb alternation on(^|[[:space:]]). Identical onorigin/mainand on this branch; a command-lane coverage gap, orthogonal to which payload shapes the guard inspects.docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.mddocs/conventions/hook-budget/README.md,.claude/rules/hook-budget.mddocs/conventions/hook-precision/README.md— repro-first stay-quiet disciplineThree CI gate interactions worth recording, because a future author will hit the same triangle. All three were found by CI, not locally.
plugin-options-docs-gate— the guardrails README options table is generated from.claude-plugin/plugin.jsonuserConfig. Hand-editing the README row passes markdownlint and fails the gate. The fix is to edit theuserConfigdescription;scripts/sync-plugin-options-docs.py --checkis the local pre-flight.hygiene— the ci-workflowsmachine-specific-pathscomposite action rejects a realistic Windows user path in a test fixture (C:\Users\dev\AppData\Local\Temp\note.txt). The prescribed remedy is the<user>placeholder.shell-portability-lint, because\<is a GNU word boundary. The only spelling satisfying all three gates is forward slashes plus the placeholder:C:/Users/<user>/AppData/Local/Temp/note.txt. The fixture carries a comment recording all three constraints so it is not "simplified" back into a failure.Not in this PR, deliberately.
scripts/check-drive-root-litter.shandscripts/check-shell-portability.share owned by other workers in the same program. One line indocs/conventions/windows-path-emit/README.mdis corrected here because it now misdescribed this guard as reading only a command string; if that conflicts with the litter-detector worker's branch, take theirs and reapply the clause.Measurement scripts are not committed (they would need a suite mapping for a throwaway); they are reproducible from the method stated in the README, and the sweep harness lifts both matchers from their own files rather than copying them.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV