Skip to content

perf(hooks): reject irrelevant tool calls with builtins before any spawn - #2246

Merged
BigSimmo merged 6 commits into
mainfrom
claude/zen-goodall-qenxit
Aug 21, 2026
Merged

perf(hooks): reject irrelevant tool calls with builtins before any spawn#2246
BigSimmo merged 6 commits into
mainfrom
claude/zen-goodall-qenxit

Conversation

@BigSimmo

Copy link
Copy Markdown
Owner

Summary

  • Move the cheapest possible discriminator to the top of .claude/hooks/push-format-guard.sh and .claude/hooks/pr-handoff-stop.sh, above every jq, grep and git invocation, so an irrelevant tool call costs one bash startup instead of a dozen process spawns.
  • Every Bash/PowerShell tool call fires three hook invocations (pr-handoff-stop.sh pre, push-format-guard.sh, pr-handoff-stop.sh post). Measured on the Windows workstation on 2026-08-22 that is ~5-6s of dead wait per command; measured here it is 22 process spawns (8 + 4 + 10) against a ~2.3ms bare-bash floor. Both scripts already exited early for irrelevant commands — the cost was the preamble that ran before those exits, not the logic after them.
  • Replace payload="$(cat)" with the read builtin in both scripts. That removes one fork+exec per invocation and is not slower: benchmarked against payloads from 1 KB to 2 MB it wins below 256 KB and draws above.
  • push-format-guard.sh: add a *git*push* glob on the raw payload. It is deliberately a superset of the git[[:space:]]+push regex it stands in for — that regex cannot match unless the bytes git appear before the bytes push, whether the command reaches it jq-decoded, grep-extracted, or as the raw payload — so it can only ever let more through, never less.
  • pr-handoff-stop.sh post mode: add a *pull* glob. Post mode acts only when tool_response carries a github.com/…/pull/<n> URL, and that gate is case-sensitive, so the lowercase bytes pull must be present; JSON escaping / as \/ cannot hide them.
  • pr-handoff-stop.sh pre mode: move the existing marker-existence gate above the payload parse, and resolve the git directory with a builtin walk that handles a linked worktree's .git pointer file. The resolver yields to git rev-parse --absolute-git-dir whenever its answer is not certain — a GIT_* override, an unresolvable pointer, a cwd that is itself a git dir, or no repository above cwd — so git rev-parse remains the authority everywhere the fast path declines to answer.
  • Both \u glob arms keep the superset argument airtight against an encoder that unicode-escapes ASCII, at the cost of taking the slow path for those rare payloads.

Result for an ordinary command: 22 spawns → 0, and all three invocations now sit at the bare-bash startup floor.

Hook invocationSpawns beforeSpawns afterWall beforeWall after
PreToolUse pr-handoff-stop.sh pre8029.67ms3.29ms
PreToolUse push-format-guard.sh4015.97ms2.73ms
PostToolUse pr-handoff-stop.sh post10033.94ms2.75ms

Bare bash -c 'exit 0' on the same machine is 2.31ms, so the remaining cost is bash startup and nothing else. Spawn count is the portable proxy for the Windows problem: a spawn costs ~1.5ms here and ~150-400ms on Git Bash.

Verification

  • npm run verify:pr-local
PR-local verification summary:
- completed: check:runtime, check:installed-lock-parity, format:changed, lint, typecheck
- failed: test (exit 1)
- not reached: check:rag:fixtures, check:medication-interactions, check:medication-lexicon-report

The single test failure is pre-existing and unrelated to this diff — tests/claude-cloud-profile.test.ts > reports pending rather than false completion when another run already holds a tier's lock, an artefact of this container's own SessionStart provisioner holding a tier lock. Reproduced on a clean tree with these two files stashed:

 Test Files 1 failed (1)
Tests 1 failed | 22 passed (23)

With the diff applied the rest of the suite is green: Tests 1 failed | 7705 passed | 1 skipped (7707).

The three checks verify:pr-local did not reach were run directly and pass:

Offline RAG fixture and manifest validation passed (36 golden cases, 26 suites).
[medication-interactions] data/medication-interaction-index.json is up to date (525 rows).
[lexicon-report] docs/medication-interaction-lexicon-review.md is up to date (37 catalogue terms).

