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
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.
block-hook-bypass.shdecides its target-based exemptions on the first whitespace-delimited wordof a quoted redirect operand, not on the operand.
strip_literalsdeliberately keeps a quotedwrite 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 thereforeresolves, for exemption purposes, to its first word.
Today that reaches the
/dev/nullexemption. It is the same shape the guard's own comment at:465-472was written to prevent for a different input (redirect ordering), and it is the onetarget-word assumption that comment does not cover.
Rows
plugins/guardrails/hooks/block-hook-bypass.sh—strip_literals(open_keep, the quotedredirect-operand branch) and
_redir_scan(the target class ending at whitespace) — a quotedredirect operand containing whitespace is exempt-tested on word 1 only.
Evidence
Measured at repo HEAD
685dd381, hook invoked as a decision function on aPreToolUseBash payload(
rc=2blocked,rc=0allowed):The first is the defect: the operand is one word to the shell, and nothing named
/dev/nullis thewrite 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: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 anddefence-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/nullhalf reproduces onorigin/mainat685dd381, before any of that work. Recovering itneeds
strip_literalsto mark a kept operand's internal whitespace with a sentinel, which is sharedmachinery 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/nullcontrol)with accepted-floor tests, so a fix here flips them together and visibly.
Adjacent
should be coordinated with them.
Provenance
Severity: MED · Provenance: reproduced at repo HEAD
685dd381by 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.