Skip to content

docs(guardrails): 0.25.0 describes block-hook-bypass's quoted-operand fail-close as operand-scoped, but it spans the whole command #2236

Description

@kyle-sexton

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

  • 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 after
    the 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.jsonblock_hook_bypass_scratch_roots's
    description lists "a quoted or escaped operand" among the things that "still block".
  • No test covers the compound-command shape. The existing
    scratch: exemption does not leak across segments case uses an unquoted second segment with a
    genuinely non-exempt target, so it passes regardless of this breadth.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions