Skip to content

fix(guardrails): block-hook-bypass exempts a quoted redirect target on its first word only #2226

Description

@kyle-sexton

block-hook-bypass.sh decides its target-based exemptions on the first whitespace-delimited word
of a quoted redirect operand, not on the operand. strip_literals deliberately keeps a quoted
write target as literal content — dropping the quotes so a quoted target still reads as a write — and
_redir_scan's target class terminates at whitespace. A quoted target containing a space therefore
resolves, for exemption purposes, to its first word.

Today that reaches the /dev/null exemption. It is the same shape the guard's own comment at
:465-472 was written to prevent for a different input (redirect ordering), and it is the one
target-word assumption that comment does not cover.

Rows

  • plugins/guardrails/hooks/block-hook-bypass.shstrip_literals (open_keep, the quoted
    redirect-operand branch) and _redir_scan (the target class ending at whitespace) — a quoted
    redirect operand containing whitespace is exempt-tested on word 1 only.

Evidence

Measured at repo HEAD 685dd381, hook invoked as a decision function on a PreToolUse Bash payload
(rc=2 blocked, rc=0 allowed):

rc=0 :: echo x > "/dev/null ../../etc/pw"        # exempted on the word `/dev/null`
rc=2 :: echo x > "/tmp/scratch/a ../../etc/pw"   # control: no exemption applies, so it blocks

The first is the defect: the operand is one word to the shell, and nothing named /dev/null is the
write destination, yet the discard exemption fires.

_redir_scan, plugins/guardrails/hooks/block-hook-bypass.sh:294:

_redir_scan=$'(^|[^0-9&])1?>>?[[:space:]]*([^|&>[:space:]\x01\x02]+)'

and the kept-operand rule the quotes are dropped by, :127-130:

  # `open_keep` carries, alongside it, whether that span is a REDIRECT OPERAND (a quoted
  # target: the char before the opening quote is `>`) — those are kept as literal
  # content instead of dropped, so a quoted write target survives the strip.

Exploitability, stated honestly

Reaching a chosen file this way needs a directory whose name ends in the whitespace-bearing
fragment (/dev/null .. above) to already exist, so this is a correctness and
defence-in-depth
defect rather than a demonstrated escape. It matters because it is the exact
assumption a target-based exemption rests on, and the guard has one such exemption today and a second
(opt-in) one landing in #2210.

Why this is filed rather than fixed in #2210

Discovered while building the scratch-root exemption (#2210) and confirmed to pre-date it — the
/dev/null half reproduces on origin/main at 685dd381, before any of that work. Recovering it
needs strip_literals to mark a kept operand's internal whitespace with a sentinel, which is shared
machinery every lane of the guard reads and which #1680 and #1667 also concern. That is a wider
change than a single carve-out row should carry. PR #2224 documents the residual in the file, the
README and the CHANGELOG, and pins both halves (the scratch target and the /dev/null control)
with accepted-floor tests, so a fix here flips them together and visibly.

Adjacent

Provenance

Severity: MED · Provenance: reproduced at repo HEAD 685dd381 by the batch-4 lane that found it;
no independent auditor pass — this row originates from implementation work, not from a ledger.
Origin: discovered during handoff-inbox batch 4, lane C, while implementing
.work/handoff-inbox-batch-4/ledgers/I5-claude-ops-guardrails-unverified.md § F2b (#2210).
Verified against repo HEAD 685dd381.

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