Skip to content

refactor(guardrails): read hook commands from shfmt's parse tree, remove the hand-written segmenter - #12896

Merged
kyle-sexton merged 25 commits into
mainfrom
refactor/12471-shfmt-command-reader
Nov 4, 2026
Merged

kyle-sexton merged 25 commits into
mainfrom
refactor/12471-shfmt-command-reader

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Closes #12471

Summary

The guardrails command reader now gets each Bash command's structure from shfmt's parse tree (shfmt -ln bash --to-json) instead of the hand-written segmenter, which is removed. A command shfmt cannot parse is refused (exit 2, fail closed). The hook::bash_parse_segments callback contract every guard uses is unchanged.

Fix

  • lib/hook-utils.sh (and its synced copies): the reader walks shfmt's JSON for segments, redirects and here-doc bodies, and adds an extglob-off second reading and an array # second reading where bash's own reading depends on them. A second reading that does not parse is refused. shfmt runs under a 5 s bound built on read -t; a timeout, a syntax error, invalid UTF-8 or a missing shfmt each refuse with their own reason.
  • Guard behaviour, measured over 1025 commands both readers allow plus the suites: 56 commands move allow to deny and none move deny to allow. The deny cases are syntax errors shfmt rejects (an unclosed here-doc, an unterminated quote), second readings that no longer parse, and:
    • git --config-env=alias.<n>=$(...) <n> (block-dangerous-git, block-noncanonical-commit): main allows it, because the old reader split the word at $( and never saw the alias as the subcommand. In a fresh git init repository with git 2.53.0, NAME='!touch pwned' bash -c 'git --config-env=alias.rh=$(printf NAME) rh' exits 0 and creates pwned, so the alias's shell command runs. The guard now denies when a substitution supplies the alias name.
  • plugins/guardrails/hooks/cloud-shfmt.sh (SessionStart): installs the pinned, hash-checked shfmt in cloud sessions and records why an install failed; the refusal message quotes that reason.
  • CI: the Windows hook and lib jobs install shfmt (pinned windows_amd64 digest), and the eval job installs it when the target is guardrails.
  • pr-body-linkage-gate.sh: its header now says a parser refusal also denies.
  • Tests, outside the oracle table:
    • The 62 segment-shape rows are not deleted: 9 keep their split, 17 assert shfmt's refusal, and 36 assert that git reset --hard reaches the callback if and only if bash runs it (extglob off and on).
    • Rows for an unclosed here-doc, the --config-env alias and two message-only refusals expect deny.
    • Fork-budget pins count processes, not Go runtime threads, and are exact where a command is parsed: one parse is 7 process creations and 2 execs.
  • The oracle table and its expected values are unchanged. bps_oracle_row counts a refused row as not hidden, since it runs nothing.

Verification

  • Oracle: 2895 rows where bash runs the HIT word, 1710 refused, none hidden. lib/hook-utils.test.sh 720/0 and hook-utils-shfmt.test.sh 139/0. Every guard suite on the reader's path passes, among them block-dangerous-git 1020/0, run-guards 730/0, block-root-delete-target 1478/0, block-cross-domain 2779/0 and pr-body-linkage-gate 183/0.

  • PowerShell differential against origin/main: 0 lost and 0 gained over 851 commands and 11036 token cells. shellcheck: 38 files, 0 findings. Shared-copy sync --check/--check-bump and validate-plugins.sh pass. The timer passes on bash 3.2.57.

  • Latency per hook call: one full run-guards.sh dispatch with the Bash guard list from hooks.json, interleaved per command, origin/main's plugins/guardrails (from git archive) against this branch, over the 1025 of 1139 corpus commands both arms allow. The corpus is the distinct commands harvested from the guardrails suites, NUL-separated. Measured 2026-11-03 on a quiet WSL2 host (32 CPUs, load 2.4 to 1.7), one run. "added" is the p50 and p95 of each command's own difference (this branch minus origin/main, same command), so it is not the difference of the two rows above it.

    p50 p95
    origin/main 47.1 ms 79.3 ms
    this branch 52.7 ms 92.6 ms
    added (same command) 4.0 ms 26.1 ms

    The commands that measured it, run from the worktree root (timing.sh and summary.py below):

    bash timing.sh origin/main timing.tsv corpus.bin
    python3 -I summary.py timing.tsv
    timing.sh
    #!/usr/bin/env bash
    # Usage: timing.sh <old ref> <out.tsv> <corpus.bin>
    # Writes <index> <arm> <microseconds> <exit> rows, one per command per arm.
    set -u
    wt=$(git rev-parse --show-toplevel)
    old_ref=$1 out=$2 corpus=$3
    tmp=$(mktemp -d)
    trap 'rm -rf "${tmp:?}"' EXIT
    mkdir -p "$tmp/old" "$tmp/new" "$tmp/cwd"
    git -C "$wt" archive "$old_ref" plugins/guardrails | tar -x -C "$tmp/old"
    cp -r "$wt/plugins/guardrails" "$tmp/new/"
    guards=(--lib lib/powershell/ps-command.sh --max-substitutions 256 --max-command-len 16384
      block-no-verify.sh block-dangerous-git.sh flag-commit-pr-skill-bypass.sh
      block-noncanonical-commit.sh block-convention-violation.sh block-windows-drive-tmp.sh
      block-exported-msys-pathconv.sh block-root-delete-target.sh block-credential-read.sh
      block-unresolved-pr-merge.sh block-fix-request-post.sh block-github-post-content.sh
      block-cross-domain.sh block-cd-prefix.sh)
    mapfile -d '' cmds <"$corpus"
    : >"$out"
    for ((i = 0; i < ${#cmds[@]}; i++)); do
      jq -cn --arg c "${cmds[i]}" --arg d "$tmp/cwd" \
        '{session_id:"timing",tool_name:"Bash",cwd:$d,tool_input:{command:$c}}' >"$tmp/p.json"
      for arm in old new; do
        root=$tmp/$arm/plugins/guardrails
        [[ $arm == new ]] && root=$tmp/new/guardrails
        t0=${EPOCHREALTIME/./}
        (cd "$tmp/cwd" && CLAUDE_PLUGIN_ROOT="$root" bash "$root/hooks/run-guards.sh" "${guards[@]}" <"$tmp/p.json" >/dev/null 2>&1)
        rc=$?
        t1=${EPOCHREALTIME/./}
        printf '%s\t%s\t%s\t%s\n' "$i" "$arm" "$((t1 - t0))" "$rc" >>"$out"
      done
    done
    summary.py
    import sys
    
    by = {}
    for line in open(sys.argv[1]):
        i, arm, us, rc = line.rstrip("\n").split("\t")
        by.setdefault(arm, {})[int(i)] = (int(us), int(rc))
    
    
    def pct(v, p):
        v = sorted(v)
        k = (len(v) - 1) * p / 100
        f = int(k)
        c = min(f + 1, len(v) - 1)
        return v[f] + (v[c] - v[f]) * (k - f)
    
    
    both = [i for i in sorted(set(by["old"]) & set(by["new"])) if by["old"][i][1] == 0 and by["new"][i][1] == 0]
    for arm in ("old", "new"):
        v = [by[arm][i][0] / 1000 for i in both]
        print(f"{arm}: n={len(v)} p50={pct(v, 50):.1f} ms p95={pct(v, 95):.1f} ms")
    d = [(by["new"][i][0] - by["old"][i][0]) / 1000 for i in both]
    print(f"added (same command): p50={pct(d, 50):.1f} ms p95={pct(d, 95):.1f} ms")
  • melo-desk-001 Windows Token census: 300 Git Bash spawns changed Token objects by +11 (an equal idle period, -9), so the spawns leak no Token per spawn.

  • Left out: read -t on a pipe under Windows Git Bash runs only in this PR's Windows jobs. The timing harness is one-off tooling, so it is quoted above rather than committed.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 9 commits November 3, 2026 00:10
HOOK_BASH_READER=shfmt selects a reader on shfmt's parse tree, with the
default reader unchanged. The old segmenter stays until the owner decides how
a refusing reader meets the oracle table: see issue 12471.

Refs: #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VQb9Q2e8NLMQ1mwcztzP39
…ll shfmt in cloud sessions

The shfmt command reader (still opt-in through HOOK_BASH_READER=shfmt) now
says why it refused: shfmt or jq missing, the 5 s bound, shfmt's own syntax
error line, or the reason a cloud session's install failed. It uses a shfmt
in ~/.local/bin when PATH has none.

- guardrails SessionStart step (cloud sessions only) installs the pinned,
  hash-checked shfmt v3.14.1 into ~/.local/bin and never blocks the session.
- The PowerShell reduction drops a statement-opening `&` call operator or
  empty `;`, which bash's grammar cannot read; block-unresolved-pr-merge
  parses PowerShell text with its backtick escapes dropped.
- The bash oracle counts a row the reader refuses (exit 2 with its BLOCKED
  text) as not hidden; any other non-zero exit fails the row.
- Each kind of parser call site has a fail-closed test under the shfmt
  reader, the run-guards forked chain included.
- CI installs the pinned shfmt on the Windows jobs that run the guard
  suites, and in the plugin eval job when it targets guardrails.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… suites red)

Save point for issue 12471 step 2: hook::bash_parse_segments reads every
command through shfmt, and the hand-written segmenter is removed. Here-doc
body commands come after the top-level ones; destructive-guard reads
substitution here-doc bodies from the whole command's parse. The suites
whose expectations the owner ruled on are updated in the next commits.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s count

The parse timer no longer starts a sleep watcher that the parent killed at a
race-dependent point. One subshell starts shfmt, prints its pid, waits and
prints its status; the next reads the status with the read -t builtin and
kills shfmt only when none comes in time. A parse that answers in time now
forks the same 7 processes and execs shfmt and jq on every run, and needs no
sleep or timeout on PATH.

- Process census in the reader-path suites counts processes, not the Go
  runtime's threads (CLONE_THREAD clones), and pins exact counts: one parse
  is 7 creations and 2 execs.
- Owner rulings on issue 12471 applied: unclosed here-doc rows and the
  --config-env substitution rows now expect deny; the fence-echo and
  escaped-quote-delimiter rows assert the reader's refusal text.
- pr-body-linkage-gate's header states that a command the reader cannot
  parse is denied.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The shape rows in lib/hook-utils.test.sh that asserted the removed
segmenter's split now assert what bash runs: each turns its
`git reset --hard` into `echo HIT >&2` and checks that the reader hands
that command to the callback exactly when bash (extglob off or on) runs
it. Rows shfmt cannot parse assert the refusal and its reason, in a
child process. Rows whose split shfmt reproduces are kept.

Two reader gaps the rows exposed are closed:

- A whole-command second reading (extglob off, or the array `#` one)
  that does not parse now refuses instead of being dropped: bash runs
  every line before its syntax error, here-doc bodies included. Before,
  `!(x)#'` followed by `cat <<B` with a `$(git reset --hard)` body was
  allowed.
- The extglob-off and array rewrites apply their edits in offset order,
  so a nested `!(x)#` no longer garbles the second reading.
- A here-doc body the reader would place inside backticks (an empty body
  after an opener whose line ends inside a backtick) is refused.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton
kyle-sexton marked this pull request as ready for review November 3, 2026 18:01
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 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-11-03T18:04:24.975875Z aad571a 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: aad571a38f

ℹ️ 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".

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on aad571a38fc27961fcfae0b0b0e33be9b9627bec; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review needs no new review of b71df9bcb03725fd92e0021445866d27e6069e9c: no file of this pull request changed since the review of 6ce73f0.

Chunk 1 of 2 (37 files)

claude-security-review: full review of chunk 1 of 2 (37 files) at 6ce73f0aa0e90200eb380c3c573db9fa276e24de. I read every hunk of all 37 files, including the plugin copy plugins/animation/hooks/hook-utils.sh.

critical-findings: 0

No security issues found in this chunk.

The command reader (lib/hook-utils.sh)

  • Parsing the jq output. The result is read with eval "__hu_bt=($__hu_bout)" from jq's @sh quoting. The newline and CR splice ('$'\n'') now reaches jq, so eval gets only quoted words. Numbers go through tostring | q.
  • Timeout. read -t bounds the shfmt run. A timeout exits 142, and with pipefail a jq error is still a failure. Both refuse, and neither reaches eval.
  • Values the environment cannot set. HOOK_BPS_TIMEOUT, HOOK_BPS_MAX_PARSES and HOOK_BPS_FAIL_OPEN are assigned when the library loads. A pre-set HOOK_BPS_PARSES or __hu_bps_in can only make the reader refuse sooner.
  • Extglob. The E entry refuses before any callback runs, soft calls included.
  • Second readings. The array # reading, backtick text and the joined here-doc body are all parsed strictly. _bps_hd_check refuses a body that only a second reading sees.
  • Backticks. A backtick holding a here-doc, or one that bash would end at a different backtick, is refused.

Guards

  • block-root-delete-target.sh and destructive-guard.sh now judge every here-doc body whenever a shell or interpreter reads one. That is stricter than the per-index mapping it replaces.
  • rdt_segment now cuts each word where a substitution starts ($(, a backtick, <(). This matches how the old reader split those words, so nothing that was denied before is allowed now.
  • ps::_bash_ready_to drops a & or ; only where it opens a statement. &&, a & after a word, quoted spans and comments are kept. Forms like (& …) still fail to parse and are refused.

New SessionStart row (hooks/hooks.json → node-or-skip → cloud-shfmt.sh)

  • It runs only when CLAUDE_CODE_REMOTE=true (cloud-shfmt.sh#L20 and L47).
  • It downloads the shfmt v3.14.1 release and installs it only after the SHA-256 matches (L32-L33, L55-L60).
  • Every path exits 0, so it never blocks a session.
  • The code it runs ships in this plugin, so installed users get no source they did not already trust. hooks.json and prerequisites.json are not plugin.json or marketplace.json, so the manifest checks do not apply.

Workflows (pr-require-checks.yml, pr-test-platforms.yml, eval-browser-dispatch.yml)

  • Each change only adds a shfmt install pinned to a version and a SHA-256.
  • run: blocks read only env: values, so there is no expression injection.
  • No permissions: block or trigger changes.
  • The pr-require-checks.yml step sits before the performance-counter check, not in a ci-status hold step.

Trust controls. No trusted-actor list, token scope, kill switch or merge hold changes in this chunk.

Instruction-surface lens. No standing instruction is deleted or loosened. The README and reference text add requirements and document refusals. Test rows only flip from allow to deny or refused.

Prior findings. All ten earlier inline findings are marked lines changed since posted: yes, and the code each one named has been removed or rewritten:

  • the extglob pattern reparse, the extglob-off rewrite and the bash 3.2 dedupe are deleted;
  • the joined here-doc body is now parsed strictly;
  • the guards that counted openers to match bodies now judge every body.

None is raised again.

Round cap applied (past the cap: yes). Only CRITICAL and IMPORTANT findings would be posted, and this chunk has none.

Pre-existing, not counted. For PowerShell, block-unresolved-pr-merge.sh now drops every backtick before parsing (L678). That turns PowerShell's escaped quote `' into a real Bash quote, so in Write-Output `'; gh pr merge 42; Write-Output `' the merge sits inside a Bash string. The segmenter on main also read that merge as a quoted word, so this is not a regression from main.

Spend, not a hold. The CI jobs and cloud sessions add a small shfmt download, and each hook call costs one shfmt+jq parse. That uses existing Actions minutes and session time, with no billing change.

Chunk 2 of 2 (10 files)

claude-security-review: chunk 2 of 2 at 6ce73f0aa0e90200eb380c3c573db9fa276e24de. I read every hunk of this chunk's 10 files.

critical-findings: 0

I found no security issues in this chunk.

What I checked:

  • plugins/source-control/hooks/worktree-add-claim-gate.sh: the gate sets HOOK_BPS_FAIL_OPEN=1 just before its hook::bash_parse_segments call and sets it back to 0 right after (L101-L105).
    • It is a PostToolUse hook, so the git worktree add has already run and exit 2 could not block it. Failing open here lets nothing through that the base stopped.
    • The variable is a plain shell variable, not exported, so it does not reach child processes. The PreToolUse containment gate still refuses a command the reader cannot parse.
    • The owner settled this design in an earlier thread (fixed in fe7bf50; those lines are unchanged since).
  • plugins/source-control/hooks/pr-body-linkage-gate.sh: only the header comment changes. It now says that a command the reader cannot parse is denied. That makes the gate stricter, not looser.
  • plugins/source-control/prerequisites.json: adds a jq 1.6 floor and a required shfmt (v3.14.1 or later) entry with a docs-only install pointer. This file is a prerequisites declaration, not a .claude-plugin manifest. Nothing in it installs or runs code, and no userConfig guard changes.
  • scripts/shared-copies.txt: maps lib/node-or-skip.{sh,ps1} to the new plugins/guardrails/lib/ copies, the same way the existing plugin copies are listed. The sync --check enforces that the copies match their source.
  • Test files (destructive-guard.test.sh, pr-body-linkage-gate.test.sh, worktree-add-claim-gate.test.sh, the two spawn-budget tests) and plugins/source-control/README.md:
    • The tests add deny rows for the here-doc ordering, count and extglob bypasses fixed in chunk 1, plus one benign allow row.
    • They turn the unterminated here-doc row into a refusal.
    • They count Go runtime threads out of the fork pins, and they change the pins to exact counts.
    • No guardrail or standing instruction is deleted or loosened, so the instruction-surface lens found nothing.

Prior findings. Every prior finding on this PR is on lib/hook-utils.sh, which is in chunk 1, so none applies here. The round cap applies (past the cap: yes), so I would have dropped any SUGGESTION finding; this chunk had none.

Spend. Nothing in this chunk spends credit or bills. The higher fork-budget pins only record what the shfmt parse already costs on each hook call.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

claude-review needs no new review of b71df9bcb03725fd92e0021445866d27e6069e9c: no file of this pull request changed since the review of 6ce73f0.

Chunk 1 of 2 (37 files)

claude-review, chunk 1 of 2 at 6ce73f0aa0e90200eb380c3c573db9fa276e24de: no findings.

Round cap applied. prior-findings.md says past the cap: yes (3 of 3 rounds), so this review reports only blocking findings. Chunk 1 has none.

What I read. I read every hunk of the 37 files in chunk 1:

  • The three changelog fragments and the three workflows: eval-browser-dispatch.yml, pr-require-checks.yml and pr-test-platforms.yml.
  • .performance/ratchets.json.
  • lib/hook-utils.sh, lib/hook-utils.test.sh and lib/hook-utils-shfmt.test.sh.
  • plugins/animation/hooks/hook-utils.sh. It is the first path of its group in identical.txt, and it matches the lib/ change apart from the generated-header offset. Every copy in that group stays at a library path (hooks/hook-utils.sh, or skills/clean/scripts/hook-utils.sh for repo-hygiene), so no copy's path changes what its content does. The .changes/* fragment copies are changelog data.
  • Every guardrails hook, test, lib and reference file in the chunk.
  • The repo-hygiene README, its prerequisites and destructive-guard.sh.

What I checked. I read the code only. I had no shell and ran nothing, so the suite counts and timings in the PR body are the author's claims and I did not confirm them.

  • The new SessionStart row (the head commit), in hooks.json L142-153:
    • It uses the same polyglot node-or-skip shape as the existing explainer-video, speech and playwright rows.
    • plugins/guardrails/lib/node-or-skip.{sh,ps1} are registered as generated copies of lib/node-or-skip.* in scripts/shared-copies.txt (chunk 2).
    • The exec-bash.test.sh pin matches the row text exactly.
    • The 90 s timeout covers curl's --max-time 60.
  • cloud-shfmt.sh:
    • Outside a cloud session it exits before loading any library, and sourced it goes on.
    • It writes ~/.local/bin/shfmt only after the SHA-256 matches, and every path exits 0.
    • The note path matches HOOK_BPS_INSTALL_NOTE in the reader.
    • The pin and hash match the Linux CI installs. The Windows jobs pin the windows_amd64 digest separately.
  • The reader (hook::bash_parse_segments_shfmt):
    • It refuses on extglob and on any second reading that does not parse.
    • The shared parse budget and HOOK_BPS_FAIL_OPEN (reset when the library loads) are as the settled threads describe.
    • The jq q splice now spells its quotes as ', so the LF/CR escape reaches jq.
  • Here-doc consumers. block-root-delete-target.sh (rdt_ix_heredocs) and destructive-guard.sh now judge every body whenever a shell or interpreter reads a here-doc. That closes the count-mapping class from the settled threads.
  • PowerShell. ps::_bash_ready_to drops a statement-opening & or an empty ; and keeps &&, ||, quoted spans and comments. block-unresolved-pr-merge.sh parses the PowerShell text with its backticks dropped. Its library is declared in guard-requires.sh and preloaded on the merge row's --lib.

Closing issue #12471.

  • The reader uses shfmt --to-json, and the hand-written segmenter is removed: met.
  • The oracle table and its expected values are unchanged. Only bps_oracle_row's counting of refused rows changed, per decision Q1(b): met.
  • p50 and p95 latency are recorded in the PR body: met (author-measured).
  • The parse differences are documented in reference/shfmt-parse-differences.md, and hook-utils-shfmt.test.sh runs each row: met.

Dropped under the cap (advisory, not counted). lib/hook-utils-shfmt.test.sh L96-98 still says a !(…) argument or [[ ]] pattern "is read once and allowed". Those forms are now refused (extglob_refused rows at L579-583). The last round also noted this.

Settled threads. I re-raised none. I found no embedded instructions in the PR context, settled-threads or prior-findings files.

Manifests. No plugin.json or marketplace.json is in this chunk. The changelog bumps (guardrails minor, repo-hygiene minor, animation patch) fit the changes.

Spend (not a finding). The new steps download shfmt in the existing CI jobs and in guardrails eval runs, which adds a few seconds of runner time on runners already in use. Each hook call adds one shfmt and jq parse.

Chunk 2 of 2 (10 files)

claude-review, chunk 2 of 2: no findings.

Round cap applied. prior-findings.md says past the cap: yes (3 of 3 rounds), so this review reports only blocking findings. This chunk has none.

What I read. I read every hunk of all 10 files in chunk-2.patch:

  • plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh
  • plugins/source-control/README.md
  • plugins/source-control/hooks/pr-body-linkage-gate.sh and its test
  • plugins/source-control/hooks/pr-linkage-spawn-budget.test.sh
  • plugins/source-control/hooks/worktree-add-claim-gate.sh and its test
  • plugins/source-control/hooks/worktree-gates-spawn-budget.test.sh
  • plugins/source-control/prerequisites.json
  • scripts/shared-copies.txt

None of these files are copies listed in identical.txt.

What I checked. I only read code. I had no shell, so I ran nothing. The suite counts and exact fork pins in the PR body and the owner's replies are the author's claims, and I did not confirm them.

  • Claim gate fails open. worktree-add-claim-gate.sh sets HOOK_BPS_FAIL_OPEN=1 after it sources the library (L103-105). The library resets the variable to 0 when it loads (hook-utils.sh L4677), so the order is right, and hook::_bps_refuse exits 0 when the flag is set (L4752). This matches the settled thread's fix. The new PostToolUse row with no shfmt on PATH expects exit 0 and no BLOCKED text.
  • pr-body-linkage-gate refusal row. The test matches got no parse and then unclosed here-document. Both strings can appear in the message: the refusal text holds got no parse (${HOOK_BPS_FAIL}) (L4753), and a syntax error puts shfmt's own message into HOOK_BPS_FAIL (L4724). The header now says this refusal is an exception to the allow-when-undeterminable rule.
  • Spawn-budget tests. The tests now drop CLONE_THREAD lines with grep … | grep -vc CLONE_THREAD. When nothing matches, grep -c prints 0. The exit status of the $(…) is ignored, because these scripts don't run under set -e. The comments explain the exact counts (7 clone calls and 2 execs per parse).
  • destructive-guard rows. The new deny rows cover the here-doc forms from the settled threads: a here-doc inside a substitution or body, a redirect-only <<A, and a here-doc inside !(…). The harmless cat <<A docs A / bash <<B echo hi B pair stays an allow row. The two extglob rows are now denied by the reader's extglob refusal.
  • prerequisites.json. The jq entry now requires 1.6 or later, which closes the settled jq-floor thread. The new shfmt entry's pattern v?([0-9.]+) matches shfmt --version output (v3.14.1). Its degrade text correctly says the claim gate leaves the tree unclaimed.
  • shared-copies.txt. It adds the guardrails node-or-skip.sh and node-or-skip.ps1 copies in alphabetical order. The two files themselves are in chunk 1.

Dropped under the cap (advisory, not counted).

  • plugins/source-control/README.md L386-387 says that without shfmt "the gates deny the commands they read". That is true of the PR-linkage and containment gates, but the worktree claim gate fails open and only leaves the tree unclaimed, as the prerequisites.json degrade text says.
  • The claim-gate no-shfmt row runs with PATH="$NOSHFMT:/usr/bin:/bin". On a host where shfmt is installed in /usr/bin, the row still passes but no longer tests a missing shfmt.

Settled threads. I raised none of them again. The claim-gate fail-open thread and the jq-floor thread are both marked lines changed since resolution: no, and this chunk holds their fixes. I found no embedded instructions in the PR context, settled-threads or prior-findings files.

Spend (not a finding). On the PR's own measurement, each parsed hook call costs about 4 ms more at p50 and 26 ms at p95. This uses only local compute and creates no charge.

…ings

- block-root-delete-target reads a word cut where a substitution starts
  (`eval $(…)` as `eval $`, `~/$(whoami)` as `~/$`, no word for `<(…)`),
  the shape its decoder-sink, eval and root rules were written for, and a
  `{ …; }` group makes the pipe feed open. Restores the decoder-sink,
  engine-parity-52 and group-pipe denials origin/main gave.
- The joined reading of an unquoted here-doc body with a backslash-newline
  is parsed strictly: bash runs that body up to its first error, so a
  joined body that does not parse now refuses instead of being dropped.
- An empty here-doc body starts on the line after its own opener (or after
  the body before it), not the start of the command or the previous body.
- block-unresolved-pr-merge drops PowerShell's `&` call operator and empty
  `;` statements before the bash parse, as the git guards' reduction does.
- cloud-shfmt.sh installs the abort boundary like every registered hook;
  the exec-bash pin allows exactly that one SessionStart row.
- shfmt is a required prerequisite with a v3.14.1 floor in guardrails,
  source-control and repo-hygiene; curl is declared for the cloud install;
  those three fragments are minor bumps.
- The check-plugins job installs the pinned shfmt before the performance
  counters, and guardrails-commit-guard-spawns counts the parse's one jq.
- hook-utils-shfmt.test.sh clears GIT_DIR and friends; engine parity 127
  and 128 expect the reader's refusal of an unclosed quote.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton

Copy link
Copy Markdown
Member Author
# Source Finding Classification Evidence Fix
1 claude-review 6611803268 #1 An empty here-doc body started at the wrong offset when its opener was not on line 1 VALID (fix now) New rows: before the fix the reader gave 8 19 (want 18 19) and 12 28 (want 27 28) e8f3b0a: the H record carries the opener offset; the body starts on the line after the opener or after the previous body
2 claude-review 6611803268 #2 shfmt declared optional with no version floor VALID (fix now) The prerequisites contract defines optional as a reduced result; without shfmt every Bash call is denied e8f3b0a: required, floor 3.14.1, in guardrails, source-control and repo-hygiene
3 claude-review 6611803268 #3 bump: patch understates the change VALID (fix now) The change adds a SessionStart hook and a hard dependency e8f3b0a: minor for guardrails, source-control and repo-hygiene; the 18 plugins that only carry the shared hook-utils.sh copy stay patch, since none calls the parser
4 security-review 6611782346 Same as the inline here-doc finding VALID (same) See its thread e8f3b0a

Same push also fixes the CI failures: the delete-target split suites (decoder sinks, { ...; } | xargs rm, rm -rf ~/$(whoami) restored to deny, matching main), the cloud-shfmt abort boundary and SessionStart pin, curl declared for cloud-shfmt, shfmt installed before the performance counters (commit-guard spawn ceiling 7 to 8 for the parse's one jq), and git env isolation in hook-utils-shfmt.test.sh. Engine-parity rows 127 and 128 (an unclosed quote bash rejects) now expect the reader's refusal.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 2 inline finding(s) on e8f3b0a6b1171c157623f8a337ee0a8bb76a1ac6; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread lib/hook-utils.sh Resolved
Comment thread lib/hook-utils.sh Resolved

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on e8f3b0a6b1171c157623f8a337ee0a8bb76a1ac6; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
…itution

- An extglob pattern's text is reparsed for the commands its substitutions
  run; a pattern holding `$(`, a backtick, `<(` or `>(` now refuses when it
  does not parse. `echo @($(cmd)|)` and `echo @(;$(cmd))` hid `cmd` from
  every guard with extglob on. A pattern with no substitution stays soft.
- HOOK_BPS_JQ spells the splice quotes as ', so jq really turns a
  word's LF or CR into an ANSI-C `$'\n'` and its output carries none raw
  (a jq that writes CRLF would otherwise put a CR into a word).
- The comments over the reparse loops say which list is soft and why.
- cloud-shfmt.sh leaves before any library loads outside a cloud session
  (check-killswitch-hoist); the abort-boundary forced-abort run passes
  CLAUDE_CODE_REMOTE=true so it still reaches the boundary.
- The guardrails, source-control and repo-hygiene READMEs name shfmt
  v3.14.1+ under Requirements.
- The ~/.local/bin shfmt row runs a wrapper that keeps the real HOME, so a
  version manager's shim still resolves.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
@kyle-sexton

Copy link
Copy Markdown
Member Author
# Source Finding Classification Fix
1 CI lint-shell check-killswitch-hoist: cloud-shfmt.sh reached its first early exit after sourcing its library VALID (fix now) cbc68ae: the hook returns before any library loads outside a cloud session; abort-boundary's forced-abort run sets CLAUDE_CODE_REMOTE=true so it still reaches the boundary. check-killswitch-hoist.sh rc 0.
2 claude-review summary 6611803268, finding 3 The guardrails, source-control and repo-hygiene READMEs do not name shfmt under Requirements VALID (fix now) cbc68ae: a shfmt v3.14.1+ line in each, with what is denied without it; guardrails also notes the cloud install.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on cbc68ae1f00d85b0436ea2c4e2f5544366aa44f7; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
…ight

- An extglob pattern holding `$(`, a backtick, `<(` or `>(` is refused
  with its own reason, with no reparse: a `#`, blank-then-`#` or newline
  in the pattern is literal to bash but a comment or separator to a
  command parse, so `echo @(#$(cmd))` and `echo @(x #$(cmd))` hid `cmd`.
  A pattern without a substitution keeps the soft parse.
- The extglob-off reading is queued only for a `!(` or `f@()` that opens a
  command. As an argument or a `[[ ]]` pattern (`ls !(*.md)`,
  `rm -- !(keep.txt)`, `[[ $x == !(y) ]]`) bash with extglob off rejects
  the line and runs nothing, and these are allowed again.
- The guardrails README says an older shfmt is used as found; the
  repo-hygiene shfmt entry drops the `apt` hint, which installs a build
  below its own 3.14.1 floor.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
@kyle-sexton

Copy link
Copy Markdown
Member Author
# Source Finding Classification Fix
1 claude-review 6611803268, finding 1 The extglob-off reading refused valid !(…) arguments and [[ ]] patterns (ls !(*.md), rm -- !(keep.txt), [[ $x == !(foo) ]]) VALID (fix now) 4325564: the off reading runs only where the extglob opens the command; four rows went exit 2 to 0
2 claude-review 6611803268, finding 2 The guardrails README said an older shfmt build makes the guards deny VALID (fix now) 4325564: the reader has no version check, so the README now says an older build is used as found
3 claude-review 6611803268, finding 3 repo-hygiene's apt hint installs a shfmt below the 3.14.1 floor VALID (fix now) 4325564: apt hint removed; brew, winget and docs remain
4 security-review 6611782346 Same as the inline extglob # finding VALID (same) 4325564

@kyle-sexton
kyle-sexton enabled auto-merge November 3, 2026 21:15

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 432556480b3b80f05b9a948fea3e9c71edc93e84; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 1 inline finding(s) on 432556480b3b80f05b9a948fea3e9c71edc93e84; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread lib/hook-utils.sh Resolved
kyle-sexton and others added 2 commits November 3, 2026 16:37
Nested extglob patterns each take a parse, and an extglob-off reading
parses the pattern again, so `echo @(!(x); @(!(x); @(… x …)))` doubled the
parse count per level: sixteen levels (153 bytes) ran past 60 s, the Bash
row's hook timeout. A budget shared down the nested calls and reset at a
top-level call (HOOK_BPS_MAX_PARSES, 64) now refuses past the cap with its
own reason, and identical pattern texts are parsed once per call. The
sixteen-deep nest and a hundred flat patterns now refuse within a second.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
The reader's jq program calls utf8bytelength, first shipped in jq 1.6;
jq 1.5 cannot compile it, so every Bash and PowerShell call would be
refused while the prerequisites probe reported jq as fine. The jq entry
in guardrails, source-control and repo-hygiene now carries a version
check with min 1.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-review: 1 inline finding(s) on 3f3cd1125a3586863aac726ca847952a03d4aa2f; the summary is in this lane's pull request comment.

claude-lane: claude-review

Comment thread lib/hook-utils.sh Resolved
…e failure

worktree-add-claim-gate.sh runs after the command (PostToolUse) and is
documented as fail-open, but a reader refusal (no shfmt or jq, the 5 s
bound, a syntax error, the parse budget) printed BLOCKED and exited 2 after
the add had run. The library gains HOOK_BPS_FAIL_OPEN, assigned 0 when it
loads so the environment cannot set it; the claim gate sets it around its
parse, and a refusal then exits 0 quietly. Every PreToolUse caller still
refuses. The claim gate is the only reader caller off PreToolUse.
source-control's shfmt degrade text now says which gates deny and that the
claim gate leaves the tree unclaimed.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on fe7bf50f9e5349abf0f42caf5d8cbb892f418d6d; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
kyle-sexton and others added 2 commits November 3, 2026 17:23
…t an associative array

On bash 3.2 `local -A` fails, so the pattern dedupe used an indexed array
and evaluated each extglob pattern's text as an arithmetic subscript,
running a `$(...)` inside it in the hook before the substitution refusal.
The refusal now runs first, and the dedupe keeps a newline-delimited list
of %q-quoted texts. Checked under bash 3.2.57: the old form runs the
substitution, the new one keeps each distinct pattern once and runs nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 993c2d639ace4860cbbea1ddaa3626b767316c82; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
…their openers

HOOK_HEREDOC_BODIES was sorted by opener offset while segments reach the
callback by statement, so a here-doc opened in a substitution of a statement
that starts earlier, or in a here-doc body (whose statements come after the
top-level ones), took the index a later-called opener's body should have.
block-root-delete-target and destructive-guard number bodies by counting
openers in callback order, so `X=$(true <<A … A) bash <<A` judged only the
inner body and let the shell's script run unchecked. Each opener is now
ranked by its holding statement as the segments are sorted, then by place,
and the list follows that rank. HOOK_HEREDOC_BODIES_SRC keeps the source
order for the scans that walk the command from its start
(block-unresolved-pr-merge's body strip, destructive-guard's substitution
scan), and run-guards replays both.

An extglob pattern holding bash 5.3's `${ cmd; }` or `${| cmd; }` (a blank,
tab, newline or `|` after `${`) is refused like the other substitutions.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on aba763ce6abc8f8ff20ded42c658531b3cfa4aa5; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
block-root-delete-target and repo-hygiene's destructive-guard matched the
here-doc a shell reads to a body by counting `<<` openers in callback order.
That count has now failed three ways: a here-doc opened in an earlier
substitution or in a body, a redirect-only statement (a body no callback
counts), and a joined body's or extglob pattern's second reading (an opener
with no body in the list). With the counts equal again, `<<A z A` then
`bash <<A rm -rf / A` then a joined `cat <<A` body judged only `z`.

Whenever any command reads a here-doc as its script, both guards now judge
every here-doc body in the command as that script, and the per-index match
is gone. No consumer needs callback order any longer, so HOOK_HEREDOC_BODIES
goes back to one source-order list (the callback ranking,
HOOK_HEREDOC_BODIES_SRC and its run-guards replay are removed), and the
contract says a consumer reads the bodies as a set.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on c0fa5e2328c362fc4c1821f5697f5af984659175; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
…nd sees

shfmt keeps a `!(…)` at a command's start as one extglob literal, so a
here-doc inside it has no body in the first tree. The extglob-off reading
and the pattern parse saw `bash <<A`, but HOOK_HEREDOC_BODIES is the first
tree's, so the guards got a shell reading a here-doc with no body and
`!(bash <<A` / `rm -rf /` / `A` / `)` ran unchecked.

After each second reading, every body it lists must be one the first tree
holds, or the reader refuses ("a here-doc only a second reading of the
command sees"), or a soft caller returns 1:
- an extglob pattern or a backtick's text: the first tree holds none of its
  bodies, so any body refuses;
- a joined here-doc body, read as `: <<D` then the joined text: its first
  body is the joined form of a first-tree body; any other refuses;
- the extglob-off and array `#` readings, which insert one character per
  rewrite: each body's start and end, moved back past those insertions,
  must be a first-tree body's.
Ordinary `!(x)` and `@(a|b)` commands, nested `!(x)#` here-docs and the
harmless here-doc pair are unchanged.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 7e142ef887ecc7ac5bf7bfb27190308f3e6ba234; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
@kyle-sexton
kyle-sexton disabled auto-merge November 4, 2026 00:11
…slash

shfmt ends an ExtGlob pattern at a `)` whatever its quoting, while bash
skips a quoted one, so `echo @(x')'$(cmd)) #'` hid a substitution bash
runs: shfmt reported the pattern as `x'` and the rest as a quoted literal.
A pattern holding a quote or backslash now refuses. New form rows for the
$(...) and backtick forms failed before the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
@kyle-sexton
kyle-sexton enabled auto-merge November 4, 2026 00:20
@kyle-sexton
kyle-sexton disabled auto-merge November 4, 2026 00:22

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-security-review: 1 inline finding(s) on 32e3237b8e9f57de0623d8a3438c977c4ac9b566; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread lib/hook-utils.sh Resolved
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

The pr-verify verifier failed head 32e3237b8e9f57de0623d8a3438c977c4ac9b566. What it wrote, as data:

What it wrote (165 lines)
Summary: Whole spec, fail: Issue #12471 (closes): the chunks together deliver the shfmt-based reader
  with the old segmenter removed (chunk 1), all 20 synced copies (chunks 1–8, the sync check is
  green), the fail-closed refusals and their tests, the oracle table passing with only
  bps_oracle_row changed under the Q1(b) ruling, the three guard suites (chunks 1 and 4) and the
  parse-differences doc with its tests (chunks 1 and 5). One done-when item fai
Chunk 1 of 8, pass-notes: Issue #12471, chunk 1 of 8. I read all 17 parts and the manifest whole.
  Chunk 1 holds the new reader in lib/hook-utils.sh, lib/hook-utils-shfmt.test.sh, the
  lib/hook-utils.test.sh changes, the animation copy of hook-utils.sh, 20 .changes entries, three
  workflow files and .performance/ratchets.json. I found no blocking defect.

Items this chunk delivers in full:
- The reader takes its commands from `shfmt -ln bash 
Chunk 2 of 8, pass-notes: Issue #12471 (closes). Chunk 2 holds three of the 20 synced copies of
  hook-utils.sh: plugins/autonomy, plugins/bash-format and plugins/biome-format. All three diffs go
  from the same base blob (ccd7e8092) to the same head blob (b576ecd82). That head blob is also the
  one plugins/harness-ops/hooks/hook-utils.sh has in chunk 5, so the copies match. Each copy removes
  the hand-written segmenter (_bps_close_word, _bps_ex
Chunk 3 of 8, pass-notes: Issue #12471, chunk 3 of 8. This chunk holds 3 of the 20 synced copies of
  hook-utils.sh: plugins/context-guard, plugins/desktop-notification and plugins/eol-normalizer. All
  three diffs carry the same blob change (ccd7e8092 -> b576ecd82), so the three copies are
  identical. In each, the hand-written character-walk segmenter is removed: _bps_close_word,
  _bps_ext_pass, _bps_hd_spans, the frame stack and the rest. hook
Chunk 4 of 8, pass-notes: Chunk 4 of 8 for #12471 (rebuild the guardrails command reader on `shfmt
  --to-json`, fail closed). I read all 20 parts and the manifest. Nothing in these parts blocks.

Done-when items, as far as these parts show:

1. **Reader uses `shfmt --to-json`, hand-written segmenter removed, synced copies regenerated:**
  delivered for 3 of the 20 copies. `plugins/explainer-video/hooks/hook-utils.sh`,
  `plugins/go-format/hooks
Chunk 5 of 8, pass-notes: Issue #12471 (closes), chunk 5 of 8. I read all 17 parts whole. To judge
  both sides of the references these parts make, I also read three parts from other chunks whole:
  chunk-1 part-003 (lib/hook-utils-shfmt.test.sh), chunk-1 part-005 (lib/hook-utils.sh) and chunk-4
  part-014 (cloud-shfmt.sh).

What this chunk holds:
- plugins/guardrails/prerequisites.json declares shfmt as a required tool with a 3.14.1 floor, jq w
Chunk 6 of 8, pass-notes: Issue #12471 (spec_kind closes): chunk 6 of 8. I read all 17 parts whole,
  plus the manifest, spec, context and upstream jobs. This chunk holds three synced copies of the
  shfmt-based reader: plugins/rate-limit-guard/hooks/hook-utils.sh,
  plugins/repo-hygiene/skills/clean/scripts/hook-utils.sh and
  plugins/ruff-format/hooks/hook-utils.sh. All three carry the same blob change
  (ccd7e8092..b576ecd82) and the same content
Chunk 7 of 8, pass-notes: Chunk 7 of 8 for #12471 (spec_kind closes; trusted body plus refinement,
  with the owner-side rulings Q1 (b), Q2 (a) and the flip decisions recorded on the issue). I read
  all 17 parts whole, the manifest, and the context, spec and upstream-jobs files.

What this chunk holds:
- Three of the 20 synced hook-utils.sh copies: plugins/source-control, plugins/speech and
  plugins/testing.
- The source-control hooks pr-body-
Chunk 8 of 8, pass-notes: Issue #12471, chunk 8 of 8. This chunk holds one file:
  `plugins/typos-format/hooks/hook-utils.sh`, one of the 20 synced copies of `lib/hook-utils.sh`. I
  read all six parts and the manifest whole.

**Done-when items this chunk delivers, in part only:**
- Item 1 (the reader uses `shfmt --to-json`, the hand-written segmenter is removed, the 20 copies
  are regenerated): delivered for the typos-format copy only.
  - The
- Item 'PR description records added time per hook call, p50 and p95, before and after, with the
  command that measured it': delivered by no chunk. The figures are only in the PR body. Chunk 1
  says the body describes the method in prose, gives no literal command and does not commit the
  timing harness, so the 'with the command that measured it' part is missing.
- Item 'Measured: added time per hook call (p50 and p95 over the test corpus), before and after,
  recorded in the PR' (issue body): no chunk's files hold this measurement. Chunks 1 and 4 also
  report that the 'added' row (4.0 ms p50, 26.1 ms p95) is not the difference of the before and
  after rows (5.6 ms, 13.3 ms), and the body does not say how 'added' was computed.
- lib/hook-utils.test.sh: the refinement says the oracle table must pass 'with its expectations not
  edited', but bps_oracle_row now counts a refused row as not hidden. The issue author's trusted
  Q1(b) decision comment authorizes this, and chunk 1 reports the table rows and expected HIT values
  are unchanged.
- plugins/source-control/hooks/worktree-add-claim-gate.test.sh: chunk 7 notes that the missing-shfmt
  fail-open row keeps /usr/bin on PATH. If shfmt is installed in /usr/bin, the row may never
  exercise the missing-shfmt path.
- No chunk result contains text that tries to direct this verifier.
- lib/hook-utils.sh: the reader reads jq's @sh-quoted output with `eval`. That is safe only because
  the jq program sends every element through `tostring | q`, and q re-spells each raw LF and CR as
  an ANSI-C escape; a row in lib/hook-utils-shfmt.test.sh tests this. Any later edit to HOOK_BPS_JQ
  must keep every element going through `q`.
- lib/hook-utils.sh: when a pattern's soft parse fails, the reader goes on without that pattern's
  commands. This is by design: a pattern that holds a substitution, a quote or a backslash is
  refused first, so a pattern that reaches the soft parse runs nothing in bash.
- .performance/ratchets.json:8: the guardrails-commit-guard-spawns ceiling rises from 7 to 8 for the
  parser's one jq. Q2(a) on #12471 authorizes this. The goal text says the count comes from reading
  the code and has not been measured.
- .github/workflows/pr-test-platforms.yml:173: the Windows shfmt pin uses a different SHA-256 from
  the linux pin. The comment says it is GitHub's digest of the windows_amd64 asset, which I cannot
  verify from the diff. A wrong digest makes the job fail rather than run an unchecked binary. These
  Windows jobs are not in upstream-jobs.json, so their result on this head is unknown.
- .github/workflows/eval-browser-dispatch.yml:114 and .github/workflows/pr-require-checks.yml:142:
  these add pinned, hash-checked shfmt installs. The pr-require-checks change only adds a step to
  the performance-counter job; no job name or lane changes.
- PR body (Verification): the 'added' row (4.0 ms p50, 26.1 ms p95) does not equal the difference
  between the before and after rows (5.6 ms, 13.3 ms). It is probably the percentile of per-command
  paired differences; the body should say so. The refinement asks for 'the command that measured
  it', but the body describes the method in prose and says the harness is not committed. A reviewer
  may want the exact command.
- lib/hook-utils.test.sh: the intake refinement said the oracle table and its expectations must not
  be edited. Only bps_oracle_row's counting changed, and the issue author's Q1(b) and flip-ruling
  comments in spec-text authorize that. The table rows and their expected HIT values are unchanged.
- No text in the diff, PR body or comments tries to steer the reviewer.
- plugins/autonomy/hooks/hook-utils.sh: this file and the bash-format and biome-format copies have
  the same head blob (b576ecd82) as plugins/harness-ops/hooks/hook-utils.sh in chunk 5. That they
  match lib/hook-utils.sh relies on check-managed-files passing; this chunk does not include
  lib/hook-utils.sh to compare against directly.
- plugins/autonomy/hooks/hook-utils.sh: `eval "__hu_bt=($__hu_bout)"` runs jq output as shell code.
  It is safe only because the jq `q` filter applies @sh and splices raw newlines and carriage
  returns as `$'\n'` and `$'\r'` outside the single quotes. Any later change to HOOK_BPS_JQ's `q`
  filter needs care, because it becomes code execution on untrusted command text if that quoting
  breaks.
- plugins/autonomy/hooks/hook-utils.sh: this adds a new timeout and budget, HOOK_BPS_TIMEOUT=5
  (seconds per shfmt run) and HOOK_BPS_MAX_PARSES=64. Both are internal limits that make the hook
  fail closed, not billing or schedule settings. Named here as required for timeout changes.
- plugins/autonomy/hooks/hook-utils.sh: in the extglob pattern loop, the soft re-parse's return
  value is ignored on purpose, so a pattern with no substitution that shfmt cannot parse is skipped
  rather than refused. Patterns holding a substitution, a quote or a backslash are refused before
  that point, which matches the design the PR's review threads record.
- Spec note: the trusted issue comment of 2026-11-03 (Q1 option b) relaxes the refinement's line
  that the oracle table 'passes unchanged, with the table and its expectations not edited', so that
  `bps_oracle_row` counts a refused row as not hidden. That change sits in lib/hook-utils.test.sh in
  chunk 1 and is judged there. The PR text read as context, with no attempt to steer the reviewer.
- plugins/context-guard/hooks/hook-utils.sh: this copy is byte-identical to
  plugins/desktop-notification/hooks/hook-utils.sh and plugins/eol-normalizer/hooks/hook-utils.sh
  (same blob change ccd7e8092 -> b576ecd82). Its match with lib/hook-utils.sh rests on the green
  check-managed-files job, not on a side-by-side read of chunk 1.
- plugins/context-guard/hooks/hook-utils.sh: new timeout and budget settings HOOK_BPS_TIMEOUT=5
  (seconds per shfmt parse) and HOOK_BPS_MAX_PARSES=64 (parses per top-level command). Both are
  assigned at library load, so the environment cannot raise them. Named here as timeout settings,
  not as a defect.
- plugins/context-guard/hooks/hook-utils.sh: per the PR's review replies, none of the three plugins
  in this chunk (context-guard, desktop-notification, eol-normalizer) calls the parser. They carry
  the shfmt-dependent reader only as the synced copy, so they declare no shfmt prerequisite. A human
  may want to confirm none of their hooks reaches hook::bash_parse_segments.
- plugins/context-guard/hooks/hook-utils.sh: hook::_bps_shfmt_to falls back to
  $HOME/.local/bin/shfmt when PATH has no shfmt, matching cloud-shfmt.sh's install target. Whoever
  can write that directory can already change PATH, so this is no new trust boundary.
- The spec conflict between the refinement's "oracle table passes unchanged, with the table and its
  expectations not edited" and refusing what shfmt rejects is settled in spec-text by the trusted Q1
  (b) and flip-ruling comments. That ruling bears on lib/hook-utils.test.sh in chunk 1, not on these
  parts.
- No text in these parts or in the PR context tried to steer the reviewer.
- plugins/guardrails/hooks/cloud-shfmt.sh: new SessionStart hook. In cloud sessions only
  (CLAUDE_CODE_REMOTE=true) it downloads a pinned, sha256-checked shfmt v3.14.1 from GitHub releases
  into ~/.local/bin. The issue asks to fail closed, not to install anything, so this is unrequested
  work. It is not blocking: the download is pinned and hash-checked, and local machines are never
  touched. A human should still confirm that a plugin fetching a binary at session start is
  acceptable.
- plugins/guardrails/hooks/hooks.json: the new SessionStart row sets timeout 90, and lib
  hook-utils.sh adds HOOK_BPS_TIMEOUT=5 and HOOK_BPS_MAX_PARSES=64. These are timeout and budget
  settings, named here as the review instructions require.
- plugins/guardrails/hooks/block-unresolved-pr-merge.sh:668: PowerShell text is parsed with every
  backtick removed. A PowerShell-escaped quote outside a string (e.g. echo `"x; gh pr merge 5 #")
  then opens a bash quoted span, which can hide a later merge from the segment reader. Main's
  segmenter read this form the same way, so it is not a regression. Worth a follow-up row.
- plugins/guardrails/hooks/block-root-delete-target.sh:4933: rdt_ix_heredocs now judges every
  here-doc body as the program of each interpreter or shell that reads one, instead of matching a
  body to its opener by count. This is stricter by design. One existing row flips to deny:
  block-root-delete-target-c.test.sh, a `cat` body beside `python3 - <<B`.
- PR body: the latency table's 'added' row (p50 4.0 ms, p95 26.1 ms) does not equal the difference
  of the rows above it (5.6 ms, 13.3 ms). It may be a per-command paired difference; the body should
  say which.
- plugins/explainer-video/hooks/hook-utils.sh, plugins/go-format/hooks/hook-utils.sh,
  plugins/guardrails/hooks/hook-utils.sh: all three end at the same blob, b576ecd82. Whether they
  match lib/hook-utils.sh (chunk 1) rests on the sync check and the green check-managed-files job,
  not on a direct read here.

This pull request leaves the merge queue, and ci-status holds it out while it carries pr-verify-failed. A pr-verify pass at a new commit removes the label.

@github-actions github-actions Bot added the pr-verify-failed pr-verify failed this head; a pass at a new commit removes it. label Nov 4, 2026
kyle-sexton and others added 4 commits November 3, 2026 19:56
shfmt keeps an extglob pattern as one literal and ends it at a `)` bash
reads inside a quote, `${…}` or `$[…]`, and bash reads the same text as
commands with extglob off. Re-reading patterns and the extglob-off text let
a command through seven ways, the last being `( echo @(${x:-) #$(cmd)})`
with a closing `)` line. The reader no longer reads them: a command whose
tree holds any ExtGlob node is refused ("an extglob pattern, which shfmt
and bash do not read the same way"), soft calls included.

Removed: the extglob pattern reparse, its dedupe, the substitution, quote
and backslash refusal arms, the extglob-off rewrite and its offset mapping.
The array `#` reading, the backtick and joined here-doc readings and the
parse budget stay. Plain `ls !(x)`, `@(a|b)` and `[[ $x == !(foo) ]]` now
refuse, which is accepted fail-closed; the parse-differences page and the
guardrails README say so.

Refs #12471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
…-skip

Main now requires every SessionStart row that needs node to run through
the node-or-skip stub pair (scripts/node-notice-rows.test.sh, #12993), so
a session with no node skips it silently. The cloud-shfmt row launched
node in exec form; it now uses the canonical polyglot, and guardrails
carries the two stubs as registered shared copies. The exec-bash test
pins the new row by its command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015te8CrNfM6v83JDbLRFDA9
@github-actions github-actions Bot removed the pr-verify-failed pr-verify failed this head; a pass at a new commit removes it. label Nov 4, 2026
@kyle-sexton
kyle-sexton added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 4799a0e Nov 4, 2026
129 of 131 checks passed
@kyle-sexton
kyle-sexton deleted the refactor/12471-shfmt-command-reader branch November 4, 2026 03:03
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.

refactor(guardrails): read hook commands from shfmt's parse tree instead of the hand-written segmenter

1 participant