Skip to content

perf(guardrails): block-hook-bypass drops six forks that never exec (#3513) - #3869

Merged
kyle-sexton merged 4 commits into
mainfrom
claude/3513-hook-bypass-perf
Sep 7, 2026
Merged

kyle-sexton merged 4 commits into
mainfrom
claude/3513-hook-bypass-perf

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #3513

Summary

block-hook-bypass.sh was the largest process creator of any guard in the plugin on a benign Bash call: 7 creations, 0 execs. Every exec-based census (the PATH-shim spawn census, run-guards.test.sh's dirname/sed pin, an xtrace command-position count) reads a fork-without-exec as free, which is why the issue's numbers and the "per-field jq" cause never matched what the kernel does. Six of the seven were in this file; the seventh is $(hook::buffer_stdin).

Measured with strace -f -e trace=clone,clone3,fork,vfork,execve on the real dispatched path (run-guards.sh block-hook-bypass.sh), HOOK_TELEMETRY_SINK unset, guard share = count minus a no-op guard dispatched the same way, three identical repeats:

Path creations before -> after execve before -> after
Guard share, benign git status --short 7 -> 1 0 -> 0
Guard share, blocked echo hi > notes.md 10 -> 5 1 -> 1
Whole Bash dispatcher, benign 36 -> 30 3 -> 3
Guard alone under the dispatcher, wall p50 / p95 (n=20, interleaved) 27.4 / 29.2 ms -> 24.3 / 27.5 ms
Whole Bash dispatcher, wall p50 / p95 (n=20, interleaved) 51.6 / 60.4 ms -> 48.2 / 49.6 ms

The execve column does not move: this is latency, not removed work.

On the issue's "at most two spawns" line. The one creation left in the guard's own share is $(hook::buffer_stdin). Its fork-free form (hook::buffer_stdin_to <var>) belongs to lib/hook-utils.sh, which is fenced by unmerged #3740 and #3838, so it is not touched here. Whether #3513 counts as met depends on what one counts: the guard's in-file contribution is now 1 creation and 0 execs, and the dispatcher's own $(source …) isolation fork is the dispatcher's, not this file's. Closes here means the in-file work is done; the remaining fork is the hook-utils item.

Fix

Five in-file creations removed, one left in place:

  1. :159 eager SUBJECT=$(hook::extract_bash_subject …) at file scope. Only emit_tel read it, and emit_tel is gated on the start stamp and the opt-in sink. The subject is now derived inside emit_tel behind both gates. Verdicts never read it.
  2. :478 EXECUTABLE=$(strip_literals "$COMMAND"). Now strip_literals_to EXECUTABLE "$COMMAND", assigning through a nameref; every trailing newline is stripped, as $(…) stripped them.
  3. :474, :1305, :1330, :1403 the four done < <(printf '%s\n' …) loops. One fork-free splitter, split_lines_to, does a sentinel-prefixed IFS=$'\n' word split under set -f (globbing restored to whatever the caller had). The sentinel prefix is what keeps blank lines and runs of newlines from collapsing, and a leading or trailing empty line from disappearing, so the array is exactly what read delivered, which strip_literals needs to keep its heredoc and open-quote state aligned with physical lines. NORMALIZED_SEGMENTS becomes an array filled once in normalize_segments; the three per-segment scans iterate it. return and continue 2 inside those loops reach the same scopes as before because neither loop shape runs its body in a subshell. A here-string was not an option: at 65536-65663 bytes bash blocks forever writing it into the pipe (documented in lib/path-detection/hardcoded-path-patterns.sh).
  4. :112 INPUT=$(hook::buffer_stdin) is left as is. It is the fenced hook-utils item, and fix(hook-utils): a hook payload cut short at EOF is a loud allow; a stall stays a block #3740 modifies this exact rc-handling block; the diff stays off it so the two do not fight at merge.

Manifest 0.32.10 -> 0.32.12, CHANGELOG entry, README "Hook budget accounting" entry with the method and table above. 0.32.11 is taken by #3849 (issue #3511), which bumps the same manifest off the same 0.32.10 base; this branch takes the next free number so the two do not collide on plugins/guardrails/CHANGELOG.md.

Two documentation corrections carried in the same renumber commit:

  • The CHANGELOG entry and the new README row said "the 602-case contract suite passes". With the pins this change set adds the suite is 611 cases, which is what it reports.
  • The comment moved onto emit_tel carried two em dashes over from the file-scope comment it replaced. .claude/rules/vendor-docs-are-not-style.md bars them from this repo's instruction surfaces, and check-purged-em-dashes.sh scans markdown only, so nothing caught them. They are parentheses now. No other comment is touched, and no guard logic changed.

Verification

  • Deny paths still deny, compared against origin/main. 244 paired runs (61 commands x Bash/PowerShell payloads x standalone/dispatched, plus a 70 KiB single-line command and a 3000-line command on each side) agree on exit code and first stderr line. The corpus covers every documented deny form (cat/echo/printf redirects, no-space forms, heredoc openers with a trailing redirect, 2>&1/>&2 before the file, prefix modifiers command/exec --/FOO=bar, group and if headers, leading redirects, discard-then-real-file > /dev/null > real.txt, python write indicators, staged mv/cp, quoted operands with embedded separators, \; and backslash-newline escapes, ec""ho / ec"xy"ho splices) and the allow forms (dup-only redirects, producer-scoped bash x.sh > out && echo done, quoted prose in commit and PR bodies, command -v, comments, blank-line-only and newline-padded commands, glob characters).
  • split_lines_to checked directly against while IFS= read -r over printf '%s\n' on: empty, single line, trailing newline, leading newline, runs of blank lines, whitespace-only lines, glob characters, the sentinel byte itself at line starts and ends, IFS characters, trailing backslashes, UTF-8, a 70 KiB line, 3001 lines. Identical arrays in every case; set -f preserved when the caller had it and restored off when it did not.
  • New strace-based budget test in block-hook-bypass.test.sh: pins the guard's benign share at exactly 1 creation and 0 execve on two benign payloads (single segment; multi-line, multi-segment), and that echo > file still exits 2 under the tracer. Skips visibly on a host without a working strace. It runs the real dispatched path, not the standalone guard, and subtracts a no-op guard so dispatcher forks do not leak into the pin. Mutation-checked: unmutated control 9/9; each of the four changes reverted alone (eager SUBJECT restored; splitter through mapfile < <(…); strip through $(…); segments through mapfile < <(…)) fails the pin on both payloads.
  • The census was re-taken after the renumber commit, since a documentation-only change must not move it, and it does not: whole Bash dispatcher on the benign payload reads 36 creations at origin/main against 30 on this branch, 3/3 identical repeats, execve flat at 3 on both; the blocked-path guard share is 5 creations and 1 execve; the contract suite's exact benign-share pin (1 creation, 0 execve) passes.
  • scripts/affected-tests.sh --run: 5 suites selected (block-hook-bypass.test.sh 611/611, flag-commit-pr-skill-bypass.test.sh, require-jq-posture.test.sh, run-guards.test.sh, scripts/check-shell-portability.test.sh), all passed, exit 0; the manifest, CHANGELOG and README map to the recorded no-suite allowlist.
  • scripts/check-changelog-parity.sh all four modes the script defines, on the committed tree: --check (pass), --check-order (pass, 91 changelogs read newest-first with no duplicate versions), --check-bump origin/main (pass), --check-preserved origin/main (pass, 155 headings preserved). --check-order is the mode that catches a doubled ## [<version>] after a bad conflict resolution, which is the failure the renumber exists to avoid; an earlier revision of this PR ran only the other three.
  • shellcheck -x clean on the guard and the test; shfmt -d no diff; markdownlint-cli2 0 issues; editorconfig-checker clean; check-purged-em-dashes.sh clean; ai-slop detector 0 findings on the README and CHANGELOG.
  • Pre-existing and not touched: test_save_point.py::test_new_origin_falls_back_to_directory_name.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob


Generated by Claude Code

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 6, 2026 19:49
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 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-06T19:55:56.066012Z c1bb271 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 6, 2026

Copy link
Copy Markdown
Contributor Author

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


Claude finished the code review

  • Gather context (PR body, changed files, origin/main diff)
  • Invoke /review:code-review and follow its instructions
  • Post inline findings
  • Post summary review comment

Scope

.github/workflows/claude-security-review.yml exists in this repo, so security findings are out of scope for this lane (per REVIEW.md); none were assessed here. Reviewed the full diff (git diff origin/main...HEAD, 5 files) for correctness and maintainability.

Findings

1 issue posted inline on plugins/guardrails/README.md:382 — the new "Hook budget accounting" row is still headed 0.32.11, but the renumber commit (c1bb271) bumped the manifest and CHANGELOG.md heading to 0.32.12 and left this README heading untouched (confirmed via git show c1bb271 -- plugins/guardrails/README.md, which only edits the "602→611 case" line, not the version label). 0.32.11 is reserved by an unrelated PR (#3849 / issue #3511); scripts/check-changelog-parity.sh never reads README.md, so nothing catches this. See the inline comment for a one-line fix.

What I checked but did not flag

  • split_lines_to's sentinel-prefixed IFS=$'\n' splitting trick (avoiding the "newline is IFS whitespace, so runs collapse and edges get stripped" pitfall by prefixing every line with \x1f so no field is ever empty): traced the logic by hand against the described edge cases (embedded sentinel bytes, leading/trailing blank lines, empty input) and it holds up. I did not independently run the differential test the PR describes against while IFS= read -r (no shell-execution tool available in this lane — see below), so treat that verification as author-claimed.
  • The four while read/process-substitution loops converted to for seg in "${NORMALIZED_SEGMENTS[@]}" / for line in "${_bbh_lines[@]}": confirmed neither loop shape ran in a subshell before or after, so return and continue 2 (in producer_redirect_bypass) still reach the same scopes.
  • Moving SUBJECT derivation into emit_tel: confirmed TOOL_NAME and COMMAND are assigned exactly once each, at file scope, before any call site of emit_tel, so lazy derivation can't observe a different value than the old eager one.
  • The nameref out-params (strip_literals_to, split_lines_to): checked for the classic bash nameref self-collision hazard (a local variable name inside the callee matching the caller's target variable name) — no collisions between the two functions' local variable names.
  • The new strace-based budget test in block-hook-bypass.test.sh: logic (regex matching clone/fork/execve completions including the <unfinished>/<resumed> split form, subtracting a no-op-guard baseline) reads correctly; not independently executed.
  • Version-bump mechanics: scripts/check-changelog-parity.sh doesn't check README, confirmed by reading the script; the CHANGELOG's jump from 0.32.12 straight to 0.32.10 (no local 0.32.11) is expected given perf(guardrails): hoist three convention-gate redirects off their substitutions #3849 owns that number on a parallel branch, not a defect.

Limitations

This lane's tool grant doesn't include general shell execution — I could not run shellcheck, shfmt, the test suite, or the strace-based census myself, so the PR's test/verification claims (611/611 pass, mutation checks, the 244 paired runs, all four check-changelog-parity.sh modes) are author-claimed and unverified by me, not independently confirmed.

Copy link
Copy Markdown
Contributor

Independent review and the merge-blocker it caught — flipped to ready

Posted by the autonomous worker lane (vm-worker-202609050257). Commit c1bb271a. This flip is the lane's handoff; the merge lane owns it from here. This lane does not merge.

The blocker, found and fixed

This PR originally took guardrails 0.32.11 — already claimed by PR #3849, off the same 0.32.10 base. git merge-tree confirmed the conflict on CHANGELOG.md. Renumbered to 0.32.12; git merge-tree origin/main HEAD now exits 0. #3529 is an issue rather than a PR, and no open PR claims 0.32.12.

The parity mode that was skipped is the one that catches this. The prior pass ran three modes and reported the script defines three. It defines four--check-order verifies each changelog reads newest-first with no repeated version, which is exactly what a bad resolution of a doubled heading produces. All four now pass, including --check-order across 91 changelogs.

Verified by independent reproduction

Check Result
SUBJECT deferral Safe — zero remaining references; emit_tel is the only consumer and its three callers all run after COMMAND/TOOL_NAME are set once and never reassigned. No verdict path reads it
Splitter vs read 17 cases identical — empty, lone/leading/trailing/run-of newlines, whitespace-only, backslashes, glob metacharacters with matching files present, CRLF, sentinel byte at line start and end, tabs, control bytes, UTF-8 — plus 70 KiB, the 65600-byte band, 3000 lines. set -f restored to the caller's prior state either way; IFS restored; clean under set -u
Nameref Safe — the circular case is passing the function's own local, and no call site does
Deny paths 224 paired runs, 0 differences in exit code or first stderr line; 30 of 56 Bash commands deny on HEAD
Measurements Reproduced 3/3: benign share 7 → 1 (execve 0 → 0), blocked 10 → 5 (1 → 1), whole lane 36 → 30 (3 → 3)
Subtraction method Sound — the no-op guard rides the same dispatcher, payload and env, so those forks cancel; before and after use identical subtraction
Mutation pin Reproduces on all four single reverts
#3740 overlap Clean — its only hunk here is the buffer_stdin rc block at :91; this diff starts at :150, and :112 is byte-identical to main

Two accuracy fixes

The changelog and README said "the 602-case contract suite"; it is 611 with the new pins. And two em dashes had been carried into an added comment — check-purged-em-dashes.sh is green only because it scans markdown, not shell, so the house-style rule went unenforced there. Both corrected.

One honest note from the re-census: measuring with the guard tree copied outside the repo gives 36 → 30, while the in-repo script path reads 31. That is a location artifact of the measurement, not of the change.

Not closable in-file

The issue's "≤2 spawns" criterion cannot be met while $(hook::buffer_stdin) at :112 stays fenced behind unmerged #3740 and #3838. The body says so plainly.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

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


Security review complete

Task: Run /review:security-review on PR #3869 (claude/3513-hook-bypass-perf @ c1bb271).

  • Gather context (diff, changed files)
  • Invoke /review:security-review skill and follow its instructions
  • Post findings

This PR is a performance refactor of plugins/guardrails/hooks/block-hook-bypass.sh (a Bash-write-bypass security guard), replacing several fork-per-call constructs ($(cmd), < <(printf …) line loops) with fork-free equivalents (nameref-based strip_literals_to, and a new split_lines_to helper backing an array-typed NORMALIZED_SEGMENTS). Since this touches the guard's own detection logic rather than unrelated code, I reviewed it for behavior-preservation bugs that could weaken the deny/bypass detection (a logic regression here is the security-relevant risk class for this file), not just style.

No security issues found. Specifics I checked:

  • split_lines_to (the new line-splitter replacing while IFS= read -r line; do … done < <(printf '%s\n' …)): traced the sentinel-prefix (\x1f) construction by hand. Because every original newline is replaced with \n + sentinel, and the whole string is sentinel-prefixed at the start, the transformed text never has adjacent newlines or leading/trailing newlines — so bash's IFS-whitespace collapse/strip behavior on IFS=$'\n' splitting can't merge blank lines or drop leading/trailing empty fields, which is the correctness property strip_literals_to's heredoc/open-quote-span tracking depends on (misaligning physical lines there is exactly the kind of bug that could let a quoted/heredoc span swallow or fail to swallow the wrong tokens and change a verdict).
  • Sentinel collision: the new \x1f sentinel is local to split_lines_to and distinct from the pre-existing \x01\x04 marks used internally by strip_literals_to/normalize_segments; confirmed only one occurrence of \x1f in the file. A command containing a literal \x1f byte is preserved correctly (only the one prepended sentinel is stripped via # shortest-match removal, so a pre-existing sentinel byte in the input survives).
  • EXECUTABLE=""; strip_literals_to EXECUTABLE "$COMMAND" (L524) replacing $(strip_literals "$COMMAND"): the old path relied on command-substitution's stripping of all trailing newlines; the new function replicates that explicitly with while [[ "$result" == *$'\n' ]]; do result="${result%$'\n'}"; done (L521) rather than a single % strip — correct, and necessary, since without it a trailing-newline difference could change EXEC_LC matching.
  • NORMALIZED_SEGMENTS changed from a newline-joined string to an array (split_lines_to NORMALIZED_SEGMENTS "$normalized", L755); grepped all three consumers (staged_write_move_bypass, cat_redirect_bypass, producer_redirect_bypass) and confirmed every one was updated from while IFS= read -r seg … done < <(printf …) to for seg in "${NORMALIZED_SEGMENTS[@]}", so no stale scalar read of the old string form remains.
  • set -f save/restore in split_lines_to: prior noglob state is captured before set -f and restored only if it wasn't already set, and IFS is scoped local to the function — no leakage into caller state that could re-enable globbing (and thus glob-expand a payload) partway through detection.
  • Removed globals (SUBJECT, string-form NORMALIZED_SEGMENTS) have no leftover references anywhere in the guard or its test file.
  • CHANGELOG/README/manifest version-bump changes are documentation/metadata only.

This matches the PR's own extensive verification narrative (244 paired deny/allow runs vs. origin/main, direct fuzzing of split_lines_to against read, and mutation testing on the 611-case suite) — the described edge-case coverage (sentinel byte itself, blank line runs, leading/trailing newlines, 70 KiB lines) lines up with the actual failure modes this kind of splitter refactor risks. Per the security-review skill's scope, this is a logic/trust-boundary review; no Actions-workflow files are touched, so zizmor's lane doesn't apply here. I was not able to execute the shell test suite directly in this environment (tool restrictions), so this is a static/code-reading verification, not a fresh test run.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: c1bb271ab9512fa558dafacda168215febee35a4. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

Comment thread plugins/guardrails/README.md Outdated
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1bb271ab9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/guardrails/README.md Outdated

Copy link
Copy Markdown
Contributor

Lane babysit-loop, instance ccr-session-babysit-loop-20260906, claimed at head c1bb271a (mergeable_state=dirty, ~70 min since last activity). Conflicts resolved and a stale perf pin corrected. Head moves c1bb271a -> 2f014eaa.

Conflicts (3, all one shape)

block-hook-bypass.sh — the actual subject of this PR — merged cleanly. All three conflicts were the guardrails version stack: main published 0.32.11 while this branch had already claimed 0.32.12.

file resolution
.claude-plugin/plugin.json keep 0.32.12, strictly above main's 0.32.11
CHANGELOG.md main's 0.32.11 entry verbatim; this branch's 0.32.12 stacked above it
README.md both sides titled a perf row 0.32.11; main's stays verbatim, this branch's renumbered to 0.32.12 to match its manifest

All four changelog-parity modes pass, as do plugin-options-docs, purged-em-dashes and shellcheck -x.

The merge made this PR's own claim out of date

Merging main brought in #3838, which landed the fork-free hook::buffer_stdin_to in lib/hook-utils.sh. This guard now calls it. That matters because this PR documented seven forks removed except the seventh, $(hook::buffer_stdin), which it explicitly left to lib/hook-utils.sh.

The contract suite caught it, exactly as designed. Its own comment said:

The one creation left is the guard's own $(hook::buffer_stdin) … when that lands this figure drops to 0 and the pin moves with it.

It has landed, so two strace cases failed with expected '1', got '0' — the guard is now cheaper than its own test allowed. Corrected in 2f014eaa:

  • the pin is 0, with the comment recording why;
  • the CHANGELOG and README rows that named $(hook::buffer_stdin) as the remaining creation now say all seven are gone;
  • figures re-measured on the merged tree with the same instrument the README documents (strace -f -e trace=clone,clone3,fork,vfork,execve, guard share = count minus a no-op guard, HOOK_TELEMETRY_SINK unset), stable across three repeats: benign guard share 7 -> 0 creations, blocked 10 -> 4.

The whole-dispatcher and wall-clock rows were measured on the CI host against the pre-#3838 base. I did not restate them from this container; the README now says so, and points at the 0.32.11 row for the dispatcher's own reduction.

Test status

scripts/affected-tests.sh --run is clean except one case, which is not branch-owned:

FAIL: symlink: a genuine temp write in the same root stays allowed: expected exit 0, got 2

It reproduces identically on unmodified origin/main in this container (PASS=601 FAIL=1), so it is a sandbox symlink-resolution artifact, not a regression. block-noncanonical-commit.test.sh is 216/216.

(One measurement note for anyone reproducing this locally: run the suites after committing the merge. With MERGE_HEAD still set, block-noncanonical-commit correctly exempts commits during an in-progress merge and 36 of its cases report expected exit 2, got 0. That is the guard working, not a break.)

Lane action: advanced. Now awaiting CI on 2f014eaa. Merge not attempted — the gh merge gate cannot read thread-resolution state under this session's pinned-GraphQL restriction, so readiness stays unproven from here. Claim released.


Generated by Claude Code

kyle-sexton pushed a commit that referenced this pull request Sep 6, 2026
One conflict: plugins/guardrails/CHANGELOG.md. Both sides wrote a different
entry under the same heading, 0.32.11 — main's is the #3838 dispatcher jq/stdin
change, this branch's is the convention-gate redirect hoist. A collision, not
a stack.

Main's 0.32.11 entry stays verbatim at the number main published it under.
This branch's entry is renumbered to 0.32.12 and the manifest moves to match.

block-convention-violation.sh merged cleanly, and this branch touches no
README row, so nothing else needed renumbering.

Note for whoever merges second: PR #3869 also claims guardrails 0.32.12
against the same base. Both are correct against main's current 0.32.11, so
the second of the two to merge needs a one-line renumber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPLatLkg4329L8eyfxhuMa
@claude claude Bot mentioned this pull request Sep 6, 2026
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…ners (#3878)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
No linked issue

## Summary

Drop leftover process creations on the hottest Bash paths in this
marketplace: the shared hook library every always-on hook sources, the
always-on formatter Write/Edit paths (typos, ruff, biome, bash,
powershell, go, actionlint, eol, markdown), the always-on
desktop-notification Notification path, always-on guardrails verifiers,
and CI scanners that used to spawn once per file, per plugin, or per
allowlist entry.

## Fix

GNU Bash runs command substitution in a subshell even for builtins
(Command Substitution, [Bash Reference
Manual](https://www.gnu.org/software/bash/manual/html_node/Command-Execution-Environment.html);
[Greg's Wiki](https://mywiki.wooledge.org/CommandSubstitution)).
Cygwin's `fork` is a non-copy-on-write Win32 `CreateProcess` ([Cygwin
User's Guide, Process
Creation](https://ftp.cygwin.com/cygwin-ug-net/highlights.html)): "fork
will almost certainly always be inefficient under Win32."

### Shared hook library (`lib/hook-utils.sh`, synced to 17 carriers,
patch bump)

Same `_to` / in-process pattern as #3838, #3732, and #3678:

- `hook::json_escape_to` deletes residual C0 bytes with parameter
expansion instead of `printf | tr -d`
- `hook::emit_channels` writes through `_to` instead of
`$(hook::json_escape …)`
- Fractional `read -t` landed in bash-4.0-alpha (CHANGES).
`hook::read_supports_fractional_timeout` is `BASH_VERSINFO`; no TMPDIR
probe file
- `hook::notice_once` reads the marker with `read`, creates the
directory only when missing, and prunes stale markers once per process
- `hook::bash_parse_segments` walks `${cmd:i:1}` instead of `read -N1`
from a process substitution, and `$'…'` bodies decode through
`ansi_c_decode_to` (`printf -v`)
- `hook::repo_root_to` / `hook::repo_relative_path_to` write in this
shell so callers skip a leftover capture around git or builtins-only
work

Isolation `$(source …)` forks are unchanged (#3685).

### typos-format (always-on Write|Edit|NotebookEdit)

- Basename via `${FILE##*/}` (plus a backslash trim), not `basename(1)`
- `repo_root_to` / `repo_relative_path_to` instead of capture subshells
- Directory existence check instead of `$(cd && pwd)`
- `command -v typos` is no longer captured; the later exec looks the
name up on PATH

### Remaining always-on formatters (ruff, biome, bash, powershell, go,
actionlint, eol, markdown)

Same leftover class as typos-format, now applied to every always-on
formatter that still captured `_to` helpers or spawned `basename` /
leftover `cd && pwd`:

- `FILE_BASE` is `${FILE##*/}` (and a backslash trim)
- `repo_root_to` / `repo_relative_path_to` write in-process
- `$(cd && pwd)` canonicalize is an existence check on the path git
already answered (ruff, biome, bash-format EditorConfig walk)
- Nested `$(normalize_path "$(physical_path …)")` in powershell-format
uses the `_to` forms
- `command -v ruff|biome|goimports` is no longer captured
- markdown-format keeps physical `pwd -P` containment and config
discovery; leftover helper-capture and membership dirname on the
root-resolution path are gone

### desktop-notification (always-on Notification)

- Field extract fuses into `hook::buffer_stdin_to` so completeness and
`.notification_type` / `.message` share one jq process
- C0 stripping is parameter expansion, not `printf | tr`
- `repo_root_to` writes in-process; OSC 9 / BEL use `printf -v`;
`terminalSequence` uses `json_escape_jq_to`
- `uname` stays so tests can PATH-stub Darwin; git for `repo_root` stays

### guardrails verifiers (always-on PostToolUse / PreToolUse)

- `skill-reference-verify`, `stale-path-verify`, and `cli-flag-verify`
call `repo_root_to` / `repo_relative_path_to` in-process
- `hardcoded-path-check` and `secret-pattern-detection` use
`normalize_path_to` instead of leftover `$(hook::normalize_path)`
captures
- Isolation `$(source …)` forks are unchanged (#3685)

### CI scanners

- Orphaned-fixture scan: one `*.test.*` index, cached `evals.json`
`files[]`, in-shell ERE escape. Unquoted `\\` matches one backslash (a
quoted `'\\'` arm is two chars and leaves `\b` as a word boundary)
- Purged-em-dash scan: one `git ls-files -z` with every `:(glob)`
pathspec; in-process component-wise attribution so `*` cannot cross `/`.
`--list` stdout is byte-identical to origin/main
- Cross-plugin source drift: one `find plugins` plus one `sha256sum` of
2+ cluster paths. Discover stdout is byte-identical to origin/main
- Discriminating-test-skips / silent-skips: one awk per corpus (`FNR` +
`FILENAME`; mawk has no `ENDFILE`)
- Hook-exec-form: one jq over every `hooks.json` and one over every
`plugin.json` (`input_filename` attributes rows). Unreadable
`hooks.json` still fails closed via per-file fallback; unreadable
manifests are still skipped

Hook-specific leftover-fork work already in flight (#3873, #3872, #3871,
#3870, #3869, #3851, #3849, #3779, #3880, #3886) is out of scope here.

## Verification

Independent census re-derived spawn counts from `84adf87b` vs `cdb93f61`
without inheriting implementer figures. Kernel census `strace -f -e
trace=clone,clone3,fork,vfork,execve`; counter over duration; 3
identical trials.

**always-on formatters** (this revision vs `84adf87b`):

| Hook | clones before | clones after | execve before | execve after |
|---|---|---|---|---|
| ruff-format no-config skip | 14 | 10 | 4 | 4 |
| powershell-format no-settings skip | 23 | 16 | 7 | 7 |
| bash-format no-EditorConfig (ShellCheck finding) | 17 | 13 | 6 (1
`basename`) | 5 (0 `basename`) |

**guardrails** (this revision):

| Hook | clones before | clones after | execve |
|---|---|---|---|
| skill-reference-verify Write, no skill refs | 18 | 17 | 8 unchanged |
| secret-pattern-detection clean Write | 10 | 8 | 4 unchanged |

Secret-pattern absolute counts are with `CLAUDE_PLUGIN_ROOT` set (Claude
Code always sets it). Without that env the leftover `PLUGIN_ROOT=$(cd …
&& pwd)` fallback adds one clone on both sides (11→9); the drop of 2 is
the same.

**CI scanners** (successful execve, exclude ENOENT; earlier commits on
this PR):

| Gate | origin/main or prior HEAD | HEAD |
|---|---|---|
| purged-em-dashes `--list` | 478 | 9 |
| cross-plugin-source-drift `--check` | 181 | 4 |
| discriminating-test-skips | 316 (awk 312) | 5 (awk 1) |
| silent-skips | 120 (awk 118) | 4 (awk 2) |
| hook-exec-form `--check` | 196 execve, jq 96, tr 96, clones 292 | 7
execve, jq 2, tr 0, clones 9 |

`--list` / discover stdout for the two listing gates is byte-identical
to origin/main.

**Local `scripts/affected-tests.sh --run`:** 153 shell suites passed or
were skipped; 14 NOT RUN python/mjs ecosystems (exit 3, expected on this
runner). No `FAIL`. Including: `lib/hook-utils.test.sh` PASS=323;
bash-format PASS=54; eol-normalizer PASS=54; markdown-format PASS=174;
powershell-format PASS=17; cli-flag-verify PASS=92; hardcoded-path-check
PASS=118; secret-pattern-detection PASS=86; skill-reference-verify
PASS=140; stale-path-verify PASS=108. ruff/biome/go/actionlint
behavioral cases skipped here (binaries absent); skip-path and source
pins still ran. `session-event-log.test.sh` PASS=53 isolated under the
fan-out.

**CI on `cdb93f61`:** lint, hook-utils, test-linux (0–3), test-windows,
changes, ci-status, and managed-files-guard all succeeded.
https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676378
https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676488

## Related

Refs #3838, #3732, #3678, #1979, #3488, #2891. Same leftover-fork class
as open PRs #3849 / #3851 / #3869 / #3873 / #3872 / #3871 / #3870 /
#3779 / #3880 / #3886 (those stay hook-specific). N/A for a dedicated
issue.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-fdfdc962-be1b-4c9c-9833-3aec57852330?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-fdfdc962-be1b-4c9c-9833-3aec57852330&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
)

Closes #3528

## Summary

A guardrails hook that died between its first line and its own final
`exit` (an unbound variable under `set -u`, a helper that no longer
existed, a `source hook-utils.sh` that failed) ended with whatever
status the last command had, usually 1, and wrote nothing of its own.
Claude Code treats any status other than 0 and 2 as a non-blocking
error: the tool call proceeds and the transcript records "exit 1,
stderr: (none)". For a PreToolUse blocking guard that is enforcement
silently skipped, which is what the issue recorded for
`block-windows-drive-tmp` and `cli-flag-verify`. Every registered
guardrails hook and the dispatcher now install a shared abort boundary
that turns that outcome into a deliberate, visible one. Enforcement is
unchanged: every hook declares fail-open, which is the status quo on
abort; the notice is the only delta.

## Fix

- New `plugins/guardrails/hooks/abort-boundary.sh` (a sibling library in
`hooks/`, not `lib/hook-utils.sh`, which is contended by #3740 and #3838
and syncs to 17 copies). `guard::abort_boundary <name> <event>
<open|closed> <chosen-status>...` installs an EXIT trap that passes the
chosen statuses through untouched and turns any other into one stderr
line naming the hook and the status plus a `systemMessage` /
`additionalContext` document, then exits with the declared posture
(`open`: exit 0, document on stdout; `closed`: exit 2, notice on
stderr). The handler clears its own trap first, is builtins-only, and
calls nothing from `hook-utils.sh`, so it still reports the case where
that library failed to load. Stream choice was checked against the
installed CLI (2.1.258: "Failed with non-blocking status code" on
non-0/2, `hook_non_blocking_error`) and the repo's hook-observability
doctrine: on exit 0 stderr reaches only the debug log and the stdout
document is what the operator and agent see, so the notice goes on both.
- All 14 registered hooks: `source abort-boundary.sh`, a per-hook
posture comment beside the install line, and `source hook-utils.sh ||
exit 70` (a non-chosen status, so a failed library load is reported
instead of limping through undefined `hook::` calls). Blocking guards
choose `0 2`; advisory hooks choose `0`. `block-hook-bypass`'s bespoke
handler (#3130 F5) is replaced by the shared one.
- `run-guards.sh`: installs the same boundary around its own prologue
and merge (an abort there skipped every guard of the event), primes
`.hook_event_name` in its existing jq call so its notice names the
event, and releases the trap before its deliberate aggregated `exit
"$RC"` so a non-block status a guard returns still surfaces exactly as
before. The event is looked up by NAME among the primed filters (review
fix pass): the first cut read `RUN_GUARDS_VALUES[9]`, the field's
position in `PRIME_FILTERS`, so a filter added ahead of it would have
put the neighbouring value into every dispatcher notice.
- One EXIT trap per shell (review fix pass): bash holds a single EXIT
trap, so a guard that later runs its own `trap ... EXIT` replaces the
boundary and is back to the bare rc 1 this PR removes (verified: with
`trap ':' EXIT` after the install line a forced abort is rc 1, empty
stdout, bash's own line only; without it, exit 0 plus the document). The
suite now fails on any `trap` naming EXIT in a registered hook or in a
library it sources (`hook-utils.sh`, the `--lib` paths). The library
header states the supported way to chain exit-time work: a slot in the
library that the handler calls before it decides, added with a suite
case in the same change, never a second trap. No hook needs one today.
- Version 0.32.16. Main is at 0.32.14; #3886 was renumbered to 0.32.15
after this branch opened (this body previously said it held 0.32.14,
which was stale). Determined by reading
`plugins/guardrails/.claude-plugin/plugin.json` at current `origin/main`
and at the head of all 31 open PRs: #3886 is the only head above main,
so 0.32.16 is the first free number. #3872 (0.32.13), #3849 and #3869
(0.32.12) and the rest sit at or below main and will need re-bumps of
their own; whichever guardrails PR lands after this one takes 0.32.17. A
CHANGELOG conflict at merge time is still expected.

Exit-status contract mapped before the change (unchanged by it): guards
exit 0 allow (including `hook::check_enabled` and `hook::require_jq`)
and 2 deny (including `hook::require_jq_blocking`), nothing else on
purpose (`cli-flag-verify`'s `exit 1`/`exit 2` strings are comment
prose); `hook::buffer_stdin_to` returns 0 payload, 1 empty, 2
stalled/malformed, and the dispatcher exits 0 on rc 1 before any guard
is sourced; the dispatcher exits 2 if any guard exited 2, else the
highest non-zero guard status, else 0; guards run in `$( )` subshells,
which do not inherit the parent's EXIT trap (verified empirically on
bash 5.2, so the two boundaries never fire for one exit).

## Verification

- `hooks/abort-boundary.test.sh`, 232 assertions, green (mode 100755,
like its 17 siblings). It reads the registered set from `hooks.json` (15
scripts, never enumerated), asserts each sources the library and
installs under its own name with a posture literal, asserts no
registered hook and no sourced library (`hooks/hook-utils.sh`,
`lib/powershell/ps-command.sh`) installs an EXIT trap of its own,
asserts `PRIME_FILTERS` still carries `.hook_event_name`, forces a `set
-u` abort in every registered guard on a plugin copy (exit 0, one stderr
line naming the guard and `rc=1`, a JSON document whose `hookEventName`
is an event the guard is registered for), forces the abort mid-hook
through a failing shared helper on `block-windows-drive-tmp` (on a
`D:/tmp` write the shipped guard still denies) and `cli-flag-verify`,
checks the dispatched path keeps a sibling's deny (exit 2) beside an
aborting guard and merges two notices into one document, checks the
dispatcher's own boundary before and after priming plus its release,
checks a prime filter inserted ahead of `.hook_event_name` on a copy
still yields a notice naming `PreToolUse`, checks chosen statuses and
the kill switch pass through silently, and checks the handler ends the
process once when its own body fails.
- Non-vacuity, one mutation at a time, suite re-run, reverted: handler
ignores the chosen-status check (137 failures); `stale-path-verify`
loses its install line (2); open posture exits 1 (27); dispatcher
forgets its release (2); `block-hook-bypass` loses `|| exit 70` (1);
closed posture exits 0 (1). Fix pass: the new suite against the previous
positional `run-guards.sh` fails exactly the new shift assertion
("expected 'PreToolUse', got ''", 231/1); a copy of `block-no-verify`
given `trap 'rm -f "$TMP_SCRATCH"' EXIT` after its install line fails
the new trap assertion by file and line. One mutation is NOT caught:
removing `trap - EXIT` from the handler (0 failures), because bash
itself never re-runs an EXIT trap. The review confirmed this
independently: that line survived 15 attack shapes, so on bash it is
redundant defense in depth rather than load-bearing; the re-entry test
proves the observable property (one notice, terminates), not that line.
- Review confirmation of the enforcement claim: the reviewer
independently ran 234 cases (22 payloads standalone and dispatched, plus
6 forced-abort modes: `hook-utils.sh` missing, `hook-utils.sh`
unparseable, an injected `set -u` abort in every guard, a missing
`hook::` helper, jq off PATH, stdin closed) and found no ALLOW/DENY flip
anywhere.
- A/B against a pristine `git archive origin/main` export of the plugin.
(a) Suite level: all 17 existing guardrails suites produce identical
`ok:`/`FAIL:` assertion lines on both trees (3,206 assertions on the
pristine side), with two explained differences: `require-jq-posture`
lists the new library in its census (now excluded, like
`hook-utils.sh`), and `block-hook-bypass`'s "symlink: genuine temp
write" case fails on EITHER tree when the suite runs from under `/tmp`
and passes on either tree from outside it (location artefact, verified
both ways). (b) Explicit corpus: 71 payloads (36 Bash, 5 PowerShell, 24
Write/Edit, 6 PostToolUse, 1 Workflow, plus non-JSON and empty stdin)
run standalone through every guard and through the dispatcher as
`hooks.json` registers it, on both trees: exit code, stdout, and stderr
byte-identical across all 71 (branch census: 38 deny, 32 allow at the
dispatcher, 1 Workflow-only). No DENY-to-ALLOW flip anywhere.
- `bash scripts/affected-tests.sh --run --shard N/4`, N in 0..3, after
the fix pass: 153 shell suites PASS (every guardrails suite among them,
`abort-boundary.test.sh` at 232/0 under the runner), 1 FAIL:
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(process-budget trace probe, 2 of 24 cases), reproduced identically on a
clean `git archive origin/main` export, so pre-existing and unrelated.
Shards 0, 2, 3 rc 3; shard 1 rc 1 is that one suite. 14 suites in other
ecosystems reported NOT RUN by the shell runner.
- Parity, all four modes green against current `origin/main`: `--check`,
`--check-order`, `--check-bump origin/main`, `--check-preserved
origin/main` (159 headings preserved).
- ShellCheck 0.11.0 clean on every changed shell file (`-x`, repo
`.shellcheckrc`); `check-silent-skips.sh` clean;
`check-shell-portability.sh origin/main` clean (22 files);
`sync-hook-utils.sh --check`: all 17 copies match, `lib/hook-utils.sh`
byte-identical to main; no em dash in any added line; no here-string
introduced in production code.
- Budget: `strace -f -e trace=clone,clone3,fork,vfork,execve` of the
whole Bash dispatcher on a benign command, three repeats per side:
creations 23 -> 23, execve 2 -> 2. Wall p50 moved about 2 to 5 ms on
this host (recorded in the README budget section). The name lookup is a
loop over ten array entries with builtins, no process. Not measured on
Windows.

Known consequence, stated plainly (verified against the 2.1.258 binary
during review): exit 0 plus a valid JSON document IS read
(`systemMessage` as a meta message, `additionalContext` accepted for
`hookEventName: "PreToolUse"`), while stderr is never read on exit 0, so
the stdout document was the correct channel. The trade-off is that
`plugins/claude-ops/hooks/hook-failure-audit.sh:146` greps only
`hook_non_blocking_error`, so these aborts leave NO hook failure record
for that detector, and the bash error line that pristine surfaced
through the exit-1 warning is now debug-log only (`claude --debug`). The
notice in the transcript and the agent's context replaces the failure
record; nothing else does. The issue's second verification item (that
detector reporting zero records over a live session with a forced abort)
was therefore not run and would hold by construction rather than by
observation. The dispatcher's notice names the event only when the
payload carries `hook_event_name` (Claude Code's payloads do; a payload
without it gets `systemMessage` only). Adjacent and untouched: #3861
(guard fail-open when no interpreter resolves) is a different path;
nothing here changes interpreter resolution.

## Related

- Refs #3713 (same defect class on four hooks in other plugins; not
reached by a guardrails-local library)
- Refs #3507, #3130 F5 (the `block-hook-bypass` handler this
generalizes)
- Refs #3740, #3838 (open `lib/hook-utils.sh` PRs; deliberately not
touched)
- Refs #3886 (0.32.15, the number below this one), #3849, #3869, #3872
(open guardrails PRs at or below main; CHANGELOG conflict expected)
- Refs #3861 (adjacent fail-open path, needs-human, not part of this
change)
- Refs #3508, #3512, #3517 (environmental trigger and latency work, out
of scope)
-
docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md
(killed PreToolUse hook yields no decision)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@cursor
cursor Bot force-pushed the claude/3513-hook-bypass-perf branch from 2f014ea to 249c5af Compare September 7, 2026 13:50
cursor Bot pushed a commit that referenced this pull request Sep 7, 2026
…it (#3529)

On every Bash and PowerShell call the guard created three processes of
its own and executed none, so the PATH-shim census read it as free. One
of the three was this file's: an eager SUBJECT=$(hook::extract_bash_subject)
at file scope feeding a telemetry envelope that is off by default and
that the verdict never reads. It is now derived inside emit_tel, behind
the start-stamp and sink gates.

Off the common path, three more sites paid a fork for a string:

- the hash-width probe $(git ... rev-parse --show-object-format 2>&1)
  cost two creations for one exec, because bash execs a substitution's
  body in its own subshell only when that body carries no redirection.
  The 2>&1 has to stay inside (git's stderr is the diagnostic the block
  message quotes), so the body is now `exec git ...`;
- a `!` alias reparse spent one $(printf '%q') per trailing argument,
  now printf -v;
- its $(effective_dir ...) around a builtins-only function is now
  effective_dir_to, a nameref assignment.

Kernel census (strace -f -e trace=clone,clone3,fork,vfork,execve, guard
share = dispatched minus a no-op guard dispatched the same way): benign
and blocked Bash calls 3 -> 2 creations, execve 0 -> 0; lease with a
full-width oid 5 -> 3, execve 1 -> 1; `!` alias with three trailing
args 8 -> 3; whole Bash dispatcher 35 -> 34, execve 3 -> 3. The two
creations left are $(hook::buffer_stdin) and the shared parser's
< <(printf ...), both lib/hook-utils.sh work (#3740, #3838), and the
PowerShell lane's fourteen live in lib/powershell/ps-command.sh.

Verdicts are unchanged: 190 paired runs against origin/main (87 Bash
and 8 PowerShell commands, standalone and dispatched, three repository
shapes, CR/BOM/zero-width/U+2028 variants) agree on exit code and full
stderr; the contract suite passes at 492 and now pins each site by the
same instrument, each pin checked by reverting its change alone.

Guardrails 0.32.13 (0.32.11 and 0.32.12 are taken by #3849 and #3869).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…l-commit (#3514) (#3886)

Closes #3514

## Summary

`block-noncanonical-commit.sh` created processes that never exec'd, so
every exec-counting census (PATH shim, `run-guards.test.sh`, an xtrace
count) read it as free while a Windows host paid a full process creation
for each. This PR removes the forks that belong to this file: the eager
telemetry subject at file scope on every Bash and PowerShell call, the
two command substitutions around builtins-only functions on the blocked
multi-line commit path, one `$(printf '%q')` per trailing argument on a
`!` alias reparse, and the `$(cd … && pwd)` plugin-root probe on the
PowerShell lane. Verdicts are unchanged.

The branch merges `main` at `1b681862` (#3878), which replaced the
shared parser's `< <(printf …)` fork in `lib/hook-utils.sh` with a
`${cmd:i:1}` walk. With that gone, this guard's own share on a benign
Bash call is now **0 creations / 0 execve**, and every figure below is
measured against that main. Guardrails is bumped to 0.32.15: `main`
carries 0.32.14 (#3878), and the open PRs #3849, #3869 and #3872 hold
0.32.12 and 0.32.13.

Scope is the one hook this issue names. `lib/hook-utils.sh` and its
synced copies are untouched by this PR (the guardrails copy is
byte-identical to `main`'s), as are the sibling guards (#3517, #3518,
#3519, #3521).

## Fix

In `plugins/guardrails/hooks/block-noncanonical-commit.sh`:

- `SUBJECT=$(hook::extract_bash_subject …)` moves from file scope into
`emit_tel`, behind the start-stamp and `hook::telemetry_enabled` gates.
Only the envelope reads it, and the envelope is off by default. Same
shape as `block-windows-drive-tmp.sh` and the sibling PRs #3869 and
#3872.
- `effective_dir` becomes `effective_dir_to <var> …` (nameref
assignment) at its three call sites; `explicit_global` /
`explicit_git_dir` become `explicit_global_to` / `explicit_git_dir_to`.
Both were `$(…)` around builtins-only bodies on the blocked path.
- The two `!` alias reparse loops quote trailing arguments with `printf
-v` instead of `$(printf '%q' …)`.
- The PowerShell lane sets
`PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$_HOOK_SELF/..}"`; `source` resolves
that path to the same file the canonicalised spelling named, and nothing
else reads `PLUGIN_ROOT`. One tradeoff, recorded in a comment: the
kernel resolves `..` physically where `cd` resolved it logically, so a
`hooks/` directory that is itself a symlink out of the plugin root needs
`CLAUDE_PLUGIN_ROOT` set. Spaces and relative invocation are unaffected.

Not changed, deliberately: `repo_git_probe`'s `$(git … 2>/dev/null;
printf 'x')` still costs two creations for one exec. The compound body
exists to keep a trailing newline in a repository path byte-exact
(documented in the function), and neither hoisting the redirect nor
`exec git` preserves that, so it stays.

No matcher predicate was added ahead of the shared parser. A safe text
pre-filter is constructible: the parser only removes or decodes
characters, and decoding requires `$'`, so a predicate that admits any
command containing `$'` or a `g…i…t` run with only quote, backslash and
newline characters between the letters must hold for every word the
parser can turn into `git` (the review verified zero fail-open across 32
DENY payloads, including `g''it`, `$'\x67it'`, `"git"`, `\git`, `g"i"t`,
`g<LF>it`, `bash -c` / `-lc` / `sh -c` wrappers and an inline `!`
alias). What it would buy is small: it skips only commands with no
`git`-shaped substring at all, and the single fork it would have saved
on those was the library parser's, which `main` has now removed. So
criterion 3 of #3514 is moot rather than impossible, and this PR adds no
predicate.

## Verification

Everything in this section except the wall-clock paragraph was re-run
**after** the merge of `main` at `1b681862`, on the merged tree at
`5d3102b1`. The two arms are `git archive` exports: pristine =
`origin/main` at `1b681862`, modified = `5d3102b1`. A content-hash
comparison of the two exports (3767 files each) shows exactly five files
differing, all under `plugins/guardrails/`:
`.claude-plugin/plugin.json`, `CHANGELOG.md`, `README.md`,
`hooks/block-noncanonical-commit.sh` and
`hooks/block-noncanonical-commit.test.sh`. `lib/hook-utils.sh`, the
guardrails `hooks/hook-utils.sh`, `hooks/run-guards.sh` and
`lib/powershell/ps-command.sh` are byte-identical across the arms, and
the pristine guard is byte-identical to the `1b681862` blob.

**Kernel census** (`strace -f -e trace=clone,clone3,fork,vfork,execve`,
dispatched through `run-guards.sh`, guard share = count minus a no-op
guard dispatched the same way, this repository as cwd,
`HOOK_TELEMETRY_SINK` unset, `CLAUDE_PROJECT_DIR` empty, three repeats
each, identical every time; re-run post-merge on the two exports above,
every figure reproduced). Against the pre-#3878 `main` (`c0fba152`)
every creation count read one higher on both sides, the parser's.

| Payload | creations before → after | execve before → after |
|---|---|---|
| Bash `git status --short` (benign) | 1 → 0 | 0 → 0 |
| Bash `git commit -m 'feat: x'` (single-line, allowed) | 1 → 0 | 0 → 0
|
| Bash multi-line `-m` (blocked, rc 2) | 5 → 2 | 1 → 1 |
| Bash `git wibble` (persisted-alias probe) | 6 → 4 | 2 → 2 |
| Bash inline `!` alias to multi-line `-m` (blocked) | 10 → 6 | 3 → 3 |
| PowerShell `git status` | 14 → 12 | 3 → 3 |
| Whole eight-guard Bash matcher, `git status --short` (absolute) | 22 →
21 | 2 → 2 |

The execve column is unchanged everywhere, which is what makes this
latency rather than removed work. A benign call now creates nothing in
this guard; the remaining PowerShell creations are in
`lib/powershell/ps-command.sh`.

**Wall clock** (measured before the merge, against `c0fba152`, not
re-run), Linux, `bash -c :` floor p50 3.0 ms / p95 7.2 ms, n=20 after 2
warmup, sides interleaved, guard alone under the dispatcher: benign p50
23.8 → 21.2 ms (p95 25.9 → 30.9 ms, noise at this scale); blocked p50
32.2 → 29.9 ms (p95 75.8 → 44.7 ms). The milliseconds are context; the
process counts are the host-independent figure.

**A/B differential** (re-run post-merge on the two exports above, in a
fresh harness): 96 payloads (85 Bash, 9 PowerShell, 2 Write-tool) × 2
modes (standalone `bash <guard>` and dispatched `run-guards.sh --lib
lib/powershell/ps-command.sh <guard>`) = 192 paired runs, plus 13
payloads × 2 modes = 26 paired runs under a PATH that carries no `git`
at all (`command -v git` confirmed empty under that PATH; the blocked
payloads in those runs still produce the full 452-byte stderr message,
429 bytes on the PowerShell lane, on both arms, so stderr is genuinely
compared there rather than trivially empty) = **218 paired runs, 218
identical on exit code, stdout and stderr (byte compare with `cmp`)**.
Verdicts: 50 deny / 46 allow per arm, identical on both arms in both
modes; no payload the pristine arm denied is allowed by the modified
arm. The corpus covers every `-m` spelling the guard matches (`-m`,
`-am`, `-m<attached>`, `-qm`, `--message=`, `--message`, `--mess=`,
`--m`, `$'…'`), the exempt forms (`--amend`, `--fixup`, `-F <path>`, `-F
-` heredoc, repeated single-line `-m`, bare `git commit`, an in-progress
merge with `MERGE_HEAD` via cwd and via `--git-dir=`), case variants
(`GIT`, `Git`), `/usr/bin/git`, `git.exe`, `g''it`, `$'\x67it'`,
`"git"`, `\git`, `g"i"t`, `git` split across a backslash-newline, `bash
-c` / `bash -lc` / `sh -c` wrappers, `env`, `NAME=value`, `sudo` and
`sudo FOO=1` prefixes, `cd sub && …`, `;`, `|`, `&&`, `||`, bare
newline, CRLF, U+2028 in the message, a leading BOM, zero-width space
inside `git` and inside the message, near-miss spellings that must not
be caught (`gitt`, `mygit`, `git-commit`, a quoted string mention, a
comment), `eval`, variable indirection, backtick, inline `-c alias.*`
(git alias, `!` shell alias, `!` alias with trailing arguments),
persisted aliases (multi-line commit, single-line, `!` shell, two-hop
chain to `commit -F -`, undefined `git wibble`), `-C .`, `-C sub`,
`--git-dir=`, repeated `--git-dir`, `--work-tree=`, `--trailer`,
`--no-verify`, literal `\n`, `$(printf …)` message, `nohup … &`, tab
separators, a `grep -m` false friend, a 70 KiB benign command, a 20 KiB
multi-line message, an empty command, a control character in the
message, an empty `cwd`, nine PowerShell forms (status, backtick-n, real
newline, here-string `-m`, here-string piped to `-F -`, `&` call
operator, `Invoke-Expression`, single-line, near-miss `gitt`), and two
Write-tool payloads. Additionally 11 telemetry envelopes captured
through a stub sink (`HOOK_TELEMETRY_SINK` set; the sink is
fire-and-forget, so the capture waits for its write) agree on `status`,
`tool`, `subject` and `form` between the two arms, across benign,
blocked, inline `!` alias, persisted-alias probe, empty-`cwd` and
PowerShell benign and blocked payloads; `subject` is the field the lazy
`SUBJECT` change touches. No payload is skipped by any new predicate,
because none was added.

**Contract suite** on the merged tree:
`block-noncanonical-commit.test.sh` PASS=227 FAIL=0 (216 on pristine
main, plus 11 new assertions). The new section pins, by the same strace
instrument: benign share exactly 0 creations / 0 execve; single-line
`-m` equal to benign; blocked multi-line `-m` exactly +2 creations / 1
execve over benign; trailing `!` alias arguments add zero creations. It
skips visibly where strace is absent.

**Mutation checks** (re-run post-merge on `5d3102b1`; apply, run the
suite, revert, worktree verified clean after each): restoring the eager
file-scope `SUBJECT` fails the benign pin (expected 0, got 1); putting
the explicit `--git-dir` back through a `$(…)` on the block path fails
the blocked delta (expected 2, got 3); restoring a `$(printf '%q')` per
trailing alias argument fails the trailing-args pin (expected 4, got 7,
three trailing arguments). Each mutant reads PASS=226 FAIL=1 with only
its named pin failing. The deltas do not depend on the library parser.
All three revert clean.

**Gates** on the merged tree (`5d3102b1`, re-run post-merge): `bash
scripts/affected-tests.sh --run` selected 7 shell suites
(`resolve-convention-pattern`, `block-convention-violation`,
`block-noncanonical-commit`, `flag-commit-pr-skill-bypass`,
`require-jq-posture`, `run-guards`, `commit-msg-convention`), "All 7
selected suites passed or were skipped", exit 0.
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
is not in that selection and fails two process-budget assertions on the
clean `1b681862` export as well, so it is pre-existing and unrelated.
`check-changelog-parity.sh` `--check`, `--check-order`, `--check-bump
origin/main`, `--check-preserved origin/main` all exit 0 against `main`
at `1b681862`. `sync-plugin-options-docs.py --check` up to date.
ShellCheck clean on the guard and its test. No em dash in any added
line.

**Unmet, stated plainly**: acceptance criterion 5, "measured on a
Windows host, under 2 s", is not met by this PR. Everything above was
measured on Linux. The substitute evidence is the host-independent
process-creation and execve census (the Windows cost is a per-spawn
multiplier on those counts) and Linux wall clock against a `bash -c :`
floor. Criterion 1's "at most 2 spawns" is met on the common path on
both counters (0 creations, 0 execve) once #3878 is in the base.

## Related

- Refs #3508 (parent: Windows process-creation tax)
- Refs #3878 (merged into this branch at `1b681862`; removed the shared
parser fork that held the benign figure at 1)
- Refs #3740, #3838 (`lib/hook-utils.sh` fork-free forms)
- Refs #3869, #3872, #3849 (sibling guardrails spawn PRs; same
`emit_tel` shape, and the version numbers they hold)
- Refs #1403 / #1385 (prior spawn-reduction art)
- Refs `docs/conventions/hook-budget/README.md` (budget accounting entry
added to the guardrails README)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---------

Co-authored-by: Kyle Sexton <ksextonclaude@outlook.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…3513)

On a benign Bash call the guard created seven processes and executed
none, so every exec census (PATH-shim spawn census, the dispatcher
test's dirname/sed pin, an xtrace command count) read it as free. Six
were in this file: an eager SUBJECT=$(hook::extract_bash_subject ...)
at file scope feeding a telemetry envelope that is off by default, a
$(strip_literals ...) around a builtin-only function, and four
`done < <(printf ...)` line loops (the literal strip and the three
per-segment scans). The seventh is $(hook::buffer_stdin), whose
fork-free form is lib/hook-utils.sh work (#3740, #3838) and is not
touched here.

After: the subject is derived inside emit_tel behind the start-stamp
and sink gates; the strip assigns through a nameref
(strip_literals_to); and one fork-free splitter (split_lines_to, a
sentinel-prefixed IFS split under set -f, so blank lines and runs of
newlines arrive exactly as read delivered them) feeds the strip and
fills NORMALIZED_SEGMENTS once as an array the three scans iterate.
return and continue 2 inside those loops reach the same scopes as
before, since neither loop shape ran its body in a subshell.

Kernel census, strace -f -e trace=clone,clone3,fork,vfork,execve on
the dispatched path, HOOK_TELEMETRY_SINK unset, guard share = count
minus a no-op guard dispatched the same way: benign git status
creations 7 -> 1, execve 0 -> 0; blocked echo > file creations
10 -> 5, execve 1 -> 1; whole Bash dispatcher 36 -> 30 creations,
execve 3 -> 3. Verdicts unchanged across 244 paired runs against
origin/main and the 602-case contract suite. The suite now pins the
benign share at exactly 1 with the same instrument; each of the four
changes reverted alone fails that pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
0.32.11 is already claimed by #3849 (issue #3511), whose branch bumps the
same manifest from 0.32.10; the two conflict on plugins/guardrails/CHANGELOG.md.
This entry takes 0.32.12 instead, in both the manifest and the changelog
heading. The entry body is unchanged. All four check-changelog-parity.sh
modes pass, including --check-order, which is the mode that catches a
doubled heading after a bad conflict resolution.

Two accuracy fixes alongside it. The CHANGELOG and the new README hook-budget
row both said "the 602-case contract suite passes"; with the pins this change
set adds the suite is 611 cases, which is what it reports and what the PR body
already claims. And the comment moved onto emit_tel carried two em dashes over
from the file-scope comment it replaced. .claude/rules/vendor-docs-are-not-style.md
bars them from this repo's instruction surfaces, and check-purged-em-dashes.sh
scans markdown only, so nothing caught them. They are parentheses now.

No guard logic changes: the kernel census is unmoved. Whole Bash dispatcher on
a benign payload still reads 36 creations at origin/main against 30 here,
3/3 repeats, execve flat at 3; the contract suite's exact pin on the guard's
benign share (1 creation, 0 execve) still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Merging main brought in #3838, which landed the fork-free
hook::buffer_stdin_to in lib/hook-utils.sh. This guard now calls it, so the
seventh process creation this PR documented as remaining is gone too.

The contract suite pinned the benign share at exactly 1 and its own comment
said the pin moves to 0 when that work lands. It has landed, so:

- the strace pin is now 0, and its comment records why;
- the changelog and README rows that named $(hook::buffer_stdin) as the
  remaining creation now say all seven are gone;
- the measured figures are corrected against the merged tree: benign guard
  share 7 -> 0 creations, blocked 10 -> 4, stable across three repeats
  under the same instrument the table documents.

The whole-dispatcher and wall-clock rows still describe the pre-#3838 base,
which the README now states rather than restating numbers not measured on
that host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPLatLkg4329L8eyfxhuMa
@cursor
cursor Bot force-pushed the claude/3513-hook-bypass-perf branch from 249c5af to 8be6483 Compare September 7, 2026 14:10
cursor Bot pushed a commit that referenced this pull request Sep 7, 2026
…it (#3529)

On every Bash and PowerShell call the guard created three processes of
its own and executed none, so the PATH-shim census read it as free. One
of the three was this file's: an eager SUBJECT=$(hook::extract_bash_subject)
at file scope feeding a telemetry envelope that is off by default and
that the verdict never reads. It is now derived inside emit_tel, behind
the start-stamp and sink gates.

Off the common path, three more sites paid a fork for a string:

- the hash-width probe $(git ... rev-parse --show-object-format 2>&1)
  cost two creations for one exec, because bash execs a substitution's
  body in its own subshell only when that body carries no redirection.
  The 2>&1 has to stay inside (git's stderr is the diagnostic the block
  message quotes), so the body is now `exec git ...`;
- a `!` alias reparse spent one $(printf '%q') per trailing argument,
  now printf -v;
- its $(effective_dir ...) around a builtins-only function is now
  effective_dir_to, a nameref assignment.

Kernel census (strace -f -e trace=clone,clone3,fork,vfork,execve, guard
share = dispatched minus a no-op guard dispatched the same way): benign
and blocked Bash calls 3 -> 2 creations, execve 0 -> 0; lease with a
full-width oid 5 -> 3, execve 1 -> 1; `!` alias with three trailing
args 8 -> 3; whole Bash dispatcher 35 -> 34, execve 3 -> 3. The two
creations left are $(hook::buffer_stdin) and the shared parser's
< <(printf ...), both lib/hook-utils.sh work (#3740, #3838), and the
PowerShell lane's fourteen live in lib/powershell/ps-command.sh.

Verdicts are unchanged: 190 paired runs against origin/main (87 Bash
and 8 PowerShell commands, standalone and dispatched, three repository
shapes, CR/BOM/zero-width/U+2028 variants) agree on exit code and full
stderr; the contract suite passes at 492 and now pins each site by the
same instrument, each pin checked by reverting its change alone.

Guardrails 0.32.13 (0.32.11 and 0.32.12 are taken by #3849 and #3869).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
@kyle-sexton
kyle-sexton enabled auto-merge (squash) September 7, 2026 15:25
@kyle-sexton
kyle-sexton merged commit dae157a into main Sep 7, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/3513-hook-bypass-perf branch September 7, 2026 15:26
cursor Bot pushed a commit that referenced this pull request Sep 7, 2026
…it (#3529)

On every Bash and PowerShell call the guard created three processes of
its own and executed none, so the PATH-shim census read it as free. One
of the three was this file's: an eager SUBJECT=$(hook::extract_bash_subject)
at file scope feeding a telemetry envelope that is off by default and
that the verdict never reads. It is now derived inside emit_tel, behind
the start-stamp and sink gates.

Off the common path, three more sites paid a fork for a string:

- the hash-width probe $(git ... rev-parse --show-object-format 2>&1)
  cost two creations for one exec, because bash execs a substitution's
  body in its own subshell only when that body carries no redirection.
  The 2>&1 has to stay inside (git's stderr is the diagnostic the block
  message quotes), so the body is now `exec git ...`;
- a `!` alias reparse spent one $(printf '%q') per trailing argument,
  now printf -v;
- its $(effective_dir ...) around a builtins-only function is now
  effective_dir_to, a nameref assignment.

Kernel census (strace -f -e trace=clone,clone3,fork,vfork,execve, guard
share = dispatched minus a no-op guard dispatched the same way): benign
and blocked Bash calls 3 -> 2 creations, execve 0 -> 0; lease with a
full-width oid 5 -> 3, execve 1 -> 1; `!` alias with three trailing
args 8 -> 3; whole Bash dispatcher 35 -> 34, execve 3 -> 3. The two
creations left are $(hook::buffer_stdin) and the shared parser's
< <(printf ...), both lib/hook-utils.sh work (#3740, #3838), and the
PowerShell lane's fourteen live in lib/powershell/ps-command.sh.

Verdicts are unchanged: 190 paired runs against origin/main (87 Bash
and 8 PowerShell commands, standalone and dispatched, three repository
shapes, CR/BOM/zero-width/U+2028 variants) agree on exit code and full
stderr; the contract suite passes at 492 and now pins each site by the
same instrument, each pin checked by reverting its change alone.

Guardrails 0.32.13 (0.32.11 and 0.32.12 are taken by #3849 and #3869).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…it (#3529) (#3872)

Closes #3529

## Summary

`block-dangerous-git.sh` is the guard with the highest external-command
call-site count in the marketplace, but on the dispatched path it
executes nothing: its cost is forks that never exec, which a PATH shim
cannot see. On every Bash and PowerShell call the guard created three
such processes of its own. This PR removes the one that was this file's
to remove on the common path, and three more on the alias and lease
paths, in-file only. `lib/hook-utils.sh`, its per-plugin copies, and
`run-guards.sh` are untouched.

## Fix

- **Common path (every Bash/PowerShell call):** the eager
`SUBJECT=$(hook::extract_bash_subject ...)` at file scope fed a
telemetry envelope that is off by default and that the verdict never
reads. It is now derived inside `emit_tel`, behind the start-stamp and
sink gates.
- **Lease path (`--force-with-lease=<ref>:<hex>`):** `out="$(git ...
rev-parse --show-object-format 2>&1)"` cost two creations for one exec,
because bash execs a substitution's body in the substitution's own
subshell only when the body carries no redirection of its own. The
`2>&1` cannot move onto an outer group here (git's stderr is the
diagnostic the block message quotes, and an outer `2>&1` would send it
to the hook's stdout), so the body is now `exec git ...`: the subshell
becomes git. A missing git still lands in the same `*` error branch,
with bash's own "not found" text captured, and the wording of that
captured text changes: see the stderr note under Verification.
- **`!` alias reparse:** one `$(printf '%q')` per trailing argument is
now `printf -v`; `$(effective_dir ...)` around a builtins-only function
is now `effective_dir_to`, a nameref assignment (the default base is
read before the nameref is written, so the caller's scratch variable
then assigns `HOOK_EFFECTIVE_BASE`).
- `$(dirname ...)` was already gone from this file before this PR
(`${BASH_SOURCE[0]%/*}`); the scout's note was stale. The PowerShell
lane's `$(cd "$_HOOK_SELF/.." && pwd)` is left alone: it runs only when
`CLAUDE_PLUGIN_ROOT` is unset, which Claude Code never leaves unset.
- Guardrails **0.32.13** (`main` is 0.32.10; #3849 takes 0.32.11 and
#3869 takes 0.32.12, re-verified against `origin/main` and the open PR
list immediately before opening this). README hook-budget accounting
entry added per hook-budget Rule 1.

## Verification

**Kernel census** (`strace -f -e trace=clone,clone3,fork,vfork,execve`;
guard share = `run-guards.sh block-dangerous-git.sh` minus a no-op guard
dispatched the same way; this repository as cwd, `HOOK_TELEMETRY_SINK`
unset, `CLAUDE_PROJECT_DIR` empty; three identical repeats). strace
rather than a PATH shim or xtrace because the subject is a fork that
never execs, which neither of those can see. execve reported separately:
it does not move anywhere, which is the evidence this is latency, not
removed work.

| Scenario | creations before → after | execve before → after |
|---|---|---|
| Guard share, benign `git status --short` / `echo hello` | 3 → 2 | 0 →
0 |
| Guard share, blocked `git push --force origin main` / `git reset
--hard` | 3 → 2 | 0 → 0 |
| Guard share, lease `--force-with-lease=main:<40-hex>` | 5 → 3 | 1 → 1
|
| Guard share, `!` alias no trailing args | 5 → 3 | 0 → 0 |
| Guard share, `!` alias with three trailing args | 8 → 3 | 0 → 0 |
| Guard share, `!` alias whose body carries a lease | 7 → 4 | 1 → 1 |
| Guard share, PowerShell `git status` | 15 → 14 | 3 → 3 |
| Standalone `bash block-dangerous-git.sh`, benign | 9 → 8 | 3 → 3 |
| Whole Bash dispatcher line from hooks.json, benign | 35 → 34 | 3 → 3 |

**Deny paths still deny.** A/B against a pristine `origin/main` export
on exit code and stderr: 190 paired runs, 0 differences. 87 Bash
commands (every form the guard matches: `--force`, `-f`, `+refspec`,
`--mirror`,
bare/`=ref`/`=ref:movable`/`=ref:<40-hex>`/`=ref:<64-hex>`/`--force-if-includes`/`--no-force-with-lease`/`--dry-run`
lease spellings, `reset --hard`/`--h`, `clean -f/-fd/-fdx/--force`,
`checkout .`/`:/`/`-f`/`--pathspec-from-file`/exclude-only, `restore .`,
`switch --discard-changes`; their near-miss safe variants `reset
--keep`, `clean -n`, `checkout -- file`, `restore --staged .`, `branch
-D`, `filter-branch`, quoted text; `!` and inline aliases, `bash -c`/`sh
-c`/`env`/`sudo` wrappers, multi-segment lines) and 8 PowerShell
commands, standalone and dispatched, across a SHA-1 repo, a SHA-256
repo, a non-repo and a nonexistent `-C` dir (probe-failure path).
Verdict tally on the new tree: 51 blocked / 36 allowed Bash, 5 / 3
PowerShell.

**One stderr string is not identical, and it is not covered by those 190
runs.** Every run above had `git` on `PATH`. On a `PATH` carrying no
`git` the lookup now fails inside the `exec` rather than around it, so
the diagnostic the block message quotes changes wording:

- before: `<hook>: line 341: git: command not found`
- after: `<hook>: line 363: exec: git: not found`

Measured directly on both trees, running the real hook against a `PATH`
shadow that excludes `git` and nothing else: both exit **2**, both emit
the same `BLOCKED: ... hash format could not be determined (...)` frame
and the same remedy line, and the probe's own status is **127** on both,
so the same `*` branch runs and the push is blocked either way. The
claim this section previously made, "full stderr", overstated that:
verdicts and stderr agree everywhere the 190 runs reached, and on the
git-absent probe the only thing that moves is the wording of a quoted
error message on a path that still denies. (Related, same mechanism: a
`git` on `PATH` that is present but not executable is rc **126** on both
trees, and the `exec` form appends a second `cannot execute: Permission
denied` line.) The 0.32.13 CHANGELOG entry and the README hook-budget
entry state the same qualification.

**Permissive-normalization check.** Field reads are not touched by this
PR. Probed anyway: a CR mid-token (`git push --for\rce origin main`) and
after the token are both blocked, identically on standalone and
dispatched paths and on both trees, so the CR strip already lives in the
library's field read rather than in the dispatcher cache, and for this
guard it acts in the restrictive direction. BOM-prefixed `git`, a
zero-width space glued to `--force`, and U+2028 glued to `--force` are
allowed on both trees (the bytes make the token something bash would
hand to git verbatim, and git rejects it); no divergence introduced.

**Contract suite.** `block-dangerous-git.test.sh` 479 → 492, all passing
(re-run at the tip of this branch: `PASS=492 FAIL=0`, strace pins
included). The 13 new assertions are strace-based pins: benign share
exactly 2 creations / 0 execve; blocked share equals benign; `!` alias
reparse is benign + 1 (the shared parser's re-entry); trailing alias
arguments add 0; lease probe is benign + 1 creation and exactly 1
execve; and a per-process check that the `git rev-parse
--show-object-format` exec's parent itself exec'd. Skips visibly where
strace is absent. **Non-vacuity proven by mutation**, each change
reverted alone: eager subject → benign pin fails (3); `exec` removed →
lease pins fail (4, `extra-fork`); `$(printf)` restored → trailing-args
pin fails (6); `$(effective_dir)` restored → alias pin fails (4).

**Gates.** `scripts/affected-tests.sh --run`: every selected suite
passes except
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(2 of 24, PS4 trace probe), which fails identically on a clean
`origin/main` export, so it is pre-existing and host-specific, not this
change. `check-changelog-parity.sh` `--check`, `--check-order`,
`--check-bump origin/main`, `--check-preserved origin/main`: all four
pass. `check-killswitch-hoist.sh` passes. `check-purged-em-dashes.sh`
passes. shellcheck and shfmt clean on both shell files;
markdownlint-cli2 clean on CHANGELOG and README.

**Acceptance criteria in #3529, stated plainly.** (1) Spawn count
before/after: reported above, by kernel trace, a superset of the
PATH-shim count the issue asked for. (2) "No more than 2 external
process spawns on the common path": this guard's own share is now 0
execs and 2 forks; the two forks are `$(hook::buffer_stdin)` and the
shared parser's `< <(printf ...)`, both `lib/hook-utils.sh` (#3740,
#3838) and outside this PR's fence, so that line is not closed from
inside this file. (3) "A non-`git` command exits before any spawn": this
guard spawns nothing of its own on any command; the dispatcher's two
`jq` execs are batch-wide since #3788 and not attributable to this
guard. (4) Behavioural tests pass and every form blocked before is still
blocked (190/190 A/B on verdict). (5) Wall-clock alongside
concurrent-process count: not reported. This Linux host's spawn floor is
under a millisecond, so a timing here would not transfer to the Windows
spawn tax the parent describes; the process count is the durable figure.

## Related

- Refs #3508 (parent). Its "per-field jq" diagnosis does not apply to
this guard: since #3788 the dispatcher buffers stdin and primes jq once
per batch, so no individual guard contributes a jq spawn; the remaining
cost here was forks that never exec.
- Precedent for the mechanism and the strace pin: #3779 (context-guard),
#3849 (guardrails 0.32.11), #3869 (guardrails 0.32.12), #3851, #3870,
#3871.
- Version-chain siblings: #3849, #3869 (entries kept verbatim; this PR
adds only 0.32.13).
- Out of fence, left for their owners: `$(hook::buffer_stdin)` fork-free
form in #3740 / #3838; the PowerShell lane's 14 creations live in
`lib/powershell/ps-command.sh`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.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.

perf(guardrails): block-hook-bypass.sh (performance) averages 75.1s and timed out 105x - reduce process spawns

3 participants