perf(claude-ops): halve hook-failure-audit spawns by moving redirects off substitutions - #3851
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Independent review and the corrections it produced — flipped to readyPosted by the autonomous worker lane ( Verified independently, not read
One divergence found and it is an improvement: The correction worth recordingThe code comment asserted as universal fact that a here-string at pipe capacity deadlocks before Traced to source: the claim originates in 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: Not fully met, stated plainlyTwo 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 Gates: Generated by Claude Code |
|
Claude finished @kyle-sexton's task in 6m 21s —— View job Code review —
|
|
Claude finished @kyle-sexton's task in 1m 31s —— View job Security review (
|
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Lane The conflictOne file,
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
Verification
Worth noting because the sibling shard #3869 did not survive this merge unchanged — main's Lane action: advanced. Awaiting CI on Generated by Claude Code |
…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> <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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
5abf82b to
5bd0ac5
Compare
…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>
5bd0ac5 to
d85be78
Compare
… 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
8040838 to
19dd8c2
Compare
…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>
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
jqforks 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 touchinglib/hook-utils.shzero times. This shard applies the same diagnosis. No shared library is touched — the fence aroundlib/hook-utils.shand 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/nullis one;$(<file)is zero. Those forks are invisible tobash -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:SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null)wcnames the file,readdrops the filename column and any padding,2>/dev/nullrides a single-command groupread_window | grep -F …, whereread_windowwascat -- 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$(cat -- "$MARKER" 2>/dev/null)$(<"$MARKER")— no subshell, no execjq … 2>/dev/null | tr -d '\r' >>"$MARKER"$(jq …)then in-shell${VAR//$'\r'/}with a builtinprintfappendmkdir -p "$MARKER_DIR" 2>/dev/nullevery warned turnhook::jq_fieldcallshook::jq_fieldscall (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:
tail | sed '1d' | grepand its2>/dev/nullontailare untouched. A pipeline element forks either way, so hoisting the redirect onto a group would newly silencesedandgrepfor no saving, and mergingsedintogrepwould change the matching semantics.printf '%s' "$RECORDS" | jq -cRsstays a pipeline rather than becoming a here-string, and the in-file comment now states the grounds as they actually stand.hook::jq_fieldin 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 beforejqis 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 unwritableTMPDIR. 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(-ffso no syscall line is split across an<unfinished>/<resumed>pair), successfulexecveonly, the harness's own top-levelbash <hook>exec excluded:The remaining 9 creations on the common path are 4 execs (
wc,grep, and twojqpasses inside the syncedhook-utils.sh) plus subshell forks inside that same fenced library. Theexecvedrop is one batchedjqand 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/nullcould silence exactly the diagnostic it exists to emit. Every hoisted group holds one command. The only stream now silenced that was not before isgrep's own stderr, andcat's own2>/dev/nullalready 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.shgains 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), orRECORDS=$(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:
scripts/affected-tests.sh --runhook-failure-audit.test.sh94/94,audit-session-id.test.sh27/27)shellcheck -xon both changed shell filesshfmt -d -i 2 -ciscripts/check-changelog-parity.sh--check/--check-order/--check-bump origin/main/--check-preserved origin/mainmarkdownlint-cli2on the changed markdownscripts/check-purged-em-dashes.shscripts/check-shell-portability.sh origin/mainscripts/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.shThe wording-only follow-up commit re-ran the test suites, all four parity modes,
shellcheck -x,shfmt -d -i 2 -ciandmarkdownlint-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_nameis 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:
hook-utils.shor abandoning the O(cap) tail bound is onewc, onegrep, and the library's own payload parse — which is what this PR reaches. If the criterion is to be met literally it needs a different design (a marker written by the failing hook, or consolidation with perf(autonomy): Stop hook: lane-stop gate averages 27.6s and timed out 73x - reduce process spawns #3515/perf(disk-hygiene): Stop-event guard_launch_monitor times out 52x via run-python-hook.sh launcher overhead #3516), not a further micro-optimization here.autonomyand perf(disk-hygiene): Stop-event guard_launch_monitor times out 52x via run-python-hook.sh launcher overhead #3516disk-hygiene) and binds to Windows Git Bash, which this runner is not. The README records the figure as owed rather than implying it was taken.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
autonomy), perf(disk-hygiene): Stop-event guard_launch_monitor times out 52x via run-python-hook.sh launcher overhead #3516 (disk-hygiene)lib/hook-utils.shand its 17 synced copies); the here-string note above citeshook::jq_fieldthere but changes nothing in itsession_id,prompt_id,tool_use_id,agent_id) to the hook-telemetry envelope spine insidehook::emit_telemetry. It does not touchhook-failure-audit.sh. This diff leaves that hook'sSESSION_IDextraction and itsdata.session_idenvelope key exactly as they are, so nothing here collides with the key migration. The two PRs both bumpplugins/claude-ops/.claude-plugin/plugin.jsonand prepend to its CHANGELOG, so whichever lands second takes a trivial version/heading rebase.🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Generated by Claude Code