Skip to content

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

Open
kyle-sexton wants to merge 19 commits into
mainfrom
refactor/12471-shfmt-command-reader
Open

kyle-sexton wants to merge 19 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 1025 commands both allow. The commands are harvested from the guardrails suites. Measured 2026-11-03 on a quiet WSL2 host.

    p50 p95
    origin/main 47.1 ms 79.3 ms
    this branch 52.7 ms 92.6 ms
    added 4.0 ms 26.1 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 and is not 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 partial review of aba763ce6abc8f8ff20ded42c658531b3cfa4aa5: read 79 of 80 changed file(s). 1 finding(s) in what was read, 1 as inline comments. This head is not reviewed.

Chunk 1 of 2 (38 files)

claude-security-review, chunk 1 of 2 (38 files at aba763ce6a)

I found one new IMPORTANT finding, posted inline on lib/hook-utils.sh L4877-4883. The joined here-doc reading puts a : <<E opener in front of the body and parses it again. Every callback sees that opener, but HOOK_HEREDOC_BODIES has no entry for it. A here-doc on a statement with no command word (<<A alone) does the opposite: it has a body entry and no callback opener. Put both in one command and the opener count equals the body count, so block-root-delete-target (rdt_ix_heredocs) and repo-hygiene destructive-guard.sh (L270) trust the index. They then judge the wrong body as the script that bash <<A reads, so rm -rf / or git reset --hard in that body is never checked. This is a different problem from the settled ordering thread on L4641-4663: that fix is correct for top-level and substitution openers. Here the openers come from second readings. Main had a similar gap through body-substitution here-docs, which this PR closes. The joined-body path is new code. It is a local guardrail bypass with no leak, takeover or charge, so it is not CRITICAL. I worked this out from the code and had no shell to run it.

What else I checked (no findings):

  • Trusted-path workflows (eval-browser-dispatch.yml, pr-require-checks.yml) and pr-test-platforms.yml: the only additions are shfmt install steps, pinned to v3.14.1 and checked with sha256sum -c. No trigger, permissions: or if: changed, and no untrusted expression reaches a run: block. In pr-require-checks.yml the new step sits before the performance-ceiling check and touches no ci-status hold step.
  • The new hooks.json SessionStart row (cloud-shfmt.sh) runs only when CLAUDE_CODE_REMOTE=true. It downloads a pinned GitHub release asset and installs it only after the SHA-256 matches. Its failure note is read back only as text in a message.
  • hook::_bps_shfmt_to and the eval of jq's @sh list: the quoting holds and the LF/CR splice is correct. The 5 s read -t bound and the shared parse budget fail closed. HOOK_BPS_FAIL_OPEN is reset when the library loads, so an inherited value cannot switch it on.
  • ps::_bash_ready_to drops only a statement-opening & or an empty ;. The commands each guard sees are unchanged.
  • block-unresolved-pr-merge parses PowerShell text with backticks dropped, and rdt_segment now cuts words at a substitution. Neither widens what is allowed.
  • run-guards.sh saves and replays HOOK_HEREDOC_BODIES_SRC. The prerequisites floors for jq ≥1.6 and shfmt ≥3.14.1 are declared.
  • No trust control in this chunk is widened: trusted-actor check, lane token, kill switch, signer and merge holds are untouched.

Bookkeeping:

  • No plugin.json or marketplace.json changes in this chunk, so there are no manifest lines.
  • Round cap: past the cap: yes (6 rounds, cap 3), so only CRITICAL and IMPORTANT findings were eligible. No SUGGESTION was dropped.
  • Prior findings: all six are marked lines changed since posted: yes, and their fixes are in the code at this head. None is reposted.
  • Files: I read every hunk of 37 of the 38 files. The one I did not read is plugins/animation/hooks/hook-utils.sh, the synced copy of lib/hook-utils.sh (its hunk layout matches); treat this as a partial read for that file. The fix in the inline comment applies to all 20 synced copies.
  • No embedded instructions in the PR context or the threads.
  • Spend: each Bash hook call now pays one shfmt+jq parse (7 processes, about 4 ms at p50 per the PR), and CI downloads shfmt in a few jobs on existing runners. Nothing is billed.

critical-findings: 0

Chunk 2 of 2 (6 files)

claude-security-review, chunk 2 of 2 (6 files under plugins/source-control/, all read in full). No security issues found in this chunk.

critical-findings: 0

