Skip to content

perf(hooks): parse Write/Edit payloads once, in the C locale, and drop per-edit spawns - #4418

Merged
kyle-sexton merged 28 commits into
mainfrom
perf/hook-we-post
Sep 25, 2026
Merged

kyle-sexton merged 28 commits into
mainfrom
perf/hook-we-post

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

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 fix(guardrails): grant the temp-tree exemption on Windows drive paths #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 feat(ci): hook slow-shapes gate, bash-prefix sweep, and exit-before-source on every event #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

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

🤖 Generated with Claude Code

kyle-sexton and others added 27 commits September 23, 2026 22:50
…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>
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 25, 2026 10:42
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-25T10:50:49.368101Z 2aeec26 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 11s —— View job


Reviewing PR #4418

  • Gather context (PR body, changed files, diff)
  • Confirm this is the CI code-review lane (/review:code-review), scoped to correctness/maintainability
  • Review core lib/hook-utils.sh JSON/locale/path-cache changes
  • Review guardrails hook changes (stale-path-verify.sh, skill-reference-verify.sh, run-guards.sh, cli-flag-verify.sh)
  • Review formatter / context-guard hook changes (bash-format, markdown-format, powershell-format, eol-normalizer, typos-format, zone-crossing-inject, index-drift)
  • Adversarially verify candidate findings
  • Post inline comments + summary

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 31s —— View job


Security review complete

  • Load repo instructions (CLAUDE.md/AGENTS.md) and the /review:security-review skill
  • Run skip gate (PR open; touches shell hooks parsing untrusted JSON/paths — security-relevant surface, proceeded)
  • Fetch PR diff (gh pr diff 4418) and enumerate changed hook scripts
  • Review lib/hook-utils.sh builtin JSON parser (17 carriers) for parsing-bypass / injection risk
  • Review per-plugin guardrail changes (stale-path-verify, skill-reference-verify, cli-flag-verify, run-guards) for check-bypass logic
  • Review remaining hook changes (bash-format, markdown-format, powershell-format, typos-format, eol-normalizer, zone-crossing-inject, index-drift)
  • Adversarially verify candidate concerns
  • Report findings

No security issues found.

What was reviewed

This PR's real logic surface (the 358/-163 hook-utils.sh diff is duplicated identically across 17 plugin carriers, so it was audited once):

  • lib/hook-utils.sh: the new builtin JSON grammar checker (hook::_json_skeleton's whole-string-rewrite reduction), hook::_json_object_proven (the builtin stand-in for jq -e . that gates whether hook::buffer_stdin_to skips spawning jq at all — the one place a parser discrepancy could matter, since it decides whether untrusted-shaped payload text is treated as valid JSON), hook::_fast_fields/hook::_fast_file_path_to field extraction, and hook::_physical_builtin_to (the cd -P realpath stand-in used by the temp-tree membership check that secret-pattern-detection.sh's D1 control depends on).
    • The C-locale gating (hook::_c_locale) is applied consistently: hook::_json_skeleton is only ever reached after LC_ALL=C is confirmed by its callers, so its cache (_HOOK_JSON_SK_TEXT/_HOOK_JSON_SK_RC) can't return a stale cross-locale answer.
    • hook::_json_split's _HOOK_JSON_PARTS=($__hu_t) unquoted expansion is deliberately safe: IFS='"' is scoped locally and globbing is disabled (set -f) around it.
    • hook::_physical_builtin_to only answers for absolute, //-free, newline-free paths that are either an existing directory or an existing non-symlink file, falling back to realpath for every other shape (symlinks, missing paths, relative paths) — matching the doc comment, and the Round-6 commit (2aeec26) specifically re-verified the D1 temp-tree resolver-shim control stays meaningful now that Linux takes this path instead of spawning realpath.
    • Every builtin fast path (typos_classify_report_only, stale-path-verify's spv__* twins) gates on a strict printable-ASCII/no-quote-or-backslash regex before interpolating captured values into hand-built JSON/text, and falls back to the jq/grep pipeline otherwise — no string-escaping gap found.
  • plugins/guardrails/hooks/{stale-path-verify,skill-reference-verify,cli-flag-verify,run-guards}.sh: the plugins-root gate in skill-reference-verify.sh moved earlier but keeps identical conditions; run-guards.sh's new per-event caches for hook::read_file_path_to/hook::repo_root_to key correctly on CLAUDE_PROJECT_DIR/scope/$PWD and only serve the cache for the event's own already-validated payload.
  • plugins/{bash-format,markdown-format,powershell-format} cygpath gating, context-guard/zone-crossing-inject.sh's new no-snapshot early exit (reuses the same $SESSION-keyed path the resolver itself already checks — no new trust surface), and eol-normalizer.sh/instruction-placement/index-drift.sh: all perf-only reorderings/OS gates with no behavior change on the check outcome.

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 .github/workflows.

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

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 9s —— View job


Reviewing PR #4418 (CI code-review lane)

  • Load /review:code-review skill and confirm scope (correctness/maintainability; security is out — a separate security lane already reviewed this PR with no findings)
  • Skip-gate check (PR open, not draft, meaningful diff, no prior review at this head — last review was at 2aeec26, this run is at f155042, one merge commit later)
  • Fetch diff (git diff origin/main...HEAD) and enumerate changed files (74 files, +7494/-3065)
  • Review lib/hook-utils.sh (17 carriers) — JSON skeleton parser rewrite, hook::_c_locale gating, hook::_json_object_proven, _fast_fields' new // false | tostring suffix handling, hook::_physical_builtin_to
  • Review guardrails hooks (stale-path-verify.sh's builtin grep/sed/sort twins and word-anchor shortcut, skill-reference-verify.sh's reordered plugins-root gate, run-guards.sh's new per-event caches for read_file_path_to/repo_root_to/jq_fields, cli-flag-verify.sh)
  • Review formatter/context-guard hooks (bash-format, markdown-format, powershell-format cygpath OS-gating; eol-normalizer's eol_root_to shortcut; typos-format's builtin report-only classifier; zone-crossing-inject's no-snapshot early exit; index-drift)
  • Adversarially verify candidate findings (traced through call sites and shared helpers by hand rather than trusting the PR's own claims)
  • Report findings

Result: no findings

Scope was correctness and maintainability only — security was intentionally left to the separate /review:security-review lane, which already ran on this PR and reported no issues.

What I actually checked by hand (not just read the PR's own verification claims for):

  • Confirmed all 17 hook-utils.sh carriers are byte-identical (sha256sum), so the dense logic only needed auditing once.
  • Traced hook::_fast_fields's new // false | tostring suffix path end-to-end against its true|false acceptance branches — the __hu_df gating correctly rejects a bare boolean for a plain (non-suffixed) filter, matching pre-PR fallback-to-jq behavior.
  • Verified stale-path-verify.sh's new word-anchor shortcut (spv_word_occurrences, an awk split on [^A-Za-z0-9_]+) against the general character-walk it bypasses: for anchors containing ./- the walk's [A-Za-z0-9_]-only token boundary can never match such an anchor, so occ is always 0 there too — the shortcut's unconditional continue for those anchors is behaviorally identical, not just faster.
  • Verified zone-crossing-inject.sh's new no-snapshot early exit and the [[ -r "$HOME/.claude/context-guard/context/$SESSION.json" ]] pre-check against context-zone.sh's own logic (scripts/context-zone.sh:104-107): the constructed path matches exactly, and even for a malformed $SESSION the shortcut is conservative — it either matches the resolver's own unknown outcome directly, or falls through to invoke the resolver, which re-validates the session id itself before touching any file. No output divergence, no information-disclosure risk from skipping that regex check locally.
  • Verified eol-normalizer.sh's eol_root_to shortcut: hook::begin --repo-root calls it with $2 = FILE_DIR (not the raw path), and the shortcut only fires when FILE_DIR is absolute — consistent with FILE_DIR derivation (lib/hook-utils.sh around hook::begin). The result feeds only git -C "$root" check-attr -- "$FILE" with an absolute $FILE (normalize-eol.sh:131), for which any same-repo -C directory is equivalent — and telemetry (the one consumer needing the real root) explicitly opts back into hook::repo_root_to.
  • Spot-checked the typos_classify_report_only regex/field-extraction against the jq path it replaces (group numbering, tostring-shaped fields, uncapped findings vs. MAX_REPORT-capped display text) — consistent.
  • Confirmed the cygpath OS-gating changes (bash-format, markdown-format, powershell-format) are simple, correct short-circuits with no behavior change on Linux/macOS/Windows.
  • Confirmed run-guards.sh's new hook::read_file_path_to/hook::repo_root_to/hook::jq_fields caches only serve a hit when the payload text (and, for repo-root, $PWD) matches the event's own already-validated input, falling through to the uncached path otherwise.

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.
· branch perf/hook-we-post

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 54s —— View job


Security review — skipped (no new PR content since last review)

  • Load repo instructions (CLAUDE.md/AGENTS.md) and the /review:security-review skill
  • Run skip gate
  • Fetch PR diff for new head f155042 (git diff origin/main...HEAD) and compare against the last-reviewed head
  • Confirm no security-relevant delta
  • Report result

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 2aeec26) are a merge of origin/main (98463ca, a repo-wide comment-dissolve chore touching ~472 unrelated files) and the merge commit itself (f155042). git diff origin/main...HEAD on the new head is identical in scope to the prior review: same 74 files, same +7494/-3065. The merge did pull in one runtime-hook change from main — plugins/guardrails/hooks/secret-pattern-detection.sh (#4475, PreToolUse) — but that file is not part of this PR's diff against origin/main (it's already on main; only its test file, secret-pattern-detection.test.sh, is in this PR's own changed-file set), so it's outside this lane's review scope, consistent with the PR body's own note ("not re-measured").

No new security-relevant surface to review. The prior finding stands: no security issues found in this PR's actual changes (lib/hook-utils.sh's builtin JSON parser/grammar checker and its 17 carrier copies, the guardrails verifier changes, and the formatter/context-guard cygpath/early-exit changes) — see the previous security-review comment on this PR for the full breakdown of what was audited.

@kyle-sexton
kyle-sexton merged commit 4e29a62 into main Sep 25, 2026
22 checks passed
@kyle-sexton
kyle-sexton deleted the perf/hook-we-post branch September 25, 2026 13:25
kyle-sexton added a commit that referenced this pull request Sep 25, 2026
…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>
kyle-sexton added a commit that referenced this pull request Sep 25, 2026
… 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>
kyle-sexton added a commit that referenced this pull request Sep 26, 2026
…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>
kyle-sexton added a commit that referenced this pull request Sep 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant