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.12.1",
"version": "0.12.2",
"description": "Nine 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, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (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
23 changes: 23 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
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.12.2]

### Fixed

- **Machine-path bodies: right boundary is now the segment class, not a
mandatory trailing separator (#1093).** The old bodies required a separator
AFTER the child segment, which inverted detection both ways: a real bare
path value at end of line (`root = <drive>:/Dev/GitHub`) was MISSED, while
prose satisfied the requirement anyway — the space-permitting segment class
greedily consumed words until a later slash on the same line, flagging a
comment as "Windows repo path detected" while the actual violations passed
clean. All five bodies in `machine-path-patterns.sh` now exclude whitespace
and the double quote from the child-segment class and drop the trailing
separator: bare values at a natural boundary (EOL, whitespace, quote) are
detected, prose spans cannot match, and a bare ROOT with no child segment
(`C:/Dev`, `/home`) still never matches. The driver's `/Users/Shared`
exclusion covers the new bare form. 15 regression cases added (bare values
in all five shapes, greedy-prose and root-plus-whitespace negatives, bare
`Shared`). Synced-component note: the same pattern change lands upstream in
`melodic-software/standards` `components/path-detection/` — the local and
upstream copies must stay byte-identical or the next standards sync reverts
this fix.

## [0.12.1]

### Fixed
Expand Down
95 changes: 95 additions & 0 deletions plugins/guardrails/hooks/hardcoded-path-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,56 @@ RC=$?
assert_exit "windows Projects checkout root → exit 2" 2 "$RC"
assert_contains "windows Projects root → message" "$OUT" "Windows repo path"

# --- Right-boundary regressions (#1093): a bare path VALUE at end of line has
# no trailing separator and must still fire. The old bodies required one, so
# exactly the config-value shape the guard exists to catch was missed while
# prose satisfied the requirement via a greedy space-permitting segment. ---
WIN_BARE_REPO="C:${SL}Dev${SL}GitHub"
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "root = ${WIN_BARE_REPO}")" 2>&1)
RC=$?
assert_exit "bare windows repo value at EOL → exit 2" 2 "$RC"
assert_contains "bare repo value → message" "$OUT" "Windows repo path"

WIN_BARE_HOME="C:${BS}Users${BS}bob"
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "home = ${WIN_BARE_HOME}")" 2>&1)
RC=$?
assert_exit "bare windows user home at EOL → exit 2" 2 "$RC"
assert_contains "bare windows home → message" "$OUT" "Windows user path"

LINUX_BARE_HOME="${SL}home${SL}jdoe"
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "cd ${LINUX_BARE_HOME}")" 2>&1)
RC=$?
assert_exit "bare linux home at EOL → exit 2" 2 "$RC"
assert_contains "bare linux home → message" "$OUT" "Linux user path"

MAC_BARE_HOME="${SL}Users${SL}alice"
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "backup ${MAC_BARE_HOME}")" 2>&1)
RC=$?
assert_exit "bare macos home at EOL → exit 2" 2 "$RC"
assert_contains "bare macos home → message" "$OUT" "macOS user path"

# JSON-escaped bare value (doubled separators, end of string value).
ESC_BARE_REPO="C:${BS}${BS}Dev${BS}${BS}GitHub"
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "cfg = \"${ESC_BARE_REPO}\"")" 2>&1)
RC=$?
assert_exit "escaped bare windows repo value → exit 2" 2 "$RC"
assert_contains "escaped bare repo value → message" "$OUT" "Escaped Windows repo path"

# The prose false positive the old greedy segment produced: checkout-root
# words plus a later slash on the same line, but no drive-letter anchor.
# Must stay clean under the whitespace-excluding segment class.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "projects - personal repos (reference${SL}reading only)")" 2>&1)
RC=$?
assert_exit "checkout-root prose with later slash → exit 0" 0 "$RC"
assert_silent "checkout-root prose → no stderr" "$OUT"

