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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- `block-unresolved-pr-merge` guard (#6463): blocks a pull-request merge while the PR has an unresolved review thread, a changes-requested review, or a failed check, and blocks an immediate merge on pending checks. It matches `gh pr merge` (`--admin` and `--auto` included), a `PUT` to the REST merge route or the Claude Code cloud `ccr/auto_merge` route through `gh api` or `curl`, the GraphQL merge, auto-merge and merge-queue mutations, and an MCP `merge_pull_request` or `enable_pr_auto_merge` tool. PR state is read over REST, with review threads from GraphQL or, where GraphQL is refused, the cloud `ccr/review_threads` route. It fails open with a visible notice when `gh` is missing, a read fails or its 35 s read budget runs out, except that a GraphQL merge mutation whose pull request cannot be resolved is blocked. Off switch: `block_unresolved_pr_merge_enabled`.
6 changes: 6 additions & 0 deletions plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
"description": "Comma-separated families block-credential-read permits: credential-fill, gh-auth-token, env-echo, credential-file-read. Empty, the default, blocks all.",
"default": ""
},
"block_unresolved_pr_merge_enabled": {
"type": "boolean",
"title": "Block-unresolved-PR-merge guard",
"description": "Blocks a pull-request merge (gh pr merge, REST, GraphQL or an MCP merge tool) while the PR has unresolved review threads, a changes-requested review, or failing checks. Fails open, visibly, when the PR state cannot be read. On by default.",
"default": true
},
"block_hook_bypass_enabled": {
"type": "boolean",
"title": "Block-hook-bypass guard",
Expand Down
18 changes: 10 additions & 8 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ A mod can also approve a call they blocked, because its `tool.check` hook runs a
| **block-exported-msys-pathconv** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Windows-only: an **exported** `MSYS_NO_PATHCONV` / `MSYS2_ARG_CONV_EXCL` (also the `declare -x` / `typeset -x` spellings), which switches off MSYS argv rewriting for every *later* command in the same command string. A later path argument then reaches a Windows-native program unconverted and git resolves its leading `/` against the current drive, so `git worktree add /d/worktrees/x` creates `<current-drive>:\d\worktrees\x` (#2870). Deliberately keys on the environment, not on a path shape: the incident command's path argument was identical to one that had already worked. A prefix whose command word is a **shell** (`MSYS_NO_PATHCONV=1 bash -c '…'`, `env … sh -c '…'`) blocks too: the prefix scopes to one *process*, and when that process is an interpreter, one process is every command inside it. A prefix on a **non-shell** command word (`MSYS_NO_PATHCONV=1 git show …`) and a bare assignment are not matched. The first scopes to exactly that command, and the second has no effect at all because the MSYS runtime reads the environment. Does not fire on non-Windows hosts. |
| **block-root-delete-target** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | A recursive `rm` whose target normalizes to a filesystem root. The command is parsed the way the shell builds argv, through launchers (`sudo`, `env`, `timeout`, the util-linux family including `setpriv` and `prlimit`, `systemd-run`), child shells (`bash -c`, `su -c`, `sg`), `eval` and command substitutions, and a segment is judged when its command word is `rm` with a recursive flag. Roots: `/` and `/*`, the MSYS-translated bare backslash in both its quoted (`rm -rf "\\"`, the shape behind the whole-volume loss in anthropics/claude-code#92593, which no permission pattern keyed on `rm -rf /*` matches) and **dangling** (`rm -rf \`) spellings, `~`, a literal `$HOME` / `${HOME}`, a drive root (`C:\`, `c:/`, `C:`), an MSYS, WSL or cygdrive drive root (`/c`, `/mnt/c`, `/cygdrive/c`), and a UNC share root (`//server/share`). `--no-preserve-root` is refused whatever it targets. Also refused: an **empty operand** (`rm -rf ""`), a **bare variable operand** made of expansions alone (`$X`, `"$X/"`, `"$X"/*`, `$X$Y`, any `${...}` form but `"${X:?}/"`), and, when the payload carries an absolute `cwd`, a target **outside the session's allowed roots**: not under the git toplevel of the payload cwd, and not strictly under a temp root, the session scratchpad or a root you list in `block_root_delete_target_allowed_roots` (`rm -rf ../../..`, `rm -rf ~/Documents/x`, `cd / && rm -rf *`, `rm -rf {/c,x}`, `rm -rf /c/Users/*/.claude`, a `link/` that points outside). Not host-gated. The guard's header in `hooks/block-root-delete-target.sh` lists how braces, globs, directory changes and symlinks are judged, the bounds past which it refuses, the known false positives, and the declared gaps. On the PowerShell tool the same classes are refused for `Remove-Item -Recurse` (aliases `ri`/`rm`/`rd`/`rmdir`, prefixes `-r`/`-rec`) and for `cmd /c rd /s` / `rmdir /s`, without loading the shared PowerShell classifier ([#4516](https://github.com/melodic-software/claude-code-plugins/issues/4516)). Each statement is judged, including one after a newline and one inside a `{ }` scriptblock, a `( )` grouping or `$( )`, and a `Remove-Item` target that is a grouping is refused. `$env:NAME` and `${env:NAME}` are judged as the Bash lane judges `$NAME`: refused bare or followed only by `\` or `\*`, allowed with a named subpath (`$env:TEMP\build`). A target whose every `$` is literal (single-quoted or backtick-escaped) is also judged as the path it names, and a Win32 stream suffix such as `::$INDEX_ALLOCATION` as the directory before it; this only adds refusals, so a target refused when every `$` read as an expansion (`'$env:TEMP'`) is refused still ([#6626](https://github.com/melodic-software/claude-code-plugins/issues/6626)). Not covered: a delete wrapped in `Start-Process` or `Invoke-Expression`, nested PowerShell, a command that is not the first word of its statement (`$r = Remove-Item -Recurse C:\`), a comma list of targets, a here-string, and, from the Bash tool, `cmd /c rd /s` or `powershell -Command Remove-Item`, because the Bash lane matches `rm` only. |
| **block-credential-read** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | A command whose output is a credential: `git credential fill`, credential-helper `get`, `gh auth token`, `echo`/`printenv` of a token-shaped variable, `jq env` (every variable), `cat` of `.git-credentials`, `.netrc`, `.env`, `.credentials.json` or `.docker/config.json`, and `jq` of the last two. Presence checks such as `gh auth status` and `test -n "$GH_TOKEN"` pass. The guard header in `hooks/block-credential-read.sh` lists the known gaps and false positives. Escape: `block_credential_read_enabled` (off switch) or `block_credential_read_allow` (comma list of `credential-fill`, `gh-auth-token`, `env-echo`, `credential-file-read`). |
| **block-unresolved-pr-merge** | PreToolUse · Bash \| PowerShell **and** `mcp__<server>__merge_pull_request` \| `mcp__<server>__enable_pr_auto_merge` | **Blocks** (exit 2) | A pull-request merge while the PR has an unresolved review thread (outdated ones included), a reviewer whose latest decisive review requests changes, or a failed check run or commit status on its head commit; a pending check blocks an immediate merge but not an auto-merge or merge-queue request, which waits for checks by design. Merge paths: `gh pr merge` with any flags (`--admin` and `--auto` included), a `PUT` to `repos/{owner}/{repo}/pulls/{n}/merge` or to the Claude Code cloud route `pulls/{n}/ccr/auto_merge` through `gh api` or `curl`, the `mergePullRequest`, `enablePullRequestAutoMerge` and `enqueuePullRequest` GraphQL mutations through `gh api graphql` or `curl`, and the GitHub MCP server's merge tools under any server prefix. PR state is read over REST; review-thread resolution, which REST does not expose, comes from GraphQL where it is served and from the cloud route `pulls/{n}/ccr/review_threads` where GraphQL is refused (Claude Code on the web). Fails open with a notice to both the user and Claude when `gh` is missing or unauthenticated, a read fails, its 35 s read budget runs out (kept under the row's 60 s timeout), or the PR cannot be identified; the one exception is a GraphQL merge mutation whose PR cannot be resolved (an id from a variable or `$(...)`, or more than one id bound to `pullRequestId`), which is blocked, with the fix of passing a literal `PR_...` id. Only the id bound to `pullRequestId` is checked, never a `PR_...` in another field. In a Claude Code cloud session GraphQL is refused, so every GraphQL merge mutation is blocked there; use `gh pr merge <number>`. A `curl -T` / `--upload-file` to the merge route counts as a `PUT`. `gh pr merge --help` and `--disable-auto` are not merges. Not covered: a merge from a script (Python, Node) or the web UI; the org ruleset requiring conversation resolution is the server-side backstop. A merge pays five REST calls, measured at 3.0 to 3.6 s in a Claude Code cloud session; any other command pays one string test. The guard header in `hooks/block-unresolved-pr-merge.sh` lists the known gaps. Escape: `block_unresolved_pr_merge_enabled` (off switch). |
| **cli-flag-verify** | PostToolUse · Write \| Edit, dispatcher `if`-gated to `.md`, `.sh`, `.bash`, `.ps1`, `.psm1` | **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 one-line advisory via `additionalContext` that points at the workflow-authoring skill, 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 All @@ -95,7 +96,7 @@ A mod can also approve a call they blocked, because its `tool.check` hook runs a
| **skill-reference-verify** | PostToolUse · Write \| Edit, dispatcher `if`-gated as above, the guard itself scans `.md` only | **Advisory** (exit 0) | A `` `/plugin:skill` `` reference in markdown that does not resolve. Only fires inside a marketplace repo, and only for a plugin that repo's own manifests own. A reference to another marketplace is left alone. Resolves through manifest and frontmatter `name`, so a renamed directory still matches. Surfaces via `additionalContext`, never blocks. |
| **stale-path-verify** | PostToolUse · Write \| Edit, dispatcher `if`-gated as above, the guard itself scans `.md` only | **Advisory** (exit 0) | A repo-relative path cited in a markdown inline code span that this repo's own history shows was **deleted** and that is gone from the working tree. The gate is provenance, not absence: the exact path must appear in `git log HEAD --no-renames --diff-filter=D --name-only`, so a path belonging to a consuming project's tree, an example, or a plan is never adjudicated. Names the surviving file when exactly one tracked path now carries that basename. Link destinations are out of scope. Surfaces via `additionalContext`, never blocks. |

The ten blocking guards feed their stderr message back to Claude as
The blocking guards feed their stderr message back to Claude as
actionable fix guidance. The five advisory guards surface their findings the same
way but always allow the operation. check-bash-file-changes passes on the blocking
guards' own message after the command has run.
Expand Down Expand Up @@ -535,7 +536,7 @@ out of scope until such a signal exists.
very write that produced the `C:\tmp\tmp.rSFIkHm5DO` this lane exists to stop.
Six seeded spellings were detected end to end.

**What that evidence does and does not support, stated plainly.** Precision is
**What that evidence does and does not support.** Precision is
1/1, so the ratio is 100% and the sample is one. This is the ADR's
near-zero-findings branch, where the seeded-detection burden carries the
argument and the precision figure by itself does not. The corpus is one
Expand Down Expand Up @@ -1223,7 +1224,7 @@ said: the harness dispatches matching hooks in parallel, so the set wall is the
max of its members rather than their sum, and a member costing ≈ 505 ms joining
a set already walling at ≈ 1 s cannot raise that wall by more than its own cost
and will usually raise it by less. A re-measurement on a quiet reference host is
the way to replace this bound with a number, and is the honest follow-up.
the way to replace this bound with a number, and is the follow-up.

**Share of the budget, and the overage.** The convention's ceiling is
≤ 1 s typical / ≤ 2 s worst-case **per tool call, counting `PreToolUse` and
Expand All @@ -1248,9 +1249,8 @@ cannot afford the addition have the per-hook kill switch below.
Each guard is toggled by its own `userConfig` boolean, default **on**, except the two
behavioral-class advisories `workflow-resilience-check` and `flag-commit-pr-skill-bypass`, which
have been default **off** since 0.20.0 per #2021. Set a switch to `true` to opt in, or to `false`
for a clean no-op. This per-hook
control is the bundle's core contract: disable one guard without touching the
others.
for a clean no-op. Each per-hook switch
disables one guard without touching the others.

| Guard | Option |
| ----- | ------ |
Expand All @@ -1264,6 +1264,7 @@ others.
| block-exported-msys-pathconv | `block_exported_msys_pathconv_enabled` |
| block-root-delete-target | `block_root_delete_target_enabled`, `block_root_delete_target_allowed_roots` |
| block-credential-read | `block_credential_read_enabled` |
| block-unresolved-pr-merge | `block_unresolved_pr_merge_enabled` |
| block-noncanonical-commit | `block_noncanonical_commit_enabled` |
| block-convention-violation | `block_convention_gate_enabled` |
| cli-flag-verify | `cli_flag_verify_enabled` |
Expand Down Expand Up @@ -1386,8 +1387,8 @@ Every guard emits one structured [hook-telemetry](../../docs/conventions/hook-te
envelope per run to whatever `HOOK_TELEMETRY_SINK` names, carrying `status`
(`blocked` on a guard block, `ok` otherwise), `duration_ms`, and a privacy-safe
`data` payload (category **labels** only, never a secret value, matched path,
or full command). Unset `HOOK_TELEMETRY_SINK` → no-op; the guards behave exactly
as before.
or full command). With `HOOK_TELEMETRY_SINK` unset, emission is a no-op and the
guards behave exactly as before.

## Requirements

Expand Down Expand Up @@ -1543,6 +1544,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, plus the PowerShell ps-unparsable-* sink tokens the README's Option details list. Empty, the default, blocks all. |
| `block_credential_read_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_CREDENTIAL_READ_ENABLED` | Blocks a Bash or PowerShell command whose output is a credential: git credential fill and credential-helper get, gh auth token, echo or printenv of a token-shaped variable, and cat of .git-credentials, .netrc or .env. On by default. |
| `block_credential_read_allow` | string | *(none)* | `CLAUDE_PLUGIN_OPTION_BLOCK_CREDENTIAL_READ_ALLOW` | Comma-separated families block-credential-read permits: credential-fill, gh-auth-token, env-echo, credential-file-read. Empty, the default, blocks all. |
| `block_unresolved_pr_merge_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_UNRESOLVED_PR_MERGE_ENABLED` | Blocks a pull-request merge (gh pr merge, REST, GraphQL or an MCP merge tool) while the PR has unresolved review threads, a changes-requested review, or failing checks. Fails open, visibly, when the PR state cannot be read. On by default. |
| `block_hook_bypass_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED` | Blocks Bash file-write workarounds that circumvent Write and Edit hook gates. On by default. |
| `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 or temp write targets (e.g. /tmp/scratch,/d/jobtmp/session). Empty by default; it adds to the shipped temp-tree and plugin-data roots. Matching, quoting and symlink rules are in the README's Option details. |
| `bash_file_change_check_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BASH_FILE_CHANGE_CHECK_ENABLED` | After a Bash or PowerShell command, runs the Write and Edit secret and hardcoded-path guards on each repository file the command changed, and reports what they find. On by default. |
Expand Down
Loading
Loading