Hook contract tests (tests/pr-handoff-stop.test.ts, tests/session-start-hook.test.ts, tests/claude-code-settings.test.ts), the smallest gate that covers this change:

 Test Files 3 passed (3)
Tests 145 passed (145)

Identical to the pre-change baseline (145/145).

  • UI verification not run: no UI, routing, styling, browser, reduced-motion, or forced-colors behaviour changed — this diff is two shell hook scripts.
  • npm run verify:release not run: not a release or handoff-confidence claim, and it is provider-backed.
  • npm run eval:retrieval:quality, npm run eval:rag, npm run eval:quality not run: no retrieval, ranking, selection, chunking, scoring, or answer-generation surface is touched. classifyPullRequestFiles returns ragRanking: false for both changed paths.
  • npm run check:production-readiness not run: no clinical workflow, privacy, environment, Supabase, source-governance, or deployment behaviour changed. classifyPullRequestFiles returns clinicalRisk: false, operationalRisk: false.
  • npm run check:deployment-readiness not run: no deployment startup, hosting, or rollout behaviour changed.

Mutation testing — proving neither guard was weakened

A speed-up that turns a guard into a check that cannot fail is a regression, not an optimisation, so each guard was tested against the exact case it exists to catch rather than only against the existing suite.

push-format-guard.sh — a git push from a checkout with no wired .githooks/pre-push and an unformatted tree. All six cases behave identically before and after the change:

### case A: plain `git push` with an unformatted tree — MUST be denied
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked: this push would land unformatted files … Unformatted: unformatted.js Code style issues found in the above file. …"}}
PASS: blocked
### case B: compound command with escaped quotes before the push — MUST be denied
PASS: blocked
### case C: same push, jq removed from PATH — MUST still be denied
PASS: blocked
### case D: tree now formatted — MUST be allowed (guard is not a blanket deny)
PASS: allowed
### case E: documented escape hatch prefix — MUST be allowed
PASS: allowed
### case F: unrelated command, unformatted tree — MUST be allowed (fast path)
PASS: allowed

Case D matters as much as case A: it shows the guard still discriminates rather than having become a blanket allow or a blanket deny.

pr-handoff-stop.sh — a post-PR CronCreate, and CI polling past the babysit budget. 45 assertions across three environments, including a linked worktree where .git is a file (the riskiest path for the new builtin resolver) and a PATH with jq removed:

--- MAIN CHECKOUT (.git is a directory) ---
--- LINKED WORKTREE (.git is a file) ---
(worktree .git file: gitdir: /tmp/tmp.hdjNVuW246/.git/worktrees/tmp.hdjNVuW246-wt)
(git rev-parse --absolute-git-dir: /tmp/tmp.hdjNVuW246/.git/worktrees/tmp.hdjNVuW246-wt)
--- NO JQ ON PATH ---
TOTAL: 45 passed, 0 failed

Each environment covers: no marker before a PR exists → allow; post writes the marker at exactly the git dir pre reads; gh pr checks inside the budget → allow; CronCreate inside the budget → deny; past the budget CronCreate, gh pr checks, gh run watch, a quoted compound hiding gh pr checks, ScheduleWakeup and mcp__github__get_pull_requestdeny; git push, gh pr merge, the CLAUDE_ALLOW_PR_FOLLOW=1 prefix and a different session id → allow. The worktree lines above confirm the builtin resolver produced byte-identical output to git rev-parse --absolute-git-dir. The same 45 assertions were run against the pre-change script and also returned 45 passed, 0 failed.

Differential fuzz. Old script vs new, requiring byte-identical stdout and exit code, over a corpus of commands, tool names, tool responses and malformed payloads, in all three marker states (none / inside budget / past budget):

compared 3402 invocation pairs across 3 marker states; 0 differences

Hook file contract

tests/session-start-hook.test.ts asserts every hook is 100755 in the index with no CR bytes. Both preserved — the scripts were rewritten by file replacement, which drops the executable bit on disk, so it was restored and pinned in the index:

100755 5dc866eb70d74ff31afeaba2b3941e8129c43184 0	.claude/hooks/pr-handoff-stop.sh
100755 8a395f8e63478bd53614de021e706950ad0545af 0	.claude/hooks/push-format-guard.sh

