Skip to content

fix(guardrails): grant the temp-tree exemption on Windows drive paths - #4394

Merged
kyle-sexton merged 6 commits into
mainfrom
guardrails-scratchpad-exemption-dead-on
Sep 24, 2026
Merged

kyle-sexton merged 6 commits into
mainfrom
guardrails-scratchpad-exemption-dead-on

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

No related issue: gh issue create was denied in this unattended session; the defect is described below.

Summary

block-hook-bypass ships a default exemption for bare Bash redirects into the host temp trees, where the harness scratchpad lives. On Windows it never granted. _norm_path folds C:/x to the Git Bash spelling /c/x, and hook::under_temp_root compared that target against candidates it had normalized to C:/..., so no drive path could match. Reproduced on de21bcb: a bare long-name redirect under TEMP returned rc 2 with CLAUDE_PROJECT_DIR unset, set to a non-repo folder, to a repository, and to $HOME.

Fix

  • hook::under_temp_root (lib/hook-utils.sh) normalizes its target through hook::normalize_path_to on Windows Git Bash hosts (msys, cygwin, win32), the same way it already normalized its candidates. It is idempotent for hook::read_file_path, whose target was already normalized. POSIX hosts compare the target as before: \ is a filename byte there, and folding it could make a root-level tmp\x compare as under /tmp.
  • _norm_path (block-hook-bypass.sh) refuses an operand starting with //. The security review found that once the compare worked, //c/Users/.../Temp/f (the share Users on host c) was exempted as local temp. The refusal narrows the temp default and configured roots alike. paths_identical collapses a leading slash run before normalizing, so the staged-move detector still treats //tmp/x and /tmp/x as one path (Codex review).
  • The library is re-synced into all 17 carrying plugins with scripts/sync-hook-utils.sh, and each plugin gets a patch bump and a CHANGELOG entry. Guardrails goes from 0.36.1 to 0.36.2. The other 16 plugins' hooks reach the function only through hook::read_file_path, so their behavior does not change.

Verification

  • lib/hook-utils.test.sh: PASS=527 FAIL=0 (base 510/0). New cases cover drive spellings, a mixed-case trailing slash, and a drive-root candidate. They also pin adversarial shapes: TempEvil, /c/tmpx, an 8.3 target, UNC, and another drive. A POSIX case pins that /tmp\x is not folded, and a real-host case runs against the runner's own TEMP. Six new cases were red on the base code.
  • block-hook-bypass.test.sh on this Windows host: PASS=667 FAIL=2 (base 652/2). This includes two staged-move cases for the leading // spellings, which were red before 462d7d0.
    • The 2 FAILs are the same plugin-data symlink cases on base (ln -s makes copies on this host), so they are host-only.
    • New host-gated cases, red first on base:
      • allowed with the project root at home and at a non-temp folder, in both spellings;
      • leading // blocked.
    • New host-gated cases that already held (regression pins): blocked with no project root, for TempEvil, for a .. climb out of temp, for a relative project file, and for an 8.3 target.
    • An NTFS junction inside TEMP that points into a project blocks, and its temp sibling is allowed.
  • run-guards.test.sh: PASS=224 FAIL=11, the identical FAIL list as the main checkout (host-only).
  • secret-pattern-detection, eol-normalizer, ruff-format pass. block-windows-drive-tmp (15 FAIL) and markdown-format (4 FAIL) show the same counts on base (host-only).
  • Security review, measured through JSON payloads on this host: no fail-open. Every rc 0 target is one hook::read_file_path also declines, so no Write|Edit content gate would have processed it.
  • Linux CI on 462d7d0:
    • block-hook-bypass.test.sh PASS=669 FAIL=0. That includes the POSIX symlink-escape cases. The one visible SKIP is the Windows host gate.
    • hook-utils lane PASS=524 FAIL=0.
    • One shard's first attempt failed the unrelated begin: a Windows backslash path case. It passed on rerun.
  • The Windows CI lane's lib/hook-utils.test.sh run failed once: the runner's bash reports TEMP=/tmp. The real-host case now gives such a TEMP its drive spelling before it compares.
  • After rebasing onto main 9c005be, these pass: sync-hook-utils.sh --check, --check-bump 9c005be9f, and check-changelog-parity.sh --check-bump 9c005be9f. autonomy and claude-ops bump to 0.23.19 and 0.59.3 above main's own bumps. check-changed-skills.sh passes. shellcheck is clean on the four touched shell files, the scoped em-dash gate passes, and added lines contain 0 em dashes.
  • The independent verifier returned PASS on AC1 to AC9. AC6's POSIX symlink half rests on the Linux CI shard.
  • skill-evidence block absent: the installed source-control and claude-ops predate feat(source-control): seat the mandatory reviews on the operator's session and retire the OAuth review lanes #4210, so no ledger sha is stamped; the owed reviews ran as nested agents: code reviewer, security reviewer, AI-slop and markdown-noise read, fresh-context verifier.

Related

  • Work item 20260910-235747 part 1 only, with the test case from the retired duplicate 20260919-144300 (project root = home, bare target under the OS temp tree).
  • Not fixed, USER-RESERVED: a target spelled with an 8.3 short name stays blocked, because _norm_path refuses any operand containing ~. The harness hands out its scratchpad in that form on hosts with an 8.3 TEMP (KYLESE~1 here, RUNNER~1 on the Windows runner). The item's "cause B" is misattributed: the candidate side already expands short names physically, and the remaining dead case is the ~ refusal. Relaxing it widens the guard and needs its own decision.
  • USER-RESERVED, untouched: part 2 of the item (block message verbosity, and the folded-in asks to name which exemption test failed).
  • Deferred: the Windows CI lane runs lib/hook-utils.test.sh but not block-hook-bypass.test.sh, so the hook-level Windows cases are measured on this host only.
  • This PR came from an unattended interview: the item's open questions took the defaults recorded in the plan.

