fix(guardrails): grant the temp-tree exemption on Windows drive paths - #4394
Conversation
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d77743b61c
ℹ️ 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".
d77743b to
3617c58
Compare
Skill evidence: a gap at
|
d459afd to
73bc75e
Compare
hook::under_temp_root normalized its temp candidates to C:/... but compared the target as given, and block-hook-bypass passes the Git Bash /c/... spelling, so the shipped temp-tree default never granted on a Windows host. The target is now normalized the same way on msys/cygwin/win32 hosts only; POSIX hosts are unchanged. Every carrying plugin is re-synced and bumped. An 8.3 short-name target is still refused by the guard's ~ rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpare _norm_path collapsed a leading `//`, so `//c/Users/...` (a UNC share on a host named c) compared as the local temp tree once drive targets matched. It now fails closed for the temp default and configured roots alike. Adds a Windows junction-escape case, states the under_temp_root precondition, and tightens the CHANGELOG wording across carrying plugins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e real-host case The GitHub Windows runner's bash converts TEMP to /tmp, so the real-host case compared a drive-spelled target against a /tmp-only candidate set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path The machine-specific-paths lint reads C:/Users/<name> as a hardcoded user path; the stubbed cases and the changelog example now avoid that shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_norm_path now refuses a leading `//`, which made paths_identical fall back to a literal compare, so `jq . f > //tmp/x && mv /tmp/x out.json` stopped matching the staged-write detector. paths_identical collapses a leading slash run before normalizing; treating the spellings as one path can only add a block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73bc75e to
9728874
Compare
|
Claude finished @kyle-sexton's task in 4m 7s —— View job Claude finished
|
|
Claude finished @kyle-sexton's task in 3m 24s —— View job Claude finished the security review
Security review — no issues foundReviewed the security-relevant surface of this PR: What I checked specifically:
No CRITICAL, IMPORTANT, or SUGGESTION-level security findings. The fix closes the reported bypass correctly and the new test cases ( Per this skill's scope, GitHub Actions hardening (triggers, |
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. Deleting this comment resets the count. |
…n no wider than the Bash exemption (#4475) Closes #4471 ## Summary Since #4394, `block-hook-bypass` exempts a Bash redirect into a host temp tree when `CLAUDE_PROJECT_DIR` names a root outside that tree. `secret-pattern-detection` had no matching decline. With a root the scanner clears as a scope (home, or a folder that is not a git work tree), a `Write` of a secret to a temp file was blocked while `echo <secret> > <same path>` passed. This PR makes the scanner decline such a target through a gate that is never wider than the Bash exemption. The README's "exempting it gives up no protection" paragraph was false for this guard; it is rewritten to say what holds. This changes the scope of a default-on guard, so ADR 0003 (`docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md`) applies. It is a narrowing to a scope the Bash guard already exempts. The change adds no new oracle and does not widen what the guard blocks. ## Fix The fix is in `plugins/guardrails/hooks/secret-pattern-detection.sh`: `spd_temp_declines`, called after the path allowlist. Order of checks: 1. **Target spelling, builtins only.** - Windows: only a drive spelling (`X:/`, `X:\`) can decline. The Write tool is Node, which resolves `/tmp/x` and `/c/x` to places other than the ones Git Bash resolves them to. - POSIX: only a `/` spelling can decline. A `\` refuses. - Both: only letters, digits and `._/:+,=@%-` may decline. Any other character (whitespace, quotes, `;`, `&`, `|`, `<`, `>`, parens, `#`, `$`, backtick, `*`, `?`, `[`, `~`) forces quoting in a Bash redirect, and `block-hook-bypass` never exempts a quoted target. `//`, `/./`, `/../`, and a trailing `/.` or `/..` also refuse. 2. **Lexical pre-match**, builtin with `nocasematch`, on a `/tmp/` or `/temp/` component or a temp-candidate prefix. A target with no temp-looking component returns here without spawning a process. 3. **Root gate.** - `CLAUDE_PROJECT_DIR` set, spelled as `block-hook-bypass` accepts. - Not `/` and not a drive root. - Not under temp, as spelled or physically resolved. The physical check makes this gate narrower than `block-hook-bypass`'s. 4. **Target under temp, as spelled and physically.** - Physically means once its nearest existing ancestor is resolved with the uncached `hook::physical_path_to`, so the file's directory never enters the per-process resolver cache. - On POSIX the lowercased spelling must also be under temp, as `block-hook-bypass` compares it. Below Bash 4, a target with a capital letter refuses. 5. **Hard links:** an existing target with a link count above 1 is scanned. The ancestor walk stops when a strip does not shorten the path, so a spelling such as `Z:` cannot loop. Documentation changes: README temp-default paragraph and consumer-seam bullet, hook comments, and a CHANGELOG `[0.36.5]` entry with refusals and residuals. Version 0.36.4 to 0.36.5: main carries 0.36.4 at dfd84d4, the rebase base. ## Verification - Symptom on main (dbed107 and origin/main), Windows host, synthetic `ghp_` token, target under `%TEMP%`: root `$HOME` spd=2 bbh=0; root non-git `C:/...` spd=2 bbh=0. On the head: spd=0 bbh=0 for both. Reproduced independently by a fresh-context verifier. - The verifier's per-criterion verdicts (AC1-AC12 in the plan) are all PASS at 4a09787: - AC1/AC2: a non-git or HOME root declines. - AC3/AC4: an unset or temp-rooted root scans. - AC5/AC6: every refused spelling scans. - AC7: a junction under temp into a non-temp directory scans; a sibling declines. - AC8: Windows `C:/`, backslash and lowercase spellings decline; `/c/`, `/tmp/` and 8.3 scan. - AC9: a non-temp target spawns no `realpath`, `readlink`, `cygpath`, `stat` or `git`. Checked with a PATH shim; a temp target does show calls. - AC12: the target's directory is absent from `_HOOK_PHYS_KEYS`. - A hard-linked temp file scans. - Two width gaps were found and fixed. - The verifier found a target containing `$`, `[` or `*` declined where the Bash redirect blocks. Fixed in ae2be05. - The review threads found the same for whitespace, quotes and shell operators. Fixed in 2826b14 by the allowlist above. - Each gap has its own refusal cases. - `secret-pattern-detection.test.sh` at 2826b14 on this Windows host: PASS=168 FAIL=0, rc 0, 168 `ok:` lines, no SKIP lines. The Linux CI shard (run 36086181666, head 81b30c8) reports PASS=160 FAIL=0. Its one SKIP is the Windows spelling block. The POSIX case-width seam ran there, so POSIX coverage comes from CI. Red evidence from the implementer on the unchanged hook: AC1, AC2, the AC7 sibling, the AC8 backslash case, the dispatcher case, the cache probe and the hard-link case each failed before the fix. - `run-guards.test.sh`: PASS=224 FAIL=11 on the branch at both 81c7280 and 4a09787. Its FAIL lines are identical to origin/main's run on this host; all are PostToolUse `if`-row and verifier cases. `coverage-manifest` 6/11 and `block-windows-drive-tmp` 199/15 have the same counts on origin/main (host-only). `require-jq-notice-isolation` 2/0, `pre-commit-content-invariants` 9/0, `abort-boundary` 216/0. - `check-changelog-parity.sh --check-bump aaf4c44` passes; `check-changed-skills` reports no skills changed; shellcheck is clean on both shell files; the scoped em-dash gate is clean; added lines contain no U+2014. - Reviews ran as nested fresh-context agents: plan reviewer, code reviewer, security reviewer, a simplification and prose (AI-slop, markdown-noise) read, and the verifier last. The skill-evidence block is absent because #4465 removed that system; the owed reviews ran as the nested agents named above. ## Related - Unattended interview: the Brief was synthesized from the queue item and the operator-accepted vetting (D1, VETTED-WITH-CHANGE). No questions were reserved for a human. - Remaining from the same queue item, left for later lanes: - 8.3 short-name acceptance, including staged-move identity across spellings in `paths_identical`. - Block message part 2. - Windows CI not running `block-hook-bypass.test.sh`. - Option E (narrow both guards to `scratchpad_dir`). If it lands, this decline narrows the same way. - Deferred: - Reword the `block-hook-bypass.sh:521-527` comment ("every Write|Edit content guard reads its file through"), left to the 8.3 lane. - `NotebookEdit` is never scanned: the guard reads `.tool_input.file_path`, but the call carries `notebook_path`. This predates this PR; it is filed as queue item 20260925-001500. - Unmeasured: - A HOME that is itself a git work tree may make `hardcoded-path-check` scan temp files. - The Bash 3.2 branch was checked by reading the code only; it runs only on macOS or Linux CI. - Residuals, recorded in the CHANGELOG: - Shared with `block-hook-bypass`: TMPDIR inside the project; the macOS `/private` spelling miss; a time-of-check link race; a git repo under temp. - Code simplifications from the prose pass (behavior-identical) were declined. Each would cost a full suite pass on this host for no behavior change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) - Commit shas cited above (4a09787, ae2be05, 2826b14, ea18dfc, 81c7280) are pre-rebase; the same changes are on the branch under new shas. The Fable merge gate re-measured the head: symptom fixed, 35+ adversarial spellings, no decline wider than block-hook-bypass for the same file, no-fork claim holds. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…p per-edit spawns (#4418) No related issue: WSL hook-latency program, rounds 1 to 5 (Write/Edit PostToolUse window); no GitHub issue was filed for it. ## Summary Every Write and Edit waits on the PostToolUse hooks, and on WSL the window was 85 ms p50 for a small `.txt` edit and 399 ms for a 33 KB Markdown edit. Most of it was not the hooks' work. Round 1: the builtin JSON parse ran under the UTF-8 locale, `hook::begin` re-piped its buffered payload through a byte-at-a-time `read`, guardrails parsed and resolved the same payload once per verifier, and `repo_relative_path_to` probed every PATH directory for a Windows-only `cygpath`. Round 2: every hook paid a `#!/usr/bin/env bash` hop, a `realpath` process, and stale-path-verify ran up to nine grep/sed/sort/head processes per Markdown edit. Round 4: the JSON skeleton spent about 3.6 ms per parse compiling one regex per token. Round 5: a 33 KB Markdown edit still started two jq processes that could be answered in the shell. No hook's output changes. ## Fix Merge with #4450 (a74f8e8, 1c5ebf1; merge 0d17cdd). #4450 moved every row to `bash "${CLAUDE_PLUGIN_ROOT}"/hooks/<script>` with `"shell": "bash"` and added exit-before-source on every event. Both hold here. The rows this PR changed keep `exec` on top of #4450's shape: bash-format (2), markdown-format (2), guardrails post-verify (5), and context-guard zone-crossing (2). Claude Code runs a row through `sh -c` (dash) on Linux, and dash forks for a trailing `bash ...`; `exec bash ...` does not (strace: 1 fork vs 0). The eol-normalizer and typos-format rows are now byte-identical to main. In zone-crossing-inject.sh, this PR's no-context-dir exit and #4450's hoisted kill switch both run before the first `source`; both are silent `exit 0`. Every overlapping carrier moves one patch above main again (claude-ops 0.61.1, source-control 0.58.1). Round 5 (0c3960e, 65fab8c; merges a80cd01, eeae865): - `lib/hook-utils.sh` (17 carriers): `hook::_fast_fields` also proves `.key // false | tostring` and `.key.sub // false | tostring` (absent or null -> `false`, a boolean -> its name, a string -> itself; anything else -> jq). Fuzzed against jq on 6000 payloads under C.UTF-8, C and OSTYPE=msys (1131 answered, 0 mismatches); the old filter shapes are unchanged on the round-4 corpora. - `guardrails/hooks/skill-reference-verify.sh`: the plugins-root gate runs before the payload read. With the lib change, the post-verify dispatcher starts no jq on a `.md` edit outside a marketplace repo (it had one per edit, for the `structuredPatch` filter). Inside a marketplace repo nothing changes: one jq, shared with stale-path-verify through the cache. run-guards test covers both. - `typos-format/hooks/typos-format.sh`: in report-only mode (the default) the finding classifier is built with builtins, byte-identical to the jq program's output, when every output line is a typo finding in typos' field order with printable-ASCII text and no quote or backslash, and the output is at most 16 KB. Anything else, write mode, and Windows bash run jq. 0.2 ms vs 2.9 ms for jq (primitive). Fuzzed on 7000 synthetic typos outputs (1857 answered, 0 mismatches); parity cases in typos-format.test.sh. Round 4 (5db7897; merges 98209f4, 8c049af, e681934): - `lib/hook-utils.sh` (17 carriers): `hook::_json_skeleton` checks the grammar with whole-string rewrites instead of one anchored regex (a regcomp) per token: one regex for whitespace inside a token or between two scalars, one for every scalar token, then a reduction loop over a grammar string (per pass `k:v`, `s:v`, `v,v`, `m,m`, `{m}`, `{}`, `[v]`, `[]`, root wrapped as `<k:ROOT>`). Escapes and control bytes are one search over the whole neutralized payload. A structure past 8192 characters outside strings goes to jq. Parse of the S1 payload 3.6 -> 0.64 ms, S3 5.0 -> 2.1 ms (primitive). - `markdown-format.sh` (`normalize_path_entry`, once per PATH entry on the missing-markdownlint notice) and `powershell-format.sh` (`to_pwsh_path`): `cygpath` only on msys/cygwin/win32; both trust gates test for a drive-letter path before the lookup. - Not done: the S3 timed stdin read. bash 5.3 masks SIGCHLD around every character of a timed `read` (`read.def` 731-742; `TMOUT` too), any untimed phase blocks forever on a never-closed pipe, and no standard tool implements the sliced idle bound, so no replacement keeps the stall semantics identical. - Every carrier re-bumped one patch above main's after #4394 took the same numbers; CHANGELOG bullets under the new version. Round 3 (cd1b9d9, 21c1ef5; merges b0a5009, 0eda101): - `bash-format/hooks/bash-format.sh`: `cygpath` is looked up only on msys/cygwin/win32, as in `hook::repo_relative_path_to`. Elsewhere the miss probed every PATH directory, including 9 `/mnt/c` entries on WSL; bash-format was the slowest `.sh` PostToolUse hook because of it. - `guardrails/hooks/stale-path-verify.sh`: a word anchor's occurrence count uses `spv_word_occurrences` (awk `split` on non-word bytes under C) instead of a per-character walk (33 KB file: 14 -> 2 ms); a word anchor with `.` or `-` skips the count, which the walk never matched. Non-word anchors keep the walk. Parity test against the walk added. - `typos-format/hooks/typos-format.sh`: the classifier returns counts and arrays through `tostring`, so `hook::jq_fields` reads them with the builtin parser instead of a second jq. - `lib/hook-utils.sh` (17 carriers): the skeleton's control-byte and escape checks are one regex search each on Linux and macOS (Windows bash keeps the glob checks, its regex decodes UTF-8 under C), and the key walks take a key's text from its split part when no escape was rewritten in it, instead of slicing the whole payload per string. - CHANGELOG bullets added to each carrier's existing entry. Round 2 (2540560, 9cc5cdf, 99d6205): - hooks.json (superseded in part by #4450, which shipped the `bash` prefix and `"shell": "bash"` on every row and `exec bash` on the typos-format and eol-normalizer rows): what remains of this PR is `exec` in front of `bash` on the markdown-format (2), bash-format (2), guardrails post-verify (5) and context-guard zone-crossing (PostToolBatch, UserPromptSubmit) rows. bash-format's launch-gate test accepts both forms. - `lib/hook-utils.sh` (synced to all 17 carriers): new `hook::_physical_builtin_to` answers realpath's question with `builtin cd -P` in one subshell, Linux only, for absolute existing directories and existing non-symlink files; every other path, and Git Bash and macOS, still runs realpath. Used by `hook::_physical_prime` and `hook::physical_path_to`. - `guardrails/hooks/stale-path-verify.sh`: builtin twins of the code-span scan, the reconstruction token set, its anchor lines and its recovered context, used only for printable-ASCII text; any other byte keeps the pipelines. - `context-guard/hooks/zone-crossing-inject.sh`: exits before sourcing its libraries when `~/.claude/context-guard/context` does not exist and jq is on PATH (no snapshot and no compaction marker for any session, so the hook could only reach a silent `unknown`). - Tests: stale-path twins vs pipelines on 14 fixed texts plus the gate; `_physical_builtin_to` vs realpath per shape and its declined shapes; the unresolved-path probe disables the builtin with `enable -n cd`; run-guards expects 0 realpath on Linux. - CHANGELOG bullets added to each carrier's existing entry for this PR (versions were bumped in round 1). Round 1 (7630993 .. c8daa81): C-locale builtin JSON parse (`hook::_c_locale`), `buffer_stdin_to` without `jq -e .` for a proven object, `read_file_path_to`/`raw_file_path_to` on the buffered payload, `cygpath` lookup only on Windows bashes, `_uncached` twins; run-guards caches path, root and fields per event; eol-normalizer skips `git rev-parse` with no sink; context-guard starts its resolver only with a readable snapshot; instruction-placement SC2154 init. ## Final verdicts - S1 `.txt`, S2 `.md`, S2 `.sh`: MET. Every exec-count target: MET. - S3 (33 KB `.md`): NOT MET. 70 ms p50 against a 60 ms target, per the independent verifier at 0d17cdd. - The guardrails post-verify exec count of 3 on a `.md` edit holds only outside a marketplace repo; inside one, the dispatcher still starts one jq. - Part of the before -> after speedup comes from main commits merged in after base 3fa014b, not from this PR alone. - The implementer's `windows.py` dropped the first call per log; the verifier's own cuts, which keep it, gave the same verdicts. - Modes not exercised: markdown-format lint output, bash-format shfmt/shellcheck output, and the Windows `cygpath` branches (covered by `test-windows` CI only). - Head f155042 adds main merges and a test-only fix (`secret-pattern-detection.test.sh`, guardrails CHANGELOG) after the measured 0d17cdd. Main changed two runtime guardrails hook files in those merges: `secret-pattern-detection.sh` (#4475, #4486; PreToolUse, outside the measured window) and `guard-requires.sh` (#4480, comment-only, sourced by run-guards). `lib/hook-utils.sh` is unchanged by main. Not re-measured. ## Verification **Post-merge re-measure (#4450)** (head 0d17cdd, main 0fb4e95). Differential, 11068 cases, run twice. Against base 3fa014b: identical except the go-format telemetry counter (`Edit m.go` and `Write m.go`, Go's random `telemetry/local/weekends` under HOME, same output) and the same 13 lib-level deltas as round 5. Against origin/main itself: the same 15 and nothing else. Every `row-*` case is identical, so `exec bash` vs main's `bash` changes no behavior, and #4450's zone-crossing hoist behaves as on main. CI dispatched at 0d17cdd: `ci.yml` (lint, lint-2, hook-utils, test-linux 0-3, ci-status) and `test-windows.yml` green. One interleaved run at 0d17cdd (5 rounds x 4 strata, base / after / safe; no local load; host qualified before and after, spread 1.67x / 1.74x, floor 0.3 / 0.4 ms). Write/Edit PostToolUse window, ms p50 / p95 (n): | Stratum | base | after | safe | target | status | |---|---|---|---|---|---| | S1 `.txt` | 89 / 106 (35) | 42 / 50 (35) | 13 / 28 (35) | p50 <= 50, p95 <= 90 | met | | S2 `.md` | 94 / 115 (30) | 43 / 61 (30) | 13 / 59 (30) | <= S1 + 20 = 62 | met | | S2 `.sh` | 86 / 136 (30) | 43 / 53 (30) | 13 / 321 (30) | <= 62 | met | | S3 33 KB `.md` | 398 / 450 (30) | 70 / 87 (30) | 12 / 52 (30) | <= S1 + 15 = 57 | missed by 13 | Counters (strace census, W/E post Edit, medians) are identical to round 5. Successful execs: typos-format S1 4 and S3 4, guardrails post-verify `.md` 3 and S3 5, eol-normalizer 3, markdown-format 3, bash-format 4, context-guard 2. Processes per hook are also unchanged (bash-format 6, markdown-format 7, post-verify 6 / 4, context-guard 1), so `exec` still saves the fork. Failed execve: 46 per hook. Failed probes: typos S3 308, post-verify `.md` 260, S3 368. **Round 5, final** (head eeae865). Differential extended to 11068 cases (replace_all as absent, null, false, true, "x", "", 1, [], {} in a marketplace repo and in a git repo without `plugins/`, a null tool_input, MultiEdit, typos content with ambiguous corrections and non-ASCII words): identical except the go-format telemetry counter and the same 13 lib-level deltas as round 4. Run at 65fab8c; the merge to eeae865 changes no file under `lib/` or any tested plugin (`git diff --stat` empty). CI dispatched at eeae865: `ci.yml` (lint, lint-2, hook-utils, test-linux 0-3, ci-status) and `test-windows.yml` green. One interleaved run at eeae865 (5 rounds x 4 strata, base / after / safe; no local load; host qualified before and after, spread 1.67x / 1.64x, floor 0.3 ms). Write/Edit PostToolUse window, ms p50 / p95 (n): | Stratum | base | after | safe | target | status | |---|---|---|---|---|---| | S1 `.txt` | 85 / 100 (35) | 41 / 47 (35) | 13 / 31 (35) | p50 <= 50, p95 <= 90 | met | | S2 `.md` | 93 / 144 (30) | 43 / 53 (30) | 12 / 21 (30) | <= S1 + 20 = 61 | met | | S2 `.sh` | 86 / 103 (30) | 44 / 79 (30) | 14 / 40 (30) | <= 61 | met | | S3 33 KB `.md` | 414 / 1828 (30) | 68 / 103 (30) | 13 / 26 (30) | <= S1 + 15 = 56 | missed by 12 | Counters (strace census, W/E post Edit, medians): successful execs typos-format S1 4, S3 5 -> 4; guardrails post-verify `.md` 4 -> 3, S3 6 -> 5; eol-normalizer 3; markdown-format 3; bash-format 4; context-guard 2. No jq in the typos or post-verify rows for Edit any more. Failed execve 46 per hook, unchanged. S3 floor (primitives, idle host): the S3 - S1 difference that no code change here can remove is the timed stdin read on the 35 KB payload (+7.5 ms, bash's per-character signal mask), the typos scan of 33 KB against 2 bytes (13.1 vs 11.5 ms, +1.6), and a minimal file_path extraction (+0.4): about 9.5 ms. So the S3 floor is about S1 + 10 and the S1 + 15 target sits above it; the measured gap is 27 ms, so about 17 ms of it is still removable in principle (payload validation and copies in `buffer_stdin_to`, about +3 ms; the rest of `hook::begin`, about +2.7 ms; guardrails' stale-path scan of the 33 KB file and its remaining parse, now the last hook to finish at 52 ms native against typos' 48). **Round 4** (head e681934). Grammar proof: all 2,396,744 token strings up to 7 tokens over `{ } [ ] , : "x" 1` give the previous walk's verdict exactly; 32k random payloads under C, C.UTF-8 and OSTYPE=msys give identical verdicts, skeletons, offsets, file paths and fields. Differential extended to 9114 cases (13 grammar payloads with a file_path, a structure past the cap, markdown-format's PATH remediation with and without an identity cygpath): identical except the go-format telemetry counter and 13 lib-level deltas (round 3's 12 plus the cap's builtin -> jq fallback, same answer); run at e681934. CI dispatched at e681934: `ci.yml` and `test-windows.yml` green. One interleaved run at e681934 (5 rounds x 4 strata, base / after / safe; no local load; host qualified, spread 1.5x / 1.81x). Write/Edit PostToolUse window, ms p50 / p95 (n): | Stratum | base | after | safe | target | status | |---|---|---|---|---|---| | S1 `.txt` | 86 / 99 (35) | 41 / 50 (35) | 9 / 16 (35) | p50 <= 50, p95 <= 90 | met | | S2 `.md` | 93 / 107 (30) | 42 / 49 (30) | 12 / 20 (30) | <= S1 + 20 = 61 | met | | S2 `.sh` | 84 / 95 (30) | 41 / 51 (30) | 11 / 33 (30) | <= 61 | met | | S3 33 KB `.md` | 402 / 1264 (24) | 71 / 81 (30) | 12 / 25 (30) | <= S1 + 15 = 56 | missed by 15 | The host ran faster than round 3 (safe 9-12 vs 14-15), so part of S1's 53 -> 41 is drift. Counters: exec counts unchanged from round 3; traced walls down about 3 ms of CPU per parse (typos S1 68 -> 59, guardrails S3 130 -> 115, strace-inflated). **Round 3** (head 0eda101). Differential extended to 7899 cases (word anchors beside non-ASCII, invalid UTF-8, CR and a 40 KB line; typos content with 0, ~30 and ~3000 findings incl. write mode; an identity `cygpath` on PATH): identical except the same go-format telemetry counter and the same 12 lib-level deltas as rounds 1-2. Fuzz: occurrence counter 3000 cases, skeleton/fast-field parser 4000 payloads (C, C.UTF-8, OSTYPE=msys), 0 mismatches. CI dispatched at 0eda101: `ci.yml` and `test-windows.yml` green. One interleaved run at 0eda101 (4 rounds x 4 strata, base / after / safe; host qualified, spread 1.43x / 1.81x). Write/Edit PostToolUse window, ms p50 / p95 (n): | Stratum | base | after | safe | target | status | |---|---|---|---|---|---| | S1 `.txt` | 98 / 130 (28) | 53 / 76 (28) | 14 / 69 (28) | p50 <= 50, p95 <= 90 | p50 missed by 3 (host ran slower: base +13, safe +3 vs round 2) | | S2 `.md` | 93 / 151 (24) | 47 / 56 (24) | 15 / 41 (24) | <= S1 + 20 = 73 | met | | S2 `.sh` | 87 / 128 (24) | 49 / 77 (24) | 15 / 24 (24) | <= 73 | met (61 at round 2) | | S3 33 KB `.md` | 449 / 723 (24) | 88 / 152 (24) | 15 / 30 (24) | <= S1 + 15 = 68 | missed by 20 | Counters (strace census): typos-format S3 execs 6 -> 5; bash-format `/mnt` probes 9 -> 0. The remaining S3 cost includes a timed-`read` floor (2 `rt_sigprocmask` per byte, ~7 ms per hook at 37 KB) that only a stall-semantics change would remove; not done. **Round 2 and earlier:** **Behavior differential** (no timing; `diff.py`, base = origin/main 3fa014b vs this head, same fixture path, fresh HOME/data/TMPDIR per run; stdout, stderr, exit code, fixture/HOME/data/TMPDIR/sink contents). 6102 cases: every changed hook and every `hook::begin` caller, the guardrails dispatcher on its three lanes and the verifiers standalone, context-guard, a lib-level probe, and now the hooks.json rows themselves run as `sh -c <row>` from each arm (1905 row cases). Round 2 adds a fixture history with deleted, moved and non-ASCII-named paths (so stale-path-verify reports findings), 30 stale-path cases (spans, unclosed and double backticks, CRLF, tabs, whitespace lines, fences, root basenames, self-repeating anchors, replace_all with over 40 hits, non-ASCII and ESC in new_string and in recovered lines, 40 KB ASCII, the 33 KB doc), context-guard with no context directory, and a HOME-unset mode, on top of round 1's corpus and modes. Result: every hook-level case identical except go-format Write/Edit `m.go`, whose only difference is Go's own random telemetry counter under HOME. 12 lib-level deltas, the same 12 as round 1 (base lacks round 1; none reachable as hook output). Verdict: no behavior change. Also: the twins were fuzzed on 3000 random texts under C and C.UTF-8 against this host's grep (ugrep 7.8.4); CI runs the fixed-case parity test on GNU grep and Git Bash. `_physical_builtin_to` matched uutils and GNU realpath on 29 path shapes wherever it answers. **Suites** (local): hook-utils 513/0, typos-format 178/0, eol-normalizer 85/0, bash-format 55/0, run-guards 242/0, stale-path-verify 121/0, skill-reference-verify 151/0, cli-flag-verify 96/0, zone-crossing-inject 103/0, claude-ops fleet-state pass; markdown-format 172/2 (the same 2 fail at c8daa81: this host has no jq in `/usr/bin`). shellcheck, shfmt, killswitch-hoist, hook-exec-form, userconfig-argv, shell-portability, hooks-description, silent-skips, wiring-liveness, changelog-parity, `sync-hook-utils.sh --check` and `--check-bump origin/main`, markdownlint: pass. **CI** at 99d6205, dispatched: `ci.yml` (lint, lint-2, hook-utils, test-linux 0-3, ci-status) and `test-windows.yml`: all green. **Measurement** (WSL2, `claude -p` Haiku; one run at 99d6205, arms base / after / `--safe-mode` interleaved, 4 rounds x 4 strata; host qualified before and after, `is_measurable` True, spread 1.72x / 1.42x). Write/Edit PostToolUse window, ms: | Stratum | base p50 / p95 (n) | after p50 / p95 (n) | safe p50 / p95 (n) | target | status | |---|---|---|---|---|---| | S1 `.txt` | 85 / 98 (28) | 43 / 52 (28) | 11 / 17 (28) | p50 <= 50, p95 <= 90 | met | | S2 `.md` | 90 / 94 (24) | 44 / 46 (24) | 13 / 20 (24) | <= S1 + 20 | met | | S2 `.sh` | 83 / 87 (24) | 61 / 70 (24) | 10 / 16 (24) | <= S1 + 20 | met | | S3 33 KB `.md` | 399 / 444 (24) | 89 / 95 (24) | 11 / 28 (24) | <= S1 + 15 | missed by 31 | Counters per W/E post Edit call (strace census, 2 traced sessions per arm per stratum), base -> after: successful execs typos-format 7 -> 4, eol-normalizer 7 -> 3, markdown-format 6 -> 3, bash-format 7 -> 4, guardrails post-verify `.md` 16 -> 4 (`.sh` 7 -> 3, 33 KB 23 -> 6), context-guard PostToolBatch 4 -> 2. Failed file probes typos 580 -> 290, eol 409 -> 212, post-verify `.md` 1060 -> 298. Failed execve stay 46 per hook: dash's `exec bash` walks PATH with execve as `env` did; plain `bash` would walk with stat but forks, and measured slower (1.5 vs 1.3 ms, hyperfine primitive). The context-guard early exit applies only on a host without the context-guard status line (this one); with the status line the directory exists and the full path runs as before. ## Related - Overlaps #4450 (row shape, exit-before-source); merged in and reconciled as described under Fix. - Pattern precedent: #4185 (dispatcher cache, `_uncached` twins, 17-carrier sync); row shape: #4421, #4422. - Plugin README cost tables (guardrails, bash-format, typos-format, eol-normalizer) are dated measurements of earlier versions and were not re-measured here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No related issue:
gh issue createwas denied in this unattended session; the defect is described below.Summary
block-hook-bypassships a default exemption for bare Bash redirects into the host temp trees, where the harness scratchpad lives. On Windows it never granted._norm_pathfoldsC:/xto the Git Bash spelling/c/x, andhook::under_temp_rootcompared that target against candidates it had normalized toC:/..., so no drive path could match. Reproduced on de21bcb: a bare long-name redirect underTEMPreturned rc 2 withCLAUDE_PROJECT_DIRunset, set to a non-repo folder, to a repository, and to$HOME.Fix
hook::under_temp_root(lib/hook-utils.sh) normalizes its target throughhook::normalize_path_toon Windows Git Bash hosts (msys, cygwin, win32), the same way it already normalized its candidates. It is idempotent forhook::read_file_path, whose target was already normalized. POSIX hosts compare the target as before:\is a filename byte there, and folding it could make a root-leveltmp\xcompare as under/tmp._norm_path(block-hook-bypass.sh) refuses an operand starting with//. The security review found that once the compare worked,//c/Users/.../Temp/f(the shareUserson hostc) was exempted as local temp. The refusal narrows the temp default and configured roots alike.paths_identicalcollapses a leading slash run before normalizing, so the staged-move detector still treats//tmp/xand/tmp/xas one path (Codex review).scripts/sync-hook-utils.sh, and each plugin gets a patch bump and a CHANGELOG entry. Guardrails goes from 0.36.1 to 0.36.2. The other 16 plugins' hooks reach the function only throughhook::read_file_path, so their behavior does not change.Verification
lib/hook-utils.test.sh: PASS=527 FAIL=0 (base 510/0). New cases cover drive spellings, a mixed-case trailing slash, and a drive-root candidate. They also pin adversarial shapes:TempEvil,/c/tmpx, an 8.3 target, UNC, and another drive. A POSIX case pins that/tmp\xis not folded, and a real-host case runs against the runner's ownTEMP. Six new cases were red on the base code.block-hook-bypass.test.shon this Windows host: PASS=667 FAIL=2 (base 652/2). This includes two staged-move cases for the leading//spellings, which were red before 462d7d0.ln -smakes copies on this host), so they are host-only.//blocked.TempEvil, for a..climb out of temp, for a relative project file, and for an 8.3 target.TEMPthat points into a project blocks, and its temp sibling is allowed.run-guards.test.sh: PASS=224 FAIL=11, the identical FAIL list as the main checkout (host-only).secret-pattern-detection,eol-normalizer,ruff-formatpass.block-windows-drive-tmp(15 FAIL) andmarkdown-format(4 FAIL) show the same counts on base (host-only).hook::read_file_pathalso declines, so no Write|Edit content gate would have processed it.block-hook-bypass.test.shPASS=669 FAIL=0. That includes the POSIX symlink-escape cases. The one visible SKIP is the Windows host gate.hook-utilslane PASS=524 FAIL=0.begin: a Windows backslash pathcase. It passed on rerun.lib/hook-utils.test.shrun failed once: the runner's bash reportsTEMP=/tmp. The real-host case now gives such a TEMP its drive spelling before it compares.sync-hook-utils.sh --check,--check-bump 9c005be9f, andcheck-changelog-parity.sh --check-bump 9c005be9f. autonomy and claude-ops bump to 0.23.19 and 0.59.3 above main's own bumps.check-changed-skills.shpasses. shellcheck is clean on the four touched shell files, the scoped em-dash gate passes, and added lines contain 0 em dashes.Related
20260910-235747part 1 only, with the test case from the retired duplicate20260919-144300(project root = home, bare target under the OS temp tree)._norm_pathrefuses any operand containing~. The harness hands out its scratchpad in that form on hosts with an 8.3TEMP(KYLESE~1here,RUNNER~1on the Windows runner). The item's "cause B" is misattributed: the candidate side already expands short names physically, and the remaining dead case is the~refusal. Relaxing it widens the guard and needs its own decision.lib/hook-utils.test.shbut notblock-hook-bypass.test.sh, so the hook-level Windows cases are measured on this host only.🤖 Generated with Claude Code