git diff --summary is empty (no mode change), and CR=0 for all five hook blobs.

Risk and rollout

  • Risk: Low, but the blast radius is a safety guard, which is why the verification above is mutation-based rather than test-suite-based. The failure mode worth naming is a guard that silently stops firing. Two things bound it. First, each fast check is provably a superset of the decision it replaces, so anything it rejects would have hit the same exit further down. Second, the builtin git-dir resolver never overridesgit rev-parse — it declines and falls through whenever its answer is uncertain (any GIT_DIR/GIT_COMMON_DIR/GIT_WORK_TREE override, an unresolvable .git pointer, a bare repo, or no repository above cwd). The one genuinely new code path is that resolver, and it is exercised directly by the linked-worktree scenario above.
  • Residual gap: the speed-up is currently protected by comments, not by a gate — nothing fails if a future edit reintroduces a spawn above the fast checks. A regression test that asserts zero spawns for an irrelevant payload would close that; happy to add it here if wanted.
  • Rollback:git revert the single commit. The hooks are self-contained scripts with no state, no migration and no consumers; reverting restores the previous behaviour immediately at the next tool call. Both guards fail open by contract, so even a malformed script leaves tool calls exactly as they were rather than blocking work.
  • Provider or production effects: None. Local developer tooling only — no application code, no Supabase, no OpenAI, no CI workflow, no deployment surface.
  • RAG impact: none

Notes

  • The two \u glob arms look redundant and are deliberate. JSON.stringify never escapes ASCII letters, so in practice they never fire; they exist so the "strict superset" claim is provable rather than probabilistic. Their only cost is that a payload carrying a unicode escape takes the old slow path.
  • pr-handoff-stop.sh post mode still runs on every Bash/PowerShell call by design (the settings matcher is broad so it can catch gh pr create). The *pull* glob is what makes that cheap; the marker-writing path itself is untouched.
  • Once a session has opened a PR its marker exists, so pre mode falls through to the full parse for the rest of that session. That is the intended minority case — the budget is 30 minutes and the enforcement logic is unchanged.

Generated by Claude Code

