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.27.2",
"version": "0.28.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
84 changes: 84 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,90 @@
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.28.0]

### Fixed

- **`block-hook-bypass` missed three inline-write forms that reach a real file, one of them a
residual the file itself recorded as accepted (#2217).** Measured against `4c90b454` (0.27.2),
hook invoked as a decision function on a `PreToolUse` Bash payload — `rc=2` blocked, `rc=0`
allowed:

```
rc=0 :: python -c "open('f','w').write('x')"
rc=0 :: py -c "open('f','w').write('x')"
rc=0 :: python3.11 -c "open('f','w').write('x')"
rc=0 :: printf 'a<newline>b<newline>' > notes.md
rc=0 :: python3 - <<'PY' … open('f','w').write('x') … PY
rc=2 :: python3 -c "open('f','w').write('x')" # the one spelling that matched
rc=2 :: printf "a\nb\n" > notes.md # the same write, escaped newline
```

Three separate causes, all in the direction of letting a write through:

1. **The interpreter detector was a spelling floor, not a rule.** Both lanes required the literal
`python3` — the Bash lane's `EXEC_LC` scan and the PowerShell lane's
`ps::might_write_via_python3` token test — so `python -c`, `py -c`, `py3 -c`, `python2 -c` and
`python3.11 -c` ran the identical inline write unseen. The guard's own scope message advertised
`python -c` as its example, naming the one spelling the regex did not match. The command word is
now the python family (`py`/`python`/`pypy` plus an optional version suffix and `.exe`), still
separator-anchored, so `notpython3`, `mypython3`, `mypy`, `spy`, `happy` and `pytest` stay
inert. `py -3 -c` is admitted because a `-<digits>` token cannot be a script path; no other gap
between interpreter and flag is, so `python3 build.py` and `python3 -m tool …` are still not
blocked.

2. **A physical newline inside a quoted span split a producer from its own redirect.** A newline
reached with a quote still OPEN is not a separator — bash is inside a quoted word, so the text
either side of the span is ONE word — but `strip_literals` re-emitted it, `normalize_segments`
split there, and `producer_redirect_bypass` requires producer and redirect in one segment. The
join is now empty rather than a newline. **Empty, not a space:** `ec"<newline>"ho x > f` is
`echo x > f` to bash, and a space join leaves `ec ho`, which `_producer_head` does not match —
the fix ships with that case as an assertion. Joining empty cannot manufacture a token bash does
not also form, because an open quote is what makes the two sides one word. The kept-operand and
backslash-newline joins are unchanged; the multi-line `--body`/`-m` prose floor is unchanged
because a dropped span's content is dropped either way.

3. **REOPENED ACCEPTED RESIDUAL** — a stdin heredoc (`python3 - <<PY … PY`, no `-c`) was recorded
as uncovered and accepted in the PowerShell lane's comment. It is reopened here on new
reachability evidence rather than treated as an oversight: this repo's own session record shows
an agent reaching for exactly that form to patch a file
(`.work/handoffs/20260809T082720Z-handoff-post-2008-followups.md:211`, `python - <<'PY'`), and
widening the `-c` arm raises the pressure toward it, since a refused `python -c` write reroutes
most naturally to the heredoc. The `-` is what makes it inline: the code sits in the command
string the hook reads, not in an opaque script file. The acceptance comment is updated in both
`block-hook-bypass.sh` and `lib/powershell/ps-command.sh` rather than contradicted.

**Direction of every change: 23 granted → refused, 1 refused → granted.** Measured, not asserted:
the new assertions were run against the PRE-change hook and the failures enumerated
(`PASS=377 FAIL=15`, every one `expected 2, got 0`), then adversarial probes written afterwards
specifically to hunt the other direction turned up eight more rows, now assertions as well.

The **one** in the other direction is `foo "a<newline>" echo x > f`, and it is a false positive
being removed rather than a new exemption. Fusing the two sides of a span back into one segment
also puts whatever preceded the span at the segment start: there bash's command word is `foo` and
`echo` is one of its *arguments*, so the redirect's producer is another program and this guard is
producer-scoped by design. The newline previously split it into a bogus `echo x > f` segment. The
single-line spelling `foo "a" echo x > f` was already allowed, so this makes the multi-line form
agree with shipped behaviour; both are asserted, as is the mirror case (`echo "a<newline>" x > f`,
where the command word really is the producer) which moves the other way.

It is one row and not a class, verified rather than reasoned: every command PREFIX the file already
models — env assignments, `env`, `if…then`, `!`, `exec -a NAME`, a leading redirect — was probed in
front of a multi-line span against both hooks, and all still block, because `_cmd_prefix` /
`_modifier_opt_arg` / `_leading_redir` peel on the fused segment. Each is pinned with its
single-line control. Every remaining floor keeps its `rc=0`: the name anchor, the `#1601`/`#2148`
over-block repros re-run for each new spelling, the multi-line prose/`--body` floor, the
`/dev/null` discard floor, and the stdin floor.

**Accepted residual, restated at its narrowed width:** `python3 <<PY … PY` — stdin with **no** `-`
argument — stays uncovered. Matching a bare trailing interpreter token would flip
`echo "pathlib" | python3` and `cat script.py | python3` to blocked, so that exemption costs this
one spelling; both floors are asserted. Same discipline as the `-c` arm: no gap is allowed between
the interpreter and its flag beyond a `-<digits>` version selector, so `python3 -O - <<PY` is
uncovered too — admitting an arbitrary option-shaped token is what would let a *script path*
through as one. Inline writes via other interpreters (`node -e`, `perl -e`, `ruby -e`, `sed -i`)
remain out of scope, unchanged.

## [0.27.2]

### Fixed
Expand Down
17 changes: 11 additions & 6 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Each guard is independently toggleable, so you run exactly the subset you want.
| **hardcoded-path-check** | PreToolUse · Write \| Edit \| NotebookEdit | **Blocks** (exit 2) | Hardcoded machine-specific paths — Windows drive-letter homes, macOS/Linux user homes, machine-specific repo checkout roots. |
| **block-no-verify** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Git hook-bypass attempts on `git commit` / `git push`: `--no-verify` / `-n`, `core.hooksPath=` assignment, and hook-manager disable env vars — a configurable prefix set defaulting to `lefthook`, `husky`, `pre_commit`, `simple_git_hooks` (e.g. `LEFTHOOK=0`, `HUSKY=0`, `PRE_COMMIT_*=false`), tunable via `block_no_verify_hook_manager_prefixes`, including inside compound `cd … && …` commands. |
| **block-dangerous-git** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Irreversible git operations: `push --force`/`-f` plus the equivalent leading-`+` refspec and `--mirror` forms, and the unsafe `--force-with-lease` spellings, in the two kinds git itself treats differently. **No expected value** (bare `--force-with-lease` or `=<refname>`) leases against the remote-tracking ref, which git documents as "trivially defeated" by a background fetch — blocked unless `--force-if-includes` is present, which git documents as the mitigation for exactly this form. **A movable `=<refname>:<expect>`** — `origin/main`, `HEAD`, a tag, an *abbreviated* object id, or hex of the wrong width for this repository's hash format, all of which git resolves at push time, and gitrevisions resolves a short hex word as a ref before trying it as an object-id prefix — is blocked unconditionally, because git declares `--force-if-includes` a no-op alongside an explicit `:<expect>`. A lease passes only when `<expect>` is immutable: a **literal** object id of the pushed repository's own hash width (detection never evaluates substitutions, so resolve it with `git rev-parse` as a separate step and pass the result) (40 hex under SHA-1, 64 under SHA-256, read from `git rev-parse --show-object-format` with the command's own `-C`/`--git-dir`/`--work-tree`/`--namespace` replayed onto it; undeterminable fails closed) or the empty string asserting the ref must not exist. The other width is a ref name there, not an object id — git ignores a ref whose name is full-width hex for its own format, but resolves one of the other width like any name. git scopes a pin to its own ref, so a bare fallback alongside a pinned entry still governs every other ref being updated; where the same ref carries several lease entries, git consults the first, and so does this guard. A trailing `--no-force-with-lease` cancels every previous lease, and a push dry-run disarms the check. Also blocked: `reset --hard`, `clean` with a force flag (any dry-run flag disarms), worktree-wide `checkout`/`restore` pathspecs (`.`, `:/`, `:(top…)` — path-scoped forms and `restore --staged .` pass), and forced `checkout -f` / `switch --discard-changes`. Accepted unique-prefix abbreviations of the blocked long options match too. `branch -D` is deliberately not blocked (reflog-recoverable; sanctioned skill flows issue it). Per-repo/per-user allow-list via the `block_dangerous_git_allow` userConfig option (comma list, any subset of `push-force,push-lease-unsafe,reset-hard,clean-force,checkout-dot,restore-dot,checkout-force`). |
| **block-hook-bypass** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates — `cat > file`, `echo … > file`, and `python3 -c` with file-write indicators. Executable-token detection ignores quoted prose/commit text that merely mentions the pattern. |
| **block-hook-bypass** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates — `cat > file`, `echo … > file`, and inline python code with file-write indicators (`python`/`python3`/`py`/`pypy`, with `-c` or reading the program from stdin as `python3 - <<PY`). Executable-token detection ignores quoted prose/commit text that merely mentions the pattern. |
| **cli-flag-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | Hallucinated CLI flags — a `--flag` written as a command that does not exist in the binary's actual `--help` output. Surfaces via `additionalContext`, never blocks. |
| **workflow-resilience-check** | PreToolUse · Workflow | **Advisory** (exit 0) | Un-throttled Workflow fan-out — a script calling `parallel()` / `pipeline()` with no wave-cap throttle (`inWaves` / `inWavesPipeline`) and no retry wrapper (`agentRetry`), which risks a burst 529 under wide Opus fan-out. Surfaces a resilience checklist via `additionalContext`, never blocks. **Opt-in — default off since 0.20.0** (behavioral-class injector config-disabled per #2021; set `workflow_resilience_check_enabled=true` to enable). |
| **block-noncanonical-commit** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | `git commit -m` whose message actually contains a newline — a multi-line `-m` flattens newlines unpredictably across shells; pipe it via `-F -` / `--file -` instead (narrowed in 0.20.0 per #2021: single-line `-m`, bare `git commit`, and repeated single-line `-m` paragraphs all pass). On the PowerShell tool a here-string `-m` value blocks too — its content is uninspectable and multi-line by construction of the form. Exempt: `--amend`, `-C`/`-c`/`--reuse-message`/`--reedit-message`, `--fixup`/`--squash`, `-F <path>`, and any commit taken while a merge/rebase/cherry-pick/revert is in progress. Resolves `bash -lc` wrappers and git aliases (inline `-c` and persisted config alike). |
Expand Down Expand Up @@ -93,12 +93,17 @@ out of scope until such a signal exists.
`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,
(`cat > f` consuming stdin, `echo`/`printf > f`, inline python writes,
the PowerShell write cmdlets, including `Tee-Object` and its `tee` alias on the
PowerShell tool) is blocked. On the **Bash** tool, **`tee` / `tee -a` and inline
writes via other interpreters (`node -e`, `perl -e`, `ruby -e`, `sed -i`, `dd
of=`, `awk >`, …) are accepted residuals** — outside the modeled surface, not
oversights. The block message carries a lane-specific scope note so a reader does
PowerShell tool) is blocked. The python lane matches the interpreter FAMILY
(`py`, `python`, `pypy`, with an optional version suffix — `py -c`, `python -c`,
`python3.11 -c` are the same write as `python3 -c`), and since **0.28.0** it also
covers a program read from stdin with an explicit `-` (`python3 - <<PY … PY`);
`python3 <<PY` with **no** `-` is an accepted residual, because matching a bare
trailing interpreter token would block `cat script.py | python3`. On the **Bash**
tool, **`tee` / `tee -a` and inline writes via other interpreters (`node -e`,
`perl -e`, `ruby -e`, `sed -i`, `dd of=`, `awk >`, …) are accepted residuals** —
outside the modeled surface, not oversights. The block message carries a lane-specific scope note so a reader does
not credit the guard with coverage it never claimed.
- **`block-hook-bypass` has one target-scoped exemption beyond `/dev/null`, and
it is off unless an operator turns it on.** `block_hook_bypass_scratch_roots`
Expand Down
Loading