🤖 Generated with Claude Code

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 23, 2026 21:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 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-23T21:21:11.437972Z d77743b 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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: d77743b61c

ℹ️ 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 plugins/guardrails/hooks/block-hook-bypass.sh
@kyle-sexton
kyle-sexton force-pushed the guardrails-scratchpad-exemption-dead-on branch from d77743b to 3617c58 Compare September 23, 2026 21:23
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Skill evidence: a gap at 972887495274f60d0f4efa68547a5b7937fd948d

The skill-evidence block under ## Verification does not cover every mandatory
skill for the files this pull request changes, read against the map in
.claude/source-control.md:

class=code
class=markdown
class=security
missing=verification:confirm
missing=review:quality-gate,review:fanout
missing=simplify
missing=ai-slop:audit
missing=docs-hygiene:audit-noise
missing=review:security-review

Run /source-control:pull-request ready to re-render the block at the current head.
This is advisory: no check turns red on it.

@kyle-sexton
kyle-sexton force-pushed the guardrails-scratchpad-exemption-dead-on branch 2 times, most recently from d459afd to 73bc75e Compare September 24, 2026 13:39
kyle-sexton and others added 6 commits September 24, 2026 10:05
hook::under_temp_root normalized its temp candidates to C:/... but compared
the target as given, and block-hook-bypass passes the Git Bash /c/...
spelling, so the shipped temp-tree default never granted on a Windows host.
The target is now normalized the same way on msys/cygwin/win32 hosts only;
POSIX hosts are unchanged. Every carrying plugin is re-synced and bumped.

An 8.3 short-name target is still refused by the guard's ~ rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpare

_norm_path collapsed a leading `//`, so `//c/Users/...` (a UNC share on a
host named c) compared as the local temp tree once drive targets matched.
It now fails closed for the temp default and configured roots alike.

Adds a Windows junction-escape case, states the under_temp_root
precondition, and tightens the CHANGELOG wording across carrying plugins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e real-host case

The GitHub Windows runner's bash converts TEMP to /tmp, so the real-host
case compared a drive-spelled target against a /tmp-only candidate set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path

The machine-specific-paths lint reads C:/Users/<name> as a hardcoded user
path; the stubbed cases and the changelog example now avoid that shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_norm_path now refuses a leading `//`, which made paths_identical fall back
to a literal compare, so `jq . f > //tmp/x && mv /tmp/x out.json` stopped
matching the staged-write detector. paths_identical collapses a leading
slash run before normalizing; treating the spellings as one path can only
add a block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton force-pushed the guardrails-scratchpad-exemption-dead-on branch from 73bc75e to 9728874 Compare September 24, 2026 14:05
@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 7s —— View job


Claude finished /review:code-review

  • Gather context (PR body, diff via gh pr diff, prior Codex review, inline comments)
  • Check skip gate — PR is open, not draft, change is substantive: proceeded
  • Review diff for correctness/maintainability
  • Adversarially trace the normalization logic by hand (no candidate findings survived to post)
  • Post inline comments for anchorable findings — none met the bar
  • Finalize summary

Summary

Scope: the two behavioral changes (hook::under_temp_root target normalization in lib/hook-utils.sh, and the //-refusal + paths_identical collapse in plugins/guardrails/hooks/block-hook-bypass.sh), synced verbatim into 16 other plugins' hooks/hook-utils.sh copies plus mechanical version/CHANGELOG bumps.

Codex's P1 finding (//c/Users/.../Temp/f exempted as local temp) was already fixed in commit 462d7d091 and is present at HEAD. I traced it independently:

  • scratch_target_exempt → _norm_path "$abs" (block-hook-bypass.sh:858) now refuses any operand starting with // (block-hook-bypass.sh:591) before the drive-letter fold ever runs, so a UNC-shaped target can no longer reach the temp-tree compare.
  • paths_identical (block-hook-bypass.sh:931-952) collapses a leading //////… run to a single / on both operands before calling _norm_path. Since this is applied identically to both sides, it can only make two previously-distinct strings compare equal — it can never make two identical strings compare unequal — so it can only add a block to the staged-move detector, never remove one. The new #2731 cases for both //tmp//tmp spellings confirm this direction.

hook::under_temp_root's Windows-only target normalization (lib/hook-utils.sh:731-736): I hand-traced hook::normalize_path_to against the new test cases (drive spellings, TempEvil non-match, /c/tmpx vs a C:/tmp candidate, the drive-root C:/ candidate, and the POSIX /tmp\x non-fold case) and the logic is consistent — target and candidates end up normalized through the same fold, and the POSIX branch is an explicit no-op so \ stays a filename byte there. The doc comment's claim that this is idempotent for hook::read_file_path's already-normalized norm_file/norm_project also checks out: re-running hook::normalize_path_to on an already-C:/...-folded string reproduces the same string.

I did not find any new correctness issues beyond what Codex already surfaced and the author already fixed. The 16 non-guardrails plugin diffs are byte-identical mechanical syncs of the same hook::under_temp_root hunk plus a version bump and CHANGELOG entry each — nothing plugin-specific to review there.

Not independently verified: I was not able to execute the test suites or shellcheck myself — Bash commands beyond simple read-only ones (git status, grep) required approval that wasn't available in this unattended run. The PASS/FAIL counts in the PR body are the author's own claims, not something I confirmed; I'm flagging that per this lane's "don't restate author-claimed verification as independently confirmed" rule rather than treating it as blocking.

Not reviewed (out of this lane's scope): security-specific framing of the // refusal is the security lane's territory (/review:security-review); Codex's review already covers that angle here and the author responded to it directly.
· Branch

@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 24s —— View job


Claude finished the security review

  • Gather context (PR body, diff, review comments)
  • Run /review:security-review per custom instructions
  • Post findings

Security review — no issues found

Reviewed the security-relevant surface of this PR: lib/hook-utils.sh (hook::under_temp_root, hook::normalize_path_to) and plugins/guardrails/hooks/block-hook-bypass.sh (_norm_path, scratch_target_exempt, paths_identical), which govern whether a Bash redirect/move is exempted from the content-guard hooks. The other 53 changed files are a mechanical hook-utils.sh sync (identical diff to 16 other plugins), version bumps, and CHANGELOG entries — no independent logic to review there.

What I checked specifically:

  • The Codex P1 already raised on this PR (//c/Users/.../Temp/f being wrongly granted the local-temp exemption) is fixed by the //-refusal in _norm_path, and every call site (scratch_target_exempt line 858, the configured-root loop line 892) treats a refused normalization as "no exemption" — i.e., fails closed to blocked, not allowed. Confirmed by tracing all callers, not just trusting the comment.
  • Traced whether the // refusal could itself be bypassed: backslash-to-slash folding happens before the check (catches \\host\share), the check matches any run of ≥2 leading slashes (catches ///tmp/x, not just exactly two), and the drive-letter fold (C:/x → /c/x) can never itself produce a new leading // since it always prepends a single / + one letter. A mid-path // introduced incidentally by the drive fold (e.g. c://x → /c//x) is inert — POSIX gives no special meaning to a non-leading //, and the component-split normalization collapses it to /c/x the same as a single slash would. No bypass found.
  • The new Windows-only normalization in hook::under_temp_root (msys/cygwin/win32 only) is correctly gated on OSTYPE, not on path shape, matching the existing case-fold precedent in hook::normalize_path_to just above it — a real drive-letter directory on a case-sensitive POSIX host is never folded.
  • paths_identical's leading-//-collapse (for the staged-move detector) can only ever add a block (treating two spellings as one path), never remove one — verified this is one-directional and matches the new pinned tests at block-hook-bypass.test.sh:2218-2223.
  • Checked hook::read_file_path's separate use of hook::under_temp_root (the PostToolUse content-gate scoping path, not the block-hook-bypass exemption path) for the same class of bug: a //-prefixed target there simply fails to match any temp candidate (string mismatch), so it degrades to "not treated as temp," which is the safe direction — it doesn't cause project content to be wrongly excluded from scanning.

No CRITICAL, IMPORTANT, or SUGGESTION-level security findings. The fix closes the reported bypass correctly and the new test cases (block-hook-bypass.test.sh:1890-1895, 2218-2223) pin the exact exploit shapes from the Codex review.

Per this skill's scope, GitHub Actions hardening (triggers, permissions:, pinning) is zizmor's lane and out of scope here; this PR touches no workflow files.
· branch guardrails-scratchpad-exemption-dead-on

@github-actions

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. Deleting this comment resets the count.

@kyle-sexton
kyle-sexton merged commit bf34734 into main Sep 24, 2026
21 of 23 checks passed
@kyle-sexton
kyle-sexton deleted the guardrails-scratchpad-exemption-dead-on branch September 24, 2026 14:52
kyle-sexton added a commit that referenced this pull request Sep 25, 2026
…n no wider than the Bash exemption (#4475)

Closes #4471

## Summary

Since #4394, `block-hook-bypass` exempts a Bash redirect into a host
temp tree when `CLAUDE_PROJECT_DIR` names a root
outside that tree. `secret-pattern-detection` had no matching decline.
With a root the scanner clears as a scope
(home, or a folder that is not a git work tree), a `Write` of a secret
to a temp file was blocked while
`echo <secret> > <same path>` passed. This PR makes the scanner decline
such a target through a gate that is never
wider than the Bash exemption. The README's "exempting it gives up no
protection" paragraph was false for this
guard; it is rewritten to say what holds.

This changes the scope of a default-on guard, so ADR 0003

(`docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md`)
applies. It is a narrowing to a scope
the Bash guard already exempts. The change adds no new oracle and does
not widen what the guard blocks.

## Fix

The fix is in `plugins/guardrails/hooks/secret-pattern-detection.sh`:
`spd_temp_declines`, called after the path
allowlist.

Order of checks:
1. **Target spelling, builtins only.**
- Windows: only a drive spelling (`X:/`, `X:\`) can decline. The Write
tool is Node, which resolves `/tmp/x` and
     `/c/x` to places other than the ones Git Bash resolves them to.
   - POSIX: only a `/` spelling can decline. A `\` refuses.
- Both: only letters, digits and `._/:+,=@%-` may decline. Any other
character (whitespace, quotes, `;`, `&`,
`|`, `<`, `>`, parens, `#`, `$`, backtick, `*`, `?`, `[`, `~`) forces
quoting in a Bash redirect, and
`block-hook-bypass` never exempts a quoted target. `//`, `/./`, `/../`,
and a trailing `/.` or `/..` also refuse.
2. **Lexical pre-match**, builtin with `nocasematch`, on a `/tmp/` or
`/temp/` component or a temp-candidate prefix.
A target with no temp-looking component returns here without spawning a
process.
3. **Root gate.**
   - `CLAUDE_PROJECT_DIR` set, spelled as `block-hook-bypass` accepts.
   - Not `/` and not a drive root.
- Not under temp, as spelled or physically resolved. The physical check
makes this gate narrower than
     `block-hook-bypass`'s.
4. **Target under temp, as spelled and physically.**
- Physically means once its nearest existing ancestor is resolved with
the uncached `hook::physical_path_to`, so
     the file's directory never enters the per-process resolver cache.
- On POSIX the lowercased spelling must also be under temp, as
`block-hook-bypass` compares it. Below Bash 4, a
     target with a capital letter refuses.
5. **Hard links:** an existing target with a link count above 1 is
scanned.

The ancestor walk stops when a strip does not shorten the path, so a
spelling such as `Z:` cannot loop.
Documentation changes: README temp-default paragraph and consumer-seam
bullet, hook comments, and a CHANGELOG
`[0.36.5]` entry with refusals and residuals. Version 0.36.4 to 0.36.5:
main carries 0.36.4 at dfd84d4, the
rebase base.

## Verification

- Symptom on main (dbed107 and origin/main), Windows host, synthetic
`ghp_` token, target under `%TEMP%`:
root `$HOME` spd=2 bbh=0; root non-git `C:/...` spd=2 bbh=0. On the
head: spd=0 bbh=0 for both. Reproduced
  independently by a fresh-context verifier.
- The verifier's per-criterion verdicts (AC1-AC12 in the plan) are all
PASS at 4a09787:
  - AC1/AC2: a non-git or HOME root declines.
  - AC3/AC4: an unset or temp-rooted root scans.
  - AC5/AC6: every refused spelling scans.
- AC7: a junction under temp into a non-temp directory scans; a sibling
declines.
- AC8: Windows `C:/`, backslash and lowercase spellings decline; `/c/`,
`/tmp/` and 8.3 scan.
- AC9: a non-temp target spawns no `realpath`, `readlink`, `cygpath`,
`stat` or `git`. Checked with a PATH shim;
    a temp target does show calls.
  - AC12: the target's directory is absent from `_HOOK_PHYS_KEYS`.
  - A hard-linked temp file scans.
- Two width gaps were found and fixed.
- The verifier found a target containing `$`, `[` or `*` declined where
the Bash redirect blocks. Fixed in
    ae2be05.
- The review threads found the same for whitespace, quotes and shell
operators. Fixed in 2826b14 by the
    allowlist above.
  - Each gap has its own refusal cases.
- `secret-pattern-detection.test.sh` at 2826b14 on this Windows host:
PASS=168 FAIL=0, rc 0, 168 `ok:` lines,
no SKIP lines. The Linux CI shard (run 36086181666, head 81b30c8)
reports PASS=160 FAIL=0. Its one SKIP is the Windows
spelling block. The POSIX case-width seam ran there, so POSIX coverage
comes from CI. Red evidence from the
  implementer on the unchanged
hook: AC1, AC2, the AC7 sibling, the AC8 backslash case, the dispatcher
case, the cache probe and the hard-link
  case each failed before the fix.
- `run-guards.test.sh`: PASS=224 FAIL=11 on the branch at both 81c7280
and 4a09787. Its FAIL lines are identical
to origin/main's run on this host; all are PostToolUse `if`-row and
verifier cases. `coverage-manifest` 6/11 and
`block-windows-drive-tmp` 199/15 have the same counts on origin/main
(host-only). `require-jq-notice-isolation`
  2/0, `pre-commit-content-invariants` 9/0, `abort-boundary` 216/0.
- `check-changelog-parity.sh --check-bump aaf4c44` passes;
`check-changed-skills` reports no skills changed;
shellcheck is clean on both shell files; the scoped em-dash gate is
clean; added lines contain no U+2014.
- Reviews ran as nested fresh-context agents: plan reviewer, code
reviewer, security reviewer, a simplification and
prose (AI-slop, markdown-noise) read, and the verifier last. The
skill-evidence block is absent because #4465
removed that system; the owed reviews ran as the nested agents named
above.

## Related

- Unattended interview: the Brief was synthesized from the queue item
and the operator-accepted vetting (D1,
  VETTED-WITH-CHANGE). No questions were reserved for a human.
- Remaining from the same queue item, left for later lanes:
- 8.3 short-name acceptance, including staged-move identity across
spellings in `paths_identical`.
  - Block message part 2.
  - Windows CI not running `block-hook-bypass.test.sh`.
- Option E (narrow both guards to `scratchpad_dir`). If it lands, this
decline narrows the same way.
- Deferred:
- Reword the `block-hook-bypass.sh:521-527` comment ("every Write|Edit
content guard reads its file through"), left
    to the 8.3 lane.
- `NotebookEdit` is never scanned: the guard reads
`.tool_input.file_path`, but the call carries `notebook_path`.
    This predates this PR; it is filed as queue item 20260925-001500.
- Unmeasured:
- A HOME that is itself a git work tree may make `hardcoded-path-check`
scan temp files.
- The Bash 3.2 branch was checked by reading the code only; it runs only
on macOS or Linux CI.
- Residuals, recorded in the CHANGELOG:
- Shared with `block-hook-bypass`: TMPDIR inside the project; the macOS
`/private` spelling miss; a time-of-check
    link race; a git repo under temp.
- Code simplifications from the prose pass (behavior-identical) were
declined. Each would cost a full suite pass
    on this host for no behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


- Commit shas cited above (4a09787, ae2be05, 2826b14, ea18dfc,
81c7280) are pre-rebase; the same changes are on the branch under new
shas. The Fable merge gate re-measured the head: symptom fixed, 35+
adversarial spellings, no decline wider than block-hook-bypass for the
same file, no-fork claim holds.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Sep 25, 2026
…p per-edit spawns (#4418)

No related issue: WSL hook-latency program, rounds 1 to 5 (Write/Edit
PostToolUse window); no GitHub issue was filed for it.

## Summary

Every Write and Edit waits on the PostToolUse hooks, and on WSL the
window was 85 ms p50 for a small `.txt` edit and 399 ms for a 33 KB
Markdown edit. Most of it was not the hooks' work. Round 1: the builtin
JSON parse ran under the UTF-8 locale, `hook::begin` re-piped its
buffered payload through a byte-at-a-time `read`, guardrails parsed and
resolved the same payload once per verifier, and `repo_relative_path_to`
probed every PATH directory for a Windows-only `cygpath`. Round 2: every
hook paid a `#!/usr/bin/env bash` hop, a `realpath` process, and
stale-path-verify ran up to nine grep/sed/sort/head processes per
Markdown edit. Round 4: the JSON skeleton spent about 3.6 ms per parse
compiling one regex per token. Round 5: a 33 KB Markdown edit still
started two jq processes that could be answered in the shell. No hook's
output changes.

## Fix

Merge with #4450 (a74f8e8, 1c5ebf1; merge 0d17cdd). #4450 moved
every row to `bash "${CLAUDE_PLUGIN_ROOT}"/hooks/<script>` with
`"shell": "bash"` and added exit-before-source on every event. Both hold
here. The rows this PR changed keep `exec` on top of #4450's shape:
bash-format (2), markdown-format (2), guardrails post-verify (5), and
context-guard zone-crossing (2). Claude Code runs a row through `sh -c`
(dash) on Linux, and dash forks for a trailing `bash ...`; `exec bash
...` does not (strace: 1 fork vs 0). The eol-normalizer and typos-format
rows are now byte-identical to main. In zone-crossing-inject.sh, this
PR's no-context-dir exit and #4450's hoisted kill switch both run before
the first `source`; both are silent `exit 0`. Every overlapping carrier
moves one patch above main again (claude-ops 0.61.1, source-control
0.58.1).

Round 5 (0c3960e, 65fab8c; merges a80cd01, eeae865):

- `lib/hook-utils.sh` (17 carriers): `hook::_fast_fields` also proves
`.key // false | tostring` and `.key.sub // false | tostring` (absent or
null -> `false`, a boolean -> its name, a string -> itself; anything
else -> jq). Fuzzed against jq on 6000 payloads under C.UTF-8, C and
OSTYPE=msys (1131 answered, 0 mismatches); the old filter shapes are
unchanged on the round-4 corpora.
- `guardrails/hooks/skill-reference-verify.sh`: the plugins-root gate
runs before the payload read. With the lib change, the post-verify
dispatcher starts no jq on a `.md` edit outside a marketplace repo (it
had one per edit, for the `structuredPatch` filter). Inside a
marketplace repo nothing changes: one jq, shared with stale-path-verify
through the cache. run-guards test covers both.
- `typos-format/hooks/typos-format.sh`: in report-only mode (the
default) the finding classifier is built with builtins, byte-identical
to the jq program's output, when every output line is a typo finding in
typos' field order with printable-ASCII text and no quote or backslash,
and the output is at most 16 KB. Anything else, write mode, and Windows
bash run jq. 0.2 ms vs 2.9 ms for jq (primitive). Fuzzed on 7000
synthetic typos outputs (1857 answered, 0 mismatches); parity cases in
typos-format.test.sh.

Round 4 (5db7897; merges 98209f4, 8c049af, e681934):

- `lib/hook-utils.sh` (17 carriers): `hook::_json_skeleton` checks the
grammar with whole-string rewrites instead of one anchored regex (a
regcomp) per token: one regex for whitespace inside a token or between
two scalars, one for every scalar token, then a reduction loop over a
grammar string (per pass `k:v`, `s:v`, `v,v`, `m,m`, `{m}`, `{}`, `[v]`,
`[]`, root wrapped as `<k:ROOT>`). Escapes and control bytes are one
search over the whole neutralized payload. A structure past 8192
characters outside strings goes to jq. Parse of the S1 payload 3.6 ->
0.64 ms, S3 5.0 -> 2.1 ms (primitive).
- `markdown-format.sh` (`normalize_path_entry`, once per PATH entry on
the missing-markdownlint notice) and `powershell-format.sh`
(`to_pwsh_path`): `cygpath` only on msys/cygwin/win32; both trust gates
test for a drive-letter path before the lookup.
- Not done: the S3 timed stdin read. bash 5.3 masks SIGCHLD around every
character of a timed `read` (`read.def` 731-742; `TMOUT` too), any
untimed phase blocks forever on a never-closed pipe, and no standard
tool implements the sliced idle bound, so no replacement keeps the stall
semantics identical.
- Every carrier re-bumped one patch above main's after #4394 took the
same numbers; CHANGELOG bullets under the new version.

Round 3 (cd1b9d9, 21c1ef5; merges b0a5009, 0eda101):

- `bash-format/hooks/bash-format.sh`: `cygpath` is looked up only on
msys/cygwin/win32, as in `hook::repo_relative_path_to`. Elsewhere the
miss probed every PATH directory, including 9 `/mnt/c` entries on WSL;
bash-format was the slowest `.sh` PostToolUse hook because of it.
- `guardrails/hooks/stale-path-verify.sh`: a word anchor's occurrence
count uses `spv_word_occurrences` (awk `split` on non-word bytes under
C) instead of a per-character walk (33 KB file: 14 -> 2 ms); a word
anchor with `.` or `-` skips the count, which the walk never matched.
Non-word anchors keep the walk. Parity test against the walk added.
- `typos-format/hooks/typos-format.sh`: the classifier returns counts
and arrays through `tostring`, so `hook::jq_fields` reads them with the
builtin parser instead of a second jq.
- `lib/hook-utils.sh` (17 carriers): the skeleton's control-byte and
escape checks are one regex search each on Linux and macOS (Windows bash
keeps the glob checks, its regex decodes UTF-8 under C), and the key
walks take a key's text from its split part when no escape was rewritten
in it, instead of slicing the whole payload per string.
- CHANGELOG bullets added to each carrier's existing entry.

Round 2 (2540560, 9cc5cdf, 99d6205):

- hooks.json (superseded in part by #4450, which shipped the `bash`
prefix and `"shell": "bash"` on every row and `exec bash` on the
typos-format and eol-normalizer rows): what remains of this PR is `exec`
in front of `bash` on the markdown-format (2), bash-format (2),
guardrails post-verify (5) and context-guard zone-crossing
(PostToolBatch, UserPromptSubmit) rows. bash-format's launch-gate test
accepts both forms.
- `lib/hook-utils.sh` (synced to all 17 carriers): new
`hook::_physical_builtin_to` answers realpath's question with `builtin
cd -P` in one subshell, Linux only, for absolute existing directories
and existing non-symlink files; every other path, and Git Bash and
macOS, still runs realpath. Used by `hook::_physical_prime` and
`hook::physical_path_to`.
- `guardrails/hooks/stale-path-verify.sh`: builtin twins of the
code-span scan, the reconstruction token set, its anchor lines and its
recovered context, used only for printable-ASCII text; any other byte
keeps the pipelines.
- `context-guard/hooks/zone-crossing-inject.sh`: exits before sourcing
its libraries when `~/.claude/context-guard/context` does not exist and
jq is on PATH (no snapshot and no compaction marker for any session, so
the hook could only reach a silent `unknown`).
- Tests: stale-path twins vs pipelines on 14 fixed texts plus the gate;
`_physical_builtin_to` vs realpath per shape and its declined shapes;
the unresolved-path probe disables the builtin with `enable -n cd`;
run-guards expects 0 realpath on Linux.
- CHANGELOG bullets added to each carrier's existing entry for this PR
(versions were bumped in round 1).

Round 1 (7630993 .. c8daa81): C-locale builtin JSON parse
(`hook::_c_locale`), `buffer_stdin_to` without `jq -e .` for a proven
object, `read_file_path_to`/`raw_file_path_to` on the buffered payload,
`cygpath` lookup only on Windows bashes, `_uncached` twins; run-guards
caches path, root and fields per event; eol-normalizer skips `git
rev-parse` with no sink; context-guard starts its resolver only with a
readable snapshot; instruction-placement SC2154 init.

## Final verdicts

- S1 `.txt`, S2 `.md`, S2 `.sh`: MET. Every exec-count target: MET.
- S3 (33 KB `.md`): NOT MET. 70 ms p50 against a 60 ms target, per the
independent verifier at 0d17cdd.
- The guardrails post-verify exec count of 3 on a `.md` edit holds only
outside a marketplace repo; inside one, the dispatcher still starts one
jq.
- Part of the before -> after speedup comes from main commits merged in
after base 3fa014b, not from this PR alone.
- The implementer's `windows.py` dropped the first call per log; the
verifier's own cuts, which keep it, gave the same verdicts.
- Modes not exercised: markdown-format lint output, bash-format
shfmt/shellcheck output, and the Windows `cygpath` branches (covered by
`test-windows` CI only).
- Head f155042 adds main merges and a test-only fix
(`secret-pattern-detection.test.sh`, guardrails CHANGELOG) after the
measured 0d17cdd. Main changed two runtime guardrails hook files in
those merges: `secret-pattern-detection.sh` (#4475, #4486; PreToolUse,
outside the measured window) and `guard-requires.sh` (#4480,
comment-only, sourced by run-guards). `lib/hook-utils.sh` is unchanged
by main. Not re-measured.

## Verification

**Post-merge re-measure (#4450)** (head 0d17cdd, main 0fb4e95).
Differential, 11068 cases, run twice. Against base 3fa014b: identical
except the go-format telemetry counter (`Edit m.go` and `Write m.go`,
Go's random `telemetry/local/weekends` under HOME, same output) and the
same 13 lib-level deltas as round 5. Against origin/main itself: the
same 15 and nothing else. Every `row-*` case is identical, so `exec
bash` vs main's `bash` changes no behavior, and #4450's zone-crossing
hoist behaves as on main. CI dispatched at 0d17cdd: `ci.yml` (lint,
lint-2, hook-utils, test-linux 0-3, ci-status) and `test-windows.yml`
green.

One interleaved run at 0d17cdd (5 rounds x 4 strata, base / after /
safe; no local load; host qualified before and after, spread 1.67x /
1.74x, floor 0.3 / 0.4 ms). Write/Edit PostToolUse window, ms p50 / p95
(n):

| Stratum | base | after | safe | target | status |
|---|---|---|---|---|---|
| S1 `.txt` | 89 / 106 (35) | 42 / 50 (35) | 13 / 28 (35) | p50 <= 50,
p95 <= 90 | met |
| S2 `.md` | 94 / 115 (30) | 43 / 61 (30) | 13 / 59 (30) | <= S1 + 20 =
62 | met |
| S2 `.sh` | 86 / 136 (30) | 43 / 53 (30) | 13 / 321 (30) | <= 62 | met
|
| S3 33 KB `.md` | 398 / 450 (30) | 70 / 87 (30) | 12 / 52 (30) | <= S1
+ 15 = 57 | missed by 13 |

Counters (strace census, W/E post Edit, medians) are identical to round
5. Successful execs: typos-format S1 4 and S3 4, guardrails post-verify
`.md` 3 and S3 5, eol-normalizer 3, markdown-format 3, bash-format 4,
context-guard 2. Processes per hook are also unchanged (bash-format 6,
markdown-format 7, post-verify 6 / 4, context-guard 1), so `exec` still
saves the fork. Failed execve: 46 per hook. Failed probes: typos S3 308,
post-verify `.md` 260, S3 368.

**Round 5, final** (head eeae865). Differential extended to 11068
cases (replace_all as absent, null, false, true, "x", "", 1, [], {} in a
marketplace repo and in a git repo without `plugins/`, a null
tool_input, MultiEdit, typos content with ambiguous corrections and
non-ASCII words): identical except the go-format telemetry counter and
the same 13 lib-level deltas as round 4. Run at 65fab8c; the merge to
eeae865 changes no file under `lib/` or any tested plugin (`git diff
--stat` empty). CI dispatched at eeae865: `ci.yml` (lint, lint-2,
hook-utils, test-linux 0-3, ci-status) and `test-windows.yml` green.

One interleaved run at eeae865 (5 rounds x 4 strata, base / after /
safe; no local load; host qualified before and after, spread 1.67x /
1.64x, floor 0.3 ms). Write/Edit PostToolUse window, ms p50 / p95 (n):

| Stratum | base | after | safe | target | status |
|---|---|---|---|---|---|
| S1 `.txt` | 85 / 100 (35) | 41 / 47 (35) | 13 / 31 (35) | p50 <= 50,
p95 <= 90 | met |
| S2 `.md` | 93 / 144 (30) | 43 / 53 (30) | 12 / 21 (30) | <= S1 + 20 =
61 | met |
| S2 `.sh` | 86 / 103 (30) | 44 / 79 (30) | 14 / 40 (30) | <= 61 | met |
| S3 33 KB `.md` | 414 / 1828 (30) | 68 / 103 (30) | 13 / 26 (30) | <=
S1 + 15 = 56 | missed by 12 |

Counters (strace census, W/E post Edit, medians): successful execs
typos-format S1 4, S3 5 -> 4; guardrails post-verify `.md` 4 -> 3, S3 6
-> 5; eol-normalizer 3; markdown-format 3; bash-format 4; context-guard
2. No jq in the typos or post-verify rows for Edit any more. Failed
execve 46 per hook, unchanged.

S3 floor (primitives, idle host): the S3 - S1 difference that no code
change here can remove is the timed stdin read on the 35 KB payload
(+7.5 ms, bash's per-character signal mask), the typos scan of 33 KB
against 2 bytes (13.1 vs 11.5 ms, +1.6), and a minimal file_path
extraction (+0.4): about 9.5 ms. So the S3 floor is about S1 + 10 and
the S1 + 15 target sits above it; the measured gap is 27 ms, so about 17
ms of it is still removable in principle (payload validation and copies
in `buffer_stdin_to`, about +3 ms; the rest of `hook::begin`, about +2.7
ms; guardrails' stale-path scan of the 33 KB file and its remaining
parse, now the last hook to finish at 52 ms native against typos' 48).

**Round 4** (head e681934). Grammar proof: all 2,396,744 token strings
up to 7 tokens over `{ } [ ] , : "x" 1` give the previous walk's verdict
exactly; 32k random payloads under C, C.UTF-8 and OSTYPE=msys give
identical verdicts, skeletons, offsets, file paths and fields.
Differential extended to 9114 cases (13 grammar payloads with a
file_path, a structure past the cap, markdown-format's PATH remediation
with and without an identity cygpath): identical except the go-format
telemetry counter and 13 lib-level deltas (round 3's 12 plus the cap's
builtin -> jq fallback, same answer); run at e681934. CI dispatched at
e681934: `ci.yml` and `test-windows.yml` green.

One interleaved run at e681934 (5 rounds x 4 strata, base / after /
safe; no local load; host qualified, spread 1.5x / 1.81x). Write/Edit
PostToolUse window, ms p50 / p95 (n):

| Stratum | base | after | safe | target | status |
|---|---|---|---|---|---|
| S1 `.txt` | 86 / 99 (35) | 41 / 50 (35) | 9 / 16 (35) | p50 <= 50, p95
<= 90 | met |
| S2 `.md` | 93 / 107 (30) | 42 / 49 (30) | 12 / 20 (30) | <= S1 + 20 =
61 | met |
| S2 `.sh` | 84 / 95 (30) | 41 / 51 (30) | 11 / 33 (30) | <= 61 | met |
| S3 33 KB `.md` | 402 / 1264 (24) | 71 / 81 (30) | 12 / 25 (30) | <= S1
+ 15 = 56 | missed by 15 |

The host ran faster than round 3 (safe 9-12 vs 14-15), so part of S1's
53 -> 41 is drift. Counters: exec counts unchanged from round 3; traced
walls down about 3 ms of CPU per parse (typos S1 68 -> 59, guardrails S3
130 -> 115, strace-inflated).

**Round 3** (head 0eda101). Differential extended to 7899 cases (word
anchors beside non-ASCII, invalid UTF-8, CR and a 40 KB line; typos
content with 0, ~30 and ~3000 findings incl. write mode; an identity
`cygpath` on PATH): identical except the same go-format telemetry
counter and the same 12 lib-level deltas as rounds 1-2. Fuzz: occurrence
counter 3000 cases, skeleton/fast-field parser 4000 payloads (C,
C.UTF-8, OSTYPE=msys), 0 mismatches. CI dispatched at 0eda101:
`ci.yml` and `test-windows.yml` green.

One interleaved run at 0eda101 (4 rounds x 4 strata, base / after /
safe; host qualified, spread 1.43x / 1.81x). Write/Edit PostToolUse
window, ms p50 / p95 (n):

| Stratum | base | after | safe | target | status |
|---|---|---|---|---|---|
| S1 `.txt` | 98 / 130 (28) | 53 / 76 (28) | 14 / 69 (28) | p50 <= 50,
p95 <= 90 | p50 missed by 3 (host ran slower: base +13, safe +3 vs round
2) |
| S2 `.md` | 93 / 151 (24) | 47 / 56 (24) | 15 / 41 (24) | <= S1 + 20 =
73 | met |
| S2 `.sh` | 87 / 128 (24) | 49 / 77 (24) | 15 / 24 (24) | <= 73 | met
(61 at round 2) |
| S3 33 KB `.md` | 449 / 723 (24) | 88 / 152 (24) | 15 / 30 (24) | <= S1
+ 15 = 68 | missed by 20 |

Counters (strace census): typos-format S3 execs 6 -> 5; bash-format
`/mnt` probes 9 -> 0. The remaining S3 cost includes a timed-`read`
floor (2 `rt_sigprocmask` per byte, ~7 ms per hook at 37 KB) that only a
stall-semantics change would remove; not done.

**Round 2 and earlier:**

**Behavior differential** (no timing; `diff.py`, base = origin/main
3fa014b vs this head, same fixture path, fresh HOME/data/TMPDIR per
run; stdout, stderr, exit code, fixture/HOME/data/TMPDIR/sink contents).
6102 cases: every changed hook and every `hook::begin` caller, the
guardrails dispatcher on its three lanes and the verifiers standalone,
context-guard, a lib-level probe, and now the hooks.json rows themselves
run as `sh -c <row>` from each arm (1905 row cases). Round 2 adds a
fixture history with deleted, moved and non-ASCII-named paths (so
stale-path-verify reports findings), 30 stale-path cases (spans,
unclosed and double backticks, CRLF, tabs, whitespace lines, fences,
root basenames, self-repeating anchors, replace_all with over 40 hits,
non-ASCII and ESC in new_string and in recovered lines, 40 KB ASCII, the
33 KB doc), context-guard with no context directory, and a HOME-unset
mode, on top of round 1's corpus and modes. Result: every hook-level
case identical except go-format Write/Edit `m.go`, whose only difference
is Go's own random telemetry counter under HOME. 12 lib-level deltas,
the same 12 as round 1 (base lacks round 1; none reachable as hook
output). Verdict: no behavior change.

Also: the twins were fuzzed on 3000 random texts under C and C.UTF-8
against this host's grep (ugrep 7.8.4); CI runs the fixed-case parity
test on GNU grep and Git Bash. `_physical_builtin_to` matched uutils and
GNU realpath on 29 path shapes wherever it answers.

**Suites** (local): hook-utils 513/0, typos-format 178/0, eol-normalizer
85/0, bash-format 55/0, run-guards 242/0, stale-path-verify 121/0,
skill-reference-verify 151/0, cli-flag-verify 96/0, zone-crossing-inject
103/0, claude-ops fleet-state pass; markdown-format 172/2 (the same 2
fail at c8daa81: this host has no jq in `/usr/bin`). shellcheck,
shfmt, killswitch-hoist, hook-exec-form, userconfig-argv,
shell-portability, hooks-description, silent-skips, wiring-liveness,
changelog-parity, `sync-hook-utils.sh --check` and `--check-bump
origin/main`, markdownlint: pass.

**CI** at 99d6205, dispatched: `ci.yml` (lint, lint-2, hook-utils,
test-linux 0-3, ci-status) and `test-windows.yml`: all green.

**Measurement** (WSL2, `claude -p` Haiku; one run at 99d6205, arms
base / after / `--safe-mode` interleaved, 4 rounds x 4 strata; host
qualified before and after, `is_measurable` True, spread 1.72x / 1.42x).
Write/Edit PostToolUse window, ms:

| Stratum | base p50 / p95 (n) | after p50 / p95 (n) | safe p50 / p95
(n) | target | status |
|---|---|---|---|---|---|
| S1 `.txt` | 85 / 98 (28) | 43 / 52 (28) | 11 / 17 (28) | p50 <= 50,
p95 <= 90 | met |
| S2 `.md` | 90 / 94 (24) | 44 / 46 (24) | 13 / 20 (24) | <= S1 + 20 |
met |
| S2 `.sh` | 83 / 87 (24) | 61 / 70 (24) | 10 / 16 (24) | <= S1 + 20 |
met |
| S3 33 KB `.md` | 399 / 444 (24) | 89 / 95 (24) | 11 / 28 (24) | <= S1
+ 15 | missed by 31 |

Counters per W/E post Edit call (strace census, 2 traced sessions per
arm per stratum), base -> after: successful execs typos-format 7 -> 4,
eol-normalizer 7 -> 3, markdown-format 6 -> 3, bash-format 7 -> 4,
guardrails post-verify `.md` 16 -> 4 (`.sh` 7 -> 3, 33 KB 23 -> 6),
context-guard PostToolBatch 4 -> 2. Failed file probes typos 580 -> 290,
eol 409 -> 212, post-verify `.md` 1060 -> 298. Failed execve stay 46 per
hook: dash's `exec bash` walks PATH with execve as `env` did; plain
`bash` would walk with stat but forks, and measured slower (1.5 vs 1.3
ms, hyperfine primitive).

The context-guard early exit applies only on a host without the
context-guard status line (this one); with the status line the directory
exists and the full path runs as before.

## Related

- Overlaps #4450 (row shape, exit-before-source); merged in and
reconciled as described under Fix.
- Pattern precedent: #4185 (dispatcher cache, `_uncached` twins,
17-carrier sync); row shape: #4421, #4422.
- Plugin README cost tables (guardrails, bash-format, typos-format,
eol-normalizer) are dated measurements of earlier versions and were not
re-measured here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.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.

1 participant