Skip to content

perf(guardrails): drop dirname and sed execs from the always-on path - #3781

Merged
kyle-sexton merged 2 commits into
mainfrom
cursor/bash-script-perf-phase1-cfed
Sep 5, 2026
Merged

perf(guardrails): drop dirname and sed execs from the always-on path#3781
kyle-sexton merged 2 commits into
mainfrom
cursor/bash-script-perf-phase1-cfed

Conversation

@kyle-sexton

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

Copy link
Copy Markdown
Contributor

No linked issue

Summary

Phase 1 of a measurement-first Bash performance program: the always-on guardrails dispatcher was still paying 7 dirname execs and 1 sed exec 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 --short showed:

spawns=13 rc=0 [7 dirname 3 git 2 jq 1 sed]

The 7 dirname were source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" in each enabled guard (the default-off flag-commit-pr-skill-bypass exits before source). The sed was eval "$(declare -f hook::jq_fields | sed …)" in run-guards.sh. The dispatcher had a builtins-only script_dir helper, 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 in markdown-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:

Fix

  • Every always-on guard (and workflow-resilience-check) locates hook-utils.sh with ${BASH_SOURCE[0]%/*} plus the bare-filename fallback, matching the formatter plugins.
  • run-guards.sh uses the same expansion and only cd && pwd for a relative spelling. It does not define a function named dirname (a dispatched guard must still see the real external command).
  • The jq-cache function copy is declare -f plus parameter expansion, not a sed pipeline.
  • run-guards.test.sh pins an empty dirname/sed shim log on the benign Bash lane, pins the source shape so the $(dirname …) form cannot return, and invokes from hooks/ as ./run-guards.sh, as a bare run-guards.sh, and as a bare block-no-verify.sh so the relative cd && pwd arm and the _HOOK_SELF=. fallback are covered (review follow-up on afc8f3bb).

Guard decisions are unchanged. A dispatched guard still sees the real dirname command (the existing shadow test).

Verification

Host qualified with plugins/performance/lib/spawn_noise.py: measurable (min 0.5 ms, spread 1.42×). HOOK_TELEMETRY_SINK unset.

Counter before after
PATH-shim spawns 13 (7 dirname, 3 git, 2 jq, 1 sed) 5 (3 git, 2 jq)
Wall p50 / p95 (n=20, Linux) 70.0 / 73.5 ms 60.7 / 62.1 ms

The milliseconds are context on this cheap-spawn host. The durable claim is the eight PATH-visible execs. git commit --no-verify still exits 2.

  • plugins/guardrails/hooks/run-guards.test.sh PASS=99 FAIL=0 (dirname/sed shim pin plus the three relative-path cases)
  • All plugins/guardrails/hooks/*.test.sh and lib/git-hooks/*.test.sh: every suite FAIL=0
  • scripts/check-killswitch-hoist.sh clean
  • scripts/check-changelog-parity.sh --check and --check-bump origin/main clean

An over-selected claude-observability.test.sh failed because clean walked /workspace/.observability/claude instead 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)

  1. Same pattern on remaining always-on/entry hooks that still source "$(dirname …)" (formatters that are if:-gated, source-control, claude-ops audits). Lower frequency than this Bash path.
  2. guardrails dispatcher is the whole per-tool-call budget miss after the hook-performance program #3685 isolation forks — convert exit to return (or an equivalent) so guards can source in-process. Needs a byte-identical differential over every guard mode.
  3. Invoke hook scripts through bash to skip the env shebang hop on every fire #3684 shebang hop — only in shell form, never a bare "command": "bash" (WSL relay).
  4. CI/test-script spawn tax (test-perf: diagnose and cut the wall clock of the local test and gate corpus, especially on Windows Git Bash #3716) — developer-time, not hook latency.

Related

Refs #3685
Refs #3523
Refs #3623

Open in Web Open in Cursor 

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>
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 5, 2026 22:14
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 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-05T22:19:09.202303Z afc8f3b Draft marked ready
ℹ️ About Codex in GitHub

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

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

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

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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


Security review (/review:security-review)

  • Fetch PR diff (gh pr diff 3781) and read all 19 changed files
  • Apply skip gate / criteria from plugins/review/skills/security-review/SKILL.md
  • Report findings

Result: no security issues found. This PR is a mechanical performance refactor with no security-relevant surface.

What changed

Every always-on guardrails hook (and run-guards.sh) replaces source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" with parameter expansion:

_HOOK_SELF="${BASH_SOURCE[0]%/*}"
[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=.
source "$_HOOK_SELF/hook-utils.sh"

and run-guards.sh replaces a declare -f | sed pipeline with parameter substitution on the captured function text.

Why this doesn't raise a security finding

  • No new trust boundary. BASH_SOURCE[0] is set by the Bash interpreter to the invoking script's own path — it isn't attacker- or user-input-controlled in this hook-dispatch context, so there's no injection surface introduced by changing how the directory is derived.
  • ${BASH_SOURCE[0]%/*} vs dirname divergence is already documented and inert. The PR's own comments note the known edge cases (/foo → empty vs /; /a/b///a/b vs /a). Since BASH_SOURCE[0] for these hooks is always a real, non-trailing-slash absolute path supplied by Claude Code's own dispatch, these divergences aren't reachable in practice, and if a divergence ever did occur it would fail closed (bad source path → script errors) rather than sourcing something unintended.
  • The sed→parameter-expansion swap in run-guards.sh operates only on declare -f hook::jq_fields, i.e., the text of a function defined by the plugin's own hook-utils.sh, never on tool input or attacker-controlled data. The ${var/pattern/replacement} single-slash form replaces only the first occurrence, matching the old sed '1s/…/' (first-line-only) semantics.
  • Guard decision logic, classification, and blocking behavior are explicitly unchanged (confirmed by the PR's own test additions in run-guards.test.sh and the "Guard decisions are unchanged" claim, which the diff supports — no guard's pattern-matching or PLUGIN_ROOT resolution logic changed, only how the same paths are computed).

No authorization/access-control, injection (command/path/template), or secrets-handling logic was touched. Per the skill's Actions-hardening carve-out: no .github/workflows files are in this diff, so that lane doesn't apply either.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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


Code review complete

  • Gather context (PR diff, files changed)
  • Review guard scripts for correctness (BASH_SOURCE fallback logic)
  • Review run-guards.sh changes
  • Review test coverage (run-guards.test.sh)
  • Post findings

Scope: correctness/maintainability review of the 19 changed files in plugins/guardrails/, per the code-review lane's split (security findings, if any, belong on the separate security lane).

Summary: No blocking issues found. This is a careful, mechanical change: replacing source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" with ${BASH_SOURCE[0]%/*} + a bare-filename fallback, matching the pattern already used in ~36 other files across the marketplace (markdown-format, typos-format, disk-hygiene, etc.). I traced the parameter-expansion logic against dirname semantics for every shape BASH_SOURCE[0] can realistically take (absolute path, bare filename, ./relative) and it's equivalent in every case that matters here — the one shape where it technically diverges (a path with a trailing slash) can't occur because BASH_SOURCE[0] is always a file, never a directory. The run-guards.sh jq-cache rename (declare -f + parameter expansion replacing the sed pipeline) is also correct: it renames only the first occurrence of hook::jq_fields () (the function header), which is exactly the sed '1s/.../ behavior it replaces, and the function body doesn't contain that literal substring elsewhere.

One minor, non-blocking observation:

  • plugins/guardrails/hooks/run-guards.sh lines 77-80 adds a branch that skips the cd ... && pwd canonicalization when _RG_DIR is already absolute (previously HOOK_DIR always went through cd && pwd unconditionally). Every invocation in run-guards.test.sh uses an absolute $DISPATCH path (HOOK_DIR="$(cd ... && pwd)" at the top of the test file), so this new absolute-path fast path is exercised by 100% of the suite, but the relative-path fallback branch (and the equivalent bare-filename fallback repeated in each of the 13 guard files) has no test hitting it directly. Practically low-risk — production hooks are always invoked with an absolute path (as the code comments note), and the pattern is already vetted elsewhere in the repo — but worth knowing this specific branch is currently unverified by any test in this PR.

Verification note: I did not independently re-run plugins/guardrails/hooks/run-guards.test.sh or the spawn-census/wall-clock measurements — tool access in this session didn't allow executing the test scripts. The PASS=94/FAIL=0 result and the spawn/wall-clock numbers in the PR description are author-reported and unverified by me.

Branch: cursor/bash-script-perf-phase1-cfed

@github-actions

github-actions Bot commented Sep 5, 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.

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>
@kyle-sexton
kyle-sexton merged commit a5f5e79 into main Sep 5, 2026
18 checks passed
@kyle-sexton
kyle-sexton deleted the cursor/bash-script-perf-phase1-cfed branch September 5, 2026 22:45
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
…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>
kyle-sexton added a commit that referenced this pull request Sep 6, 2026
…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>&nbsp;<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>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
kyle-sexton pushed a commit that referenced this pull request Sep 7, 2026
…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>
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.

2 participants