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.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",
Expand Down
15 changes: 15 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion plugins/guardrails/hooks/hardcoded-path-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions plugins/guardrails/hooks/hardcoded-path-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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\"")"
Expand Down
Loading