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
4 changes: 2 additions & 2 deletions 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.26.1",
"version": "0.27.0",
"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, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), 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, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.",
"author": {
"name": "Melodic Software",
Expand Down Expand Up @@ -125,7 +125,7 @@
"block_hook_bypass_scratch_roots": {
"type": "string",
"title": "block-hook-bypass scratch roots",
"description": "Comma-separated ABSOLUTE directories block-hook-bypass exempts as scratch/temp write targets (e.g. /tmp/scratch,/d/jobtmp/session); empty (the default) exempts nothing and leaves the guard's shipped behaviour unchanged. Matching is on the effective stdout target after lexical normalization, at a path-component boundary \u2014 a sibling merely sharing the name prefix, a `..` escape out of a root, and a discard-then-real-file redirect all still block. Any quote or backslash after the first `>` CHARACTER in the command (operator or not) cancels the exemption for the whole command \u2014 so a quote in a later segment, or a `>` inside quoted content, also cancels it. Symlinks are not followed",
"description": "Comma-separated ABSOLUTE directories block-hook-bypass exempts as scratch/temp write targets (e.g. /tmp/scratch,/d/jobtmp/session); empty (the default) exempts nothing and leaves the guard's shipped behaviour unchanged. Matching is on the effective stdout target after lexical normalization, at a path-component boundary a sibling merely sharing the name prefix, a `..` escape out of a root, and a discard-then-real-file redirect all still block. A quoted or escaped OPERAND is never exempt: the operand is marked so it survives the quote strip and the segment split as one word, and an operand carrying whitespace, `;`, `|`, `&`, `(`, `)`, a newline or a backslash escape exempts nothing. Quotes elsewhere in the command no longer matter. Symlinks are not followed",
"default": ""
},
"stdin_read_timeout": {
Expand Down
99 changes: 99 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,100 @@
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.27.0]

### Fixed

- **`block-hook-bypass` exempted a quoted redirect target on its first word, so a write whose real
destination was somewhere else entirely was waved through as a `/dev/null` discard (#2226).**
A quoted redirect operand is ONE pathname to bash. This guard decided its target-based exemptions
on the operand's first whitespace- or separator-delimited fragment, because the machinery either
side of that decision disagreed about what a kept operand is: `strip_literals` **keeps** a quoted
write target as literal content — dropping the quotes so a quoted target still reads as a write —
while `normalize_segments` then read a `;`, `|`, `&`, `(`, `)` or newline *inside* it as a segment
boundary and `_redir_scan`'s target class ended at whitespace.

Measured at `56f5cd21` (0.25.3), hook invoked as a decision function on a `PreToolUse` Bash
payload — `rc=2` blocked, `rc=0` allowed:

```
rc=0 :: echo x > "/dev/null ../../etc/pw" # exempted on the word /dev/null
rc=0 :: echo x > "/dev/null;/../../etc/passwd"
rc=0 :: echo x > '/dev/null ../../etc/pw'
rc=0 :: echo x > /dev/"null ../../etc/pw"
rc=0 :: echo x > /dev/null\;/../../etc/passwd # the unquoted escaped spelling
rc=0 :: cat > "/dev/null ../../etc/pw" # and the cat lane
```

Nothing named `/dev/null` is the destination in any of them. Reaching a *chosen* file this way
needs a directory whose name ends in the whitespace-bearing fragment to already exist, so this is
correctness and defence-in-depth rather than a demonstrated escape — but it is the exact assumption
every target-based exemption rests on, and this guard now has two.

`strip_literals` marks a kept operand's literal content with two sentinels. `\x03` **OPAQUE**
stands in for one character whose literal value would read as syntax downstream, or for a backslash
escape this strip cannot reproduce faithfully (inside double quotes bash *retains* the backslash
unless it escapes `$`, `` ` ``, `"`, `\` or a newline). It is inert to every scan, so the operand
survives as one token, and its presence means the pathname is not recoverable here — no exemption
of any kind may be granted. `\x04` **QUOTED** is emitted where a kept span opens; the discard
compare strips it, so `> "/dev/null"` is still a discard, while the scratch-root axis keeps its
shipped floor of never exempting a quoted operand. A raw `\x01`-`\x04` byte arriving in the command
text is mapped to OPAQUE, so a forged sentinel can only ever *cost* an exemption.

Every mark is gated on "this word began right after a `>`" — the same test the quoted-operand keep
already used, now factored out and applied to the unquoted backslash branch too. **That gating is
load-bearing, not tidiness:** `normalize_segments`, `_producer_head`, `_cat_redir` and every
whitespace trim in the file are byte-for-byte as shipped, so an escaped separator *between*
commands (`echo x \; > f`) still travels the unchanged `\x02`-to-space path, and a backslash in a
command word (`/c/Python313/python3.exe -c`) is untouched. #1680 and #1667 read this same
normalization; their shapes (`echo x >&2`, `printf … >&2`, `echo x &>file`, `1>&2`, `2>&1`,
`>&2>file`, `cat 1>&2`, `cat 1>&-`) carry no quotes and no backslashes, emit no mark, and were
measured before and after with **no delta**. Neither issue moves.

### Changed

- **The scratch-root exemption's fail-close is keyed on the redirect operand instead of the whole
raw command, retiring both blunt edges 0.25.1 documented (#2236).** With the operand/quote
association restored above, `scratch_target_exempt` no longer has to infer it from
`${COMMAND#*>}`. It reads the operand's own marks, so the two frictions 0.25.1 recorded as
unfixable-without-#2226 are gone:

| command, root `/tmp/scratch` | 0.25.3 | 0.27.0 |
| --- | --- | --- |
| `echo x > /tmp/scratch/f && grep foo "notes.txt"` | blocked | **allowed** |
| `echo x > /tmp/scratch/f; cat "notes.txt"` | blocked | **allowed** |
| `echo "a > b" > /tmp/scratch/f` | blocked | **allowed** |
| `echo 'x > y' > /tmp/scratch/f` | blocked | **allowed** |
| `echo x > "/tmp/scratch/f"` — a merely quoted operand | blocked | blocked |
| `echo x > "/tmp/scratch/a;/../../etc/passwd"` | blocked | blocked |

**Grade every verdict this release moves on the direction that matters:** refusing an exemption is
friction, granting one is a bypass. Counted from the new suite run against 0.25.3's hook, which
reports 19 failures split 15/4 by direction:

- **15 move from GRANTED to REFUSED.** The `/dev/null` family above in its quoted, single-quoted,
partially-quoted, escaped, fd-numbered (`1>`), `cat`-lane and real-file-then-operand spellings,
plus a multi-line quoted operand, an operand continued by a backslash-newline, and an empty
quoted target (`> ""`).
- **4 move from REFUSED to GRANTED** — the first four rows of the table above. That is the entire
grant surface of this release, and each one lands on a target the marks *prove* was bare: no
quote mark, no opaque mark, no backslash.

A forged sentinel byte and an escaped-space operand are **not** in either set: both already blocked
at 0.25.3 and are pinned here as regression guards, not flips. The scratch axis's own floor is
deliberately *not* widened even though the operand is now known precisely: a quoted operand stays
non-exempt, and 0.25.0's assertion saying so is untouched.

0.25.1's entry below says the breadth "stays" and that narrowing it needs #2226. Both were true
when written; #2226 is now fixed and this entry is that entry's erratum. Per Keep a Changelog the
0.25.x entries are left as they shipped.

One assertion 0.25.0 shipped is **retired** rather than kept: `control: /dev/null still shows the
inherited truncation (#2226, allowed)`, which 0.25.0's own PR wrote so that it "flips visibly when
this issue is fixed". It is replaced by six `/dev/null` assertions covering the whole family, not
just its `;` spelling. Every other 0.25.0 and 0.25.1 assertion still passes unmodified except the
four graded above.

## [0.26.1]

### Fixed
Expand Down Expand Up @@ -149,6 +243,11 @@ All notable changes to the `guardrails` plugin are documented here. Format follo

### Changed

> **Erratum:** the second bullet below states that the fail-close's breadth stays, because narrowing
> it needs #2226. #2226 is fixed in 0.26.0 and the breadth is gone — the check is keyed on the
> redirect operand now. The mechanism this entry corrects for 0.25.0 was accurate for 0.25.x; see
> 0.26.0 above for what replaced it. Everything else in this entry is unchanged.

- **`block-hook-bypass`'s scope note now names `tee` and other inline-interpreter
write families it does not model (#2218).** No behaviour changes — lane-specific
`_BYPASS_SCOPE_NOTE_BASH` / `_BYPASS_SCOPE_NOTE_PWSH`, two `SCOPE (documented
Expand Down
33 changes: 16 additions & 17 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,22 @@ out of scope until such a signal exists.
blocks, and a relative, unexpanded (`$VAR`, `~`) or glob target is never
exempt. A **quoted or escaped** operand is never exempt either, and that one
fails closed rather than being documented: the quote strip drops a kept
target's quotes and the segment split then reads a `;`, `|`, `&` or space
target's quotes, and the segment split would then read a `;`, `|`, `&` or space
*inside* the operand as syntax, so `> "/tmp/scratch/a;/../../etc/passwd"` —
one pathname to bash — would otherwise be judged on `/tmp/scratch/a`. The rule
is therefore blunt, and blunt in two directions worth stating exactly: **any
quote or backslash after the first `>` character in the command — operator or
not — cancels the exemption.** It is not scoped to the segment being evaluated,
so a quote in an unrelated later segment cancels it too
(`echo x > /tmp/scratch/f && grep foo "notes.txt"` blocks; the same compound
without quotes does not). And it is not keyed on the redirect *operator*, so a
`>` inside quoted content starts the scan early and that content's own closing
quote falls inside it — `echo "hi there" > /tmp/scratch/f` is exempt, but
`echo "a > b" > /tmp/scratch/f` is **not**. Both are the safe direction: the
check can only ever refuse an exemption, never grant one. Making it precise
needs the same thing in both cases — knowing which `>` and which quotes are
syntax rather than content — which is exactly what the quote strip destroys
before this code runs. The same root cause reaches the `/dev/null` exemption and
predates this option — filed as #2226 and pinned by a control test. Two residuals remain, both
one pathname to bash — would be judged on `/tmp/scratch/a`. Since **0.26.0**
the operand is **marked** wherever that would happen, so it reaches the compare
as one word and the decision is made on the whole thing: an operand carrying
whitespace, `;`, `|`, `&`, `(`, `)`, a newline or a backslash escape exempts
nothing, and a merely quoted operand is refused by this axis on its shipped
floor. **Quotes and backslashes elsewhere in the command no longer matter.**
Before 0.26.0 this test read the whole raw command tail after the first `>`
*character*, so a quote in an unrelated later segment, or a `>` inside quoted
content, cancelled the exemption for an earlier plain write. Both were friction
rather than protection and both are gone — `echo x > /tmp/scratch/f && grep foo
"notes.txt"` and `echo "a > b" > /tmp/scratch/f` are exempt again. The same
truncation reached the `/dev/null` discard and predated this option (#2226);
the same marking closes it, so a quoted `/dev/null` operand carrying a second
fragment now **blocks** where it was allowed. Two residuals remain, both
deliberate and both pinned: normalization is lexical, so symlinks out of a
root are not followed, and the compare is case-insensitive because the segment
scan runs over the lowercased command. Naming a root is accepting that root's
Expand Down Expand Up @@ -317,7 +316,7 @@ reads it from.
| `block_dangerous_git_allow` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW` | Comma-separated forms block-dangerous-git permits: push-force, push-lease-unsafe, reset-hard, clean-force, checkout-dot, restore-dot, checkout-force; empty blocks all |
| `block_noncanonical_commit_allow` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW` | Comma-separated form tokens to allow (currently: message-flag, which permits `-m` even when the message contains a newline) |
| `block_no_verify_hook_manager_prefixes` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_NO_VERIFY_HOOK_MANAGER_PREFIXES` | Comma-separated hook-manager env-var name prefixes block-no-verify treats as a bypass when set to 0/false (e.g. lefthook,husky); empty uses the built-in default set (lefthook, husky, pre_commit, simple_git_hooks) |
| `block_hook_bypass_scratch_roots` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_SCRATCH_ROOTS` | Comma-separated ABSOLUTE directories block-hook-bypass exempts as scratch/temp write targets (e.g. /tmp/scratch,/d/jobtmp/session); empty (the default) exempts nothing and leaves the guard's shipped behaviour unchanged. Matching is on the effective stdout target after lexical normalization, at a path-component boundary — a sibling merely sharing the name prefix, a `..` escape out of a root, and a discard-then-real-file redirect all still block. Any quote or backslash after the first `>` CHARACTER in the command (operator or not) cancels the exemption for the whole command — so a quote in a later segment, or a `>` inside quoted content, also cancels it. Symlinks are not followed |
| `block_hook_bypass_scratch_roots` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_SCRATCH_ROOTS` | Comma-separated ABSOLUTE directories block-hook-bypass exempts as scratch/temp write targets (e.g. /tmp/scratch,/d/jobtmp/session); empty (the default) exempts nothing and leaves the guard's shipped behaviour unchanged. Matching is on the effective stdout target after lexical normalization, at a path-component boundary — a sibling merely sharing the name prefix, a `..` escape out of a root, and a discard-then-real-file redirect all still block. A quoted or escaped OPERAND is never exempt: the operand is marked so it survives the quote strip and the segment split as one word, and an operand carrying whitespace, `;`, `\|`, `&`, `(`, `)`, a newline or a backslash escape exempts nothing. Quotes elsewhere in the command no longer matter. Symlinks are not followed |
| `stdin_read_timeout` | number<br>*min 1* | `2` | `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT` | Idle bound on reading the hook payload from stdin — how long a silent pipe is tolerated before a blocking guard fails closed |

### How to set these
Expand Down
Loading
Loading