perf(guardrails): drop dirname and sed execs from the always-on path - #3781
Conversation
Every always-on guard located hook-utils.sh with `source "$(dirname …)"`
even after the dispatcher had already loaded the library, and the
dispatcher copied hook::jq_fields through sed. A PATH-shim census of a
benign `git status --short` counted those as 7 dirname plus 1 sed of 13
total execs. Parameter expansion (`${BASH_SOURCE[0]%/*}`) and a builtin
`declare -f` rename drop them to 0; remaining 5 are the primed jq parse
and git probes. Spawn census 13 → 5. Guards still see the real dirname.
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 1m 26s —— View job Security review (
|
|
Last security-reviewed head: |
|
Claude finished @kyle-sexton's task in 6m 0s —— View job Code review complete
Scope: correctness/maintainability review of the 19 changed files in Summary: No blocking issues found. This is a careful, mechanical change: replacing One minor, non-blocking observation:
Verification note: I did not independently re-run Branch: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
The dispatcher and every guard now derive their source dir with
${BASH_SOURCE[0]%/*} plus a same-string fallback to `.`. The suite
previously always invoked via $DISPATCH (absolute), so those fallbacks
were untested. Invoke from hooks/ as ./run-guards.sh, as a bare
run-guards.sh (PATH lookup), and as a bare block-no-verify.sh so the
relative and `.` paths stay covered.
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…e at 1.1 (#3769) Closes #3758 Its two bases (#3765, #3762) have merged, `main` is merged in, and the diff is reduced to this change alone. ## Summary The reference sink files an envelope per session only when the envelope carries a session id, and only the nine claude-ops audit hooks sent one, so the per-session report's "hooks fired" table never listed the formatters or the guards. The library now puts the payload's correlation keys on the envelope spine for every producer, at contract 1.1. ## Fix - **`lib/hook-utils.sh`** (synced into its 17 carriers): `hook::emit_telemetry` reads the payload the producer buffered (`HOOK_TELEMETRY_PAYLOAD` when set, else `INPUT`, the variable every fleet hook assigns from `hook::buffer_stdin`) and copies `session_id`, `prompt_id`, `tool_use_id` and `agent_id` onto the envelope between `duration_ms` and `data`, each only when present as a plain id (`[A-Za-z0-9._-]+`), on both the builtin and the jq path. No jq, no subprocess, no producer change. `schema_version` reads `1.1`. - **`docs/conventions/hook-telemetry`**: `envelope.schema.json` gains the four optional properties; the README gains a "Correlation keys" section and rewrites the sink-routing note; the contract CHANGELOG records 1.1 as an additive minor. - **Sink** (`plugins/claude-ops/hooks/hook-telemetry-sink.sh` and the repo-local `.claude/hooks/` copy): routes on the spine `session_id` first and falls back to `data.session_id`, so envelopes from 1.0 producers keep their route. - **Seventeen carrier bumps** with CHANGELOG entries: actionlint 0.8.37, autonomy 0.22.28, bash-format 0.7.38, biome-format 0.6.36, claude-ops 0.42.11, context-guard 0.7.44, desktop-notification 0.6.31, eol-normalizer 0.6.37, go-format 0.3.40, guardrails 0.32.7, instruction-placement 0.11.28, markdown-format 0.11.46, powershell-format 0.7.39, rate-limit-guard 0.7.35, ruff-format 0.6.37, source-control 0.55.54, typos-format 0.6.44. ## The keys are selected by depth Review found that searching the raw payload takes the leftmost match anywhere in it. Two failures, both reproduced against the first version of this branch: - `{"session_id":"sess-real","tool_input":{"options":{"prompt_id":"NESTED-WRONG"}}}` emitted `"prompt_id":"NESTED-WRONG"`. - `{"tool_input":{"n":{"session_id":"NESTED-WRONG"}},"session_id":"sess-real"}` emitted `"session_id":"NESTED-WRONG"` — not a mis-join, the row lands in **another session's** `sessions/<id>.jsonl`. Cutting the search at the first nested container fixed those and broke something else: the documented payload places `tool_use_id` **after** `tool_input` (`session-event-log.sh` says so in its early-stop note, "tool_input closed, tool_use_id still to come"), so the cut dropped it on every tool event, and the fixture added alongside listed the four ids up front so it passed anyway. So the keys are selected by depth instead. Escapes are neutralized, the payload is split on the quote character, and the alternating fields are walked — even fields structure, odd fields string bodies. A string body is kept only at depth 1, so nested objects collapse to brace-and-colon rubble carrying no quotes and no nested key can match, while a root key after a container is still reached. The walk costs one step per string, not one per byte, and what it renders is short, so the four searches run over a small string whatever the payload size. ## Bounded, and the gap is filed The neutralizing passes are superlinear in escape count. Per emit, escape-bearing payload, this container: | payload | ungated walk | shipped | |---|---|---| | 16 KiB | 4 ms | 4 ms | | 64 KiB | 13 ms | 4 ms | | 128 KiB | 38 ms | 6 ms | | 512 KiB | **486 ms** | **22 ms** | The walk is gated at 65536 bytes; past it the payload takes the head cut. That stays safe at any size — nothing nested is reachable — but it is not complete: a root key after the first container is omitted above the gate, so `tool_use_id` is dropped on payloads over 64 KiB. `session_id` and `prompt_id` lead the payload, so routing is unaffected. **#3784** carries that gap with these measurements and two candidate approaches, and the code comment points at it. ## Verification - `lib/hook-utils.test.sh` **303/303**. The suite discriminates against both wrong versions: **3 failures against the un-anchored original** and **3 against the truncating fix**. New cases cover the documented key order (`tool_use_id` after `tool_input`), a nested decoy with no root key, a nested key ahead of the root one, decoys inside a root array, a multi-megabyte payload, and the size-gate boundary. - `hook-telemetry-sink.test.sh` 41/41, `api-error-audit.test.sh` 11/11, `run-guards.test.sh` 99/99, `claude-observability.test.sh` 60/60, `lib/rewrite-guard.test.sh` 22/22. - `scripts/affected-tests.sh --run`: one failure, not this PR's — `block-hook-bypass.test.sh`, "symlink: a genuine temp write in the same root stays allowed". Reproduced identically on unmodified `main` at `73eb4d98` in a clean worktree (PASS=601 FAIL=1). - `scripts/sync-hook-utils.sh --check`, `scripts/sync-rewrite-guard.sh --check`, `scripts/check-changelog-parity.sh --check-bump origin/main`, `--check`, `--check-preserved origin/main`, `scripts/validate-plugins.sh`, `scripts/check-shell-portability.sh origin/main`, `scripts/check-killswitch-hoist.sh`: all pass. shellcheck clean; the one shfmt hunk is pre-existing (`main` carries 49, this branch 1, and it is not in the changed region). ## Renumbered three times #3770 (fleet-wide prompt audit) bumped 64 plugins and took eight of the numbers this branch claimed; #3762 and #3765 then took guardrails 0.32.5 and claude-ops 0.42.10; #3781 then took guardrails 0.32.6. Every one of the seventeen was re-derived as the next patch above `origin/main` and verified against `git show origin/main:plugins/<p>/.claude-plugin/plugin.json` after each move. Each CHANGELOG conflict was resolved by keeping the released entry at its own heading and lifting this change's entry above it; `--check-preserved origin/main` passing is what proves no released entry was dropped. ## Related - Refs #930 (closed, the thread this finishes), #3750 (the nine-hook step and the sink route), #3765 and #3762 (the bases, now merged), #3770 / #3781 (took claimed numbers), #3784 (the size-gate gap), #3410, #3408. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --------- Co-authored-by: Claude <noreply@anthropic.com>
…3788) <!-- CURSOR_AGENT_PR_BODY_BEGIN --> No related issue: Phase 1 of a measured leftover after the hook-performance program; does not close the isolation-fork or shebang-hop work. ## Summary Highest-impact leftover on the always-on Bash path after #3781: the guardrails dispatcher still parsed `ps-command.sh` (~41 KB) five times on every Bash tool call, advisory `zone-gate.sh` still parsed `hook-utils.sh` to discover it was inert, and remaining production hooks still located siblings with `$(dirname)`. Those parses and execs are gone. Guard decisions are unchanged. ## Fix - **Guardrails:** `run-guards.sh` defers `--lib` until `.tool_name` is known and skips it when that name is `Bash`. Each git/commit guard that still runs alone sources `ps-command.sh` only inside `if [[ "$TOOL_NAME" == "PowerShell" ]]`, the same shape `block-hook-bypass` already used. Isolation subshells do not inherit the include guard unless the parent sourced first, so both skips are required. `block-noncanonical-commit` compares `-m` values against `PS_HERESTRING_PLACEHOLDER` only when the classifier defined it, so a Bash single-line `-m` does not abort under `set -u`. - **context-guard:** `zone-gate.sh` inlines the default-advisory MODE check above every `source`, the same shape as the kill-switch hoist. - **Remaining production hooks:** `${BASH_SOURCE[0]%/*}` / `${FILE%/*}` with the bare-filename and root fallbacks, including formatter parent walks. GNU Bash forks a subshell for every command substitution even when the body is a builtin (Command Substitution, Bash Reference Manual; https://mywiki.wooledge.org/CommandSubstitution). The repo-local `.claude` telemetry sink stays byte-identical with the plugin copy modulo its `hook-utils` path. What this phase does **not** do: - Per-guard `$(source …)` isolation forks (#3685) — high correctness risk - Splitting `hook-utils.sh` - The remaining 3 git execs on a `git` command - `affected-tests.sh` / `check-shell-portability.sh` / context-budget Node hook - disk-hygiene PowerShell `if:` — documented as a correctness residual, not a leftover ## Verification Host qualified with `plugins/performance/lib/spawn_noise.py`: measurable (min 0.5 ms, spread 1.78×). `HOOK_TELEMETRY_SINK` unset. Spawn census through a stable PATH shim (`plugins/performance/scripts/spawn-census.sh`). | Subject | Counter | before | after | |---|---|---|---| | Bash dispatcher (`git status --short`) | PATH-shim spawns | 5 (`3 git`, `2 jq`) | 5 (`3 git`, `2 jq`) | | Bash dispatcher | `source …/ps-command.sh` (`bash -x`) | 5 | 0 | | Bash dispatcher | Wall p50 / p95 (n=20) | 51.9 / 53.8 ms | 46.8 / 48.1 ms | | zone-gate advisory | PATH-shim spawns | 0 | 0 | | zone-gate advisory | `source hook-utils.sh` (`bash -x`) | 1 | 0 | | zone-gate advisory | Wall p50 / p95 (n=20) | 4.8 / 5.0 ms | 1.4 / 1.5 ms | | bash-format matching `.sh` Write | PATH-shim spawns | 9 (`5 dirname`, …) | 4 (`dirname` 0) | The milliseconds are context on this cheap-spawn host. The durable claims are the five classifier parses, the advisory library parse, and the five `dirname` execs. - `plugins/guardrails/hooks/run-guards.test.sh` PASS=101 FAIL=0 - `plugins/guardrails/hooks/block-no-verify.test.sh` PASS=240 FAIL=0 - `plugins/guardrails/hooks/block-noncanonical-commit.test.sh` PASS=213 FAIL=0 - `plugins/context-guard/hooks/zone-gate.test.sh` PASS=26 FAIL=0 - `.claude/hooks/hook-telemetry-sink.test.sh` PASS - `scripts/check-changelog-parity.sh --check` and `--check-bump origin/main` clean - `scripts/check-killswitch-hoist.sh` 31 scripts ## Related Refs #3685 Refs #3781 Refs #3623 Refs #3749 <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-0f213be7-77f3-4f53-8578-2bc4e573c0dc?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-0f213be7-77f3-4f53-8578-2bc4e573c0dc&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>
…3513) (#3869) Closes #3513 ## Summary `block-hook-bypass.sh` was the largest process creator of any guard in the plugin on a benign Bash call: 7 creations, 0 execs. Every exec-based census (the PATH-shim spawn census, `run-guards.test.sh`'s dirname/sed pin, an xtrace command-position count) reads a fork-without-exec as free, which is why the issue's numbers and the "per-field jq" cause never matched what the kernel does. Six of the seven were in this file; the seventh is `$(hook::buffer_stdin)`. Measured with `strace -f -e trace=clone,clone3,fork,vfork,execve` on the real dispatched path (`run-guards.sh block-hook-bypass.sh`), `HOOK_TELEMETRY_SINK` unset, guard share = count minus a no-op guard dispatched the same way, three identical repeats: | Path | creations before -> after | execve before -> after | |---|---|---| | Guard share, benign `git status --short` | 7 -> 1 | 0 -> 0 | | Guard share, blocked `echo hi > notes.md` | 10 -> 5 | 1 -> 1 | | Whole Bash dispatcher, benign | 36 -> 30 | 3 -> 3 | | Guard alone under the dispatcher, wall p50 / p95 (n=20, interleaved) | 27.4 / 29.2 ms -> 24.3 / 27.5 ms | | | Whole Bash dispatcher, wall p50 / p95 (n=20, interleaved) | 51.6 / 60.4 ms -> 48.2 / 49.6 ms | | The execve column does not move: this is latency, not removed work. **On the issue's "at most two spawns" line.** The one creation left in the guard's own share is `$(hook::buffer_stdin)`. Its fork-free form (`hook::buffer_stdin_to <var>`) belongs to `lib/hook-utils.sh`, which is fenced by unmerged #3740 and #3838, so it is not touched here. Whether #3513 counts as met depends on what one counts: the guard's in-file contribution is now 1 creation and 0 execs, and the dispatcher's own `$(source …)` isolation fork is the dispatcher's, not this file's. `Closes` here means the in-file work is done; the remaining fork is the hook-utils item. ## Fix Five in-file creations removed, one left in place: 1. `:159` eager `SUBJECT=$(hook::extract_bash_subject …)` at file scope. Only `emit_tel` read it, and `emit_tel` is gated on the start stamp and the opt-in sink. The subject is now derived inside `emit_tel` behind both gates. Verdicts never read it. 2. `:478` `EXECUTABLE=$(strip_literals "$COMMAND")`. Now `strip_literals_to EXECUTABLE "$COMMAND"`, assigning through a nameref; every trailing newline is stripped, as `$(…)` stripped them. 3. `:474`, `:1305`, `:1330`, `:1403` the four `done < <(printf '%s\n' …)` loops. One fork-free splitter, `split_lines_to`, does a sentinel-prefixed `IFS=$'\n'` word split under `set -f` (globbing restored to whatever the caller had). The sentinel prefix is what keeps blank lines and runs of newlines from collapsing, and a leading or trailing empty line from disappearing, so the array is exactly what `read` delivered, which `strip_literals` needs to keep its heredoc and open-quote state aligned with physical lines. `NORMALIZED_SEGMENTS` becomes an array filled once in `normalize_segments`; the three per-segment scans iterate it. `return` and `continue 2` inside those loops reach the same scopes as before because neither loop shape runs its body in a subshell. A here-string was not an option: at 65536-65663 bytes bash blocks forever writing it into the pipe (documented in `lib/path-detection/hardcoded-path-patterns.sh`). 4. `:112` `INPUT=$(hook::buffer_stdin)` is left as is. It is the fenced hook-utils item, and #3740 modifies this exact rc-handling block; the diff stays off it so the two do not fight at merge. Manifest 0.32.10 -> **0.32.12**, CHANGELOG entry, README "Hook budget accounting" entry with the method and table above. 0.32.11 is taken by #3849 (issue #3511), which bumps the same manifest off the same 0.32.10 base; this branch takes the next free number so the two do not collide on `plugins/guardrails/CHANGELOG.md`. Two documentation corrections carried in the same renumber commit: - The CHANGELOG entry and the new README row said "the 602-case contract suite passes". With the pins this change set adds the suite is 611 cases, which is what it reports. - The comment moved onto `emit_tel` carried two em dashes over from the file-scope comment it replaced. `.claude/rules/vendor-docs-are-not-style.md` bars them from this repo's instruction surfaces, and `check-purged-em-dashes.sh` scans markdown only, so nothing caught them. They are parentheses now. No other comment is touched, and no guard logic changed. ## Verification - **Deny paths still deny, compared against `origin/main`.** 244 paired runs (61 commands x Bash/PowerShell payloads x standalone/dispatched, plus a 70 KiB single-line command and a 3000-line command on each side) agree on exit code and first stderr line. The corpus covers every documented deny form (cat/echo/printf redirects, no-space forms, heredoc openers with a trailing redirect, `2>&1`/`>&2` before the file, prefix modifiers `command`/`exec --`/`FOO=bar`, group and `if` headers, leading redirects, discard-then-real-file `> /dev/null > real.txt`, python write indicators, staged `mv`/`cp`, quoted operands with embedded separators, `\;` and backslash-newline escapes, `ec""ho` / `ec"xy"ho` splices) and the allow forms (dup-only redirects, producer-scoped `bash x.sh > out && echo done`, quoted prose in commit and PR bodies, `command -v`, comments, blank-line-only and newline-padded commands, glob characters). - `split_lines_to` checked directly against `while IFS= read -r` over `printf '%s\n'` on: empty, single line, trailing newline, leading newline, runs of blank lines, whitespace-only lines, glob characters, the sentinel byte itself at line starts and ends, IFS characters, trailing backslashes, UTF-8, a 70 KiB line, 3001 lines. Identical arrays in every case; `set -f` preserved when the caller had it and restored off when it did not. - **New strace-based budget test** in `block-hook-bypass.test.sh`: pins the guard's benign share at exactly 1 creation and 0 execve on two benign payloads (single segment; multi-line, multi-segment), and that `echo > file` still exits 2 under the tracer. Skips visibly on a host without a working strace. It runs the real dispatched path, not the standalone guard, and subtracts a no-op guard so dispatcher forks do not leak into the pin. **Mutation-checked**: unmutated control 9/9; each of the four changes reverted alone (eager SUBJECT restored; splitter through `mapfile < <(…)`; strip through `$(…)`; segments through `mapfile < <(…)`) fails the pin on both payloads. - **The census was re-taken after the renumber commit**, since a documentation-only change must not move it, and it does not: whole Bash dispatcher on the benign payload reads 36 creations at `origin/main` against 30 on this branch, 3/3 identical repeats, execve flat at 3 on both; the blocked-path guard share is 5 creations and 1 execve; the contract suite's exact benign-share pin (1 creation, 0 execve) passes. - `scripts/affected-tests.sh --run`: 5 suites selected (`block-hook-bypass.test.sh` 611/611, `flag-commit-pr-skill-bypass.test.sh`, `require-jq-posture.test.sh`, `run-guards.test.sh`, `scripts/check-shell-portability.test.sh`), all passed, exit 0; the manifest, CHANGELOG and README map to the recorded no-suite allowlist. - `scripts/check-changelog-parity.sh` **all four modes** the script defines, on the committed tree: `--check` (pass), `--check-order` (pass, 91 changelogs read newest-first with no duplicate versions), `--check-bump origin/main` (pass), `--check-preserved origin/main` (pass, 155 headings preserved). `--check-order` is the mode that catches a doubled `## [<version>]` after a bad conflict resolution, which is the failure the renumber exists to avoid; an earlier revision of this PR ran only the other three. - `shellcheck -x` clean on the guard and the test; `shfmt -d` no diff; markdownlint-cli2 0 issues; editorconfig-checker clean; `check-purged-em-dashes.sh` clean; ai-slop detector 0 findings on the README and CHANGELOG. - Pre-existing and not touched: `test_save_point.py::test_new_origin_falls_back_to_directory_name`. ## Related - #3508 parent campaign. Its stated cause (per-field `jq` forks needing a shared helper) does not describe this guard: since #3788 the dispatcher primes the fields once, and this guard contributed 0 execs before this change. - #3779 precedent (#3520): an xtrace command-position count read 2 where the kernel made 8; the same instrument gap is why the shim-based budget test could not see any of these seven. - #3740 adjacent: modifies this script's `buffer_stdin` rc handling. This diff stays off that block. #3838 is the other fence on `lib/hook-utils.sh`. - #3849 (issue #3511) holds guardrails 0.32.11; this PR now takes 0.32.12, so the two no longer collide. - Credit note for the record: this file's `dirname` exec died in `a5f5e799` (#3781); #3788 never touched this file. 🤖 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>
No linked issue
Summary
Phase 1 of a measurement-first Bash performance program: the always-on guardrails dispatcher was still paying 7
dirnameexecs and 1sedexec on every Bash tool call. Those are gone. Spawn census 13 → 5.Discovery (why this, not all 766 scripts)
This marketplace has already run a large hook-performance program (#3623, disk-hygiene #3523, hook-utils spawn cuts). The remaining per-tool-call budget miss is still guardrails (#3685). A PATH-shim census of the current Bash dispatcher on a benign
git status --shortshowed:The 7
dirnameweresource "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"in each enabled guard (the default-offflag-commit-pr-skill-bypassexits beforesource). Thesedwaseval "$(declare -f hook::jq_fields | sed …)"inrun-guards.sh. The dispatcher had a builtins-onlyscript_dirhelper, then wrapped it in$(…), which GNU Bash still forks (Command Substitution, Bash Reference Manual; https://mywiki.wooledge.org/CommandSubstitution). The house pattern for this is already inmarkdown-format/typos-format/disk-hygiene:${BASH_SOURCE[0]%/*}.What this phase does not do, because it was already measured and refused or is a later phase:
$(source …)isolation forks (~7 spawn-eq floor whileexit/trapstay standalone) — guardrails dispatcher is the whole per-tool-call budget miss after the hook-performance program #3685, high correctness risktypos-format/eol-normalizerif:gates — closed as won't-do in perf(hooks): gate the three ungated PostToolUse rows with anif:predicate (typos-format, eol-normalizer, guardrails trio) #3751: they have work to do on every file typebash script.shshebang hop (Invoke hook scripts through bash to skip the env shebang hop on every fire #3684) — shell-formbashis a WSL-relay trap in exec form (PLUGIN-PHILOSOPHY)Fix
workflow-resilience-check) locateshook-utils.shwith${BASH_SOURCE[0]%/*}plus the bare-filename fallback, matching the formatter plugins.run-guards.shuses the same expansion and onlycd && pwdfor a relative spelling. It does not define a function nameddirname(a dispatched guard must still see the real external command).declare -fplus parameter expansion, not asedpipeline.run-guards.test.shpins an empty dirname/sed shim log on the benign Bash lane, pins the source shape so the$(dirname …)form cannot return, and invokes fromhooks/as./run-guards.sh, as a barerun-guards.sh, and as a bareblock-no-verify.shso the relativecd && pwdarm and the_HOOK_SELF=.fallback are covered (review follow-up onafc8f3bb).Guard decisions are unchanged. A dispatched guard still sees the real
dirnamecommand (the existing shadow test).Verification
Host qualified with
plugins/performance/lib/spawn_noise.py: measurable (min 0.5 ms, spread 1.42×).HOOK_TELEMETRY_SINKunset.7 dirname,3 git,2 jq,1 sed)3 git,2 jq)The milliseconds are context on this cheap-spawn host. The durable claim is the eight PATH-visible execs.
git commit --no-verifystill exits 2.plugins/guardrails/hooks/run-guards.test.shPASS=99 FAIL=0 (dirname/sed shim pin plus the three relative-path cases)plugins/guardrails/hooks/*.test.shandlib/git-hooks/*.test.sh: every suite FAIL=0scripts/check-killswitch-hoist.shcleanscripts/check-changelog-parity.sh --checkand--check-bump origin/maincleanAn over-selected
claude-observability.test.shfailed becausecleanwalked/workspace/.observability/claudeinstead of its tmpdir. That suite is not in this plugin; the failure is isolation against the workspace log root, not this change.Remaining phases (not this PR)
source "$(dirname …)"(formatters that areif:-gated, source-control, claude-ops audits). Lower frequency than this Bash path.exittoreturn(or an equivalent) so guards can source in-process. Needs a byte-identical differential over every guard mode."command": "bash"(WSL relay).Related
Refs #3685
Refs #3523
Refs #3623