Skip to content

perf(claude-ops): halve hook-failure-audit spawns by moving redirects off substitutions - #3851

Merged
kyle-sexton merged 2 commits into
mainfrom
claude/3512-failure-audit-perf
Sep 7, 2026
Merged

perf(claude-ops): halve hook-failure-audit spawns by moving redirects off substitutions#3851
kyle-sexton merged 2 commits into
mainfrom
claude/3512-failure-audit-perf

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #3512

Summary

The Stop-event unsurfaced-failure detector (plugins/claude-ops/hooks/hook-failure-audit.sh) timed out 113 times in the #3508 window at a 21.9 s average, roughly 44x the 500 ms the hook-budget convention gives the whole always-on per-turn set.

The parent issue's stated cause is wrong for this hook. #3508 blames per-field jq forks needing a new shared helper. Shard #3520 (PR #3779) established that the real cost is redirection placement, and merged PR #3788 fixed 34 hook scripts across 17 plugins while touching lib/hook-utils.sh zero times. This shard applies the same diagnosis. No shared library is touched — the fence around lib/hook-utils.sh and its 17 synced copies (unmerged #3740, #3838) holds.

The mechanism: bash runs the command of a command substitution in the substitution's own subshell and skips the extra fork only when that command carries no redirection of its own. $(wc -c <file 2>/dev/null) is two processes for one byte count; $(cat -- file 2>/dev/null) is two for a file read; { V=$(cmd); } 2>/dev/null is one; $(<file) is zero. Those forks are invisible to bash -x, which reads command positions — which is why the campaign kept mis-attributing the cost to the exec count.

Fix

Six in-file sites, all in hook-failure-audit.sh:

Site Before After
Transcript size SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null) wc names the file, read drops the filename column and any padding, 2>/dev/null rides a single-command group
Pre-filter, under the cap read_window | grep -F …, where read_window was cat -- file 2>/dev/null { RECORDS=$(grep -F … -- "$TRANSCRIPT"); } 2>/dev/null — grep opens the transcript itself; the helper function was a second subshell on top of the substitution's own
Marker read $(cat -- "$MARKER" 2>/dev/null) $(<"$MARKER") — no subshell, no exec
Marker write jq … 2>/dev/null | tr -d '\r' >>"$MARKER" $(jq …) then in-shell ${VAR//$'\r'/} with a builtin printf append
Marker directory mkdir -p "$MARKER_DIR" 2>/dev/null every warned turn `[[ -d … ]]
Payload fields two hook::jq_field calls one hook::jq_fields call (the batching helper the library already ships, used by 10 other hooks)

The CHANGELOG entry originally said "Five sites" over that same list of six; it now says six.

Deliberately not changed, both documented in the file:

  • Over the tail cap, tail | sed '1d' | grep and its 2>/dev/null on tail are untouched. A pipeline element forks either way, so hoisting the redirect onto a group would newly silence sed and grep for no saving, and merging sed into grep would change the matching semantics.
  • printf '%s' "$RECORDS" | jq -cRs stays a pipeline rather than becoming a here-string, and the in-file comment now states the grounds as they actually stand. hook::jq_field in the shared library documents this hazard and refuses the here-string form for it: bash fills a here-string's pipe itself, so a payload at or above the pipe capacity can block before jq is exec'd. The trace behind that note (fix(hook-utils): read hook stdin in chunks so a large payload is not blocked #1587: 65536 bytes hung indefinitely while 65000 returned at once) comes from this repo's Windows Git Bash hosts. It does not reproduce on Linux bash 5.2 — re-checked here at 65535, 65536, 65537, 200 kB and 2 MB, each returning immediately, including under an unwritable TMPDIR. The call keeps the library's conservative form anyway rather than bet the hazard is Linux-only: the forgone saving is one fork on the warning path only, since a turn with no failure record exits before that line. Earlier revisions of this PR and of the comment asserted the deadlock as universal fact, which is more than is known.

Verification

Process counts, measured with strace -ff -qq -e trace=clone,clone3,fork,vfork,execve (-ff so no syscall line is split across an <unfinished>/<resumed> pair), successful execve only, the harness's own top-level bash <hook> exec excluded:

Path Creations before after execs before after
No failure recorded, under the tail cap (the common case) 18 9 6 4
No failure recorded, over the tail cap 19 12 7 6
A turn that warns 34 24 17 14

The remaining 9 creations on the common path are 4 execs (wc, grep, and two jq passes inside the synced hook-utils.sh) plus subshell forks inside that same fenced library. The execve drop is one batched jq and two removed helper processes — removed processes, not removed work: every input is still read and every record still classified.

No wall-clock figure is claimed. This Linux host says nothing about the Windows spawn tax the budget binds to, and on the #3508 host one process creation costs 180-2,841 ms (median 1,108 ms at 501 concurrent). The process count is the honest proxy and the README says so.

Behaviour is unchanged, proven, not asserted. This hook's whole job is surfacing failures that otherwise pass unnoticed, so a carelessly hoisted 2>/dev/null could silence exactly the diagnostic it exists to emit. Every hoisted group holds one command. The only stream now silenced that was not before is grep's own stderr, and cat's own 2>/dev/null already discarded that same stream on the same path. Pre- and post-change stdout, stderr, exit codes and marker-file contents were compared byte for byte across ten scenarios — first warn, dedup, a new failing registration re-warning while the already-warned one stays muted, all three classification branches (launch / ambiguous / completed), the tail cap, a clean transcript, a missing transcript, the kill switch, and no data directory. Identical.

Budget test, mutation-checked. hook-failure-audit.test.sh gains an strace-based assertion on both the creation and exec ceilings, skipping cleanly where strace is unavailable or not permitted. Non-vacuity was demonstrated, not assumed: moving either silenced redirect back inside its substitution — SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null), or RECORDS=$(grep -F … -- "$TRANSCRIPT" 2>/dev/null) — pushes creations from 9 to 10 with no change to the exec count, leaves all 93 behavioural assertions green, and fails the budget assertion. That is precisely the fork xtrace cannot see.

Gates, all foreground:

Gate Result
scripts/affected-tests.sh --run exit 0, both selected suites pass (hook-failure-audit.test.sh 94/94, audit-session-id.test.sh 27/27)
shellcheck -x on both changed shell files clean
shfmt -d -i 2 -ci clean
scripts/check-changelog-parity.sh --check / --check-order / --check-bump origin/main / --check-preserved origin/main all four pass
markdownlint-cli2 on the changed markdown clean
scripts/check-purged-em-dashes.sh pass
scripts/check-shell-portability.sh origin/main pass
scripts/check-silent-skips.sh, check-killswitch-hoist.sh, check-hook-exec-form.sh, check-hook-wiring-liveness.sh, check-cross-plugin-source-drift.sh, validate-plugins.sh pass

The wording-only follow-up commit re-ran the test suites, all four parity modes, shellcheck -x, shfmt -d -i 2 -ci and markdownlint-cli2: unchanged, including the 9-creation / 4-exec budget assertions, which is the expected result for a comment-and-prose change.

test_save_point.py::test_new_origin_falls_back_to_directory_name is pre-existing and not in this diff's selection.

Acceptance criteria not fully met

Two of the issue's criteria are not satisfied, and no amount of in-file work would satisfy them:

A third is met in a different form than specified: the issue asks for a PATH shim spawn census. This uses strace instead, which is strictly stronger for this defect — a PATH shim sees only programs that are exec'd, and the whole cost here is subshell forks that never exec anything.

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:18
@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:21:46.744730Z b8ae268 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.

Copy link
Copy Markdown
Contributor Author

Independent review and the corrections it produced — flipped to ready

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

Verified independently, not read

Check Result
mkdir TOCTOU Safe. The gate is [[ -d ]] || mkdir -p … 2>/dev/null, so a race loser still runs the idempotent -p form. 200 concurrent fresh-dir invocations, 0 failures; a failure would only leave MARKER empty (re-warn path), never break the hook
Detection unchanged 13 scenarios × 4 artefacts (stdout, stderr, rc, marker bytes) = 64 comparisons, 0 diffs against origin/main — including tail cap, missing transcript, kill switch, no data dir, whitespace transcript path, pre-existing CRLF marker
wc naming vs redirect Equivalent on whitespace path, newline-in-name, and missing file
$(<file) vs $(cat) Identical on empty, absent, trailing-newline, CRLF, and unreadable (tested as uid 65534)
grep window Byte-identical under cap; boundary probes at cap−1/cap/cap+1 and mid-line cuts select the same lines
execve drops accounted common 6→4 = cat + one batched jq; warn 17→14 = cat, tr, one jq. grep, wc, sed, tail, find unchanged — forks elided, no work removed
Mutation Reproduces: either redirect back inside its substitution gives creations 9→10, execs 4→4, failing only the ceiling
Fences and #3769 Clean; all 17 synced copies byte-identical to lib, no SESSION_ID line in the diff

One divergence found and it is an improvement: jq_fields now keeps "0" for a non-string session_id where the old code fell to no-session because gsub on a number failed. Unreachable with real UUID payloads.

The correction worth recording

The code comment asserted as universal fact that a here-string at pipe capacity deadlocks before jq execs — the stated reason for leaving a printf | jq fork in place. The reviewer could not reproduce it on Linux bash 5.2 at 65535, 65536, 65537 bytes, 200 kB or 2 MB, even with an unwritable TMPDIR.

Traced to source: the claim originates in dfda6ec3 (#1587), whose trace (65536 hung indefinitely, 65000 returned at once) was measured entirely on Git Bash. The shared library's own comment says only "on this platform". The comment now says exactly that — the hazard is documented and reproduced in that context, does not reproduce on Linux bash 5.2, and the pipeline is kept because the forgone saving is one fork on the warning path only, not because the hazard is universal.

The decision was right; the justification was overstated. A comment asserting an unverified platform-specific hazard as universal outlives the PR and can stop a future worker from taking a real saving.

Also fixed: CHANGELOG.md:16 said "Five sites" over a list of six.

Not fully met, stated plainly

Two acceptance criteria are not met and the body says so under its own heading: the hook cannot exit before any spawn, because reading the transcript is how it learns there is nothing to report (1 wc + 1 grep + the fenced library's parse is the floor); and the 500 ms parallel-wall figure is cross-plugin and Windows-only, recorded in the README as owed. The spawn census uses strace rather than the issue's PATH shim, which is strictly stronger — a shim cannot see forks that never exec.

Gates: affected-tests.sh --run exit 0 (94/94 and 27/27, budget assertions still 9 creations / 4 execs), shellcheck, shfmt -d -i 2 -ci, four changelog-parity modes, silent-skips, killswitch-hoist, hook-exec-form, hook-wiring-liveness, em-dash purge — all clean.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 6m 21s —— View job


Code review — /review:code-review

  • Gather context (PR body, diff, changed files)
  • Run /review:code-review per instructions
  • Post inline findings (if any)
  • Post final summary

Skip gate: PR is open, not draft, and the change has real review surface (five files, the core logic in hook-failure-audit.sh), so a full review applies. No prior successful review from this lane exists on this head.

Scope reviewed: git diff origin/main...HEAD (5 files: plugin.json, CHANGELOG.md, README.md, hook-failure-audit.sh, hook-failure-audit.test.sh). Security concerns are out of scope for this lane.

What I checked line by line in hook-failure-audit.sh:

  • hook::jq_fields batching (lines 71–79): correctly handles the documented difference from hook::jq_field — an absent field arrives as "" with rc 0 rather than a non-zero return, and the new code spells out [[ -n "$TRANSCRIPT" ... ]] / [[ -n "$SESSION" ]] explicitly instead of riding on ||. This is a real (and correctly handled) semantic difference from the two-call version, not a copy-paste risk.
  • SIZE=""; { read -r SIZE _ < <(wc -c -- "$TRANSCRIPT"); } 2>/dev/null (lines 97–99): the 2>/dev/null on the enclosing group redirects the current shell's fd 2 before the process substitution forks, so the child wc inherits the already-redirected fd — the stderr silencing is preserved. Failure (missing/unreadable file) leaves SIZE empty via read's EOF, caught by the same [[ -n "$SIZE" ]] || exit 0 the original had.
  • Pre-filter split into over-cap / under-cap branches (lines 164–173): under-cap now has grep open the transcript directly instead of cat | grep; over-cap pipeline is left untouched, matching the stated rationale (a pipeline element forks either way, so hoisting there buys nothing).
  • Marker read { WARNED=$(<"$MARKER"); } 2>/dev/null (line 230): $(<file) is bash's zero-fork file-read form; behavior on a missing/unreadable file matches the "any bookkeeping failure leaves WARNED empty" contract already documented above it.
  • Marker write (lines 314–317): FINGERPRINTS=$(jq ...) strips only the trailing newline; printf '%s\n' puts exactly one back, and jq's internal newlines between multiple fingerprint lines survive the round-trip through command substitution, so multi-hookName output still lands one-fingerprint-per-line in the marker file — same on-disk shape as the old tr-piped version.
  • mkdir -p guarded by [[ -d "$MARKER_DIR" ]] || first (line 224): equivalent — an existing-but-unwritable directory fell through the old mkdir -p the same way it falls through -d now, and still fails at the write below.
  • No dangling references to the removed read_window helper anywhere in the plugin; hook-failure-audit.sh isn't synced into any other plugin, so no cross-plugin drift risk from this change.

Docs/version: README's relative link to ../../docs/conventions/hook-budget/README.md resolves; CHANGELOG's "six sites" list matches the six sites actually touched in the diff; plugin.json version bump (0.42.20 → 0.42.21) matches the new CHANGELOG heading.

Test file (hook-failure-audit.test.sh): the new strace-based budget block gates cleanly on strace availability/permission before asserting. One low-confidence observation, not raised as a finding: prog_count() (used for the exact grep/cat/wc assertions) matches any execve line for that program regardless of exit status, while the EXECS/PROGS diagnostics above it filter to = 0$ (successful) only. In practice bash resolves a full path before issuing a single execve, so this shouldn't produce spurious failed-attempt lines for standard coreutils — not blocking.

Not independently verified by me: this lane's sandbox restricts Bash to read-only git/gh inspection, so I read the diff and library code but did not execute scripts/affected-tests.sh, shellcheck, shfmt, the test suite, or strace myself. The PR body's process-creation counts (18→9, 6→4), the "93 behavioural assertions green," and the four changelog-parity/lint gates are author-claimed and unverified by me — noted here rather than restated as confirmed.

Result: no high-confidence correctness or maintainability findings. The six optimization sites are semantically equivalent to what they replace, including the edge cases (empty-field extraction, missing/unreadable files, multi-record marker writes), and the change stays scoped to the one file it targets plus its test and docs.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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


Security review (/review:security-review)

  • Gather context (PR diff via gh pr diff 3851, read changed files)
  • Skip-gate check
  • Review changed files for security-relevant surface
  • Report

Skip gate: PR is open; the diff does touch file I/O (hook-failure-audit.sh reads a transcript path and writes a marker file), so I did not stop at the skip gate — reviewed in full.

Scope reviewed: all 5 changed files — hook-failure-audit.sh, hook-failure-audit.test.sh, plugin.json, README.md, CHANGELOG.md.

Finding: none.

This is a pure performance refactor of hook-failure-audit.sh (redirection placement to cut subshell forks); no new inputs, no new file writes, no new external calls, and no behavior change was introduced beyond process count (the PR's own byte-for-byte marker/stdout/stderr comparison across ten scenarios backs this). Specifically checked:

  • Path handling: TRANSCRIPT and the marker path are unchanged in provenance from before this diff. SESSION is still passed through the same ${SESSION//[^A-Za-z0-9_-]/-} allowlist before being used in MARKER="$MARKER_DIR/${SESSION}" (hook-failure-audit.sh#L97), so no path-traversal surface opened up.
  • Flag/argument injection: the two sites that switched from redirection to a file-argument form both add -- before the filename — wc -c -- "$TRANSCRIPT" (L94) and grep -F '"hook_non_blocking_error"' -- "$TRANSCRIPT" (L166) — so a transcript path beginning with - still can't be parsed as an option.
  • Quoting: every substitution touched ($(<"$MARKER"), $(jq …), the read -r … < <(wc …) process substitution) stays double-quoted where expansion happens; no new word-splitting/glob surface.
  • Fail-open discipline preserved: every new guard ([[ -n "$SIZE" ]], [[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]], the mkdir -p/-d fallback) still exits 0 or falls through to "treat as new" on any bookkeeping failure, matching the hook's existing advisory/fail-open contract — no path where a read/parse failure would silently suppress a real warning.
  • No shared-library edit: lib/hook-utils.sh and its synced copies are untouched, consistent with the PR's stated fence.
  • Test file changes are strace-based budget assertions over fixed, test-controlled fixtures — no untrusted input, no injection surface.

No CRITICAL/IMPORTANT/SUGGESTION findings to report.
· branch claude/3512-failure-audit-perf

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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

@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.

Copy link
Copy Markdown
Contributor Author

Lane babysit-loop, instance ccr-session-babysit-loop-20260906, claimed at head b8ae268b (mergeable_state=dirty, ~1.7 h since last activity). Conflict resolved. Head moves b8ae268b -> 5abf82b3.

The conflict

One file, plugins/claude-ops/CHANGELOG.md. Both sides had written a different entry under the same heading 0.42.21 — a version collision, not a stack:

Resolved by the convention this cluster already uses: main's entry stays verbatim at the number main published it under, and this branch's entry is renumbered above it. This branch's entry becomes 0.42.22 and plugins/claude-ops/.claude-plugin/plugin.json moves to 0.42.22 to match.

hooks/hook-failure-audit.sh, the subject of this PR, merged cleanly. No README version reference needed renumbering.

Verification

  • All four changelog-parity modes pass, including --check-bump origin/main and --check-order across 91 changelogs.

  • scripts/affected-tests.sh --run: no failures.

  • hook-failure-audit.test.sh run explicitly (the merge diff is docs plus manifest, so the suite is not otherwise selected): PASS=94 FAIL=0, including the strace budget assertions this PR added:

    ok: budget: one grep pre-filter, reading the transcript directly (1)
    ok: budget: no cat feeding the pre-filter (0)
    ok: budget: one wc for the tail-cap decision (1)
    

Worth noting because the sibling shard #3869 did not survive this merge unchanged — main's buffer_stdin_to dropped its guard's last fork and invalidated an exact pin there. This PR's budget assertions still hold as written.

Lane action: advanced. Awaiting CI on 5abf82b3. 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

@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>
@cursor
cursor Bot force-pushed the claude/3512-failure-audit-perf branch from 5abf82b to 5bd0ac5 Compare September 7, 2026 13:42
kyle-sexton added a commit that referenced this pull request Sep 7, 2026
…ing them when no telemetry sink is set (#3913)

Closes #3862

## Summary

Guard decisions left the process only through `HOOK_TELEMETRY_SINK`,
which is inert unless an environment variable names an executable. On an
ordinary install every decision was discarded as it was made, so "why
was this denied", "has it denied this all along", and "did the guard run
at all" had no evidence to answer from.

New `plugins/disk-hygiene/lib/guard_decision_log.py` appends one JSON
object per line to
`<CLAUDE_PLUGIN_DATA>/guard-decisions/decisions.jsonl`, default on, no
configuration. `destructive_guard.py` records every branch that reaches
a verdict; `guard_launch_monitor.py` records the did-not-run state the
guard structurally cannot write about itself.

Version taken: **0.23.0**. Verified free against current `main`
(`6db96637d`, 0.21.9) and against the head of every open PR at the
moment of opening: #3880 claims 0.21.10, #3783 claims 0.22.0, and
#3851/#3887/#3900/#3779 leave the manifest at 0.21.9. 0.23.0 is strictly
greater than all of them, so it cannot collide under any merge order;
the 0.22.x gap is what `--check-order` explicitly reads as correctly
ordered.

## Fix

**Where the record lives, and why it survives.** Under the plugin's own
persistent data root, resolved by the guard's existing
`resolve_authorized_data_root()` (the `--authorized-data-root` /
`--plugin-root` / `CLAUDE_PLUGIN_DATA` ladder) beside the run records
already kept there. Because this plugin is the one that deletes things,
the filename was checked against the engine's own discovery hints in
`reference/baseline-policy.json`: `decisions.jsonl` and
`decisions.previous.jsonl` match none of the 14 bundled name globs
(`*.tmp`, `tmp-*`, `tmp_*`, `scratch*`, `*.lock`, `__pycache__`,
`*.partial`, `*.crdownload`, `*.tmp.*`, `.claude.json.tmp.*`,
`temp_git_*`, `.pulumi-write-test-*`, `.DS_Store`, `Thumbs.db`), so the
plugin's own hints never nominate its audit trail. Appending directly
rather than writing a temp file and renaming is deliberate for the same
reason: an atomic-write staging name would land on `*.tmp.*`, which is a
bundled hint.

**What is recorded.** `schema_version`, `timestamp` (UTC, milliseconds),
`hook`, `decision`, `rule`, `tool`, `mode`, `command`, `reason`.
`decision` is `allow` / `ask` / `deny` / `none` (ran, issued no
`permissionDecision`) / `not-run`. `rule` names the branch that fired,
so `kill-switch-disabled-apply` is distinguishable from
`not-exact-engine-command`: two different answers to "why". `command` is
the input that drove it and `reason` is the exact text the host was
given, so the record and the host cannot disagree. Both are clipped to
400 characters, which keeps it a record of the decision rather than a
copy of the payload and keeps every line short enough that concurrent
hook processes appending to the same file do not interleave.

**Bounded, enforced.** The live file rotates to
`decisions.previous.jsonl` at 1 MiB via `os.replace`, so the record
occupies at most about 2 MiB forever with no operator pruning. The bound
is checked from the offset the append already returns (`handle.tell()`
in append mode), so enforcing it costs no extra syscall.

**Cost.** Measured with `strace -f -e
trace=clone,clone3,fork,vfork,execve,openat,write` against a detached
worktree of `origin/main` at `6db96637d`, five invocations per arm plus
a warm-path detail run:

| Path | Before | After |
| --- | --- | --- |
| defer (a Bash command not naming the engine, the always-on branch) | 1
`execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 0 record syscalls |
| decision (deny), warm data root | 1 `execve`, 1 `clone3` | 1 `execve`,
1 `clone3`, 1 `openat` + 1 `write` |
| decision (deny), first write of an install | 1 `execve`, 1 `clone3` |
plus 1 failed `openat` and 1 `mkdir` |

The single `clone3` is `CLONE_THREAD`, the existing watchdog thread, not
a process. **The process and exec census is unchanged on every path.**
The plugin-level defer branch, which is what this always-on hook takes
for work unrelated to disk-hygiene, writes nothing at all and is
byte-for-byte the path it was. Wall clock over 40 invocations per arm,
alternated twice, moved inside run-to-run noise on this host (52 to 58
ms both before and after, the sign of the difference changing between
repetitions), which is why the syscall census rather than a duration is
the figure cited.

**Failure behavior: the verdict never changes.** Two boundaries, both
load-bearing. `guard_decision_log.record` returns a bool and catches
`BaseException` around the whole write. `_record_decision` in the guard
wraps its own call, because the data root and mode are resolved in the
argument list, outside `record`'s protection, and one of its call sites
is `main`'s own `except BaseException` handler, where a raise would
reach the interpreter's default handler: exit 1, which PreToolUse treats
as non-blocking, so the command the guard just denied would run. Every
record call is made after the verdict has been emitted, and its result
is discarded.

**What is deliberately not recorded.** The plugin-level defer (hot path,
and not a decision anyone reconstructs later). The watchdog expiry path:
that callback runs while the main thread is presumed wedged inside a
filesystem call and stays syscall-free for exactly that reason, so a
write there could hang on the same filesystem. Both are stated in the
README rather than left implicit.

**Adjacency, stayed out of.** #3861 (the guard's fail-open when no
interpreter resolves) is `needs-human`. This change touches the guard's
decision branches but not interpreter resolution, and adds no new
fail-open path; the `not-run` record makes the fail-open class more
visible after the fact without adjudicating it.

