perf(hooks): parse Write/Edit payloads once, in the C locale, and drop per-edit spawns - #4418
Conversation
…p per-edit spawns - lib/hook-utils.sh: the builtin JSON parse (_fast_file_path_to, _fast_fields, json_compact_to) runs under LC_ALL=C through hook::_c_locale, which restores the caller's LC_ALL. buffer_stdin_to validates an object payload the skeleton accepts without `jq -e .`. hook::begin reads the path from its buffered payload with the new read_file_path_to and raw_file_path_to instead of a printf pipe into a capture subshell. repo_relative_path_to looks for cygpath only when OSTYPE is msys, cygwin or win32. read_file_path_uncached_to and repo_root_uncached_to name the bodies for a caching dispatcher. - guardrails run-guards.sh caches read_file_path_to and repo_root_to per event; the three PostToolUse verifiers read the path with read_file_path_to. - eol-normalizer: with no telemetry sink and an absolute path, check-attr runs from the file's directory, so the separate rev-parse is not run. - context-guard zone-crossing-inject: skip the resolver when no snapshot is readable (it answered unknown at that check). - Synced to the 17 carriers with a patch bump and CHANGELOG entry each. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…to writes it ShellCheck (SC2154) now flags the read: the shared library no longer declares a same-named local that masked it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…er guard already ran Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…affected-suite walk stays narrow Naming normalize-eol.sh in the lib made the R4 dependent walk from eol-normalizer.sh reach the lib and every carrier (affected-tests.test.sh 'co-located change stays narrow'). Comment-only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… stale-path pipelines
- hooks.json: the typos, eol, markdown-format, bash-format, guardrails
post-verify and context-guard zone-crossing rows run
`exec bash "${CLAUDE_PLUGIN_ROOT}"/hooks/<script>` with "shell": "bash",
dropping the `#!/usr/bin/env bash` env process.
- hook-utils.sh: hook::_physical_builtin_to answers realpath's question with
cd -P in one subshell on Linux for absolute, existing, non-symlink-file
paths; everything else still runs realpath.
- stale-path-verify.sh: builtin twins of the grep/sed/sort/head pipelines for
printable-ASCII text; other text keeps the pipelines.
- zone-crossing-inject.sh: exits before sourcing its libraries when the
context directory does not exist and jq is on PATH.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ools they replace stale-path-verify.test.sh runs each builtin twin against the grep/sed/sort/head pipeline on the host's own tools; hook-utils.test.sh compares hook::_physical_builtin_to with realpath per path shape and checks the shapes it must decline. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d cd function cannot answer An inherited BASH_FUNC_cd%% shadowed the plain `cd -P` in hook::_physical_builtin_to (claude-ops fleet-state.test.sh caught it). The unresolved-path test disables the builtin with `enable -n cd` instead of a function shadow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dit payloads - hook-utils: skeleton control-byte and escape checks are regex searches; key walks reuse the split part when no escape was rewritten in it - stale-path-verify: word-anchor occurrence count splits lines on non-word bytes under C instead of a per-character walk; [.-] anchors skip the count - typos-format: classifier returns string-valued fields so jq_fields answers with the builtin parser, no second jq - bash-format: look up cygpath only on msys/cygwin/win32 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ash, whose regex decodes UTF-8 under C Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # plugins/autonomy/CHANGELOG.md
…ewrites The skeleton ran one anchored regex per token (a regcomp each) and a regex per string for escapes and control bytes: about 3.6 ms per parse on a small payload. It now checks whitespace, scalars and the grammar with a few whole-string rewrites (member, element list and container reductions over a root wrap), and escapes and control bytes with one search over the whole payload. Same verdicts: 2.7M exhaustive token strings and 32k fuzz payloads agree with the previous walk. A structure past 8192 characters outside strings goes to jq. markdown-format and powershell-format look up cygpath only on a Windows bash, as bash-format and repo_relative_path_to already do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # plugins/actionlint/CHANGELOG.md # plugins/autonomy/CHANGELOG.md # plugins/bash-format/CHANGELOG.md # plugins/biome-format/CHANGELOG.md # plugins/claude-ops/CHANGELOG.md # plugins/context-guard/CHANGELOG.md # plugins/desktop-notification/CHANGELOG.md # plugins/eol-normalizer/CHANGELOG.md # plugins/go-format/CHANGELOG.md # plugins/guardrails/CHANGELOG.md # plugins/instruction-placement/CHANGELOG.md # plugins/markdown-format/CHANGELOG.md # plugins/powershell-format/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md # plugins/ruff-format/CHANGELOG.md # plugins/source-control/CHANGELOG.md # plugins/typos-format/CHANGELOG.md
# Conflicts: # plugins/source-control/CHANGELOG.md
# Conflicts: # plugins/claude-ops/.claude-plugin/plugin.json # plugins/claude-ops/CHANGELOG.md
hook-utils.sh: hook::_fast_fields proves `.key // false | tostring` (and the `.key.sub` form): absent or null gives `false`, a boolean its name, a string itself; anything else still goes to jq. Fuzzed against jq on 6000 payloads under C.UTF-8, C and OSTYPE=msys; old filters unchanged on the round-4 corpora. skill-reference-verify.sh: the plugins-root gate runs before the payload read, so the structuredPatch jq is spent only where the guard can act. Together the PostToolUse verifiers start no jq on a .md edit outside a marketplace repo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In report-only mode the residual set is the scan set, so the classifier only lists findings. typos_classify_report_only writes the jq program's CLASSIFIED text with builtins when every line is a typo finding in typos' field order with printable-ASCII path, token and corrections (no quote or backslash) and the output is at most 16 KB; anything else, write mode and Windows bash run jq. Byte-identical to jq on 7000 synthetic outputs (1857 answered) under C.UTF-8 and C; parity cases added to typos-format.test.sh. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolves the overlap with #4450. hooks.json rows and the bash-format registration test take main's `bash "${CLAUDE_PLUGIN_ROOT}"/hooks/<script>` with "shell": "bash" verbatim: this branch's `exec` prefix on those rows starts no fewer processes (bash -c execs a lone simple command), and the eol-normalizer and typos-format rows were already identical. The branch's hooks.json changelog bullets are dropped; each overlapping plugin moves one patch above main (claude-ops 0.60.3). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The merge took main's `bash "${CLAUDE_PLUGIN_ROOT}"/hooks/<script>` rows,
but Claude Code runs a row through `sh -c` on Linux, and dash forks for a
trailing `bash ...` where `exec bash ...` replaces the shell (strace: 1 fork
vs 0). Restore `exec` on the bash-format, markdown-format, guardrails
post-verify and context-guard zone-crossing rows; #4450's `"shell": "bash"`
and bash prefix stay. The bash-format registration test accepts both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
claude-ops and source-control move one patch above main (0.61.1, 0.58.1). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolve #4460 conflicts: keep both CHANGELOG entries with this branch's entry bumped one patch above main in the 13 overlapping plugins, and keep the hook::read_file_path_to stub in lib/hook-utils.test.sh (hook::begin no longer pipes the payload into the reader). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the builtin cd -P resolver Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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 2m 11s —— View job Reviewing PR #4418
|
|
Claude finished @kyle-sexton's task in 5m 31s —— View job Security review complete
No security issues found. What was reviewedThis PR's real logic surface (the 358/-163
No command/path/JSON-injection surface, authorization gap, or secret-handling issue was found in the diff. GitHub Actions hardening is out of scope for this lane (zizmor's advisory) and none of the changed files touch |
Conflicts in 12 CHANGELOGs and guardrails plugin.json: kept both entries and moved this PR's entry one patch above main's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 7m 9s —— View job Reviewing PR #4418 (CI code-review lane)
Result: no findingsScope was correctness and maintainability only — security was intentionally left to the separate What I actually checked by hand (not just read the PR's own verification claims for):
Nothing here rose to a "a careful senior reviewer would block this" finding — the changes are performance-only reorderings/builtin-twins with behavior explicitly designed (and, per the PR's own differential/fuzz harness, tested) to match what they replace, and my own tracing through the actual call sites didn't turn up a divergence. |
|
Claude finished @kyle-sexton's task in 54s —— View job Security review — skipped (no new PR content since last review)
Skip gate hit: condition 3 — this head already has a successful security review that still applies. The only commits added since the last completed security review (at No new security-relevant surface to review. The prior finding stands: no security issues found in this PR's actual changes ( |
…he root-delete guard (#4469) Closes #4468 ## Summary `block-root-delete-target` finds the command word by walking a fixed table of launchers. When a launcher was missing from that table, the walk stopped there, so a recursive delete of a root passed whenever one of these launchers wrapped it. On base 49dac96, all 90 root-delete payloads routed through runuser, taskset, chrt, flock, unshare, nsenter, chroot or numactl exited 0, both run directly and under `run-guards.sh`. The item's two examples were `runuser -c 'rm -rf /'` and `taskset 1 rm -rf /`. The guard now reads all eight launchers and refuses those deletes, and it also bounds nesting depth and the number of readings per command. guardrails goes to 0.36.9. Main's 0.36.8 through 0.36.3 entries are unchanged. ## Fix Each launcher's operand grammar comes from its upstream C source (util-linux, coreutils, numactl, sudo). - **taskset, chrt, flock, unshare, nsenter, numactl, chroot.** Each one is a launcher arm that knows its operand-taking options, both short and long. - A required leading positional is consumed, including when it comes after `--`. That covers taskset's mask, flock's lock file and chroot's new root. - chrt consumes its priority only when it is all digits, as `isdigit_string` does. - timeout consumes a word after `--` only if it has strtod's shape, so `timeout -- rm -rf /` is still refused. - flock's `-c` / `--command` after the lock file is parsed as a shell command. With `--fd`, the first positional is the command. - **runuser.** Its option parser permutes, so the guard rebuilds the argv the way getopt reads it (`rdt_runuser_argv`) and remaps the argument-quoting record to the new indices. Both readings are judged: - every `-c` / `--command` / `--session-command` operand, as su would run it; - under `-u`, the rebuilt command as a launched argv. An unknown or ambiguous option is never taken as the command word. - **su.** Every word after a `-c`-like word is parsed, not just the first. That covers the attached (`-c'…'`) and `=` forms, abbreviated long names, and an operand that is exactly `--`. A `-s` / `--shell` operand that is not a shell is judged as the program it runs. - **Abbreviated long options and short-option clusters on the non-sudo launchers.** Each segment is judged twice: once on the plain reading (main's), and once on a resolved reading. In the resolved reading, every abbreviated option takes its operand, and every short cluster ending in an operand letter is read the way getopt reads it. A block from either reading stands. The resolved reading never forks, and past 256 resolved walks in one command the guard refuses rather than skipping the check. - **Nesting depth.** `rdt_check_segment` re-enters itself through launchers, `su -s`, the runuser readings and `eval`. It now refuses (`nesting-too-deep-launcher`) past `MAX_SEGMENT_DEPTH=24`. `eval` text also counts against `MAX_COMMAND_LEN` (`eval-too-long`). Before this, `runuser -u bob -- ` repeated 120 times printed BLOCKED but exited 0, and deeper nesting crashed with exit 139. The exit 0 came from bash exhausting its stack inside the telemetry emit (`rdt_emit_tel` / `hook::_json_split`) before `exit 2` ran. - **Reading budget.** The depth cap limits how deep the nesting goes, not how wide. Some shapes are read twice at every level: `runuser -u x -s env -- ` repeated N times is judged once through the `-s` program and once through the `-u` argv. That made the work grow as 2^N, so N=20 and above ran past 60 s. A hook the harness cancels on its timeout is cancelled without a block, so that timeout was an allow. Nested readings now count against `MAX_SEGMENTS=1024` per command, and past that the guard refuses (`too-many-readings`). N=20 and N=25 now refuse in about 2 s, while realistic commands (200 flat segments, or 3-8 levels of nesting) stay well under the budget. Every change adds refusals relative to main. The header, the README row, the config-table text and the CHANGELOG state what the guard covers and which gaps remain declared. ## Verification - Head b564a17, rebased onto main 4e29a62 (the fork point). - Main's #4418 rewrote `hooks/hook-utils.sh`, so the hook functions this guard calls were re-checked against it: - All 12 `hook::` functions the guard calls are present. - Nine are byte-identical. - `hook::buffer_stdin_to` adds a proof that skips jq and keeps the same return contract. - The Bash PreToolUse row in `hooks.json` is unchanged. - Guard suite: PASS=697 FAIL=0. Every case runs direct and under `run-guards.sh`. - Payload corpus: 244 JSON payload files fed on stdin match their expected verdict (miss=0), plus 8 of 8 for the nesting corpus. Checked direct and through the dispatcher. Nothing in either corpus was executed. - Differential against main, rebuilt from 4e29a62, over the Bash dispatcher row as `hooks.json` wires it: 252 inputs, **0 looser**. - The Fable merge gate's own adversarial corpus: 171 inputs, 0 looser. Its one defect, the unbounded nesting above, is fixed and pinned: runuser -u x120 and x900, and su -s x300, all exit 2 both alone and dispatched. A security re-check of that fix found the exponential-breadth shape; the reading budget above fixes it. A further re-check of the budget found no edge. - `abort-boundary` passes 216/0. - `run-guards.test.sh` (226 pass, 13 fail) and `secret-pattern-detection.test.sh` (205 pass, 1 fail) give the same results on this branch as on main 4e29a62 on this host. Those failures already exist on main and are not caused by this change. - shellcheck is clean on the guard and the suite. - markdownlint reports 0 issues on the README and CHANGELOG. - `sync-plugin-options-docs.py --check` reports the docs are current. - `scripts/check-changelog-parity.sh` returns 0 against 4e29a62. - On the prior head 67c61c0, a fresh-context verifier re-ran all eight acceptance criteria itself, and all passed. CI was green on 67c61c0 and on feeaffc. - Security reviews were fed payload files only. Every bypass they found was fixed and pinned. The re-checks of the abbreviated-option walk and the short-cluster reading found no remaining edge. - `coverage-manifest.test.sh` fails 11 cases on this Windows host, and fails the same 11 on base 49dac96. That failure is host-only and predates this change. - skill-evidence block absent: the installed source-control and claude-ops predate #4210, so no ledger sha is stamped. The owed reviews ran as nested agents instead: a code reviewer, a simplification pass, an AI-slop and markdown-noise read of the added prose, the security reviewer, and the fresh-context verifier last. ## Related - Queue item `20260922-070000-guardrails-delete-guard-undeclared-launchers-runuser-taskset` ran as an unattended interview. Each open question took the item's default or is marked USER-RESERVED below. - **USER-RESERVED (Q1):** `chroot /mnt rm -rf /` deletes the new root, which is `/mnt` on the host. It stays refused (fail-closed), like every other root spelling. Loosening it is the user's call. - Declared gaps, deferred and pinned at expect-0: - `sudo -R` / `--chroot`; - a sudo short-option cluster ending in an operand-taking letter (`sudo -Eu bob rm -rf /`); - an abbreviated sudo long option (`sudo --us`). Reading these correctly would change how the guard reads sudo lines that main refuses today. - Known overblocks, kept on purpose: - `chrt -r rm -rf /` is refused, although chrt itself would reject it. - `runuser -u bob rm -rf /` is refused, although runuser itself would reject it. - Pre-existing on main and not changed here: - `env -S 'rm -rf' \` loses the dangling-backslash provenance; - `env --spl` (abbreviated `--split-string`) is not unwrapped; - `nsenter --t 1 rm -rf /*` passes; - `eval` repeated 3000 times runs past the hook timeout, on base and branch alike; - `POSIXLY_CORRECT=1 su -s env x su -s env x rm -rf /` passes; - launchers still outside the table stay under the declared unlisted-launcher gap: `setpriv`, `prlimit`, `systemd-run` and `sg -c`. `sg -c` runs its operand through a shell, so it is the strongest follow-up candidate. - Not verified: a comma-decimal timeout duration under a comma locale. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… cases (#4494) Closes #4493 ## Summary On Windows Git Bash, `secret-pattern-detection.test.sh` on main fe2c5ba reported PASS=205 FAIL=1 (`D1 seam: posix, lowercase temp root declines: expected '0', got '1'`). The Linux CI shard passes the same case (run 36147426456, test-linux (0)), and `test-windows.yml` does not run this suite. No hook behavior changes; this is a test precondition fix. Root cause: the seam forces `OSTYPE=linux-gnu`, so the Linux-only `hook::_physical_builtin_to` (`builtin cd -P`, added to `hook-utils.sh` in #4418, the only guardrails commit in 1d77292..fe2c5ba) resolves the seam's temp candidate. On Git Bash `cd -P /tmp` follows the mount to `/c/Users/<user>/AppData/Local/Temp`, while the test's precondition still checked `realpath /tmp`, which prints `/tmp`. So the precondition passed and `hook::under_temp_root` compared the target spelled `/tmp/...` against the Windows spelling and found no prefix. The same seam with `hook::_physical_builtin_to` stubbed to `return 1` returns 0. Real Git Bash never reports a `linux*` OSTYPE, so production is unaffected. ## Fix - `secret-pattern-detection.test.sh`: the seam precondition asks the resolver the seam uses: a child shell with `OSTYPE=linux-gnu` sources `hook-utils.sh` and requires `hook::physical_path_to` of each seam dir to equal its spelling. On Git Bash the pair skips with that reason. On Linux a failed precondition is now a `bad` naming the failed step and the resolver's answer (it was a silent SKIP), so the case stays pinned there. - guardrails 0.36.9 -> 0.36.10 with a CHANGELOG entry (rebased onto main after #4469 took 0.36.9). - `hook-utils.sh` and the hook are unchanged. ## Verification - Local (Windows Git Bash) at the rebased head 3ec8129: secret-pattern-detection PASS=204 FAIL=0; the seam SKIP line covers the 2 seam asserts (204 + 2 = 206, main's 205 + 1). - Other selected suites (`affected-tests.sh --explain`), before the rebase: skill-reference-verify 152/0, stale-path-verify 122/0, require-jq-notice-isolation 2/0, pre-commit-content-invariants 9/0. block-windows-drive-tmp is 199/15 both here and on unmodified main fe2c5ba (host-only, untouched). - `check-changelog-parity.sh --check-bump` and `--check-preserved` against origin/main 735b140, `check-changed-skills.sh`, shellcheck, and the scoped em-dash gate: pass. - Linux CI shard for the two `ok: D1 seam:` lines: see the test-linux run on this PR. - skill-evidence block absent: the installed source-control and claude-ops predate #4210, so no ledger sha is stamped; the owed reviews ran as nested agents: plan reviewer, code reviewer (with security pass), simplification and AI-slop read, verifier. ## Related - Unattended interview: the queue item was the only requester input; open questions took the defaults in the lane's PLAN.md (version above main; skip through the real resolver rather than stubbing it; `bad` on Linux only). - #4418 (introduced `hook::_physical_builtin_to`), #4469 (guardrails 0.36.9, merged first). - Deferred: block-windows-drive-tmp.test.sh's 15 host-only failures on this Windows host were not investigated here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…lues (#4495) No related issue: locks in counts #4418 already delivered for #4390; nothing left to close. ## Summary #4418 met the #4390 targets for the guardrails PostToolUse verify bundle: finding / no-finding / `.sh` spawns went from 102 / 65 / 24 to 38 / 13 / 6, against targets of <=60 / <=35 / <=12. Main only ratchets the finding row, still at 102, and has no rows for the other two. This PR only locks the measured values so a regression fails CI. ## Fix In `.performance/ratchets.json`: - `guardrails-posttooluse-run-guards-spawns`: ceiling 102 -> 38. Its `--check` now requires `STALE_PATH: docs/old.md` in the hook output instead of the bare path, so the row proves the stale-path finding fired. - Add `guardrails-posttooluse-run-guards-nofinding-spawns` (ceiling 13) and `guardrails-posttooluse-run-guards-sh-spawns` (ceiling 6), with the same payloads and checks as branch `perf/4390-guardrails-baseline` commit b03b5cd. No plugin files change, so no changelog or version bump. ## Verification - Measured on main f4fbc3d (CI run 36146948358): 38 / 13 / 6. - `workflow_dispatch` of `ci.yml` on this branch's head 75f881f (run 36171523857, success). The "Check performance counter ceilings" step reported: - `guardrails-posttooluse-run-guards-spawns: spawns=38 = ceiling` - `guardrails-posttooluse-run-guards-nofinding-spawns: spawns=13 = ceiling` - `guardrails-posttooluse-run-guards-sh-spawns: spawns=6 = ceiling` - The stricter `STALE_PATH:` check passed and left the finding count at 38. ## Related - #4390 (targets), #4418 (the change that met them), #4479, parent #4373. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rows (#4510) No related issue: Phase 5 of the hook latency program (PreToolUse windows), follow-up to #4418. ## Summary - **`exec` in guardrails' Write/Edit and Bash/PowerShell PreToolUse rows.** `exec bash "${CLAUDE_PLUGIN_ROOT}"/hooks/run-guards.sh ...`, the form #4418 gave the PostToolUse rows. Under the `sh -c` wrapper Claude Code uses on Linux and macOS, bash replaces the shell instead of running as its child: one process fewer per Bash, Write and Edit call. Shell form and `"shell": "bash"` stay. - **hardcoded-path-check: one git process instead of two.** `git rev-parse --is-inside-work-tree --show-toplevel` answers the work-tree test and the toplevel the repo-path branch needs. - **`hpp::scan_text` pre-gate in the shell first.** Under `LC_ALL=C` it answers "clean" only where its two `grep` processes would: the OS-path literals are a byte-substring test, and the project-root segment is compared with ASCII case folding only when the content and the segment are both ASCII and the segment holds no newline. Non-ASCII content still goes to `grep -Fi`. A readonly `LC_ALL` (or bash before 4.4) skips the pre-gate and the greps decide; restoring a caller's `LC_ALL` that names a missing locale is silent. A clean ASCII Write or Edit now starts no `grep` in this guard. - **Not changed: context-budget's `settings-write-ask` `if` gate.** On Linux (2.1.281) `Write(//**/*settings*.json)` plus `Edit(//**/*settings*.json)` rows would cover every path the script handles, but the repo's recorded Windows probe (2026-09-02) found `if` file rules skip absolute paths outside the working directory, which would drop the user-global and managed-settings checks there. The row stays unconditioned. ## Fix - **Bug fix: dash-leading project roots.** A project root whose last path segment starts with `-` (`-foo`, `-e`, `--help`) reached `grep -qFi` / `grep -nFi` as an option. grep errored or printed its usage, so a write carrying that project's own absolute path passed clean, or `--help` produced a false violation holding grep's usage text. Those greps now take `--`. This is the one intended behavior change in this PR. ## Verification - Tests: all 22 guardrails suites pass locally, including `hardcoded-path-check.test.sh` 135/0 (new: case-variant root, Kelvin-sign root parity with the host's `grep -Fi`, `-foo` / `-e` / `--help` roots flagged and clean content silent, readonly `LC_ALL` still flags the root, a missing-locale `LC_ALL` is restored without a warning), `run-guards.test.sh` 243/0, `pre-commit-content-invariants.test.sh` 9/0. - Gates: shellcheck, shfmt, check-killswitch-hoist, check-hook-exec-form, check-hook-slow-shapes, check-changelog-parity, check-shell-portability, sync-hook-utils `--check` / `--check-bump`. - Differential, main (63ed8aa) vs head, with the independent verifier's harness: - Scan level: 4848 cases x 14 caller states (including readonly `LC_ALL` and an `LC_ALL` naming a missing locale). All 2399 differing blocks have a root whose last segment starts with `-`; stderr is identical in every state. Against an oracle of main's lib plus only the `--` fix: 0 differences in all 67872 case-runs. - Hook level: 4917 cases, 4878 identical; all 39 differences are the dash-root fixture, exit 0 -> 2 with a "Machine-specific repo path detected" deny for content that holds the project's own path. - Counters (strace census): guardrails Bash pre 2 processes -> 1; Write/Edit pre successful execs 8 -> 5 (git 3 -> 2, grep 3 -> 1), unchanged by the fix-up. - Timing (interleaved main / head / safe, before the fix-up, which adds no process): Bash pre 25/27 -> 25/26 ms p50/p95; Write/Edit pre txt 30/33 -> 26/32, md32 30/35 -> 27/30. The node hook is now within about 1 ms of guardrails in Write/Edit pre. - Not verified: Git Bash behavior of the `[!\x01-\x7f]` class, `nocasematch` under C and `${LC_ALL@a}`; `test-windows.yml` does not run the guardrails suites. ## Related - #4418 (the PostToolUse half of this program) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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
.txtedit 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::beginre-piped its buffered payload through a byte-at-a-timeread, guardrails parsed and resolved the same payload once per verifier, andrepo_relative_path_toprobed every PATH directory for a Windows-onlycygpath. Round 2: every hook paid a#!/usr/bin/env bashhop, arealpathprocess, 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 keepexecon 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 throughsh -c(dash) on Linux, and dash forks for a trailingbash ...;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 firstsource; both are silentexit 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_fieldsalso proves.key // false | tostringand.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.mdedit outside a marketplace repo (it had one per edit, for thestructuredPatchfilter). 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_skeletonchecks 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 passk: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) andpowershell-format.sh(to_pwsh_path):cygpathonly on msys/cygwin/win32; both trust gates test for a drive-letter path before the lookup.read(read.def731-742;TMOUTtoo), 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.Round 3 (cd1b9d9, 21c1ef5; merges b0a5009, 0eda101):
bash-format/hooks/bash-format.sh:cygpathis looked up only on msys/cygwin/win32, as inhook::repo_relative_path_to. Elsewhere the miss probed every PATH directory, including 9/mnt/centries on WSL; bash-format was the slowest.shPostToolUse hook because of it.guardrails/hooks/stale-path-verify.sh: a word anchor's occurrence count usesspv_word_occurrences(awkspliton 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 throughtostring, sohook::jq_fieldsreads 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.Round 2 (2540560, 9cc5cdf, 99d6205):
bashprefix and"shell": "bash"on every row andexec bashon the typos-format and eol-normalizer rows): what remains of this PR isexecin front ofbashon 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): newhook::_physical_builtin_toanswers realpath's question withbuiltin cd -Pin 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 byhook::_physical_primeandhook::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/contextdoes not exist and jq is on PATH (no snapshot and no compaction marker for any session, so the hook could only reach a silentunknown)._physical_builtin_tovs realpath per shape and its declined shapes; the unresolved-path probe disables the builtin withenable -n cd; run-guards expects 0 realpath on Linux.Round 1 (7630993 .. c8daa81): C-locale builtin JSON parse (
hook::_c_locale),buffer_stdin_towithoutjq -e .for a proven object,read_file_path_to/raw_file_path_toon the buffered payload,cygpathlookup only on Windows bashes,_uncachedtwins; run-guards caches path, root and fields per event; eol-normalizer skipsgit rev-parsewith no sink; context-guard starts its resolver only with a readable snapshot; instruction-placement SC2154 init.Final verdicts
.txt, S2.md, S2.sh: MET. Every exec-count target: MET..md): NOT MET. 70 ms p50 against a 60 ms target, per the independent verifier at 0d17cdd..mdedit holds only outside a marketplace repo; inside one, the dispatcher still starts one jq.windows.pydropped the first call per log; the verifier's own cuts, which keep it, gave the same verdicts.cygpathbranches (covered bytest-windowsCI only).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(fix(guardrails): decline temp-tree targets in secret-pattern-detection no wider than the Bash exemption #4475, fix(guardrails): scan NotebookEdit cell source in secret-pattern-detection #4486; PreToolUse, outside the measured window) andguard-requires.sh(chore: prompt audit for Fable 5.1 and Opus 5.5, then repo-wide comment dissolve #4480, comment-only, sourced by run-guards).lib/hook-utils.shis 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.goandWrite m.go, Go's randomtelemetry/local/weekendsunder HOME, same output) and the same 13 lib-level deltas as round 5. Against origin/main itself: the same 15 and nothing else. Everyrow-*case is identical, soexec bashvs main'sbashchanges 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) andtest-windows.ymlgreen.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):
.txt.md.sh.mdCounters (strace census, W/E post Edit, medians) are identical to round 5. Successful execs: typos-format S1 4 and S3 4, guardrails post-verify
.md3 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), soexecstill saves the fork. Failed execve: 46 per hook. Failed probes: typos S3 308, post-verify.md260, 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 underlib/or any tested plugin (git diff --statempty). CI dispatched at eeae865:ci.yml(lint, lint-2, hook-utils, test-linux 0-3, ci-status) andtest-windows.ymlgreen.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):
.txt.md.sh.mdCounters (strace census, W/E post Edit, medians): successful execs typos-format S1 4, S3 5 -> 4; guardrails post-verify
.md4 -> 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 ofhook::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" 1give 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.ymlandtest-windows.ymlgreen.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):
.txt.md.sh.mdThe 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
cygpathon 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.ymlandtest-windows.ymlgreen.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):
.txt.md.sh.mdCounters (strace census): typos-format S3 execs 6 -> 5; bash-format
/mntprobes 9 -> 0. The remaining S3 cost includes a timed-readfloor (2rt_sigprocmaskper 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 everyhook::begincaller, 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 assh -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/Editm.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_tomatched 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 --checkand--check-bump origin/main, markdownlint: pass.CI at 99d6205, dispatched:
ci.yml(lint, lint-2, hook-utils, test-linux 0-3, ci-status) andtest-windows.yml: all green.Measurement (WSL2,
claude -pHaiku; one run at 99d6205, arms base / after /--safe-modeinterleaved, 4 rounds x 4 strata; host qualified before and after,is_measurableTrue, spread 1.72x / 1.42x). Write/Edit PostToolUse window, ms:.txt.md.sh.mdCounters 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
.md16 -> 4 (.sh7 -> 3, 33 KB 23 -> 6), context-guard PostToolBatch 4 -> 2. Failed file probes typos 580 -> 290, eol 409 -> 212, post-verify.md1060 -> 298. Failed execve stay 46 per hook: dash'sexec bashwalks PATH with execve asenvdid; plainbashwould 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
_uncachedtwins, 17-carrier sync); row shape: perf(autonomy): check lane-stop-gate opt-in before sourcing its libraries #4421, perf(disk-hygiene): cheaper guard-launch-monitor Stop hook #4422.🤖 Generated with Claude Code