diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 866f303b3f..da68efb8be 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.9.5", + "version": "0.9.6", "description": "Eight 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, (advisory) hallucinated CLI flags, (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 5d3d29bf45..2875d807e6 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,19 @@ 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.9.6] + +### Fixed + +- **`flag-commit-pr-skill-bypass` advisory now honors user-global plugin + enablement.** The `source_control_enabled` probe read only the consuming + project's `.claude/settings.json` (plus its local override), so when + source-control was enabled solely at user-global scope (`~/.claude/settings.json`) + — a common install — the probe false-negatived and the `gh pr create` advisory + never fired. Enablement now resolves across user-global, project, and local + scopes in Claude Code's precedence order (user-global base, project overrides, + local overrides), matching how the platform actually merges `enabledPlugins`. + ## [0.9.5] ### Fixed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 2cfda5b02e..aa2f8d33f9 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -122,14 +122,18 @@ repo-specific policy of their own: (`claude gh dotnet docker npm kubectl terraform az aws`); override with the `cli_flag_verify_bins` option (`bin1,bin2,…`) and skip specific binaries with `cli_flag_verify_skip_bins`. -- **Skill-availability gating.** `flag-commit-pr-skill-bypass` reads - `enabledPlugins` from the consuming project's own `.claude/settings.json` - (`.claude/settings.local.json` as an override, only for a key already present - in `settings.json` — CC ignores a local-only key per - [anthropics/claude-code#27247](https://github.com/anthropics/claude-code/issues/27247)) - to confirm `source-control@…` is actually enabled before advising toward its - skills. Missing/uncertain state (no settings file, no jq, key absent) fails - quiet — never advises toward a skill the project doesn't have installed. +- **Skill-availability gating.** `flag-commit-pr-skill-bypass` resolves + `enabledPlugins` the way Claude Code merges it across scopes — user-global + (`$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`) as the + base, the project's `.claude/settings.json` overriding it, and + `.claude/settings.local.json` overriding that (a local override counts only + for a key the project already declares — CC ignores a local-only key per + [anthropics/claude-code#27247](https://github.com/anthropics/claude-code/issues/27247)). + Each exact `source-control@…` key is resolved independently; if ANY resolves + enabled the advisory fires. So a plugin enabled **only** at user-global (a + common install) still triggers it — the project need not carry its own + `settings.json`. Missing/uncertain state (no key enabled at any scope, no jq) + fails quiet — never advises toward a skill that is not enabled for the session. ## Telemetry (opt-in) diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index 9127d56fb3..fe1ddffc69 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -4,13 +4,16 @@ # (source-control plugin). # Triggered on Bash tool calls. # -# SCOPE — only fires when the source-control plugin's skills are actually -# available in the consuming project (`enabledPlugins["source-control@…"]` -# resolves `true` in the consuming project's `.claude/settings.json`, with -# `.claude/settings.local.json` honored as an override only for a key that -# already exists in `settings.json` — CC ignores a local-only key). Uncertain -# state (no jq, no settings file, key absent) fails QUIET (exit 0, no -# advisory) — an advisory firing on unknown state is noise, not signal. +# SCOPE — only fires when the source-control plugin is actually enabled for this +# session. Claude Code merges `enabledPlugins` across scopes, so enablement is +# resolved across user-global (`~/.claude/settings.json`), project +# (`/.claude/settings.json`), and local (`.claude/settings.local.json`) in +# precedence order (user-global is the base, project overrides it, local +# overrides that) — a plugin enabled ONLY at user-global (a common install) is +# active in every project, so a probe reading the project file alone +# false-negatives and this advisory never fires. Uncertain state (no jq, no +# value at any scope) fails QUIET (exit 0, no advisory) — an advisory firing on +# unknown state is noise, not signal. # # WHAT IT FLAGS: # gh pr create — invoked at all. /pull-request create's value is PROCESS @@ -99,35 +102,63 @@ emit_tel() { hook::emit_telemetry "flag-commit-pr-skill-bypass" "PreToolUse" "ok" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } -# Is the source-control plugin's skills surface available to this session? -# Resolves the consuming project's own settings, never this plugin's cache -# location. Returns 1 (unknown/absent → stay silent) on any uncertain step. +# The source-control@ plugin keys declared in one settings file (empty when the +# file is absent). A settings file can carry more than one — e.g. after moving +# the plugin between marketplaces — so enablement is resolved PER exact key. +# shellcheck disable=SC2329 # invoked below via command substitution +sc_keys() { + local file="$1" + [[ -f "$file" ]] || return 0 + jq -r '(.enabledPlugins // {}) | keys[] | select(startswith("source-control@"))' "$file" 2>/dev/null +} + +# One exact key's value in one settings file: "true"/"false", empty if absent. +# shellcheck disable=SC2329 # invoked below via command substitution +sc_key_value() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 0 + # `has($k)` so a boolean `false` is read as "false", not collapsed to empty by + # jq's `//` (which treats false as absent). + jq -r --arg k "$key" '(.enabledPlugins // {}) | if has($k) then (.[$k] | tostring) else empty end' "$file" 2>/dev/null | head -1 +} + +# Is any source-control@ plugin enabled for this session? Resolves EACH exact +# key across scopes (user-global base, project override, local override only for +# a key the project already declares) and returns true when ANY resolves enabled +# — collapsing distinct keys to one value would wrongly decide a mixed migration +# state. Uncertain/absent -> return 1 (stay silent). source_control_enabled() { - local root settings local_settings base_val local_val + local root user_settings settings local_settings keys key uval bval lval effective root=$(hook::repo_root "${CLAUDE_PROJECT_DIR:-.}") + # User-global settings live at $CLAUDE_CONFIG_DIR/settings.json when that is + # set (Claude Code's relocatable config dir), else ~/.claude/settings.json. + user_settings="${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/settings.json}" + [[ -n "$user_settings" ]] || user_settings="${HOME:+$HOME/.claude/settings.json}" settings="$root/.claude/settings.json" local_settings="$root/.claude/settings.local.json" - [[ -f "$settings" ]] || return 1 - - base_val=$(jq -r ' - (.enabledPlugins // {}) | to_entries[] - | select(.key | startswith("source-control@")) - | .value - ' "$settings" 2>/dev/null | head -1) - [[ -n "$base_val" ]] || return 1 - - local_val="" - if [[ -f "$local_settings" ]]; then - local_val=$(jq -r ' - (.enabledPlugins // {}) | to_entries[] - | select(.key | startswith("source-control@")) - | .value - ' "$local_settings" 2>/dev/null | head -1) - fi - local effective="$base_val" - [[ -n "$local_val" ]] && effective="$local_val" - [[ "$effective" == "true" ]] + keys=$( + { + [[ -n "$user_settings" ]] && sc_keys "$user_settings" + sc_keys "$settings" + sc_keys "$local_settings" + } | sort -u + ) + [[ -n "$keys" ]] || return 1 + + while IFS= read -r key; do + [[ -n "$key" ]] || continue + uval="" + [[ -n "$user_settings" ]] && uval=$(sc_key_value "$user_settings" "$key") + bval=$(sc_key_value "$settings" "$key") + lval=$(sc_key_value "$local_settings" "$key") + effective="$uval" + [[ -n "$bval" ]] && effective="$bval" + # A local override counts only for a key the project settings already declare. + [[ -n "$bval" && -n "$lval" ]] && effective="$lval" + [[ "$effective" == "true" ]] && return 0 # any enabled key -> skill available + done <<<"$keys" + return 1 } source_control_enabled || exit 0 diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh index 40277c5af2..1ef88e912a 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh @@ -43,10 +43,38 @@ ENABLED_PROJECT="$(make_project true)" DISABLED_PROJECT="$(make_project false)" NO_KEY_PROJECT="$(make_project)" +# make_home -> a HOME dir carrying ~/.claude/settings.json +# so a user-global enablement can be exercised hermetically. +make_home() { + local dir enabled="$1" + dir="$(mktemp -d -p "$TEST_TMPDIR")" + mkdir -p "$dir/.claude" + jq -n --argjson v "$enabled" '{enabledPlugins:{"source-control@melodic-software":$v}}' \ + >"$dir/.claude/settings.json" + printf '%s' "$dir" +} + +# A clean HOME with no ~/.claude, so a case that does not set its own HOME never +# reads the CI runner's real user-global settings. Cases exercising user-global +# scope pass HOME= through run_hook's trailing env args. +HERMETIC_HOME="$(mktemp -d -p "$TEST_TMPDIR")" + +# settings.json directly in the dir (the CLAUDE_CONFIG_DIR layout, no `.claude/`). +make_config_dir() { + local dir enabled="$1" + dir="$(mktemp -d -p "$TEST_TMPDIR")" + jq -n --argjson v "$enabled" '{enabledPlugins:{"source-control@melodic-software":$v}}' \ + >"$dir/settings.json" + printf '%s' "$dir" +} + run_hook() { local input="$1" project="$2" shift 2 - env CLAUDE_PROJECT_DIR="$project" "$@" bash "$HOOK" <<<"$input" 2>&1 + # -u CLAUDE_CONFIG_DIR so a leaked value never overrides the HOME fixture; a + # case exercising it passes CLAUDE_CONFIG_DIR=... in the trailing args (which + # come after the -u and win). + env -u CLAUDE_CONFIG_DIR CLAUDE_PROJECT_DIR="$project" HOME="$HERMETIC_HOME" "$@" bash "$HOOK" <<<"$input" 2>&1 } # --- source-control enabled: bypass shapes fire ------------------------------ @@ -90,6 +118,53 @@ OVERRIDE_ON="$(make_project false true)" out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$OVERRIDE_ON") assert_contains "settings.local.json true overrides settings.json false" "$out" "gh pr create" +# --- user-global scope: enablement resolves across ~/.claude too ------------- +HOME_ENABLED="$(make_home true)" +HOME_DISABLED="$(make_home false)" + +# The exact false-negative this fixes: enabled ONLY at user-global, project has +# no settings file at all — the advisory MUST fire. +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$NO_SETTINGS_PROJECT" HOME="$HOME_ENABLED") +assert_contains "user-global enable fires with no project settings" "$out" "gh pr create" + +# user-global enabled, project settings present but key absent — fires. +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$NO_KEY_PROJECT" HOME="$HOME_ENABLED") +assert_contains "user-global enable fires when project key absent" "$out" "gh pr create" + +# project explicitly disables what user-global enabled — silent (project wins). +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$DISABLED_PROJECT" HOME="$HOME_ENABLED") +assert_silent "project false overrides user-global true" "$out" + +# user-global disabled, project enables — fires (project wins over base). +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$ENABLED_PROJECT" HOME="$HOME_DISABLED") +assert_contains "project true overrides user-global false" "$out" "gh pr create" + +# A local key the project settings.json does NOT declare is ignored by Claude +# Code, so it must not override — only the project+user-global values apply. +LOCAL_ONLY_OFF="$(make_project '' false)" # project {} (no key), local=false +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$LOCAL_ONLY_OFF" HOME="$HOME_ENABLED") +assert_contains "local-only false ignored (no project key) -> user-global fires" "$out" "gh pr create" + +LOCAL_ONLY_ON="$(make_project '' true)" # project {} (no key), local=true +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$LOCAL_ONLY_ON" HOME="$HOME_DISABLED") +assert_silent "local-only true ignored (no project key) -> user-global disabled stays silent" "$out" + +# user-global via a relocated CLAUDE_CONFIG_DIR (not ~/.claude) is honored. +CFG_ENABLED="$(make_config_dir true)" +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$NO_SETTINGS_PROJECT" CLAUDE_CONFIG_DIR="$CFG_ENABLED") +assert_contains "user-global via CLAUDE_CONFIG_DIR fires" "$out" "gh pr create" + +# Multiple source-control@ keys (marketplace migration): ANY enabled key means +# the skill is available, resolved per exact key — not collapsed to one value. +MK_HOME="$(mktemp -d -p "$TEST_TMPDIR")" +mkdir -p "$MK_HOME/.claude" +jq -n '{enabledPlugins:{"source-control@old":true}}' >"$MK_HOME/.claude/settings.json" +MK_PROJ="$(mktemp -d -p "$TEST_TMPDIR")" +mkdir -p "$MK_PROJ/.claude" +jq -n '{enabledPlugins:{"source-control@new":false}}' >"$MK_PROJ/.claude/settings.json" +out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$MK_PROJ" HOME="$MK_HOME") +assert_contains "an enabled source-control@old fires despite a disabled @new" "$out" "gh pr create" + # --- kill switch — disabled path is a clean no-op even on a bypass shape ----- out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$ENABLED_PROJECT" \ CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED=false)