**Escape hatch.** `DISK_HYGIENE_GUARD_DECISION_LOG` set to `0` / `off` /
`false` / `no` turns the record off. Opt-out, not opt-in: any other
value, including an absent one, records.

## Verification

All runs local and in the foreground; draft CI is not cited as test
evidence.

- `bash scripts/affected-tests.sh --run --shard N/4`: shards 0, 1, 2
exit **3** (success, with `NOT RUN` non-shell ecosystems), 0 `FAIL`
lines each. Shard 3 exits 1 with exactly three `FAIL` lines, all from
`plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh`
(`process budget: the trace probe actually counted something`, `process
budget: a one-install report costs at most 26 process creations`).
**Reproduced unchanged on a clean detached worktree of `origin/main` at
`6db96637d`**: same suite, same 2 cases, exit 1. Pre-existing, not this
change. Every changed file maps to at least one suite; the three
docs/manifest files resolve through the recorded no-suite allowlist.
- Direct suites: `hygiene.test.sh` RC=0 (350 cases),
`guard_launch_monitor.test.sh` RC=0 (28 cases),
`run-python-hook.test.sh` RC=0, `test_guard_decision_log.py` +
`test_hook_telemetry.py` RC=0 (15 new cases).
- All four parity modes green: `--check`, `--check-order`, `--check-bump
origin/main`, `--check-preserved origin/main`.
- `scripts/run-ruff.sh check plugins/disk-hygiene`: all checks passed.
`format --check`: my four touched/new Python files are clean. Five files
remain unformatted in this plugin (`killswitch_config.py`, `hygiene.py`,
`guard_launch_monitor.py:137`, `test_hygiene.py:2373`,
`test_kill_switch_probe.py:96`); all five are identically unformatted on
`origin/main`, so none is introduced here.
- Gates run clean: `check-purged-em-dashes.sh`,
`check-drive-root-litter.sh`, `check-silent-skips.sh`,
`check-discriminating-test-skips.sh`, `check-fixture-git-isolation.sh`,
`check-hook-exec-form.sh`, `check-killswitch-hoist.sh`, all RC=0. New
test file committed `100755` (verified with `git ls-tree`), the new
library `100644` matching its sibling `hook_telemetry.py`.
- No shell files changed, so shellcheck and shfmt have nothing to say
about this diff. `lib/hook-utils.sh` untouched.

**Mutation proof (the new assertions discriminate).** Nine mutations
applied one group at a time, each reverted:

| Mutation | Caught by |
| --- | --- |
| rotation call disabled | 3 lib cases (`rotates_at_the_bound`,
`discards_only_the_generation_before_last`, `a_failing_rotation...`) |
| `_clip` returns text unchanged |
`long_command_and_reason_are_truncated` |
| `enabled()` hardcoded True | 5 lib subtests +
`the_record_can_be_turned_off_without_changing_a_verdict` |
| deny rule string collapsed onto the kill-switch rule |
`denied_engine_command_is_recorded_with_its_rule_and_input` |
| a record added on the defer path | `engine_gate_defer_records_nothing`
|
| `_record_decision`'s try/except removed |
`a_broken_decision_record_never_changes_a_verdict` (record raises),
`record_decision_swallows_a_failure_in_data_root_resolution`, and the
**pre-existing**
`every_call_graph_function_failure_denies_at_exit_2_never_1` for both
`resolve_mode` and `resolve_authorized_data_root` |
| `_record_not_run` call removed from the monitor | 3 monitor cases |

The write-failure proof is
`test_a_broken_decision_record_never_changes_a_verdict`, which drives
all five verdict shapes (`allow`, `ask`, `deny`-by-authority,
`deny`-by-kill-switch, and the no-output defer) three times:
unsabotaged, against a data root whose parent is a regular file (a real
filesystem `OSError` on both the append and the `mkdir` behind it), and
with `record` raising `RuntimeError`. All three runs produce the
identical verdict list, and the unwritable root is asserted to still not
exist afterwards.
`test_a_write_failure_leaves_the_deny_exit_status_untouched` pins the
same thing end to end through `main`.

**Hermeticity fix included.** `run_guard_engine_gate` previously passed
`SCRIPT_DIR / "data-root"` as the authorized data root. With records
being written, that would have littered the checkout on every test run,
so it now takes a per-test temp path, and `GuardTests.setUp` pops an
inherited `CLAUDE_PLUGIN_DATA` so a developer's real plugin data
directory is never written to by the suite.

## Related

- Closes #3862; parent #3347 finding F12.
- Sibling #3861 (guard fail-open when no interpreter resolves) is
`needs-human` and deliberately untouched; the `not-run` record is what
makes that class visible after the fact.
- Sibling finding on a false denial: `rule` plus `command` plus `reason`
is what answers it from the record instead of by reproduction.
- Hook budget convention: `docs/conventions/hook-budget/README.md`,
`.claude/rules/hook-budget.md`. Measured share stated in the plugin
README's trust-surface record.
- Version contention checked against open PRs #3783 (0.22.0) and #3880
(0.21.10).

🤖 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/3512-failure-audit-perf branch from 5bd0ac5 to d85be78 Compare September 7, 2026 14:18
@kyle-sexton
kyle-sexton enabled auto-merge (squash) September 7, 2026 15:25
… off substitutions

The Stop-event unsurfaced-failure detector timed out 113 times in the #3508
window at a 21.9 s average, roughly 44x the 500 ms the hook-budget convention
gives the whole always-on per-turn set. The parent issue blames per-field jq
forks needing a shared helper; that diagnosis is wrong here, as shard #3520 and
PR #3788 established twice. The cost is redirection placement.

Bash runs the command of a command substitution in the substitution's own
subshell and skips the extra fork ONLY when that command carries no redirection
of its own. `$(wc -c <file 2>/dev/null)` is two processes for one byte count;
`$(cat -- file 2>/dev/null)` is two for a file read; `{ V=$(cmd); } 2>/dev/null`
is one. Those forks are invisible to `bash -x`, which reads command positions,
which is why the campaign kept mis-attributing the cost to the exec count.

Six sites in hook-failure-audit.sh change and no shared library does:

- `wc` names the transcript instead of redirecting it in, and `read` drops the
  filename column along with the padding some wc builds add.
- The `read_window` helper is gone. Under the tail cap grep opens the transcript
  itself rather than being fed by `cat` through a function call that was a
  second subshell on top of the substitution's own.
- The marker read is `$(<file)`, which forks nothing and execs nothing.
- The marker write strips carriage returns in the shell instead of piping
  through `tr`.
- The marker directory is probed with `-d` before `mkdir -p` spawns.
- The two payload fields come from one `hook::jq_fields` pass instead of two
  `hook::jq_field` calls.

Over the tail cap the `tail | sed | grep` pipeline and its redirect placement
are untouched: a pipeline element forks either way, and hoisting the redirect
onto a group would newly silence sed and grep for no saving. The `printf | jq`
feeding the structural selection also stays a pipeline, because a here-string at
or above the pipe capacity deadlocks before jq is exec'd and that payload
routinely clears it; it is off the common path regardless.

Common path (a turn with no failure recorded, under the cap): 18 process
creations and 6 execs before, 9 and 4 after. Over the cap: 19 and 7 before, 12
and 6 after. A turn that warns: 34 and 17 before, 24 and 14 after. Measured with
`strace -ff -e trace=clone,clone3,fork,vfork,execve`. No wall-clock figure is
claimed: this Linux host says nothing about the Windows spawn tax the budget
binds to, and on the #3508 host one creation costs 180-2,841 ms.

Behaviour is unchanged, which for this hook is the whole point: it exists to
surface failures that otherwise pass unnoticed, so a hoisted `2>/dev/null` could
silence exactly the diagnostic it is for. Every group holds one command, and the
one stream now silenced that was not before is grep's, which `cat`'s own
redirect already discarded. Pre- and post-change stdout, stderr, exit codes and
marker contents were compared byte for byte across ten scenarios: first warn,
dedup, a new failing registration re-warning while the warned one stays muted,
all three classification branches, the tail cap, a clean transcript, a missing
transcript, the kill switch, and no data directory. Identical.

The contract test gains an strace budget assertion on both counts, mutation-
checked: moving either silenced redirect back inside its substitution adds a
fork with no new exec, leaves every behavioural assertion green, and trips the
creation ceiling. The README states the measured share per hook-budget Rule 1,
as a process count, and says plainly that the Windows parallel-wall figure is
still owed.

Two acceptance criteria are not met and are called out in the PR: the hook
cannot exit before any spawn, since reading the transcript is how it learns
there is nothing to report, and the 500 ms parallel-wall figure for the
three-plugin per-turn set is cross-plugin and Windows-only.

Refs #3508. Precedent #3779, #3788.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…claim

Two wording corrections on top of the hook-failure-audit spawn work. No
code behaviour changes: the script's logic, its process counts, and its
tests are untouched.

The changelog entry said "Five sites in `hook-failure-audit.sh` changed"
and then enumerated six. The PR body and the hook's own comment both say
six, so the count was the outlier; it now reads six.

The comment above the `printf | jq` pipeline asserted, as universal
fact, that a here-string at or above the pipe capacity deadlocks before
jq is exec'd, and rested the decision to keep the pipeline on that. The
hazard is real but its reproduction is platform-specific: `hook::jq_field`
in the shared library documents it, and the trace behind that note (#1587:
65536 bytes hung indefinitely, 65000 returned at once) comes from this
repo's Windows Git Bash hosts. It does not reproduce on Linux bash 5.2 —
65535, 65536, 65537, 200 kB and 2 MB all return immediately, including
under an unwritable TMPDIR. The comment now says exactly that, and states
the decision on its actual grounds: the forgone saving is one fork on the
warning path only, so the call keeps the library's conservative form
rather than bet the hazard is Linux-only. Left as it was, an unverified
platform-specific claim stated as fact would outlive this PR and could
stop a future reader from taking a real saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
@cursor
cursor Bot force-pushed the claude/3512-failure-audit-perf branch from 8040838 to 19dd8c2 Compare September 7, 2026 15:29
@kyle-sexton
kyle-sexton merged commit fad23df into main Sep 7, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/3512-failure-audit-perf branch September 7, 2026 15:34
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(claude-ops): Stop-event unsurfaced-failure detector times out 113x, ~44x the per-turn hook budget

2 participants