From 3500bc8efa6747c4b05327046e70a9ff433873be Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:43:17 -0400 Subject: [PATCH 1/8] fix(guardrails): scope block-hook-bypass echo redirect to the actual producer (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `block-hook-bypass`'s `echo > file` heuristic matched any Bash command string containing both an `echo` (or `>`) token, conflating the `echo "content" > file` content-authoring bypass with legitimate commands that capture a subprocess's stdout to a data file while an unrelated `echo` status line appears in the same compound call. Replace the anywhere-co-occurrence with producer-scoped detection: split the literal-stripped command into simple-command segments on shell separators and flag only a segment whose command word is echo/printf AND that redirects stdout into a real file. Leading compound-command keywords (`do`/`then`/`else`/`{`) are peeled so a producer in a loop, conditional, or brace-group body is still caught. The literal-strip now carries an open quote across physical lines, so a multi-line quoted argument (a `gh issue create --body "…"` payload) stays inert instead of leaking its tokens. `printf … > file` is now caught alongside `echo`. Adds 11 regression tests (48 total pass) covering the three issue false positives and the true-positive forms including compound-command bodies. Patch-bumps guardrails 0.8.0 → 0.8.1 with a CHANGELOG entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 20 +++ plugins/guardrails/hooks/block-hook-bypass.sh | 116 +++++++++++++++--- .../hooks/block-hook-bypass.test.sh | 40 ++++++ 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index bc1e605e1..dd21c4492 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.8.0", + "version": "0.8.1", "description": "Eight safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, (advisory) hallucinated CLI flags, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index c745861c5..8d34aa00f 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] + +### Fixed + +- **`block-hook-bypass` no longer false-fires when an `echo`/`printf` token and a + `>` redirect merely co-occur in one Bash command.** The `echo > file` heuristic + matched any command string containing both tokens, so capturing a subprocess's + stdout to a scratchpad data file (`bash fetch.sh 526 > pr526.json && echo + "EXIT: $?"`), a bounded poll loop with a status `echo`, or a `gh issue create + --body "…"` whose text merely mentions the tokens were all blocked. The check is + now producer-scoped: it splits the literal-stripped command into simple-command + segments and flags only a segment whose command word is `echo`/`printf` AND that + redirects stdout into a real file — so the redirect's producer must be the + echo/printf, not a co-located but unrelated one. It correctly fires inside loop, + conditional, and brace-group bodies (`for …; do echo x > f; done`). The + literal-strip now also carries an open quote across physical lines, so a + multi-line quoted argument (a `--body "…"` payload spanning newlines) stays + inert instead of leaking its tokens from the second line on. `printf … > file` + content-authoring is now caught alongside `echo … > file`. + ## [0.8.0] ### Changed diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 8d33be4a1..1a758e8a7 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -4,7 +4,7 @@ # # Catches common file-write bypass patterns: # cat > path -# echo ... > path +# echo ... > path (and printf ... > path) # python3 -c ... file write # # Detection runs over the LITERAL-STRIPPED command for the executable token @@ -12,6 +12,12 @@ # and over the RAW command for the python write indicators (they legitimately # live inside the quoted `-c` payload the strip removes). # +# The echo/printf redirect is PRODUCER-SCOPED (see producer_redirect_bypass): it +# fires only when the echo/printf is itself the command whose stdout is +# redirected into a real file, NOT when an `echo` token and a `>` token merely +# co-occur in one compound command (`bash x.sh > out.json && echo done` — the +# redirect's producer is `bash`) or survive only inside a quoted argument. +# # SCOPE (documented residual): the strip treats a quoted span as inert, so a # write inside a command substitution in double quotes # (`echo "$(python3 -c 'import pathlib ...')"`) is NOT caught — catching it needs @@ -85,10 +91,15 @@ emit_tel() { # Strip single- and double-quoted literal spans so the executable-token scan # sees only shell syntax, not payload text. Heredoc bodies are dropped wholesale -# (their content is data, not a command). Line-oriented so a quoted span never -# spills its match across the command. +# (their content is data, not a command). The quote strip carries an OPEN quote +# across physical lines, so a quoted argument spanning newlines (a `--body "..."` +# payload whose text merely mentions `echo`/`>`) stays inert end-to-end instead +# of leaking its tokens from the second line on. strip_literals() { local cmd="$1" line result="" in_heredoc=0 delim="" trimmed + # `open_quote` carries a single- or double-quote span across lines: "" outside + # any quote, "'" or '"' inside one that opened on an earlier line. + local open_quote="" out i n c # `(^|[^<])` before `<<` excludes a here-string `<<<` — matching `<<` inside # `<<<` would capture a bogus delimiter and strand the stripper in-heredoc, # swallowing every later line (a here-string bypass). The delimiter body @@ -109,7 +120,10 @@ strip_literals() { [[ "$trimmed" == "$delim" ]] && in_heredoc=0 continue fi - if [[ "$line" =~ $heredoc_start_re ]]; then + # A heredoc opener is shell syntax only OUTSIDE a quoted span — a `<file`). _cat_redir='(^|[[:space:];|&()]+)cat[[:space:]]*>' -# `echo` followed by whitespace OR a redirect (`echo>file`, `echo>>file` have no -# space before `>`) — the redirect-adjacent form still writes a file. -_echo_redir='(^|[[:space:];|&()]+)echo([[:space:]]|>>?)' +# A simple-command segment whose command token is `echo` or `printf` — the +# content producers a `> file` redirect turns into a Write/Edit bypass. Anchored +# to the segment start (see producer_redirect_bypass), so it never matches an +# `echo`/`printf` mention buried mid-command. +_producer_head='^(echo|printf)([[:space:]]|>)' # stdout-to-file redirect: `>` / `>>` NOT preceded by an fd digit or `&`, so # stderr/fd redirects (`2>/dev/null`, `2>&1`, `&>`) do not trip. _echo_devnull # exempts a stdout discard (`>/dev/null`) — that's not a Write/Edit bypass. @@ -147,6 +203,38 @@ _echo_file_out='(^|[^0-9&])>>?[[:space:]]*[^|&>[:space:]]' _echo_devnull='(^|[^0-9&])>>?[[:space:]]*/dev/null' _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' +# Flag ONLY when the producer being redirected into a real file is echo/printf +# authoring content — not any command string that merely co-mentions an `echo` +# token and a `>` token. Split the literal-stripped command into simple-command +# segments on shell separators (`; | & ( )` and newlines), then require, WITHIN +# one segment, that the command token is echo/printf AND that same segment +# redirects stdout to a real file. This passes `bash x.sh > out.json && echo done` +# (the redirect's producer is `bash`, not the trailing `echo`) and a bounded poll +# loop `... > poll.json; echo "..."`, while still blocking `echo "x" > file`. +producer_redirect_bypass() { + local exec_lc="$1" seps=$';\n|&()' normalized seg + # Each separator becomes a segment boundary; args cannot contain a raw + # separator (quoted spans are already stripped), so a segment holds at most one + # simple command and the redirect in it is that command's own. + normalized="${exec_lc//[$seps]/$'\n'}" + while IFS= read -r seg || [[ -n "$seg" ]]; do + seg="${seg#"${seg%%[![:space:]]*}"}" + # Peel leading compound-command keywords / group openers so a producer in a + # loop, conditional, or brace-group body is still seen as the command word + # (`; do echo x > f`, `then echo ...`, `{ echo ...`) rather than being hidden + # behind the `do`/`then`/`else`/`{` token at the segment head. + while [[ "$seg" =~ ^(do|then|else|\{)([[:space:]]|$) ]]; do + seg="${seg#"${BASH_REMATCH[1]}"}" + seg="${seg#"${seg%%[![:space:]]*}"}" + done + [[ "$seg" =~ $_producer_head ]] || continue + [[ "$seg" =~ $_echo_file_out ]] || continue + [[ "$seg" =~ $_echo_devnull ]] && continue + return 0 + done <<<"$normalized" + return 1 +} + block_bypass() { local form="$1" reason="$2" echo "BLOCKED: $reason" >&2 @@ -161,11 +249,11 @@ if [[ "$EXEC_LC" =~ $_cat_redir ]]; then block_bypass "cat-redirect" "cat > file write bypasses Write/Edit hooks" fi -# echo ... > file (stdout-to-real-file only; not stderr/fd redirects or /dev/null) -if [[ "$EXEC_LC" =~ $_echo_redir ]] && - [[ "$EXEC_LC" =~ $_echo_file_out ]] && - ! [[ "$EXEC_LC" =~ $_echo_devnull ]]; then - block_bypass "echo-redirect" "echo > file write bypasses Write/Edit hooks" +# echo/printf ... > file — only when the echo/printf IS the producer redirected +# into a real file (stdout-to-real-file only; not stderr/fd redirects, /dev/null, +# a co-located but unrelated echo, or tokens inside a quoted argument). +if producer_redirect_bypass "$EXEC_LC"; then + block_bypass "echo-redirect" "echo/printf > file write bypasses Write/Edit hooks" fi # python3 -c with file-write indicators. Detect the `python3 -c` INVOCATION in diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 54f438fff..91b8d2e41 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -55,6 +55,46 @@ run "echo append > file still blocked" "echo line >> real.txt" 2 run "echo > file with 2>/dev/null still blocked" \ "echo data > real.txt 2>/dev/null" 2 +# --- Producer-scoped redirect (issue #546 false-fire regression) ------------- +# The guard must flag ONLY when the echo/printf is itself the producer whose +# stdout is redirected into a file — not any compound command that merely +# CO-MENTIONS an `echo` token and a `>` token. The three cases below are the +# false positives observed while PR-babysitting #526 (a script's stdout captured +# to a scratchpad data file, with an unrelated `echo` status line in the same +# call), which must now be ALLOWED. +# 1. Script stdout captured to a JSON sink + a trailing echo status line. +run "script stdout capture + echo status (allowed)" \ + 'bash fetch-all-pr-comments.sh 526 > pr526.json && echo "EXIT: $?"' 0 +run "script stdout capture; echo status semicolon (allowed)" \ + 'bash fetch.sh 526 > pr526.json; echo "EXIT: $?"' 0 +# 2. Same capture inside a bounded poll loop whose body also echoes a summary. +# shellcheck disable=SC2016 # literal loop is the command under test, not for expansion +run "poll-loop redirect + echo summary (allowed)" \ + 'for i in 1 2 3; do bash fetch.sh 526 > poll.json; echo "poll $i"; done' 0 +# 3. echo/`>` tokens appearing ONLY inside a quoted --body argument (the +# `gh issue create` for the bug report itself). Single-line and multi-line +# quoted payloads both stay inert — the multi-line body is the exact form that +# forced the reporter to fall back to `--body-file`. +run "gh issue create --body mentioning echo > file, single line (allowed)" \ + 'gh issue create --title t --body "echo > file write bypasses"' 0 +GH_MULTILINE_BODY=$(printf 'gh issue create --title t --body "The guard blocks:\necho > file write bypasses\nremove the echo statements"') +run "gh issue create --body mentioning echo > file, multi-line (allowed)" \ + "$GH_MULTILINE_BODY" 0 + +# True positives must still block: the echo/printf IS the redirected producer, +# including inside compound-command bodies (loops, conditionals, brace groups) +# where the issue's false positives all lived. +run "echo content > file still blocked" 'echo "some content" > file.txt' 2 +run "printf content > file (blocked)" 'printf "%s" "content" > file.txt' 2 +run "echo > file after an unrelated command (blocked)" \ + 'ls foo 2>/dev/null; echo "x" > real.txt' 2 +# shellcheck disable=SC2016 # literal loop is the command under test, not for expansion +run "echo > file in for-loop body (blocked)" \ + 'for f in a b; do echo "$f" > out.txt; done' 2 +run "echo > file in if-then body (blocked)" \ + 'if true; then echo x > real.txt; fi' 2 +run "echo > file in brace group (blocked)" '{ echo x > real.txt; }' 2 + # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is # documentation, not a Write/Edit bypass. The python write-indicator scan stays From f5d4affae83a1f521189d0ffa57c5e287698cf7e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:01:58 -0400 Subject: [PATCH 2/8] fix(guardrails): drop issue tracker ref from test comment The comment-hygiene check flags in-code issue-tracker references. The producer-scoped redirect section header carried an "issue #546" ref; scope the header to the behavior it exercises. The surrounding comment block already documents the producer-scope intent in full. Co-Authored-By: Claude Sonnet 5 --- plugins/guardrails/hooks/block-hook-bypass.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 91b8d2e41..f20fa6600 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -55,7 +55,7 @@ run "echo append > file still blocked" "echo line >> real.txt" 2 run "echo > file with 2>/dev/null still blocked" \ "echo data > real.txt 2>/dev/null" 2 -# --- Producer-scoped redirect (issue #546 false-fire regression) ------------- +# --- Producer-scoped redirect (false-fire regression) ------------------------ # The guard must flag ONLY when the echo/printf is itself the producer whose # stdout is redirected into a file — not any compound command that merely # CO-MENTIONS an `echo` token and a `>` token. The three cases below are the From 5f1597eaad4697aae314c128858487b0d69283dc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:05:52 -0400 Subject: [PATCH 3/8] fix(guardrails): drop tracker refs from comments, document group-redirect floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CI comment-hygiene gate (tracker-ref:repo-issue): rephrase two test comments to describe the false-fire scenario in plain prose without citing tracker issue numbers. Also document, per bot review, the accepted-floor limitation surfaced on the PR: a redirect applied to a GROUP (`{ echo x; } > file`, `( echo x ) > file`) is not caught because the closing `}`/`)` are segment separators, so the redirect is a separate segment from the echo inside. Catching it needs brace/paren-depth tracking, out of scope for a false-positive fix. Add a SCOPE note beside producer_redirect_bypass and an accepted-floor test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- plugins/guardrails/hooks/block-hook-bypass.sh | 7 +++++++ plugins/guardrails/hooks/block-hook-bypass.test.sh | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 1a758e8a7..e7c40e9fa 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -211,6 +211,13 @@ _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' # redirects stdout to a real file. This passes `bash x.sh > out.json && echo done` # (the redirect's producer is `bash`, not the trailing `echo`) and a bounded poll # loop `... > poll.json; echo "..."`, while still blocking `echo "x" > file`. +# +# SCOPE (documented residual): a redirect applied to a GROUP rather than to the +# echo itself — `{ echo x; } > file` / `( echo x ) > file` — is NOT caught. The +# closing `}` / `)` are separators, so the redirect lands in a different segment +# from the echo inside the group. Catching it needs brace/paren-depth tracking, +# out of scope for a false-positive fix; the form is structurally unusual for LLM +# output and covered by an accepted-floor test. producer_redirect_bypass() { local exec_lc="$1" seps=$';\n|&()' normalized seg # Each separator becomes a segment boundary; args cannot contain a raw diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index f20fa6600..49670334f 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -59,7 +59,7 @@ run "echo > file with 2>/dev/null still blocked" \ # The guard must flag ONLY when the echo/printf is itself the producer whose # stdout is redirected into a file — not any compound command that merely # CO-MENTIONS an `echo` token and a `>` token. The three cases below are the -# false positives observed while PR-babysitting #526 (a script's stdout captured +# false positives observed during PR babysitting (a script's stdout captured # to a scratchpad data file, with an unrelated `echo` status line in the same # call), which must now be ALLOWED. # 1. Script stdout captured to a JSON sink + a trailing echo status line. @@ -94,6 +94,11 @@ run "echo > file in for-loop body (blocked)" \ run "echo > file in if-then body (blocked)" \ 'if true; then echo x > real.txt; fi' 2 run "echo > file in brace group (blocked)" '{ echo x > real.txt; }' 2 +# Group-level redirect (`{ echo x; } > file`) is NOT caught — the closing `}` +# and `)` are seps, so the redirect is a separate segment from the echo inside. +# Accepted as the floor: this form is structurally unusual for LLM output. +run "echo in brace group with group-level redirect (accepted floor — allowed)" \ + '{ echo x; } > real.txt' 0 # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is From 3dd4c611909d3d212f3030ee50839d280efc0374 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:52:32 -0400 Subject: [PATCH 4/8] fix(guardrails): peel command prefixes before echo/printf producer scan (#568) The producer-scoped redirect check matched only a segment's first token, so a producer hidden behind a valid shell prefix bypassed the guard: FOO=bar echo, command echo, builtin printf, and env echo all redirected stdout into a real file yet exited 0. Peel environment assignments and the command-name modifiers command/builtin/exec/env at the segment head before the echo/printf check. Peeling is block-safe -- the producer gate still requires echo/printf, so revealing a non-producer command word never causes a block (command ls > out and FOO=bar make > log stay allowed). External command-runner utilities that carry their own options (nohup/nice/time/timeout/sudo/xargs, non-bare env) are an accepted, documented floor, mirroring the group-redirect floor. Addresses chatgpt-codex-connector P1 review finding. 57 tests pass (8 new). Co-Authored-By: Claude Sonnet 5 --- plugins/guardrails/CHANGELOG.md | 12 +++++++ plugins/guardrails/hooks/block-hook-bypass.sh | 32 ++++++++++++++++--- .../hooks/block-hook-bypass.test.sh | 26 +++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 8d34aa00f..43b87c0c6 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -22,6 +22,18 @@ All notable changes to the `guardrails` plugin are documented here. Format follo multi-line quoted argument (a `--body "…"` payload spanning newlines) stays inert instead of leaking its tokens from the second line on. `printf … > file` content-authoring is now caught alongside `echo … > file`. +- **The producer scan now peels command prefixes, so a producer hidden behind a + valid shell prefix is no longer a trivial bypass.** The head-only producer match + looked only at a segment's first token, so `FOO=bar echo x > file`, + `command echo x > file`, `builtin printf x > file`, and `env echo x > file` all + slipped through even though their stdout is redirected into a real file — the + prior anywhere-in-command detector caught them. The segment head now peels + environment assignments and the command-name modifiers `command`/`builtin`/ + `exec`/`env` before the echo/printf check, closing that hole. Peeling is + block-safe: the producer gate still requires echo/printf, so revealing a + non-producer command word never causes a block. External command-runner + utilities that carry their own options (`nohup`/`nice`/`time`/`timeout`/`sudo`/ + `xargs`, non-bare `env`) remain an accepted, documented floor. ## [0.8.0] diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index e7c40e9fa..fc870fbd3 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -196,6 +196,16 @@ _cat_redir='(^|[[:space:];|&()]+)cat[[:space:]]*>' # to the segment start (see producer_redirect_bypass), so it never matches an # `echo`/`printf` mention buried mid-command. _producer_head='^(echo|printf)([[:space:]]|>)' +# Command-prefix tokens that legitimately precede the real command word in a +# simple command: environment assignments (`FOO=bar cmd`) and the command-name +# modifiers `command` / `builtin` / `exec` / `env`. Peeling them (see +# producer_redirect_bypass) exposes an echo/printf hidden behind a valid prefix +# (`command echo x > f`, `FOO=bar echo x > f`) so the producer scan still sees it. +# The compound-command keywords / group opener (`do`/`then`/`else`/`{`) that put a +# producer inside a loop, conditional, or brace-group body are peeled by the same +# pass. Peeling is safe: the `_producer_head` gate still requires echo/printf, so +# revealing a NON-echo command word can never cause a block. +_cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|do|then|else|\{)([[:space:]]|$)' # stdout-to-file redirect: `>` / `>>` NOT preceded by an fd digit or `&`, so # stderr/fd redirects (`2>/dev/null`, `2>&1`, `&>`) do not trip. _echo_devnull # exempts a stdout discard (`>/dev/null`) — that's not a Write/Edit bypass. @@ -218,6 +228,16 @@ _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' # from the echo inside the group. Catching it needs brace/paren-depth tracking, # out of scope for a false-positive fix; the form is structurally unusual for LLM # output and covered by an accepted-floor test. +# +# SCOPE (documented residual): the command-prefix peel (see _cmd_prefix) covers +# the bounded shell-grammar set — env assignments and `command`/`builtin`/`exec`/ +# bare `env`. External command-runner utilities that take their own options and a +# command argument — `nohup`/`nice`/`time`/`timeout N`/`sudo`/`stdbuf -oL`/`xargs`, +# and non-bare `env` (`env -i echo …`, `/usr/bin/env echo …`) — are NOT peeled, so +# `nohup echo x > f` and friends are not caught. Peeling them correctly requires +# per-utility argument parsing (each has a different option grammar), out of scope +# for this false-positive fix; the forms are structurally unusual for LLM output +# and covered by an accepted-floor test. producer_redirect_bypass() { local exec_lc="$1" seps=$';\n|&()' normalized seg # Each separator becomes a segment boundary; args cannot contain a raw @@ -226,11 +246,13 @@ producer_redirect_bypass() { normalized="${exec_lc//[$seps]/$'\n'}" while IFS= read -r seg || [[ -n "$seg" ]]; do seg="${seg#"${seg%%[![:space:]]*}"}" - # Peel leading compound-command keywords / group openers so a producer in a - # loop, conditional, or brace-group body is still seen as the command word - # (`; do echo x > f`, `then echo ...`, `{ echo ...`) rather than being hidden - # behind the `do`/`then`/`else`/`{` token at the segment head. - while [[ "$seg" =~ ^(do|then|else|\{)([[:space:]]|$) ]]; do + # Peel leading command-prefix tokens (see _cmd_prefix) so a producer hidden + # behind an env assignment (`FOO=bar echo x > f`), a command-name modifier + # (`command echo ...`, `builtin printf ...`, `exec echo ...`, `env echo ...`), + # or a compound-command keyword / group opener (`; do echo x > f`, `then echo + # ...`, `{ echo ...`) is still seen as the segment's command word rather than + # being masked by the prefix at the head. + while [[ "$seg" =~ $_cmd_prefix ]]; do seg="${seg#"${BASH_REMATCH[1]}"}" seg="${seg#"${seg%%[![:space:]]*}"}" done diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 49670334f..b9386533d 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -100,6 +100,32 @@ run "echo > file in brace group (blocked)" '{ echo x > real.txt; }' 2 run "echo in brace group with group-level redirect (accepted floor — allowed)" \ '{ echo x; } > real.txt' 0 +# --- Command-prefix producers (bypass regression) ---------------------------- +# A producer preceded by a valid shell prefix — an env assignment or a +# command-name modifier (`command`/`builtin`/`exec`/`env`) — must still be caught: +# its stdout is redirected into a real file exactly like a bare `echo > file`. +# The head-only producer match would otherwise skip these, making the Write/Edit +# bypass trivial via `command echo` or `FOO=bar echo`. +run "env-assignment prefix before echo > file (blocked)" \ + 'FOO=bar echo content > real.txt' 2 +run "command modifier before echo > file (blocked)" \ + 'command echo content > real.txt' 2 +run "builtin modifier before printf > file (blocked)" \ + 'builtin printf x > real.txt' 2 +run "exec modifier before echo > file (blocked)" 'exec echo x > real.txt' 2 +run "env modifier before echo > file (blocked)" 'env echo content > real.txt' 2 +# No new false positive: peeling a prefix only reveals the command word; a +# NON-producer command word after the prefix is still allowed. +run "command modifier before non-producer > file (allowed)" \ + 'command ls > out.txt' 0 +run "env-assignment before non-producer > file (allowed)" \ + 'FOO=bar make > log.txt' 0 +# Floor: external command-runner utilities (own options + a command arg) are NOT +# peeled — each needs per-utility argument parsing. Accepted as the floor; these +# forms are structurally unusual for LLM output. +run "nohup wrapper before echo > file (accepted floor — allowed)" \ + 'nohup echo x > real.txt' 0 + # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is # documentation, not a Write/Edit bypass. The python write-indicator scan stays From 3f7f28a3f7f328d57180c531acd4bc78d8edcee2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:02:13 -0400 Subject: [PATCH 5/8] fix(guardrails): handle fd-dup redirects and compound headers in producer scan (#568) Two more producer-scoped bypass forms the narrowed detector missed, both caught by the prior anywhere-in-command detector: - fd-duplication redirect before the stdout redirect: `echo x 2>&1 > file` and `echo x >&2 > file` split on the dup's `&`, orphaning the trailing `> file`. Protect the `&` in a redirect (`>&`, `<&`, `&>`) from the control-operator split so the simple command stays one segment; `&&` and background `&` still split. - compound-command headers / negation before a producer: `! echo x > file`, `if echo x > file; then ...`, and `while`/`until`/`elif` forms. Peel if/elif/while/until/! at the segment head alongside the existing do/then/else/{, completing the before-command keyword class. No new false positives (peel/protect is block-safe -- the gate still requires echo/printf). Addresses two chatgpt-codex-connector P1 findings on 3dd4c611. 66 tests pass (9 new). Co-Authored-By: Claude Sonnet 5 --- plugins/guardrails/CHANGELOG.md | 12 ++++++++ plugins/guardrails/hooks/block-hook-bypass.sh | 30 +++++++++++++------ .../hooks/block-hook-bypass.test.sh | 24 +++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 43b87c0c6..0bed27653 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -34,6 +34,18 @@ All notable changes to the `guardrails` plugin are documented here. Format follo non-producer command word never causes a block. External command-runner utilities that carry their own options (`nohup`/`nice`/`time`/`timeout`/`sudo`/ `xargs`, non-bare `env`) remain an accepted, documented floor. +- **An fd-duplication redirect before the stdout redirect no longer splits the + producer off from its `> file`.** Segmenting on every `&` cut `echo x 2>&1 > + file` and `echo x >&2 > file` at the dup's `&`, orphaning the trailing stdout + redirect so neither blocked. The `&` in a redirect (`>&`, `<&`, `&>`) is now + protected from the control-operator split, so the simple command stays one + segment and its `> file` is scanned as the echo's own; `&&` and a background + `&` still split as separators. +- **Compound-command headers and pipeline negation before a producer are now + peeled.** `! echo x > file`, `if echo x > file; then …`, and the `while`/`until`/ + `elif` forms wrote the file yet slipped past the head-only match, since only + `do`/`then`/`else`/`{` were peeled. The header set now also peels + `if`/`elif`/`while`/`until`/`!`, completing the before-command keyword class. ## [0.8.0] diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index fc870fbd3..69cc14b78 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -201,11 +201,12 @@ _producer_head='^(echo|printf)([[:space:]]|>)' # modifiers `command` / `builtin` / `exec` / `env`. Peeling them (see # producer_redirect_bypass) exposes an echo/printf hidden behind a valid prefix # (`command echo x > f`, `FOO=bar echo x > f`) so the producer scan still sees it. -# The compound-command keywords / group opener (`do`/`then`/`else`/`{`) that put a -# producer inside a loop, conditional, or brace-group body are peeled by the same +# The compound-command header keywords / group opener / pipeline negation that put +# a producer inside a loop, conditional, or negated command +# (`if`/`elif`/`while`/`until`/`do`/`then`/`else`/`{`/`!`) are peeled by the same # pass. Peeling is safe: the `_producer_head` gate still requires echo/printf, so # revealing a NON-echo command word can never cause a block. -_cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|do|then|else|\{)([[:space:]]|$)' +_cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|if|elif|then|else|while|until|do|!|\{)([[:space:]]|$)' # stdout-to-file redirect: `>` / `>>` NOT preceded by an fd digit or `&`, so # stderr/fd redirects (`2>/dev/null`, `2>&1`, `&>`) do not trip. _echo_devnull # exempts a stdout discard (`>/dev/null`) — that's not a Write/Edit bypass. @@ -239,19 +240,30 @@ _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' # for this false-positive fix; the forms are structurally unusual for LLM output # and covered by an accepted-floor test. producer_redirect_bypass() { - local exec_lc="$1" seps=$';\n|&()' normalized seg - # Each separator becomes a segment boundary; args cannot contain a raw + local exec_lc="$1" seps=$';\n|&()' soh=$'\x01' normalized seg + # Protect fd-duplication / both-streams redirect ampersands (`2>&1`, `>&2`, + # `&>file`) with a sentinel before the `&` control-operator split below, so a + # redirect `&` never cuts a producer away from a LATER stdout redirect — + # `echo x 2>&1 > file` and `echo x >&2 > file` must stay ONE segment so the + # trailing `> file` is still scanned as the echo's own. Restored right after the + # split, before the per-segment scan. `&&` and a background `&` carry no + # adjacent `<`/`>`, so they are untouched here and still split as separators. + normalized="${exec_lc//>&/>$soh}" + normalized="${normalized//<&/<$soh}" + normalized="${normalized//&>/$soh>}" + # Each remaining separator becomes a segment boundary; args cannot contain a raw # separator (quoted spans are already stripped), so a segment holds at most one # simple command and the redirect in it is that command's own. - normalized="${exec_lc//[$seps]/$'\n'}" + normalized="${normalized//[$seps]/$'\n'}" + normalized="${normalized//"$soh"/&}" while IFS= read -r seg || [[ -n "$seg" ]]; do seg="${seg#"${seg%%[![:space:]]*}"}" # Peel leading command-prefix tokens (see _cmd_prefix) so a producer hidden # behind an env assignment (`FOO=bar echo x > f`), a command-name modifier # (`command echo ...`, `builtin printf ...`, `exec echo ...`, `env echo ...`), - # or a compound-command keyword / group opener (`; do echo x > f`, `then echo - # ...`, `{ echo ...`) is still seen as the segment's command word rather than - # being masked by the prefix at the head. + # or a compound-command header / group opener / negation (`; do echo x > f`, + # `if echo x > f`, `while echo ...`, `! echo ...`, `{ echo ...`) is still seen + # as the segment's command word rather than being masked by the prefix head. while [[ "$seg" =~ $_cmd_prefix ]]; do seg="${seg#"${BASH_REMATCH[1]}"}" seg="${seg#"${seg%%[![:space:]]*}"}" diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index b9386533d..3f814dbb3 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -126,6 +126,30 @@ run "env-assignment before non-producer > file (allowed)" \ run "nohup wrapper before echo > file (accepted floor — allowed)" \ 'nohup echo x > real.txt' 0 +# --- fd-duplication redirect before stdout redirect (bypass regression) ------ +# An fd-dup redirect (`2>&1`, `>&2`) before the real stdout redirect must not let +# the `&` split cut the producer away from its `> file`. The whole simple command +# stays one segment so the trailing stdout-to-file redirect is still the echo's. +run "echo 2>&1 then > file (blocked)" 'echo x 2>&1 > real.txt' 2 +run "echo >&2 then > file (blocked)" 'echo x >&2 > real.txt' 2 +# The dup redirects themselves, with no stdout-to-file target, are NOT writes. +run "echo piped with 2>&1 dup (allowed)" 'echo hi 2>&1 | cat' 0 +run "ls to stderr via >&2 dup (allowed)" 'ls foo >&2' 0 + +# --- Compound-command headers / negation before a producer (bypass regression) +# `if`/`elif`/`while`/`until` headers and `!` negation can precede the command +# word just like `do`/`then`/`else`; the producer inside them must still be seen. +run "echo > file after ! negation (blocked)" '! echo x > real.txt' 2 +run "echo > file in if header (blocked)" \ + 'if echo x > real.txt; then :; fi' 2 +run "echo > file in while header (blocked)" \ + 'while echo x > real.txt; do :; done' 2 +run "echo > file in until header (blocked)" \ + 'until echo x > real.txt; do :; done' 2 +# No new false positive: a non-producer command word after the header is allowed. +run "non-producer in if header > file (allowed)" \ + 'if grep -q x file; then ls; fi' 0 + # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is # documentation, not a Write/Edit bypass. The python write-indicator scan stays From 7eadee4868de9ddeb09b3fe9e67e6474eb4fce65 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:57:12 -0400 Subject: [PATCH 6/8] fix(guardrails): close comment, leading-redirect, and coproc producer-scan bypasses (#546) strip_literals now drops an unquoted # comment to end-of-line without carrying quote state, so an unmatched quote in a comment can no longer leak a span onto the next line and strip a real producer there. The producer scan peels a leading redirect (bash allows redirections before the command word) and a bare coproc header before the echo/printf test, while the file-write test still runs on the un-peeled segment so the redirect stays the write signal. Adds regression tests for all three forms plus the mid-word/parameter-expansion # discriminators. --- plugins/guardrails/hooks/block-hook-bypass.sh | 80 ++++++++++++++++--- .../hooks/block-hook-bypass.test.sh | 48 +++++++++++ 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 69cc14b78..271300a06 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -94,12 +94,14 @@ emit_tel() { # (their content is data, not a command). The quote strip carries an OPEN quote # across physical lines, so a quoted argument spanning newlines (a `--body "..."` # payload whose text merely mentions `echo`/`>`) stays inert end-to-end instead -# of leaking its tokens from the second line on. +# of leaking its tokens from the second line on. An unquoted `#` comment is dropped +# to end-of-line without carrying quote state, so an unmatched quote inside a +# comment cannot leak a span onto the next line (see the `#` case below). strip_literals() { local cmd="$1" line result="" in_heredoc=0 delim="" trimmed # `open_quote` carries a single- or double-quote span across lines: "" outside # any quote, "'" or '"' inside one that opened on an earlier line. - local open_quote="" out i n c + local open_quote="" out i n c prev # `(^|[^<])` before `<<` excludes a here-string `<<<` — matching `<<` inside # `<<<` would capture a bogus delimiter and strand the stripper in-heredoc, # swallowing every later line (a here-string bypass). The delimiter body @@ -169,6 +171,25 @@ strip_literals() { open_quote='"' ((i += 1)) ;; + '#') + # `#` starts a comment only at a word boundary — start of line, or + # after an unquoted blank or shell metacharacter (`;|&()<>`). The rest + # of the physical line is comment text and is dropped WITHOUT touching + # `open_quote`, so an unmatched quote inside a comment (`true # "`) + # cannot leak a quote span onto the next line and silently strip a real + # producer there. Mid-word (`echo a#b`, `${v#x}`) the `#` is literal and + # kept, so a genuine `a#b > file` write still reaches the scan. A + # backslash-escaped `#` never lands here — the `\` case above consumes it. + if ((i == 0)) || + { + prev="${line:i-1:1}" + [[ "$prev" == [[:space:]] || "$prev" == [\;\|\&\(\)\<\>] ]] + }; then + break + fi + out+="$c" + ((i += 1)) + ;; $'\\') out+="${line:i:2}" ((i += 2)) @@ -201,12 +222,24 @@ _producer_head='^(echo|printf)([[:space:]]|>)' # modifiers `command` / `builtin` / `exec` / `env`. Peeling them (see # producer_redirect_bypass) exposes an echo/printf hidden behind a valid prefix # (`command echo x > f`, `FOO=bar echo x > f`) so the producer scan still sees it. +# A bare `coproc` is a command header that can precede the producer of a simple +# command (`coproc echo x > f`), so it is peeled too. Only the bare keyword is +# peeled: the optional NAME form is `coproc NAME compound-command`, so eating a +# second token would swallow the real command word in `coproc echo …`. # The compound-command header keywords / group opener / pipeline negation that put # a producer inside a loop, conditional, or negated command # (`if`/`elif`/`while`/`until`/`do`/`then`/`else`/`{`/`!`) are peeled by the same # pass. Peeling is safe: the `_producer_head` gate still requires echo/printf, so # revealing a NON-echo command word can never cause a block. -_cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|if|elif|then|else|while|until|do|!|\{)([[:space:]]|$)' +_cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|coproc|if|elif|then|else|while|until|do|!|\{)([[:space:]]|$)' +# A leading redirection element (`> file cmd`, `< in cmd`, `2> f cmd`, `>& n cmd`): +# bash permits redirections before the command word, so a producer can hide behind +# one (`> real.txt echo x`). Peeled (operator + its target word) — like _cmd_prefix +# — to expose the producer for _producer_head, while _echo_file_out/_echo_devnull +# still run on the UN-peeled segment so the redirect itself remains the write +# signal. Not anchored to stdout-to-file: any leading redirect is peeled for +# producer exposure; whether a real file write exists is decided by _echo_file_out. +_leading_redir='^([0-9]*(>>?|<)&?|&>>?)[[:space:]]*' # stdout-to-file redirect: `>` / `>>` NOT preceded by an fd digit or `&`, so # stderr/fd redirects (`2>/dev/null`, `2>&1`, `&>`) do not trip. _echo_devnull # exempts a stdout discard (`>/dev/null`) — that's not a Write/Edit bypass. @@ -239,6 +272,12 @@ _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' # per-utility argument parsing (each has a different option grammar), out of scope # for this false-positive fix; the forms are structurally unusual for LLM output # and covered by an accepted-floor test. +# +# SCOPE (documented residual): only the BARE `coproc echo …` header is peeled (see +# _cmd_prefix). The named form `coproc NAME { echo x > f; }` is not, because NAME is +# indistinguishable from a command word by prefix-peeling alone, and the redirect +# there is group-level (same brace-group floor as above). Structurally unusual for +# LLM output and covered by an accepted-floor test. producer_redirect_bypass() { local exec_lc="$1" seps=$';\n|&()' soh=$'\x01' normalized seg # Protect fd-duplication / both-streams redirect ampersands (`2>&1`, `>&2`, @@ -258,17 +297,32 @@ producer_redirect_bypass() { normalized="${normalized//"$soh"/&}" while IFS= read -r seg || [[ -n "$seg" ]]; do seg="${seg#"${seg%%[![:space:]]*}"}" - # Peel leading command-prefix tokens (see _cmd_prefix) so a producer hidden - # behind an env assignment (`FOO=bar echo x > f`), a command-name modifier - # (`command echo ...`, `builtin printf ...`, `exec echo ...`, `env echo ...`), - # or a compound-command header / group opener / negation (`; do echo x > f`, - # `if echo x > f`, `while echo ...`, `! echo ...`, `{ echo ...`) is still seen - # as the segment's command word rather than being masked by the prefix head. - while [[ "$seg" =~ $_cmd_prefix ]]; do - seg="${seg#"${BASH_REMATCH[1]}"}" - seg="${seg#"${seg%%[![:space:]]*}"}" + # Peel leading command-prefix tokens (see _cmd_prefix) and leading redirections + # (see _leading_redir) into `head` so a producer hidden behind an env assignment + # (`FOO=bar echo x > f`), a command-name modifier (`command echo ...`, `builtin + # printf ...`, `exec echo ...`, `env echo ...`, `coproc echo ...`), a + # compound-command header / group opener / negation (`; do echo x > f`, `if echo + # x > f`, `while echo ...`, `! echo ...`, `{ echo ...`), or a leading redirect + # (`> real.txt echo x`) is still seen as the segment's command word. The redirect + # itself is left in `seg`: _echo_file_out/_echo_devnull below decide whether a + # real file write exists, so a leading redirect stays the write signal. + local head="$seg" tgt + while :; do + if [[ "$head" =~ $_cmd_prefix ]]; then + head="${head#"${BASH_REMATCH[1]}"}" + head="${head#"${head%%[![:space:]]*}"}" + continue + fi + if [[ "$head" =~ $_leading_redir ]]; then + head="${head#"${BASH_REMATCH[0]}"}" + tgt="${head%%[[:space:]]*}" + head="${head#"$tgt"}" + head="${head#"${head%%[![:space:]]*}"}" + continue + fi + break done - [[ "$seg" =~ $_producer_head ]] || continue + [[ "$head" =~ $_producer_head ]] || continue [[ "$seg" =~ $_echo_file_out ]] || continue [[ "$seg" =~ $_echo_devnull ]] && continue return 0 diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 3f814dbb3..7cb3aa8ec 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -150,6 +150,54 @@ run "echo > file in until header (blocked)" \ run "non-producer in if header > file (allowed)" \ 'if grep -q x file; then ls; fi' 0 +# --- Leading redirect before the producer word (bypass regression) ----------- +# Bash permits redirections before the command word, so a producer can hide +# behind one. The leading redirect is peeled to expose echo/printf, while the +# redirect itself still counts as the write signal. +run "leading redirect before echo (blocked)" '> real.txt echo x' 2 +run "leading redirect before printf (blocked)" '> real.txt printf x' 2 +run "leading redirect glued to target before echo (blocked)" '>real.txt echo x' 2 +run "leading redirect + env-assignment before echo (blocked)" \ + '> real.txt FOO=bar echo x' 2 +# No new false positive: a leading INPUT redirect writes nothing, a leading +# stdout /dev/null discard is not a bypass, and a non-producer command word after +# a leading redirect is allowed. +run "leading input redirect before echo (allowed)" '< input.txt echo x' 0 +run "leading /dev/null redirect before echo (allowed)" '> /dev/null echo x' 0 +run "leading redirect before non-producer (allowed)" '> out.txt ls -la' 0 + +# --- coproc header before the producer (bypass regression) ------------------- +# A bare `coproc` header can precede the producer of a simple command; it is +# peeled like the other command headers so the producer inside is still seen. +run "coproc before echo > file (blocked)" 'coproc echo x > real.txt' 2 +run "coproc before printf > file (blocked)" 'coproc printf x > real.txt' 2 +# No new false positive: a non-producer command word after coproc is allowed. +run "coproc before non-producer > file (allowed)" 'coproc make > log.txt' 0 + +# --- Comment quote-state leak (bypass regression) ---------------------------- +# strip_literals carries an open quote across physical lines. An unmatched quote +# inside a `#` comment must NOT leak a quote span onto the next line — otherwise +# the next line's real producer gets stripped away and the write slips through. +COMMENT_QUOTE_LEAK=$(printf 'true # "\necho x > real.txt') +run "unmatched quote in comment, next-line bypass (blocked)" \ + "$COMMENT_QUOTE_LEAK" 2 +# Same leak reachable via an operator-preceded comment (`;#`), a word boundary too. +COMMENT_QUOTE_LEAK_SEMI=$(printf 'true;# "\necho x > real.txt') +run "unmatched quote in operator-preceded comment, next-line bypass (blocked)" \ + "$COMMENT_QUOTE_LEAK_SEMI" 2 +# Discriminating: a `#` mid-word is literal, not a comment introducer, so a real +# `echo a#b > file` write must STILL block (the comment strip must not over-reach). +run "mid-word # is literal, real write still blocked" 'echo a#b > real.txt' 2 +# A parameter expansion `${v#x}` carries a `#` that is not a comment either — the +# producer + redirect after it must still block. +# shellcheck disable=SC2016 # literal ${v#x} is the command under test, not for expansion +run "parameter-expansion # then echo > file (blocked)" \ + 'echo "${v#x}" > real.txt' 2 +# A genuine trailing comment on an allowed command stays allowed and does not +# swallow a following unrelated line via a leaked quote. +COMMENT_BENIGN=$(printf 'ls -la # list files\ngit status') +run "benign trailing comment, no leak (allowed)" "$COMMENT_BENIGN" 0 + # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is # documentation, not a Write/Edit bypass. The python write-indicator scan stays From 3b1841d497d0ae475bab7f415f635a48239b07d8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:34:05 -0400 Subject: [PATCH 7/8] fix(guardrails): peel command/exec options and escaped separators in producer scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer-scoped block-hook-bypass narrowing missed two true-positive forms Codex flagged at HEAD: an echo/printf hidden behind a command/exec modifier option (`command -p echo x > f`, `exec -a name echo x > f`) and a producer split from its redirect by a backslash-escaped separator (`echo x \; > f`, an escaped-newline continuation). Both wrote the file yet returned 0. Peel command/exec options (bash built-in help: `command [-pVv]`, `exec [-cl] [-a name]`, plus a `--` end-of-options marker), consuming exec's `-a name` value word. Peeling is scoped to command/exec, so env/builtin keep their bare-only floor. `command -v`/`-V` DESCRIBE their argument rather than run it, so `command -v echo > f` (which writes the word "echo", not echo's output) stays allowed — the producer-scoped contract holds. Protect backslash-escaped separators from the segment split so the simple command stays one segment. Also backfills the [0.8.1] CHANGELOG with the three fixes from 7eadee4 (comment quote-state leak, leading redirect before producer, coproc header) that landed in code but not the changelog. Adds regression tests across all new forms, including the describe-lookup false positives and the cross-segment / env-prefix / exec-name edge cases. Co-Authored-By: Claude Sonnet 5 --- plugins/guardrails/CHANGELOG.md | 39 +++++++++++ plugins/guardrails/hooks/block-hook-bypass.sh | 60 +++++++++++++++- .../hooks/block-hook-bypass.test.sh | 69 +++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 0bed27653..79b3f281c 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -46,6 +46,45 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `elif` forms wrote the file yet slipped past the head-only match, since only `do`/`then`/`else`/`{` were peeled. The header set now also peels `if`/`elif`/`while`/`until`/`!`, completing the before-command keyword class. +- **An unmatched quote inside a `#` comment no longer leaks a quote span onto the + next line.** `strip_literals` carries an open quote across physical lines, so an + unclosed `"` in a trailing comment (`true # "`) previously stripped the following + line's real producer as a quoted span, and `true # "` + newline + `echo x > file` + returned 0. An unquoted `#` at a word boundary (line start, or after a blank or + one of `;|&()<>`) is now dropped to end-of-line WITHOUT touching the quote state, + so the comment cannot leak a span. A mid-word `#` (`echo a#b > file`) and a + parameter expansion (`${v#x}`) stay literal, so those real writes still block. +- **A leading redirection before the command word no longer hides the producer.** + Bash permits redirections before the command word, so `> real.txt echo x` writes + the file, yet the segment-head producer match (anchored at `^(echo|printf)`) never + saw it and returned 0. A leading redirect is now peeled (operator + its target + word) to expose the producer, while the redirect itself stays in the segment so + `_echo_file_out`/`_echo_devnull` still decide whether a real write exists — a + leading input redirect or `/dev/null` discard stays allowed. +- **The bare `coproc` header before a producer is now peeled.** `coproc echo x > + file` writes the file but `coproc` was absent from the peeled header set, so it + returned 0. `coproc` is added to the command-header peel. Only the bare keyword is + peeled; the named form `coproc NAME { … }` remains a documented floor (NAME is + indistinguishable from a command word by prefix-peeling, and its redirect is + group-level — the same brace-group floor). +- **Options of the `command`/`exec` modifiers are now peeled too.** Both were + peeled but their options were not, so a producer behind a valid option leaked: + `command -p echo x > file` and `exec -a name echo x > file` wrote the file yet + returned 0. The producer scan now also peels the modifier options documented by + bash built-in help (`command [-pVv]`, `exec [-cl] [-a name]`, and a `--` + end-of-options marker), consuming the value word of the argument-taking + `exec -a name` so the echo/printf behind it is still seen. Option peeling applies + only to `command`/`exec` — `env`/`builtin` keep their bare-only floor. As an + exception, `command -v`/`-V` DESCRIBE their argument instead of running it, so + `command -v echo > file` (which writes the word "echo", not echo's output) stays + allowed — the guard blocks only a genuine echo/printf producer. +- **Backslash-escaped separators no longer split a producer from its redirect.** + The segment split treated an escaped separator as a command boundary, so + `echo x \; > file` and an escaped-newline continuation (`echo x \` + newline + + `> file`) — both a single simple command in bash that writes the file — landed + the producer and its `> file` in different segments and returned 0. Escaped + separators (`\;`, `\|`, `\&`, `\(`, `\)`, and an escaped newline) are now + protected from the split so the simple command stays one segment. ## [0.8.0] diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 271300a06..ab43de483 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -232,6 +232,27 @@ _producer_head='^(echo|printf)([[:space:]]|>)' # pass. Peeling is safe: the `_producer_head` gate still requires echo/printf, so # revealing a NON-echo command word can never cause a block. _cmd_prefix='^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|command|builtin|exec|env|coproc|if|elif|then|else|while|until|do|!|\{)([[:space:]]|$)' +# Options of the two command-name modifiers that take options (bash built-in help: +# `command [-pVv]`, `exec [-cl] [-a name]`). A modifier alone is peeled by +# _cmd_prefix, but its options otherwise sit between the modifier and the producer +# (`command -p echo x > f`, `exec -a name echo x > f`) and hide it. Peeled in the +# same loop, on the already-lowercased segment, and ONLY when `command`/`exec` was +# the immediately preceding modifier — `env`/`builtin` keep their bare-only floor +# (see the SCOPE note below), so option grammar is not widened past those two. +# `_modifier_opt_arg` covers the one value-taking option — exec's `-a name` (a +# short-option cluster ending in `a`) — and consumes the NAME word too; +# `_modifier_opt` covers no-argument clusters; `_modifier_optend` peels a lone `--` +# end-of-options marker (`exec -- echo x > f` writes the file). Safe like the +# modifier peel: `_producer_head` still gates, so exposing a non-producer word +# never blocks. Only leading (post-modifier) options match, so a real command word +# — which never starts with `-` — is untouched. EXCEPTION: `command -v`/`-V` (lower- +# cased to `v`) flip `command` to DESCRIBE its argument rather than run it, so a +# following `echo`/`printf` is a bareword being looked up, not a content producer +# (`command -v echo > f` writes the word "echo", not echo's output) — that segment +# is skipped, not blocked, keeping the producer-scoped contract. +_modifier_opt_arg='^-[a-z]*a[[:space:]]+[^[:space:]]+([[:space:]]|$)' +_modifier_opt='^-[a-z]+([[:space:]]|$)' +_modifier_optend='^--([[:space:]]|$)' # A leading redirection element (`> file cmd`, `< in cmd`, `2> f cmd`, `>& n cmd`): # bash permits redirections before the command word, so a producer can hide behind # one (`> real.txt echo x`). Peeled (operator + its target word) — like _cmd_prefix @@ -279,7 +300,20 @@ _py_write='open[[:space:]]*\(|\.write[[:space:]]*\(|pathlib|path[[:space:]]*\(' # there is group-level (same brace-group floor as above). Structurally unusual for # LLM output and covered by an accepted-floor test. producer_redirect_bypass() { - local exec_lc="$1" seps=$';\n|&()' soh=$'\x01' normalized seg + local exec_lc="$1" seps=$';\n|&()' soh=$'\x01' esc=$'\x02' normalized seg s i + # Protect backslash-escaped separators (`echo x \; > file`, an escaped-newline + # line continuation `echo x \ > file`) before the split: bash keeps an + # escaped separator inside the SAME simple command (`\;` is a literal argument, + # `\` is removed as a continuation), so it must not cut the producer + # away from its redirect. strip_literals preserves the escaping backslash, so an + # escaped separator reaches here as `\`. Sentinel each, then restore to an + # inert space after the split — its only role is to stay non-splitting; its + # literal value never feeds the producer/redirect scan. + normalized="$exec_lc" + for ((i = 0; i < ${#seps}; i++)); do + s="${seps:i:1}" + normalized="${normalized//\\"$s"/$esc}" + done # Protect fd-duplication / both-streams redirect ampersands (`2>&1`, `>&2`, # `&>file`) with a sentinel before the `&` control-operator split below, so a # redirect `&` never cuts a producer away from a LATER stdout redirect — @@ -287,7 +321,7 @@ producer_redirect_bypass() { # trailing `> file` is still scanned as the echo's own. Restored right after the # split, before the per-segment scan. `&&` and a background `&` carry no # adjacent `<`/`>`, so they are untouched here and still split as separators. - normalized="${exec_lc//>&/>$soh}" + normalized="${normalized//>&/>$soh}" normalized="${normalized//<&/<$soh}" normalized="${normalized//&>/$soh>}" # Each remaining separator becomes a segment boundary; args cannot contain a raw @@ -295,6 +329,7 @@ producer_redirect_bypass() { # simple command and the redirect in it is that command's own. normalized="${normalized//[$seps]/$'\n'}" normalized="${normalized//"$soh"/&}" + normalized="${normalized//"$esc"/ }" while IFS= read -r seg || [[ -n "$seg" ]]; do seg="${seg#"${seg%%[![:space:]]*}"}" # Peel leading command-prefix tokens (see _cmd_prefix) and leading redirections @@ -306,20 +341,39 @@ producer_redirect_bypass() { # (`> real.txt echo x`) is still seen as the segment's command word. The redirect # itself is left in `seg`: _echo_file_out/_echo_devnull below decide whether a # real file write exists, so a leading redirect stays the write signal. - local head="$seg" tgt + local head="$seg" tgt prev_mod="" while :; do if [[ "$head" =~ $_cmd_prefix ]]; then + prev_mod="${BASH_REMATCH[1]}" head="${head#"${BASH_REMATCH[1]}"}" head="${head#"${head%%[![:space:]]*}"}" continue fi if [[ "$head" =~ $_leading_redir ]]; then + prev_mod="" head="${head#"${BASH_REMATCH[0]}"}" tgt="${head%%[[:space:]]*}" head="${head#"$tgt"}" head="${head#"${head%%[![:space:]]*}"}" continue fi + # Options belong only to the option-taking modifiers command/exec (see + # _modifier_opt*); env/builtin keep their bare-only floor. The arg-taking + # form (exec's `-a name`) is peeled first so its NAME word is consumed too, + # else the plain-cluster peel would stop at `-a` and leave NAME masking the + # producer. `--` ends options (`exec -- echo x > f`). + if [[ "$prev_mod" == command || "$prev_mod" == exec ]]; then + if [[ "$head" =~ $_modifier_opt_arg || "$head" =~ $_modifier_opt || + "$head" =~ $_modifier_optend ]]; then + # `command -v`/`-V` describes its argument instead of running it, so the + # echo/printf after it is a looked-up bareword, not a producer — skip the + # whole segment rather than exposing it (would be a false block). + [[ "$prev_mod" == command && "${BASH_REMATCH[0]}" == *v* ]] && continue 2 + head="${head#"${BASH_REMATCH[0]}"}" + head="${head#"${head%%[![:space:]]*}"}" + continue + fi + fi break done [[ "$head" =~ $_producer_head ]] || continue diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 7cb3aa8ec..dccb12d47 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -126,6 +126,56 @@ run "env-assignment before non-producer > file (allowed)" \ run "nohup wrapper before echo > file (accepted floor — allowed)" \ 'nohup echo x > real.txt' 0 +# --- Options of command-name modifiers before the producer (bypass regression) +# The peeled modifiers that take options — `command [-pVv]` and `exec [-cl] +# [-a name]` (bash built-in help) — leave those options between the modifier and +# the producer. They must be peeled too, else `-p`/`-a name` masks the echo/printf +# and the redirect writes a real file unblocked. +run "command -p before echo > file (blocked)" \ + 'command -p echo x > real.txt' 2 +run "exec -a name before echo > file (blocked)" \ + 'exec -a visible echo x > real.txt' 2 +run "exec -cl flags before printf > file (blocked)" \ + 'exec -cl printf x > real.txt' 2 +run "exec -- end-of-options before echo > file (blocked)" \ + 'exec -- echo x > real.txt' 2 +run "command -- end-of-options before printf > file (blocked)" \ + 'command -- printf x > real.txt' 2 +# No new false positive: the arg-taking `-a name` consumes its NAME word, so a +# NON-producer command after it is still allowed; a modifier-lookup with no +# redirect writes nothing. +run "exec -a name before non-producer > file (allowed)" \ + 'exec -a visible ls > out.txt' 0 +run "command -v lookup, no redirect (allowed)" 'command -v echo' 0 +# `command -v`/`-V` DESCRIBE the argument (lookup) rather than run it, so the +# redirect captures the builtin's lookup output, not echo/printf content — the +# producer-scoped guard must NOT block these, even with a `>` redirect. +run "command -v echo describe-lookup > file (allowed)" \ + 'command -v echo > out.txt' 0 +run "command -V echo describe-lookup > file (allowed)" \ + 'command -V echo > out.txt' 0 +run "command -pv cluster with describe flag > file (allowed)" \ + 'command -pv echo > out.txt' 0 +run "command -v printf describe-lookup >> file append (allowed)" \ + 'command -v printf >> out.txt' 0 +# The describe-skip drops ONLY the lookup segment: a real producer bypass in a +# LATER segment of the same command must still block. +run "command -v describe then real echo > file in next segment (blocked)" \ + 'command -v echo > a.txt; echo x > b.txt' 2 +# The describe-skip fires even when the modifier sits behind an env-assignment +# prefix (`prev_mod` must survive the assignment peel into `command`). +run "env-assignment before command -v describe > file (allowed)" \ + 'FOO=bar command -v echo > f' 0 +# exec's `-a` consumes its NAME word even when NAME is the letter `v`; the +# describe-skip is command-only, so exec's echo producer still blocks. +run "exec -a v name then echo > file (blocked)" \ + 'exec -a v echo x > f' 2 +# Option peeling is scoped to command/exec: `env` keeps its bare-only floor, so an +# optioned `env` before a producer stays an accepted-floor miss (documented), not a +# partial/inconsistent catch. +run "env -i optioned before echo > file (accepted floor — allowed)" \ + 'env -i echo x > real.txt' 0 + # --- fd-duplication redirect before stdout redirect (bypass regression) ------ # An fd-dup redirect (`2>&1`, `>&2`) before the real stdout redirect must not let # the `&` split cut the producer away from its `> file`. The whole simple command @@ -174,6 +224,25 @@ run "coproc before printf > file (blocked)" 'coproc printf x > real.txt' 2 # No new false positive: a non-producer command word after coproc is allowed. run "coproc before non-producer > file (allowed)" 'coproc make > log.txt' 0 +# --- Escaped separators between producer and redirect (bypass regression) ---- +# A backslash-escaped separator is NOT a command boundary — bash keeps `\;` `\|` +# `\&` as literal arguments and removes a `\` line continuation, all +# within the SAME simple command. The segment split must not cut the producer +# from its `> file` at an escaped separator, or the write slips through. +run "escaped semicolon then echo > file (blocked)" \ + 'echo x \; > real.txt' 2 +run "escaped pipe then echo > file (blocked)" \ + 'echo x \| > real.txt' 2 +run "escaped ampersand then echo > file (blocked)" \ + 'echo x \& > real.txt' 2 +ESCAPED_NEWLINE=$(printf 'echo x \\\n> real.txt') +run "escaped-newline continuation then echo > file (blocked)" \ + "$ESCAPED_NEWLINE" 2 +# No new false positive: an UNescaped separator still splits, so a captured +# subprocess stdout with an unrelated trailing echo stays allowed. +run "unescaped separator, capture + echo status (allowed)" \ + 'bash fetch.sh > out.json; echo done' 0 + # --- Comment quote-state leak (bypass regression) ---------------------------- # strip_literals carries an open quote across physical lines. An unmatched quote # inside a `#` comment must NOT leak a quote span onto the next line — otherwise From 905963c773ad41030f4931c66fac4094e20d58c8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:25:32 -0400 Subject: [PATCH 8/8] fix(guardrails): detect quoted redirect operands in producer scan The producer-scoped block-hook-bypass narrowing failed OPEN on the most common real write form: a quoted redirect target. strip_literals drops quoted spans so their tokens stay inert, but it also dropped a quoted redirect TARGET, leaving the segment as `echo x > ` with no surviving operand -- so _echo_file_out (which requires a non-space target) did not match and the write returned 0. Thus `echo x > "$out"`, `echo x > 'out.txt'`, and `printf y > "$file"` all wrote real files while bypassing the Write/Edit hooks. The pre-narrowing anywhere-in-command detector caught these. Fix at the root in strip_literals: a quoted span that belongs to a redirect operand word -- the word the quote sits in began right after a `>` -- is now kept as literal content (quote marks dropped) instead of discarded, so the write signal survives the strip. The boundary test strips the trailing operand word and its leading whitespace and checks for `>`, so partial quoting is covered too (`echo x > "$dir"/out.txt`). Keeping the literal content (not a placeholder) preserves the /dev/null exemption: `echo x > "/dev/null"` and `echo x > /dev/"null"` still resolve to /dev/null and stay allowed, so the fix adds no false positive. A quoted span anywhere else (prose, a `--body "..."` payload, a quoted echo argument) still drops, so the #546 false-positive fix is untouched. Adds 11 regression tests: quoted var/literal targets (echo and printf, with and without spaces, append form), partial quoting, the quoted and partially-quoted /dev/null discards (allowed), a non-producer stdout capture to a quoted sink (allowed), and a quoted echo argument that must not be mistaken for the target. Full suite 112/112; shellcheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHLeuVpB2ezBDGiZqDdn23 --- plugins/guardrails/hooks/block-hook-bypass.sh | 44 ++++++++++++++----- .../hooks/block-hook-bypass.test.sh | 33 ++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index ab43de483..f2c28d4d1 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -100,8 +100,11 @@ emit_tel() { strip_literals() { local cmd="$1" line result="" in_heredoc=0 delim="" trimmed # `open_quote` carries a single- or double-quote span across lines: "" outside - # any quote, "'" or '"' inside one that opened on an earlier line. - local open_quote="" out i n c prev + # any quote, "'" or '"' inside one that opened on an earlier line. `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. + local open_quote="" open_keep="" out i n c prev tail # `(^|[^<])` before `<<` excludes a here-string `<<<` — matching `<<` inside # `<<<` would capture a bogus delimiter and strand the stripper in-heredoc, # swallowing every later line (a here-string bypass). The delimiter body @@ -152,23 +155,44 @@ strip_literals() { while ((i < n)); do c="${line:i:1}" if [[ "$open_quote" == "'" ]]; then - [[ "$c" == "'" ]] && open_quote="" + if [[ "$c" == "'" ]]; then + open_quote="" + open_keep="" + elif [[ -n "$open_keep" ]]; then + out+="$c" + fi ((i += 1)) elif [[ "$open_quote" == '"' ]]; then if [[ "$c" == $'\\' ]]; then + # Inside double quotes a backslash escapes the next char; when this span + # is a kept redirect operand, keep that escaped char literally. + [[ -n "$open_keep" ]] && out+="${line:i+1:1}" ((i += 2)) else - [[ "$c" == '"' ]] && open_quote="" + if [[ "$c" == '"' ]]; then + open_quote="" + open_keep="" + elif [[ -n "$open_keep" ]]; then + out+="$c" + fi ((i += 1)) fi else case "$c" in - "'") - open_quote="'" - ((i += 1)) - ;; - '"') - open_quote='"' + "'" | '"') + # Open a quote span. Keep its inner content (as a literal, quote marks + # dropped) ONLY when it belongs to a REDIRECT-OPERAND word — the word the + # quote sits in began right after a `>`. This preserves a quoted write + # target (`echo x > "$out"` -> `echo x > $out`, still a detectable write; + # partial `echo x > /dev/"null"` -> `echo x > /dev/null`, still exempt), + # while a quoted span anywhere else (prose, `--body "..."`, a quoted echo + # argument) is dropped as before so its tokens stay inert. Boundary: strip + # the current trailing operand word (chars up to the last whitespace or + # shell metachar), then the whitespace before it, and test for `>`. + open_quote="$c" + tail="${out%"${out##*[[:space:]<>|&\;()]}"}" + tail="${tail%"${tail##*[![:space:]]}"}" + [[ "${tail: -1}" == ">" ]] && open_keep=1 || open_keep="" ((i += 1)) ;; '#') diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index dccb12d47..18e7df881 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -267,6 +267,39 @@ run "parameter-expansion # then echo > file (blocked)" \ COMMENT_BENIGN=$(printf 'ls -la # list files\ngit status') run "benign trailing comment, no leak (allowed)" "$COMMENT_BENIGN" 0 +# --- Quoted redirect operands (bypass regression) ---------------------------- +# strip_literals drops quoted spans so their tokens stay inert, but a quoted +# redirect TARGET is not inert prose — it is the write's destination. Dropping it +# left the segment as `echo x > ` with no surviving operand, so _echo_file_out +# (which needs a non-space target) did not match and the write slipped through. +# A quoted operand word is now kept as literal content (quote marks dropped) so +# the write signal survives; a quoted span anywhere else still drops. +# shellcheck disable=SC2016 # literal $out path is the command under test, not for expansion +run "echo > double-quoted var target (blocked)" 'echo x > "$out"' 2 +run "echo > single-quoted literal target (blocked)" "echo x > 'out.txt'" 2 +run "echo > double-quoted literal target (blocked)" 'echo x > "out.txt"' 2 +run "echo>quoted target no space (blocked)" 'echo hi>"foo.txt"' 2 +# shellcheck disable=SC2016 # literal $out path is the command under test, not for expansion +run "printf > quoted var target (blocked)" 'printf y > "$out"' 2 +# shellcheck disable=SC2016 # literal $out path is the command under test, not for expansion +run "echo >> quoted target append (blocked)" 'echo x >> "$out"' 2 +# Partial quoting is the common real form — a quoted segment inside an otherwise +# unquoted operand word must still count as the target. +# shellcheck disable=SC2016 # literal $dir path is the command under test, not for expansion +run "echo > partially-quoted target (blocked)" 'echo x > "$dir"/out.txt' 2 +# The dropped quote marks must NOT strand the /dev/null exemption: a quoted (or +# partially-quoted) /dev/null discard is not a Write/Edit bypass and stays allowed. +run "echo > fully-quoted /dev/null (allowed)" 'echo x > "/dev/null"' 0 +run "echo > partially-quoted /dev/null (allowed)" 'echo x > /dev/"null"' 0 +# No new false positive: a NON-producer whose stdout is captured to a quoted data +# sink is the original false-positive report's form and must stay allowed — the +# producer is the script, not an echo. +run "script stdout to quoted sink (allowed)" \ + 'bash fetch.sh 526 > "pr526.json"' 0 +# A quoted span that is NOT a redirect operand (a quoted echo ARGUMENT) still +# drops — only the following real `> file` write drives the block, not the arg. +run "echo quoted arg then > quoted file (blocked)" 'echo "done" > "log.txt"' 2 + # --- Executable-token vs quoted-argument detection -------------------------- # Prose or a commit message merely MENTIONING a bypass in a quoted span is # documentation, not a Write/Edit bypass. The python write-indicator scan stays