Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
30 changes: 30 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions plugins/guardrails/hooks/block-hook-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading