diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 88f744e6e..3dd49cece 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.3", + "version": "0.18.4", "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 97ea6ce70..7a43ae899 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,36 @@ 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.4] + +### 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 — 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. + +- **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.3] ### 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..dae2a46b9 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 @@ -428,10 +431,24 @@ 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 — 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" 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..603e5d6de 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -605,4 +605,28 @@ 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 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" + +# 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