diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index e31ad1d63..2d16e35d3 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.1", + "version": "0.9.2", "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 854e8ac9d..f9130c33b 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,21 @@ 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.2] + +### Fixed + +- **`hardcoded-path-check` no longer hard-denies every absolute path under a home-rooted + project.** The repo-path branch matched `PROJECT_ROOT` as a bare substring with no + context gate, so a session rooted at the user home flagged any real path under it + (`AppData\...`, `Desktop\...`) as a checkout-root leak. The branch now engages only + when the resolved project root is a real git checkout that is neither the home + directory nor one of its ancestors; a missing home resolution leaves the branch + active (fail toward detection). Adds the branch's first MUST-FIRE regression case + plus two stay-quiet repros (non-git home project; checkout equal to home). The + sibling percent-env false positive lives in the upstream-owned pattern library and + ships separately via the standards distribution. + ## [0.9.1] ### Fixed diff --git a/plugins/guardrails/hooks/hardcoded-path-check.sh b/plugins/guardrails/hooks/hardcoded-path-check.sh index c387c0886..75558d9f5 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.sh @@ -124,6 +124,38 @@ else PROJECT_ROOT="" fi +# hpp::scan_text's repo-path branch matches PROJECT_ROOT as a +# literal substring and is never OS-suppressed. That is a valid machine-specific +# marker ONLY when PROJECT_ROOT sits inside a genuine git checkout whose ROOT is +# not the user's home (nor an ancestor of it). The comparison is against the +# discovered TOPLEVEL, not PROJECT_ROOT itself: a project dir can be a +# subdirectory of its checkout, and when home is itself a checkout (e.g. +# chezmoi-managed dotfiles) a project dir like $HOME/Desktop would clear a +# PROJECT_ROOT-only home test and wrongly re-enable the branch, hard-denying +# every path under it. When the enclosing checkout is home — or the path is not +# in a checkout at all — skip the branch. Resolve a SCAN_ROOT the scanner uses +# for it: PROJECT_ROOT when the gate passes (the literal being scanned for is +# still the project dir), empty otherwise (empty is the lib's documented seam to +# skip the branch). PROJECT_ROOT is unchanged — telemetry below still uses it for +# the repo-relative path. Native Windows exposes home as %USERPROFILE%, not $HOME, +# so fall back to it; a missing home leaves the branch active (fail toward +# detection, never a false negative). +SCAN_ROOT="" +if [[ -n "$PROJECT_ROOT" ]]; then + _toplevel="$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null)" + if [[ -n "$_toplevel" ]]; then + _tl_norm="$(hook::normalize_path "$_toplevel")" + _tl_norm="${_tl_norm%/}" + _home_norm="$(hook::normalize_path "${HOME:-${USERPROFILE:-}}")" + _home_norm="${_home_norm%/}" + if [[ -n "$_home_norm" && ( "$_tl_norm" == "$_home_norm" || "$_home_norm" == "$_tl_norm"/* ) ]]; then + SCAN_ROOT="" # enclosing checkout is home or an ancestor of home — suppress the branch + else + SCAN_ROOT="$PROJECT_ROOT" + fi + fi +fi + # Repo-relative file for telemetry data.file (best-effort prefix strip). FILE_REL="$FILE" if [[ -n "$PROJECT_ROOT" ]]; then @@ -154,7 +186,7 @@ emit_tel() { hook::emit_telemetry "hardcoded-path-check" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } -VIOLATIONS=$(hpp::scan_text "$CONTENT" "$PROJECT_ROOT" "$FILE") +VIOLATIONS=$(hpp::scan_text "$CONTENT" "$SCAN_ROOT" "$FILE") if [[ -n "$VIOLATIONS" ]]; then { printf 'Hardcoded machine-specific path(s) in %s:\n\n' "$FILE" diff --git a/plugins/guardrails/hooks/hardcoded-path-check.test.sh b/plugins/guardrails/hooks/hardcoded-path-check.test.sh index 045a6ee1d..02992a222 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.test.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.test.sh @@ -55,6 +55,29 @@ assert_exit "Edit new_string → exit 2" 2 "$RC" OUT=$(bash "$HOOK" <<<"$(notebook_json "$FIXTURE" "cd ${MAC_HOME}")" 2>&1); RC=$? assert_exit "NotebookEdit new_source → exit 2" 2 "$RC" +# Repo-path branch: a genuine (non-home) git checkout root hardcoded in content +# is a machine-specific marker and MUST still fire — guards against the home-gate +# over-suppressing the branch entirely (the branch had no prior test). +REPO_REAL="$TEST_TMPDIR/realrepo" +mkdir -p "$REPO_REAL" +git -C "$REPO_REAL" init -q +OUT=$(HOME="$TEST_TMPDIR/elsewhere" CLAUDE_PROJECT_DIR="$REPO_REAL" \ + bash "$HOOK" <<<"$(write_json "$REPO_REAL/notes.txt" "checkout at $REPO_REAL/src")" 2>&1); RC=$? +assert_exit "repo root in real non-home checkout → exit 2" 2 "$RC" +assert_contains "repo root → machine-specific repo message" "$OUT" "Machine-specific repo path" + +# Project dir a SUBDIR of a genuine non-home checkout: the toplevel is non-home, +# so the branch stays active and a hardcoded project-dir path still fires. Guards +# the toplevel comparison against over-suppressing real checkouts reached via a +# subdirectory. +SUBREPO="$TEST_TMPDIR/subrepo" +mkdir -p "$SUBREPO/pkg" +git -C "$SUBREPO" init -q +OUT=$(HOME="$TEST_TMPDIR/elsewhere2" CLAUDE_PROJECT_DIR="$SUBREPO/pkg" \ + bash "$HOOK" <<<"$(write_json "$SUBREPO/pkg/notes.txt" "at $SUBREPO/pkg/x")" 2>&1); RC=$? +assert_exit "repo subdir of non-home checkout → exit 2" 2 "$RC" +assert_contains "repo subdir → machine-specific repo message" "$OUT" "Machine-specific repo path" + # ============================ ALLOW (exit 0) ================================ OUT=$(bash "$HOOK" <<<"$(write_json "$FIXTURE" 'echo "hello world"')" 2>&1); RC=$? assert_exit "clean content → exit 0" 0 "$RC" @@ -102,6 +125,51 @@ OUT=$(CLAUDE_PLUGIN_OPTION_HARDCODED_PATH_CHECK_ENABLED=false bash "$HOOK" <<<"$ assert_exit "kill switch off → exit 0" 0 "$RC" assert_silent "kill switch off → no stderr" "$OUT" +# --- Repo-path branch must not flag paths under a project dir that is the +# user's home (or a non-git dir). The branch matched PROJECT_ROOT as a literal +# substring and was never OS-suppressed. --- + +# F1a — the reported incident: project dir is a NON-git home (e.g. a +# chezmoi-managed home, which is not itself a work tree). A .cmd suppresses the +# Windows-user branch, so ONLY the repo branch could fire; it must stay silent. +HOME_DIR="C:${BS}Users${BS}bob" +HOME_CMD="${HOME_DIR}${BS}Desktop${BS}run.cmd" +HOME_UNDER="${HOME_DIR}${BS}AppData${BS}Local${BS}Docker${BS}img.vhdx" +OUT=$(CLAUDE_PROJECT_DIR="$HOME_DIR" bash "$HOOK" <<<"$(write_json "$HOME_CMD" "copy $HOME_UNDER dst")" 2>&1); RC=$? +assert_exit "F1: non-git home project + path under home → exit 0" 0 "$RC" +assert_silent "F1: non-git home → no stderr" "$OUT" + +# The gate compares the git toplevel against $HOME, so the two must be in the +# same path form. git canonicalizes an MSYS /tmp path to a native Windows path on +# Git Bash, so derive $HOME from git's own --show-toplevel output (an identity on +# Linux, where /tmp is not remapped) — this mirrors a real environment, where +# $HOME and git agree on the path form. + +# F1b — belt-and-suspenders: project dir IS a git checkout but equals $HOME +# (dotfiles-as-home). The enclosing-checkout-is-home clause suppresses the branch. +GITHOME="$TEST_TMPDIR/githome" +mkdir -p "$GITHOME" +git -C "$GITHOME" init -q +GITHOME_TL="$(git -C "$GITHOME" rev-parse --show-toplevel)" +OUT=$(HOME="$GITHOME_TL" CLAUDE_PROJECT_DIR="$GITHOME" \ + bash "$HOOK" <<<"$(write_json "$GITHOME/notes.txt" "path $GITHOME/data/app.bin")" 2>&1); RC=$? +assert_exit "F1: git checkout equal to \$HOME → exit 0" 0 "$RC" +assert_silent "F1: \$HOME checkout → no stderr" "$OUT" + +# F1c — the side door: $HOME is itself a git checkout (chezmoi dotfiles) and the +# project dir is a SUBDIR of home (e.g. $HOME/Desktop). rev-parse discovers the +# parent checkout at home; the home comparison must run against that TOPLEVEL, +# not the subdir — comparing the subdir leaves it neither home nor a home-ancestor +# and re-enables the branch, hard-denying paths under the subdir. +HOMEREPO="$TEST_TMPDIR/homerepo" +mkdir -p "$HOMEREPO/Desktop" +git -C "$HOMEREPO" init -q +HOMEREPO_TL="$(git -C "$HOMEREPO" rev-parse --show-toplevel)" +OUT=$(HOME="$HOMEREPO_TL" CLAUDE_PROJECT_DIR="$HOMEREPO/Desktop" \ + bash "$HOOK" <<<"$(write_json "$HOMEREPO/Desktop/run.txt" "path $HOMEREPO/Desktop/data.bin")" 2>&1); RC=$? +assert_exit "F1: home-is-checkout, project = subdir of home → exit 0" 0 "$RC" +assert_silent "F1: home-checkout subdir → no stderr" "$OUT" + # ============================ TELEMETRY ==================================== TEL="$(mktemp -p "$TEST_TMPDIR")" SINK="$(make_sink "cat >\"$TEL\"")"