From 00b59d9ad0104cd16a923063e8b95345720eed35 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:30:25 -0400 Subject: [PATCH 1/2] harden(guardrails): block heredoc-opener stdout redirect in block-hook-bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip_literals truncated the opener line at '<<', dropping a trailing stdout redirect (cat < file) before the redirect scan — a real file-write bypass leaked (exit 0). Preserve the text after the heredoc delimiter token so the redirect still reaches _cat_redir/_echo_redir. Regression tests: opener redirect blocks (exit 2); plain heredoc with no redirect stays allowed (exit 0). Patch guardrails 0.3.1 -> 0.3.2. --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/hooks/block-hook-bypass.sh | 6 +++++- plugins/guardrails/hooks/block-hook-bypass.test.sh | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index f94efd3de0..abf116e89d 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.3.1", + "version": "0.3.2", "description": "Six safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, Bash file-write workarounds that circumvent Write/Edit hooks, (advisory) hallucinated CLI flags, and (advisory) un-throttled Workflow fan-out that risks burst 529s — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index b30e0e45db..8ec4a01216 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -109,7 +109,11 @@ strip_literals() { delim="${delim%\'}" delim="${delim#\"}" delim="${delim%\"}" - line="${line%%<<*}" + # Drop only the heredoc operator + delimiter token, keeping the text before + # `<<` AND any text after the delimiter on the opener line — a trailing + # stdout redirect (`cat < file`) must still reach the redirect scan + # rather than being truncated away with the body. + line="${line%%<<*}${line#*"${BASH_REMATCH[0]}"}" in_heredoc=1 fi line=$(printf '%s' "$line" | sed "s/'[^']*'//g" | sed -E 's/"([^"\\]|\\.)*"//g') diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 1cf1eced7e..beb6e96dea 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -93,6 +93,16 @@ run "heredoc metachar delim, trailing cat > bypass (blocked)" "$HEREDOC_METACHAR HEREDOC_BACKSLASH=$(printf 'cat <<\\EOF\ncontent line\nEOF\ncat > real.txt') run "heredoc backslash delim, trailing cat > bypass (blocked)" "$HEREDOC_BACKSLASH" 2 +# A stdout redirect ON the heredoc opener line (`cat < file`) is a real +# file-write bypass. The strip must drop only the heredoc operator + delimiter, +# keeping the trailing `> file` so the redirect scan still fires — truncating +# everything after `<<` would leak this form (exit 0). +HEREDOC_OPENER_REDIR=$(printf 'cat < real.txt\ncontent line\nEOF') +run "heredoc opener stdout redirect (blocked)" "$HEREDOC_OPENER_REDIR" 2 +# A plain heredoc with NO redirect on the opener stays allowed. +HEREDOC_NO_REDIR=$(printf 'cat < real.txt' '"' '"') From 9d871a8b20dee0c7432f7e0829fb1b286bdd5d2a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:33:29 -0400 Subject: [PATCH 2/2] fix(guardrails): stop heredoc delimiter capture at redirect operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A redirect glued to the delimiter (cat <real.txt, no space) was swallowed — the greedy delimiter capture absorbed EOF>real.txt, so no > survived for the redirect scan (leaked, exit 0). Exclude > (keep < excluded) from the delimiter body so bash's own word-boundary at > is honored. Adds glued-form, tab-strip (<<-EOF), and quoted-delimiter (<<'EOF') opener redirect regressions (37 cases, 0 fail). --- plugins/guardrails/hooks/block-hook-bypass.sh | 9 ++++++--- plugins/guardrails/hooks/block-hook-bypass.test.sh | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 8ec4a01216..c2bbb7d33c 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -85,9 +85,12 @@ strip_literals() { local cmd="$1" line result="" in_heredoc=0 delim="" trimmed # `(^|[^<])` 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's first - # char is `[^[:space:]<]` for the same reason. - local heredoc_start_re='(^|[^<])<<-?[[:space:]]*([^[:space:]<][^[:space:]]*)' + # swallowing every later line (a here-string bypass). The delimiter body + # excludes `<` for the same reason, and `>` so a redirect glued to the + # delimiter (`cat <file`) terminates the token — bash ends the delimiter + # word at `>`, so the `>file` is a real redirect that must reach the scan + # instead of being swallowed into a bogus `EOF>file` delimiter. + local heredoc_start_re='(^|[^<])<<-?[[:space:]]*([^[:space:]<>]+)' while IFS= read -r line || [[ -n "$line" ]]; do if ((in_heredoc)); then diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index beb6e96dea..0358b5a903 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -102,6 +102,18 @@ run "heredoc opener stdout redirect (blocked)" "$HEREDOC_OPENER_REDIR" 2 # A plain heredoc with NO redirect on the opener stays allowed. HEREDOC_NO_REDIR=$(printf 'cat <`, so `cat <real.txt` is a real stdout redirect. The delimiter capture +# must stop at `>` (not greedily swallow `EOF>real.txt`) so the `>` survives. +HEREDOC_GLUED_REDIR=$(printf 'cat <real.txt\ncontent line\nEOF') +run "heredoc opener redirect glued to delimiter (blocked)" "$HEREDOC_GLUED_REDIR" 2 +# Tab-stripping opener form (`<<-EOF`): the `<<-?` regex + fix cover it. +HEREDOC_TAB_STRIP=$(printf 'cat <<-EOF > real.txt\ncontent line\nEOF') +run "heredoc tab-strip opener redirect (blocked)" "$HEREDOC_TAB_STRIP" 2 +# Quoted delimiter (`<<'EOF'`): BASH_REMATCH[0] is `<<'EOF'`; the suffix scan +# still preserves the trailing redirect. +HEREDOC_QUOTED_DELIM=$(printf "cat <<'EOF' > real.txt\ncontent line\nEOF") +run "heredoc quoted-delimiter opener redirect (blocked)" "$HEREDOC_QUOTED_DELIM" 2 # A here-string (<<<) has no terminator — it must NOT be mistaken for a heredoc, # which would strand the stripper and swallow the trailing bypass.