guardrails 0.25.0 shipped a fail-close on the new scratch-root exemption: a quote or backslash
after the first redirect operator cancels the exemption. Four surfaces describe that check as
operand-scoped, and it is not. It reads the whole raw command tail — not the segment being
evaluated, and not the target word — so a quote anywhere in a later segment cancels the exemption
for an earlier, unambiguous write.
This is a documentation-accuracy defect in released code, not a behaviour bug. The check is
one-directional: it can only ever refuse an exemption, never grant one, so the failure mode is
lost convenience, not a bypass. But an operator reading any of the four surfaces would predict the
wrong verdict for an ordinary two-step command, and the behaviour was unpinned by any test.
Rows
Evidence
Reproduced against origin/main at fd7ffc42 (0.25.0 as merged), with
block_hook_bypass_scratch_roots=/tmp/scratch:
rc=2 :: echo x > /tmp/scratch/f && grep foo "notes.txt"
rc=0 :: echo x > /tmp/scratch/f && grep foo notes.txt
rc=2 :: echo x > /tmp/scratch/f; cat "notes.txt"
Segment 1's target is a plain path strictly under the configured root; by the containment rule alone
it is exempt. The quote belongs to an unrelated grep argument in segment 2, and it refuses the
exemption anyway — so the ordinary "write a scratch file, then read something" pattern blocks
entirely.
The check, plugins/guardrails/hooks/block-hook-bypass.sh:
[[ "${COMMAND#*>}" == *[\"\'\\]* ]] && return 1
${COMMAND#*>} is the whole raw command after the first >, which is why the scope is
command-wide rather than operand-wide.
Disposition
The breadth should stay. Anchoring to the raw command is what makes the fix work at all — by the
time a per-segment target exists, strip_literals has already dropped the quote and backslash
characters the check needs to see. Narrowing it to the operand requires knowing which quotes belonged
to the operand, which is exactly the association strip_literals destroys — that is #2226, and
it is not fixed by correcting these docs.
So the work here is: make all four surfaces state the true scope, and pin both sides of the
compound-command boundary with regression tests.
Provenance
Severity: LOW (documentation accuracy on released behaviour; no bypass, fail-closed direction)
Provenance: reproduced against merged main at fd7ffc42.
Origin: review comment on PR #2224, which arrived after that PR merged. Handoff-inbox batch 4, lane
C — a review finding, not a ledger row.
Related, not fixed by this: #2226 (the strip_literals operand/quote association) and its
adjacent machinery issues #1680, #1667.
guardrails0.25.0 shipped a fail-close on the new scratch-root exemption: a quote or backslashafter the first redirect operator cancels the exemption. Four surfaces describe that check as
operand-scoped, and it is not. It reads the whole raw command tail — not the segment being
evaluated, and not the target word — so a quote anywhere in a later segment cancels the exemption
for an earlier, unambiguous write.
This is a documentation-accuracy defect in released code, not a behaviour bug. The check is
one-directional: it can only ever refuse an exemption, never grant one, so the failure mode is
lost convenience, not a bypass. But an operator reading any of the four surfaces would predict the
wrong verdict for an ordinary two-step command, and the behaviour was unpinned by any test.
Rows
plugins/guardrails/hooks/block-hook-bypass.sh— the comment above the fail-close says"a QUOTED or ESCAPED redirect operand", describing operand scope.
plugins/guardrails/CHANGELOG.md, 0.25.0 entry — "fails closed on any quote or backslash afterthe first redirect operator" is presented under the heading "A quoted or escaped redirect operand
is never exempt".
plugins/guardrails/README.md— the guard's caveat paragraph carries the same operand framing.plugins/guardrails/.claude-plugin/plugin.json—block_hook_bypass_scratch_roots'sdescription lists "a quoted or escaped operand" among the things that "still block".
scratch: exemption does not leak across segmentscase uses an unquoted second segment with agenuinely non-exempt target, so it passes regardless of this breadth.
Evidence
Reproduced against
origin/mainatfd7ffc42(0.25.0 as merged), withblock_hook_bypass_scratch_roots=/tmp/scratch:Segment 1's target is a plain path strictly under the configured root; by the containment rule alone
it is exempt. The quote belongs to an unrelated
grepargument in segment 2, and it refuses theexemption anyway — so the ordinary "write a scratch file, then read something" pattern blocks
entirely.
The check,
plugins/guardrails/hooks/block-hook-bypass.sh:${COMMAND#*>}is the whole raw command after the first>, which is why the scope iscommand-wide rather than operand-wide.
Disposition
The breadth should stay. Anchoring to the raw command is what makes the fix work at all — by the
time a per-segment target exists,
strip_literalshas already dropped the quote and backslashcharacters the check needs to see. Narrowing it to the operand requires knowing which quotes belonged
to the operand, which is exactly the association
strip_literalsdestroys — that is #2226, andit is not fixed by correcting these docs.
So the work here is: make all four surfaces state the true scope, and pin both sides of the
compound-command boundary with regression tests.
Provenance
Severity: LOW (documentation accuracy on released behaviour; no bypass, fail-closed direction)
Provenance: reproduced against merged
mainatfd7ffc42.Origin: review comment on PR #2224, which arrived after that PR merged. Handoff-inbox batch 4, lane
C — a review finding, not a ledger row.
Related, not fixed by this: #2226 (the
strip_literalsoperand/quote association) and itsadjacent machinery issues #1680, #1667.