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.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",
Expand Down
13 changes: 13 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
93 changes: 62 additions & 31 deletions plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# (`<repo>/.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
Expand Down Expand Up @@ -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=$(
{
Comment thread
kyle-sexton marked this conversation as resolved.
[[ -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
Expand Down
77 changes: 76 additions & 1 deletion plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,38 @@ ENABLED_PROJECT="$(make_project true)"
DISABLED_PROJECT="$(make_project false)"
NO_KEY_PROJECT="$(make_project)"

# make_home <enabledPlugins-value> -> 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=<make_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 ------------------------------
Expand Down Expand Up @@ -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)
Expand Down
Loading