From 776052cf94803c1c5f753c0cca1e54add3aab9ae Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:39:58 -0400 Subject: [PATCH 1/3] fix(guardrails): state block-hook-bypass's enforcement scope in its block message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message said a write was prevented and that Write/Edit is the sanctioned path, with nothing about scope, so it read as "shell file writes are blocked". The guard is deliberately producer-scoped over a single command string, and the gap runs in both directions: an agent concludes shell file writes are unavailable and contorts around a restriction a script file does not have, while a human credits the guard with coverage it never claimed — the more expensive error where the guard is load-bearing in someone's threat model. Verified against the hook with fixture input. printf 'x' > out.log blocks, while bash execute.sh — whose script may write freely — is allowed, as reported. Two shapes the report did not name are allowed too, and they decide the wording: bash execute.sh >> run.log and sort data.txt > out.txt are direct redirects in the command string and are allowed by the producer-scoped design, as is cat a.txt b.txt > c.txt, since only the stdin-consuming cat > f form is a write workaround. The report's suggested line, "direct redirects in this command only", would therefore have overstated coverage in the other direction. The shipped note says instead that only this command string is inspected, only known file-write forms in it are matched, and neither a write inside an invoked script nor a redirect produced by another program is seen. The README's residuals section states the same scope beside the existing quoted-span residual, so the limit is documented where consumers read the guard's guarantees rather than only at the moment of a block. No hook logic changes. The behaviour the note describes is pinned by tests beside the message-content assertions, so the two move together. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 29 +++++++++++++++++++ plugins/guardrails/README.md | 9 ++++++ plugins/guardrails/hooks/block-hook-bypass.sh | 13 +++++++++ .../hooks/block-hook-bypass.test.sh | 22 ++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 5d80a79dc..8df8c1709 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.18.1", + "version": "0.18.2", "description": "Twelve 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, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (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 b7dc22686..418a9f906 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,35 @@ 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.18.2] + +### Fixed + +- **`block-hook-bypass`'s block message now states the scope the guard actually has (#1802).** The + message said a write was prevented and that Write/Edit is the sanctioned path, with nothing about + scope, so it read as "shell file writes are blocked". The guard is deliberately producer-scoped + over a single command string, and the gap runs in both directions: an agent concludes shell file + writes are unavailable and contorts around a restriction a script file does not have, while a + human credits the guard with coverage it never claimed — the more expensive error where the guard + is load-bearing in someone's threat model. + + Verified against the hook with fixture input: `printf 'x' > out.log` blocks, while `bash + execute.sh` — whose script may write freely — is allowed, as reported. Two shapes the report did + **not** name are allowed too, and they matter for the wording: `bash execute.sh >> run.log` and + `sort data.txt > out.txt` are *direct redirects in the command string* and are allowed by the + producer-scoped design, as is `cat a.txt b.txt > c.txt` (only the stdin-consuming `cat > f` form + is a write workaround). So the report's suggested line — "direct redirects in this command only" — + would have overstated coverage in the other direction. The shipped note says instead that only + this command string is inspected, only known file-write forms in it are matched, and neither a + write inside an invoked script nor a redirect produced by another program is seen. + + No hook logic changes. The behaviour the note describes is now pinned by tests beside the + message-content assertions, so the two move together. + +- **The `README` residuals section names the same scope**, next to the existing quoted-span residual + for this guard, so the guarantee is stated where consumers read the guard's limits rather than + only at the moment of a block. + ## [0.18.1] ### Fixed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index e0ab6beb4..b0b16c639 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -77,6 +77,15 @@ out of scope until such a signal exists. (`echo "$(python3 -c 'import pathlib …')"`) is **not** caught — the strip treats the quoted span as inert. Same friction-guard, not-a-sandbox posture as `block-no-verify`. +- **`block-hook-bypass` inspects one command string, and only the write forms + listed above.** It reads `.tool_input.command`; it does not read the contents + of a script that command invokes, so `bash build.sh` runs whatever writes + `build.sh` performs. It is also producer-scoped by design, so a redirect whose + producer is another program (`sort f > out`, `curl … > page.html`, `cat a b > + c`) is allowed — only a content producer writing a real file + (`cat > f` consuming stdin, `echo`/`printf > f`, inline `python3 -c` writes, + the PowerShell write cmdlets) is blocked. The block message carries this scope + so a reader does not credit the guard with coverage it never claimed. - **`flag-commit-pr-skill-bypass` is a nudge, not a gate.** Detection is a literal-stripped top-level regex match, not a full argv-grammar parser — it does not evaluate shell variable / command substitution, and a determined diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index bcc8edc5e..81c758cb9 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -428,10 +428,23 @@ producer_redirect_bypass() { return 1 } +# The scope this guard actually has, stated where a reader meets it. Without it +# the block reads as "shell file writes are blocked" and is over-trusted in both +# directions: an agent contorts around a restriction a script file does not +# have, and a human credits the guard with coverage it never claimed. The guard +# is a speed bump against specific accidental write-workaround forms in one +# command string, not a boundary — and it is deliberately producer-scoped, so +# ordinary data-processing redirects (`sort f > out`, `curl … > page.html`) are +# allowed by design too, not only writes inside an invoked script. +_BYPASS_SCOPE_NOTE="Scope: only this command string is inspected, and only known \ +file-write forms in it. Writes performed inside a script or program this command \ +invokes, and redirects produced by another program, are not seen." + block_bypass() { local form="$1" reason="$2" echo "BLOCKED: $reason" >&2 echo "Use the Write or Edit tool instead of a shell file-write workaround." >&2 + echo "$_BYPASS_SCOPE_NOTE" >&2 emit_tel "blocked" "$form" exit 2 } diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index d39763537..853e2e21b 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -605,4 +605,26 @@ psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" assert_absent "PS write block message is shell-agnostic" "$psout" "Bash file-write" +# --- Enforcement-scope disclosure ------------------------------------------- +# The message asserted "use Write or Edit instead" with no scope, so it read as +# "shell file writes are blocked" when the guard is deliberately producer-scoped +# over one command string. Both lanes must carry the scope, and the behaviour +# the scope describes is pinned below it so message and reality move together. +scopeout=$(bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" 2>&1) +assert_contains "bash block states its scope" "$scopeout" \ + "only this command string is inspected" +assert_contains "bash block names the invoked-script gap" "$scopeout" \ + "inside a script or program this command invokes" +psscope=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) +assert_contains "powershell block states its scope" "$psscope" \ + "only this command string is inspected" + +# The behaviour the scope note describes. A write inside an invoked script is +# not inspected, and a redirect whose producer is another program is allowed by +# the producer-scoped design — so the note must not promise either is blocked. +run "invoked script is not inspected (allowed)" "bash execute.sh" 0 +run "invoked script with its own redirect (allowed)" "bash execute.sh >> run.log" 0 +run "non-producer redirect (allowed)" "sort data.txt > out.txt" 0 +run "cat with input files is not a heredoc write (allowed)" "cat a.txt b.txt > c.txt" 0 + report From 3b81f15584dddca18e0b07bf7d6f35908139df72 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:47:20 -0400 Subject: [PATCH 2/3] chore(guardrails): excuse a portability false positive the changed-file gate surfaced shell-portability-lint gates CHANGED files, so editing block-hook-bypass.sh surfaced a pre-existing hit at an untouched line: the linter reads the `\<` and `\>` inside a Bash bracket PATTERN as GNU grep/sed word-boundary operators. They are backslash-escaped literals, and Bash pattern matching behaves identically on BSD userland, so the construct is portable as written. Annotated with the repo's sanctioned portability-ok escape and the reason at the site, rather than rewriting a correct predicate to satisfy a text scan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C --- plugins/guardrails/hooks/block-hook-bypass.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 81c758cb9..92d0b2258 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -223,6 +223,9 @@ strip_literals() { if ((i == 0)) || { prev="${line:i-1:1}" + # portability-ok: `\<` and `\>` here are backslash-escaped literals + # inside a Bash bracket PATTERN, not GNU grep/sed word-boundary + # operators; Bash pattern matching is identical on BSD userland. [[ "$prev" == [[:space:]] || "$prev" == [\;\|\&\(\)\<\>] ]] }; then break From 2b5ba4d2133bed5bf3e312571dc8439cbafe4f5f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:34 -0400 Subject: [PATCH 3/3] fix(guardrails): exempt inspected inline interpreter code from the blind-spot note The scope disclosure claimed writes inside an invoked program are not seen immediately after _py_write blocked exactly such a write. The blind spot now claims only an invoked script file or a program's own opaque code, with the inline-code exemption pinned by the tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STASxNiqEm7doy2Z65un4H --- plugins/guardrails/CHANGELOG.md | 5 +++-- plugins/guardrails/hooks/block-hook-bypass.sh | 7 ++++--- plugins/guardrails/hooks/block-hook-bypass.test.sh | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 418a9f906..6471d3d16 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -22,8 +22,9 @@ All notable changes to the `guardrails` plugin are documented here. Format follo producer-scoped design, as is `cat a.txt b.txt > c.txt` (only the stdin-consuming `cat > f` form is a write workaround). So the report's suggested line — "direct redirects in this command only" — would have overstated coverage in the other direction. The shipped note says instead that only - this command string is inspected, only known file-write forms in it are matched, and neither a - write inside an invoked script nor a redirect produced by another program is seen. + this command string is inspected — known shell file-write forms plus recognized inline + interpreter code (`python -c` IS scanned, so the blind spot claims only an invoked script file + or a program's own opaque code) — and that a redirect produced by another program is not seen. No hook logic changes. The behaviour the note describes is now pinned by tests beside the message-content assertions, so the two move together. diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 92d0b2258..dae2a46b9 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -439,9 +439,10 @@ producer_redirect_bypass() { # command string, not a boundary — and it is deliberately producer-scoped, so # ordinary data-processing redirects (`sort f > out`, `curl … > page.html`) are # allowed by design too, not only writes inside an invoked script. -_BYPASS_SCOPE_NOTE="Scope: only this command string is inspected, and only known \ -file-write forms in it. Writes performed inside a script or program this command \ -invokes, and redirects produced by another program, are not seen." +_BYPASS_SCOPE_NOTE="Scope: only this command string is inspected — known shell \ +file-write forms plus recognized inline interpreter code (e.g. python -c). Writes \ +inside an invoked script file or a program's own opaque code, and redirects \ +produced by another program, are not seen." block_bypass() { local form="$1" reason="$2" diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 853e2e21b..603e5d6de 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -614,7 +614,9 @@ scopeout=$(bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" 2>&1) assert_contains "bash block states its scope" "$scopeout" \ "only this command string is inspected" assert_contains "bash block names the invoked-script gap" "$scopeout" \ - "inside a script or program this command invokes" + "inside an invoked script file" +assert_contains "bash block exempts inspected inline code from the gap" "$scopeout" \ + "recognized inline interpreter code" psscope=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) assert_contains "powershell block states its scope" "$psscope" \ "only this command string is inspected"