Skip to content

fix(guardrails): cap Bash substitutions before sourcing the PreToolUse chain (#4684) - #4714

Merged
cursor[bot] merged 4 commits into
mainfrom
cursor/4684-guard-chain-subst-cap-37e9
Sep 28, 2026
Merged

cursor[bot] merged 4 commits into
mainfrom
cursor/4684-guard-chain-subst-cap-37e9

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #4684

Summary

Bash/PowerShell PreToolUse refuses (exit 2) commands with more than 256 command/process substitutions before any guard is sourced. run-guards.sh --max-substitutions 256 (text-only count). Timed-out hooks fail open (hooks timeouts).

guardrails → 0.37.4 (serialize above #4699 0.37.3; series 4684→4682→4685→4678).

Test plan

  • Issue payloads ~39 ms rc 2; at-cap benign still runs (per implementer)
Open in Web Open in Cursor 

…any guard runs (#4684)

The Bash/PowerShell row's cost grows with the number of command and
process substitutions while every per-command cap still holds: 2,339
sibling $(: rm) in 16,378 characters took 65 s through the row on
Windows, past the 60 s timeout at which a PreToolUse command hook is
cancelled without blocking. run-guards.sh now counts $(, <(, >( and
backtick pairs as text before it sources the first guard and exits 2
past --max-substitutions, which the row passes as 256.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR body contract — issue linkage

This PR body does not yet satisfy the issue-linkage contract:

  • Missing a "## Fix" section. State the concrete change and how it addresses the problem.
  • Missing a "## Verification" section. Record concrete evidence the change works (commands, gates, output).
  • Missing a "## Related" section. List related PRs, ADRs, or decision-log entries this PR does not close.

Edit the body and this comment updates itself on the next run.

cursoragent and others added 2 commits September 27, 2026 23:55
Keep the substitution cap and the command-length short-circuit on the same Bash row.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Re-bump guardrails to 0.38.2 after 0.38.1 landed.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot marked this pull request as ready for review September 27, 2026 23:58
Re-bump guardrails to 0.38.3 after 0.38.2 landed.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot merged commit 6075fc6 into main Sep 28, 2026
19 checks passed
@cursor
cursor Bot deleted the cursor/4684-guard-chain-subst-cap-37e9 branch September 28, 2026 00:20
kyle-sexton added a commit that referenced this pull request Oct 1, 2026
…ion cap (#5689)

Closes #4684

## Summary

The substitution cap in `run-guards.sh` (`--max-substitutions 256`)
still refused a command whose quoted-delimiter heredoc body held more
than 256 substitution spellings, for example a PR body written through
`cat <<'EOF'` with many inline-code spans. Bash expands nothing in such
a body, so the refusal was a false positive. This is option A of the
owner decision of 2026-10-01 on #4684.

`block-root-delete-target.sh` is unchanged. It still reads quoted
heredoc bodies as commands, so the root-delete judgment is exactly what
it was.

## Fix

- `run_guards::counted_text` in `plugins/guardrails/hooks/run-guards.sh`
drops the body of a heredoc whose delimiter is quoted (`<<'EOF'`,
`<<"EOF"`, `<<\EOF`, `<<E'O'F`, `<<-'EOF'` with tab-indented terminator)
before the recount. The operator, the delimiter word and the rest of its
line stay in the counted text.
- The recount now also runs when the command holds `<<`, not only a
single quote.
- The body is dropped only where bash and `hook::bash_parse_segments`
end it on the same line. Everything else counts whole: an unquoted or
expanding heredoc, an empty or non-`[A-Za-z0-9_]` delimiter, a quote,
`\`, `$`, `#`, `<`, `(` or `)` on the delimiter line, a second heredoc,
an enclosing `$(`, `<(`, `>(`, backtick or arithmetic, no terminator
line, a single-quoted span after a dropped body, and any command that
names a shell, `eval`, `su`, `env`, `source`, `. file` or `alias` (`bash
<<'EOF'` and `source /dev/stdin <<'EOF'` count whole).
- Header comment and README updated.

Security review of the branch diff (focused on the cap change):

- Substitutions bash will expand cannot escape the count. Bash expands a
heredoc body only when no part of the delimiter is quoted, and the
discount requires a quoted part. Checked with 37 spellings against real
`run-guards.sh`: unquoted, `<<-` unquoted, empty `''`, delimiter with a
space, `$'EOF'`, trailing-space or missing terminator, two heredocs on a
line, heredoc inside `$(`, backticks, `<(`, double quotes and arithmetic
all still count (exit 2); only the quoted-delimiter forms and
pre-existing single-quoted spans are discounted.
- Root-delete coverage is unchanged: the file is not in the diff, and
`bash <<'EOF'` with a `$(rm -rf /)` body is refused by it (test below).
The guards still read every byte; the cap only counts.
- Guards and the cap read one text (carriage returns are stripped before
both), and the guard library ends a heredoc at the same line as the cap,
so the cap cannot drop text a guard would treat as commands.
- Worst-case cost with the cap no longer protecting a quoted body, full
10-guard row, 16,384-char quoted heredoc, WSL2: `$(: rm)`, backtick
pairs, `<(: rm)`, `"$(: rm)"`, `$(rm -rf /tmp/x)` and 390 small heredocs
each finished in 0.1 to 3.0 s, none near the 60 s hook timeout. For a
no-work body such as `$(: rm)`, root-delete's 25 s deadline never
starts; `--max-command-len` (16,384 characters, about 1.1 ms per
spelling, 2.6 to 3.7 s measured) is what bounds the cost, and the
`run-guards.sh` comment and README now say so.
- Found, not changed: `bash <<'EOF'` with a plain `rm -rf /` body (no
substitution) is allowed by every guard on `origin/main` too, because
root-delete reads only substitutions in a heredoc body. Pre-existing and
outside this change.

## Verification

- `plugins/guardrails/hooks/run-guards.test.sh`: PASS=641 FAIL=0. With
the heredoc arm of `counted_text` reverted, 25 cases fail (every exit-0
quoted-heredoc case); with the `source` and `. file` arm reverted, the
sourcing cases fail; restored.
- `plugins/guardrails/hooks/abort-boundary.test.sh`: PASS=260 FAIL=0.
Every other `plugins/guardrails/hooks/*.test.sh`, `shellcheck`, `typos`
and `check-shell-portability` are clean.
- New and updated cases cover: quote forms and `<<-` tab terminator no
longer counted; `gh --body-file -` form; unquoted, expanding,
after-terminator, missing-terminator and two-heredoc commands still exit
2; `bash <<'EOF'` with a `$(rm -rf /)` body refused through the full
row; the `$(: x)` spelling.
- Full-row timing (owner condition), WSL2 Linux, `cat <<'EOF'` + 2,338 x
`$(: rm)` + `EOF` (16,382 chars), median of 5, recorded on the issue
(comment 5932518425): 2,663 ms idle and 3,260 ms with 8 busy loops, rc
0, against 1,441 ms and 1,739 ms for the same text without `$`. About
3.3 s worst case, far inside 60 s, so option B is not needed. Windows
rate (about 1.1 ms per spelling, about 2.6 s at the ceiling) is in
comment 5922576224.
- #4520 headroom, Windows Git Bash (melo-desk-001, GNU bash 5.3.15, 24
logical CPUs), measured on the shipped guard code (guardrails 0.43.2)
with the `block-root-delete-target` input at the cap, `echo ` + 256 x
`$(: rm)`: the guard used 656-676 ms idle and 630-644 ms under 8 busy
loops, against its 25 s `RDT_DEADLINE` (about 37x headroom). The full
10-guard row took 2.3-2.5 s of the 60 s hook timeout. Source: issue
comment 5922576224.
- Over-cap payloads on Windows, same host and comment: `echo ` + 2339 x
`$(: rm)` (p1) and 2330 x `$(: rm)` + `; rm -rf /` (p2) both exit 2 at
the substitution cap through `run-guards.sh` before any guard is
sourced. p1 took 81 ms idle and 74 ms busy; p2 took 82 ms idle and 77 ms
busy. This change does not touch that path: the recount runs only on a
command holding a quote or `<<`.
- Version 0.46.2 and a CHANGELOG entry, because `ci-status` runs
`check-changelog-parity.sh --check-bump` and fails a guardrails change
without them.

## Related

- #5543 shipped the single-quoted half of (c).
- #4714 put the cap before the guard chain.
- #4520 root-delete deadline headroom: answered under Verification.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(guardrails): bound the Bash guard chain under the 60 s hook timeout

2 participants