# Root-with-no-child prose: separator then whitespace. The class requires at
# least one non-space child character, so this must not match.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "see ${SL}Users${SL} for details")" 2>&1)
RC=$?
assert_exit "bare Users root + prose → exit 0" 0 "$RC"
assert_silent "bare Users root prose → no stderr" "$OUT"

# ============================ NO-PROJECT SKIP ================================
# No active project (CLAUDE_PROJECT_DIR unset): the hook does not scan at all.
# A no-project target (e.g. a $HOME dotfile) is machine-local, not a portable
Expand Down Expand Up @@ -131,6 +181,51 @@ OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE"
RC=$?
assert_exit "macos Shared dir → exit 0" 0 "$RC"

# Bare Shared at EOL: the body now matches it (no trailing separator needed),
# so the driver's Shared exclusion must cover the bare form too.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "ls ${SL}Users${SL}Shared")" 2>&1)
RC=$?
assert_exit "bare macos Shared at EOL → exit 0" 0 "$RC"

# Shared exclusion must be match-level, not line-level: a line holding a bare
# Shared path AND a user-specific path still fires on the user-specific one.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "cp ${SL}Users${SL}Shared ${SL}Users${SL}alice")" 2>&1)
RC=$?
assert_exit "Shared + user path on one line → exit 2" 2 "$RC"
assert_contains "Shared + user path → macOS message" "$OUT" "macOS user path"

# Defang boundary guard: a real segment merely PREFIXED with Shared is a user
# directory, not the shared one — it must still flag.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "ls ${SL}Users${SL}SharedStuff")" 2>&1)
RC=$?
assert_exit "SharedStuff segment → exit 2" 2 "$RC"

# Shell / prose punctuation right after Shared is a boundary, not a longer
# segment — common command shapes must stay clean.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "cd ${SL}Users${SL}Shared; ls")" 2>&1)
RC=$?
assert_exit "Shared followed by semicolon → exit 0" 0 "$RC"

OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "cp '${SL}Users${SL}Shared' out")" 2>&1)
RC=$?
assert_exit "single-quoted Shared → exit 0" 0 "$RC"

# Left boundary: a URL whose path merely CONTAINS a home-root suffix is not a
# filesystem root — must stay clean (macOS and Linux shapes).
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "see https://example.test${SL}home${SL}alice for docs")" 2>&1)
RC=$?
assert_exit "URL containing home suffix → exit 0" 0 "$RC"
assert_silent "URL home suffix → no stderr" "$OUT"

OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "see https://example.test${SL}Users${SL}alice page")" 2>&1)
RC=$?
assert_exit "URL containing Users suffix → exit 0" 0 "$RC"

# Colon-prefixed value position (yaml/docker) is a boundary — must still flag.
OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" "vol:${SL}home${SL}alice mount")" 2>&1)
RC=$?
assert_exit "colon-prefixed linux home → exit 2" 2 "$RC"

OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$PS1_FIXTURE" "\$cfg = '${WIN_HOME}'")" 2>&1)
RC=$?
assert_exit "Windows path in .ps1 → suppressed → exit 0" 0 "$RC"
Expand Down
59 changes: 45 additions & 14 deletions plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,18 @@ hpp::scan_text() {
# OS-context flags — non-empty when file is unambiguously OS-scoped
local windows_context="" macos_context="" linux_context=""
case "$norm_file" in
*.ps1 | *.psm1 | *.psd1 | *.cmd | *.bat | *.reg) windows_context=1 ;;
*/scripts/windows/* | */tests/windows/*) windows_context=1 ;;
*-windows.* | *-win32.*) windows_context=1 ;;
*) ;;
*.ps1 | *.psm1 | *.psd1 | *.cmd | *.bat | *.reg) windows_context=1 ;;
*/scripts/windows/* | */tests/windows/*) windows_context=1 ;;
*-windows.* | *-win32.*) windows_context=1 ;;
*) ;;
esac
case "$norm_file" in
*/scripts/macos/* | *-macos.* | *-osx.* | *-darwin.*) macos_context=1 ;;
*) ;;
*/scripts/macos/* | *-macos.* | *-osx.* | *-darwin.*) macos_context=1 ;;
*) ;;
esac
case "$norm_file" in
*/scripts/linux/* | *-linux.*) linux_context=1 ;;
*) ;;
*/scripts/linux/* | *-linux.*) linux_context=1 ;;
*) ;;
esac

# Windows user home paths: C:\Users\<name>\ or C:/Users/<name>/
Expand All @@ -119,18 +119,49 @@ hpp::scan_text() {
[[ -n "$match" ]] && violations="${violations}Windows user path detected:${nl}${match}${nl}${nl}"
fi

# macOS user home paths: /Users/<name>/
# Exclusions via pipe (replaces Perl lookbehind/lookahead):
# grep -v '/Users/Shared/' — legitimate shared directory
# Left boundary for the slash-rooted macOS/Linux bodies — the driver-owned
# prefix the pattern lib's contract calls for, so a URL or relative-path
# suffix (https://example.test + the home root) is not treated as a
# filesystem root. Line start, whitespace, quote, backtick, paren, "=", ":"
# (yaml/docker value position), or a file:// scheme. Mirrors the
# ci-workflows/medley verification drivers' boundary, plus ":".
local _posix_boundary
_posix_boundary="(^|[[:space:]\"'\`(=:]|file://)"

# macOS user home paths (the Users root with a child segment).
# Exclusions (replace Perl lookbehind/lookahead):
# grep -vE '[A-Za-z]:[/\\]' — Windows paths (caught above)
# Shared exclusion — the Users/Shared directory is legitimately shareable.
# NOT a line-level grep -v: one line can hold a Shared path AND a
# user-specific one, and dropping the whole line would silently pass the
# real violation. Each candidate line is re-tested with its Shared tokens
# defanged; only lines whose SOLE matches are Shared drop out. The defang
# boundary is any character that cannot CONTINUE a real directory name
# (word chars, dot, hyphen) — not an enumerated delimiter list, so shell
# and prose punctuation right after the value (";", a closing quote, a
# paren) counts as a boundary while a longer segment like SharedStuff
# stays untouched and flagged. Basic-regex sed expressions (no in-group
# anchors) keep macOS stock sed compatibility; the ORIGINAL line is
# reported, never the defanged copy.
# The Shared literal is assembled from pieces so this driver's own source
# never carries a contiguous Users-root token for the write-scan hook
# that consumes these bodies to flag.
if [[ -z "$macos_context" ]]; then
match=$(printf '%s' "$content" | grep -nE "$HPP_MACOS_USER_BODY" 2>/dev/null | grep -v '/Users/Shared/' | grep -vE '[A-Za-z]:[/\\]' | head -3)
local _shared _shared_defused
_shared='/Use''rs/Shared'
_shared_defused='/Use''rs-Shared'
match=$(printf '%s' "$content" | grep -nE "${_posix_boundary}${HPP_MACOS_USER_BODY}" 2>/dev/null | grep -vE '[A-Za-z]:[/\\]' |
while IFS= read -r _line; do
_defanged=$(printf '%s' "$_line" | sed -e "s|${_shared}\([^A-Za-z0-9._-]\)|${_shared_defused}\1|g" \
-e "s|${_shared}\$|${_shared_defused}|")
printf '%s' "$_defanged" | grep -qE "${_posix_boundary}${HPP_MACOS_USER_BODY}" && printf '%s\n' "$_line"
done | head -3)
[[ -n "$match" ]] && violations="${violations}macOS user path detected:${nl}${match}${nl}${nl}"
fi

# Linux user home paths
# Linux user home paths (same driver-owned left boundary).
if [[ -z "$linux_context" ]]; then
match=$(printf '%s' "$content" | grep -nE "$HPP_LINUX_USER_BODY" 2>/dev/null | head -3)
match=$(printf '%s' "$content" | grep -nE "${_posix_boundary}${HPP_LINUX_USER_BODY}" 2>/dev/null | head -3)
[[ -n "$match" ]] && violations="${violations}Linux user path detected:${nl}${match}${nl}${nl}"
fi

Expand Down
35 changes: 19 additions & 16 deletions plugins/guardrails/lib/path-detection/machine-path-patterns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,23 @@
# macOS/Linux bodies need a driver-side boundary prefix so a substring like
# "doc/Users/guide" inside a longer word does not false-match.
#
# The trailing separator every body ends in is its right boundary: a match
# needs at least one child segment past the root, so a bare root — the home
# or checkout directory itself, e.g. C:\Users\Alice, /home/alice, or
# D:\repos\acme — is intentionally NOT matched. Dropping it re-admits prose
# false positives (the segment class permits spaces, so "/Users/ for details"
# would match), and where a bare root ends at a value boundary is
# format-specific — a driver-owned concern like the left prefix. A consumer
# that needs bare-root detection adds that right boundary in its own driver.
HPP_WIN_USER_BODY='[A-Za-z]:(/|\\\\?)Users(/|\\\\?)[^/\\$%<{~]+(~[0-9]+)?(/|\\\\?)'
HPP_MACOS_USER_BODY='/Users/[^/$<{~]+/'
HPP_LINUX_USER_BODY='/home/[^/$<{~]+/'
# Right boundary: the child-segment class itself. Each body requires at least
# one child segment past its root — a bare root (the home or checkout-parent
# directory with no child) never matches — but the child needs NO trailing
# separator: the class excludes whitespace and the double quote, so a match
# ends at the segment's natural value boundary (EOL, whitespace, quote, or
# the next separator). A mandatory trailing separator was the original design
# and inverted detection both ways: a real bare value at end of line
# ("root = <drive>:/Dev/GitHub") has no trailing separator and was MISSED,
# while prose satisfied the requirement anyway — the old space-permitting
# segment class greedily consumed words until a later slash appeared on the
# same line, flagging comments instead of values. Excluding whitespace from
# the class is what makes dropping the separator prose-safe: a phrase like
# "/Users/ for details" cannot match because at least one non-space child
# character must follow the root.
HPP_WIN_USER_BODY='[A-Za-z]:(/|\\\\?)Users(/|\\\\?)[^\\$%<{~"[:space:]/]+(~[0-9]+)?'
HPP_MACOS_USER_BODY='/Users/[^\\$%<{~"[:space:]/]+'
HPP_LINUX_USER_BODY='/home/[^\\$%<{~"[:space:]/]+'
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
# The checkout-parent segment is drive-letter-anchored, so broadening it beyond
# `repos` to the other common checkout-root names stays false-positive-safe —
# only a genuine `X:\<root>\<child>\` absolute path matches, never prose. Both
Expand All @@ -48,8 +54,5 @@ HPP_LINUX_USER_BODY='/home/[^/$<{~]+/'
# OWN checkout root is already caught by the driver's project-root literal scan;
# this generic body catches references to OTHER machines' checkout paths in
# written content.
HPP_WIN_REPO_BODY='[A-Za-z]:(/|\\\\?)(repos|Repos|projects|Projects|dev|Dev)(/|\\\\?)[^/\\$%<{~]+(~[0-9]+)?(/|\\\\?)'
# SC1003 false positive: the trailing \\\\ is a deliberate literal-backslash ERE
# body (a JSON-escaped path separator), not a botched single-quote escape.
# shellcheck disable=SC1003
HPP_ESCAPED_WIN_REPO_BODY='[A-Za-z]:\\\\(repos|Repos|projects|Projects|dev|Dev)\\\\[^\\$%<{~]+(~[0-9]+)?\\\\'
HPP_WIN_REPO_BODY='[A-Za-z]:(/|\\\\?)(repos|Repos|projects|Projects|dev|Dev)(/|\\\\?)[^\\$%<{~"[:space:]/]+(~[0-9]+)?'
HPP_ESCAPED_WIN_REPO_BODY='[A-Za-z]:\\\\(repos|Repos|projects|Projects|dev|Dev)\\\\[^\\$%<{~"[:space:]/]+(~[0-9]+)?'
Loading