What I checked

  • worktree-add-claim-gate.sh: the new HOOK_BPS_FAIL_OPEN=1 around the parse. When the flag is 1, hook::_bps_refuse exits 0 (hook-utils.sh L4773). Three things stop this from weakening any blocking gate:

    • The library sets the flag to 0 when it loads (L4698), so a value inherited from the environment can't switch it on.
    • Only this PostToolUse hook sets it.
    • In plugins/source-control/hooks/hooks.json, each gate runs as its own exec-bash.mjs process. The PreToolUse pr-body-linkage-gate.sh and worktree-add-containment-gate.sh therefore never see the flag and still refuse an unparsable command.

    By the time this hook runs, the git worktree add has already happened. Failing open matches the hook's documented contract (L18-26); the missed claim shows up later in report / check-enter. Nothing becomes allowed that the base blocked.

  • prerequisites.json: adds a jq version.min of 1.6 and a new shfmt entry (need: required, a docs link, and a version probe that runs shfmt --version). This is a probe declaration, not a plugin manifest: no command, server or hook is added, and nothing new runs for installed users. The degrade text matches the reader's behaviour in this chunk: the PreToolUse gates deny, and the PostToolUse claim gate leaves the tree unclaimed.

  • pr-body-linkage-gate.test.sh: the unterminated here-doc now expects deny instead of allow, so it is stricter.

  • The two spawn-budget tests: they change only how processes are counted (threads with CLONE_THREAD are excluded) and replace upper-bound pins with exact ones. No security impact.

Other notes

  • The plugin copies in .claude-lane/identical.txt are in chunk 1, not this chunk. None of their paths is in this chunk's scope.
  • The round cap applies (past the cap: yes), so only CRITICAL and IMPORTANT findings would be reported; there are none. Every earlier finding from this lane is on lib/hook-utils.sh (chunk 1), so there are no repeat lines here.
  • Spend: running the reader adds shfmt and jq process forks to each hook call. That costs local CPU time only, with no billing impact.
  • The PR context contains no embedded instructions aimed at this lane.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed aba763ce6abc8f8ff20ded42c658531b3cfa4aa5: read 27 of 27 changed file(s); 0 finding(s), 0 as inline comments.

claude-review: incremental review of 993c2d6..aba763c. No findings.

Round cap applied. prior-findings.md says past the cap: yes (3 of 3 rounds), so I would post only blocking findings. I found no blocking or advisory findings in this push.

Scope. I read every hunk of the 27 files in the incremental list:

  • lib/hook-utils.sh and lib/hook-utils-shfmt.test.sh.
  • plugins/animation/hooks/hook-utils.sh, standing for its 19 identical copies listed in identical.txt. All of them sit at the same hooks/ (or skills/clean/scripts/) library path as before, so none changes role.
  • block-root-delete-target.test.sh, block-unresolved-pr-merge.sh, run-guards.sh, destructive-guard.sh and destructive-guard.test.sh.

What I checked. I read the code only; I had no shell, so I ran nothing.

  • Here-doc bodies listed in callback order (lib/hook-utils.sh L4644-4663, L4869-4872). $hord ranks each <</<<- by [k, Stmt.Pos.Offset, OpPos.Offset]. It uses the same statement selector and the same in-body k test as the segment sort, so entry n is the n-th opener counted across the callbacks.
    • Openers that share a statement ({ cat <<A; } <<B) also rank in the order the callback reads its redirects.
    • The H list is still walked in source order, so the empty-body start (__hu_bp) and the joined-body reparse are unchanged.
    • __hu_hb[rank] is written once per call (each nested call has its own local -a), and the final ( "${__hu_hb[@]}" ) packs any gap left by the 100000 + $p fallback.
    • A statement with no command word still takes a rank but gets no callback. That leaves the consumer's opener count mismatched, so it reads every body, as before.
  • Hand-counted offsets. I counted the offsets of both new hd_are rows by hand, and they match: 28 38 … | 13 17 …, and 8 26 … | 35 53 … | 18 22 ….
  • Consumers.
    • block-unresolved-pr-merge.sh (body strip) and destructive-guard.sh (substitutions_to) walk the command from its start, and now read HOOK_HEREDOC_BODIES_SRC.
    • rdt_ix_heredocs and _gp_hd_feed count openers, and keep the callback-ordered list.
    • run-guards.sh records and replays both lists.
    • The one consumer this push did not change, plugins/guardrails/lib/github-post/github-post-body.sh L499, slices each body into a list whose order no caller relies on, so it is safe under either order.
  • bash 5.3 ${ cmd; } / ${| cmd; } inside an extglob pattern (L5019). The case runs before the dedupe and the reparse, and it covers blank, tab, newline and | after ${. The paired row confirms that @(${x}|y) still parses. The new pattern_refused rows check exit 2 and the refusal text, which holds whatever bash version runs them.
  • New guard rows. The rdt and destructive-guard rows cover both the substitution-prefix form and the body-nested form. The owner says these rows failed before the fix and pass now; I have not checked that.

Settled threads. None re-raised. The here-doc ordering thread (lib/hook-utils.sh L4641-4663, resolved at this head) is the change under review here, and I agree with its fix. I found no embedded instructions in the PR context, settled-threads or prior-findings files.

Spend (not a finding). None in this push.

…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

This branch has not been deployed

No deployments
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