Every Bash/PowerShell tool call fires three hook invocations, and all three
launched a string of helper processes purely to conclude they had nothing to
do. Measured on Windows that is ~5-6s of dead wait per command; measured here
it is 22 process spawns (8 + 4 + 10) against a ~2.3ms bare-bash floor.
Both scripts already exited early for irrelevant commands. The cost was the
preamble before those exits — four jq runs, a grep and a `git rev-parse` in
pr-handoff-stop.sh, two jq runs and a grep in push-format-guard.sh — so this
moves the cheapest possible discriminator above them, using shell builtins only:
- `$(cat)` -> the `read` builtin (one fewer fork+exec; benchmarked equal or
faster for payloads from 1 KB to 2 MB).
- push-format-guard.sh: a `*git*push*` glob on the raw payload. Deliberately a
superset of the `git[[:space:]]+push` regex it stands in for, so it can only
let more through, never less.
- pr-handoff-stop.sh post: a `*pull*` glob, superset of the case-sensitive
`github.com/…/pull/<n>` URL gate that is the only thing post mode acts on.
- pr-handoff-stop.sh pre: the marker-existence gate, moved above the parse, with
a builtin git-dir resolver (handles linked worktrees' `.git` pointer files).
It yields to `git rev-parse` whenever its answer is not certain — a GIT_*
override, an unresolvable pointer, a bare repo, or no repository above cwd.
Both `\u` arms keep the superset argument airtight against an encoder that
unicode-escapes ASCII, at the cost of the slow path for those rare payloads.
Result: 22 spawns -> 0 for an ordinary command; all three invocations now sit at
the bare-bash startup floor (29.67/15.97/33.94ms -> 3.29/2.73/2.75ms locally).
Neither guard is weakened. Verified by mutation-testing the exact case each one
exists to catch: an unformatted `git push` from a checkout with no wired
.githooks/pre-push is still denied (plain, compound-quoted, and with jq removed),
while the same push on a formatted tree is still allowed; a post-PR CronCreate is
still denied inside and past the budget, as is CI polling past it, across a main
checkout, a linked worktree and a jq-less PATH (45/45). A 3,402-pair differential
of old vs new produced byte-identical stdout and exit codes in all three marker
states.
Gates: tests/pr-handoff-stop.test.ts + session-start-hook + claude-code-settings
145/145; full offline suite 7705 passed with one pre-existing container failure
(tests/claude-cloud-profile.test.ts, reproduced on a clean tree); format:check
clean. LF endings and 100755 index modes preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eKsTaFcpHvRhSmeH4JraY
@coderabbitai

coderabbitaiBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in:31 minutes

Limit details: You’ve used the included review currently available. Your 87 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1e6e45c9-de6b-4ebf-a4a0-9a27ba5b8468

📥 Commits

Reviewing files that changed from the base of the PR and between cdfcbac and 9ff9424.

📒 Files selected for processing (3)
  • .claude/hooks/pr-handoff-stop.sh
  • .claude/hooks/push-format-guard.sh
  • tests/pr-handoff-stop.test.ts

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 21, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@BigSimmo
BigSimmo marked this pull request as ready for review August 21, 2026 17:10

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:8d5e30a696

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread.claude/hooks/pr-handoff-stop.sh Outdated
@BigSimmo
BigSimmo enabled auto-merge (squash) August 21, 2026 17:24
Claudeand others added 3 commits August 21, 2026 17:31
Addresses the Codex P2 review finding on PR #2246. Verified before fixing, by
differential reproduction rather than taking the report at face value: from a
subdirectory with GIT_CEILING_DIRECTORIES excluding the enclosing checkout,
`git rev-parse --absolute-git-dir` reports NO repository, so the parent hook put
the PR marker in TMPDIR — while the new builtin walk ascended into the excluded
checkout and wrote it to that repo's .git instead.
OLD marker in TMPDIR: YES marker in excluded repo .git: no
NEW marker in TMPDIR: no marker in excluded repo .git: YES
That is the "guard silently stops firing" class this change was supposed to be
immune to, not a cosmetic path difference: a post/pre pair that straddles the
disagreement writes the marker in one place and reads it from another, so the
babysit budget is never enforced. The previous commit's claim that the resolver
yields on "a GIT_* override" was therefore wrong — it honoured GIT_DIR,
GIT_COMMON_DIR and GIT_WORK_TREE but walked straight past discovery controls.
Fix: treat every git discovery control as uncertain and fall through to the
authoritative `git rev-parse`, adding GIT_CEILING_DIRECTORIES and
GIT_DISCOVERY_ACROSS_FILESYSTEM to that bail-out. Old and new now agree:
OLD TMPDIR: YES excluded repo .git: no
NEW TMPDIR: YES excluded repo .git: no
Adds the focused ceiling-excluded-subdirectory test the review asked for. It
asserts both halves — post writes to TMPDIR and not into the excluded checkout,
and pre reads the same location so CronCreate is still denied. Mutation-checked
rather than assumed green: with the two discovery controls removed again it
fails on the exact assertion (`expected false, received true`), and passes with
them restored.
Not claimed as fixed: a walk can still cross a filesystem boundary that git's
default discovery would not, because that has no environment signal to read and
no builtin way to detect a mount. It needs cwd outside any repository with a
foreign repository above it across a mount — unreachable in this repo's runtime,
and both modes stay internally consistent there, so the guard still fires.
Gates: pr-handoff-stop + session-start-hook + claude-code-settings + guard-push
211/211. Mutation suites re-run unchanged — push-format-guard 6/6, babysit-budget
45/45 across a main checkout, a linked worktree and a jq-less PATH. Differential
of pre-change vs post-change script over 3402 invocation pairs in all three
marker states: 0 differences. Fast path intact — still 0 spawns for an ordinary
command on all three hook invocations. format:changed clean; LF and 100755 kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eKsTaFcpHvRhSmeH4JraY
@BigSimmo
BigSimmo merged commit 65393e4 into mainAug 21, 2026
24 checks passed
@BigSimmo
BigSimmo deleted the claude/zen-goodall-qenxit branch August 21, 2026 17:45
Sign up for freeto 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.

1 participant

@BigSimmo