From 6ab01a68b81b8459be9956c67b38c75df980ac5b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:13:10 -0400 Subject: [PATCH 1/4] =?UTF-8?q?fix(guardrails):=20machine-path=20right=20b?= =?UTF-8?q?oundary=20=E2=80=94=20segment=20class,=20not=20trailing=20separ?= =?UTF-8?q?ator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five machine-path bodies required a separator AFTER the child segment, inverting detection both ways (#1093): a real bare path value at end of line ("root = :/Dev/GitHub") was missed, while the space-permitting segment class let prose satisfy the requirement by greedily consuming words until a later slash on the same line — the hook flagged a comment while the actual violations passed clean. The classes now exclude whitespace and the double quote and the mandatory trailing separator is gone: bare values at a natural boundary (EOL, whitespace, quote) are detected, prose spans cannot match, and a bare root with no child segment still never matches. The driver's /Users/Shared exclusion covers the new bare form. 15 regression cases added; suite 59/0, shellcheck clean. Synced-component note: machine-path-patterns.sh is a managed standards component — the identical change lands upstream in melodic-software/standards components/path-detection/ so the next sync does not revert it. Closes #1093 Co-Authored-By: Claude Fable 5 (200k context) --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 23 ++++++++ .../hooks/hardcoded-path-check.test.sh | 56 +++++++++++++++++++ .../path-detection/hardcoded-path-patterns.sh | 24 ++++---- .../path-detection/machine-path-patterns.sh | 35 ++++++------ 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index ab4503b5ba..73cfa931d7 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.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", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 18cb88fe1a..ec10a6aefb 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -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 = :/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 diff --git a/plugins/guardrails/hooks/hardcoded-path-check.test.sh b/plugins/guardrails/hooks/hardcoded-path-check.test.sh index 92e6ffab29..4a058e7a05 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.test.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.test.sh @@ -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 @@ -131,6 +181,12 @@ 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" + 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" diff --git a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh index 9cd47f4452..d0d9ec0231 100644 --- a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh +++ b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh @@ -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\\ or C:/Users// @@ -119,12 +119,14 @@ hpp::scan_text() { [[ -n "$match" ]] && violations="${violations}Windows user path detected:${nl}${match}${nl}${nl}" fi - # macOS user home paths: /Users// + # macOS user home paths: /Users/ # Exclusions via pipe (replaces Perl lookbehind/lookahead): - # grep -v '/Users/Shared/' — legitimate shared directory + # Shared exclusion — legitimate shared directory; the body matches it bare + # (no trailing separator required), so the exclusion accepts EOL, + # whitespace, quote, or slash after the segment # grep -vE '[A-Za-z]:[/\\]' — Windows paths (caught above) 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) + match=$(printf '%s' "$content" | grep -nE "$HPP_MACOS_USER_BODY" 2>/dev/null | grep -vE '/Users/Shared($|[[:space:]"/])' | grep -vE '[A-Za-z]:[/\\]' | head -3) [[ -n "$match" ]] && violations="${violations}macOS user path detected:${nl}${match}${nl}${nl}" fi diff --git a/plugins/guardrails/lib/path-detection/machine-path-patterns.sh b/plugins/guardrails/lib/path-detection/machine-path-patterns.sh index 785d18097c..257cfcbd13 100644 --- a/plugins/guardrails/lib/path-detection/machine-path-patterns.sh +++ b/plugins/guardrails/lib/path-detection/machine-path-patterns.sh @@ -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 = :/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:]/]+' # 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:\\\` absolute path matches, never prose. Both @@ -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]+)?' From 6770a1b0b2f45a4557507027eadf50cc0ff55681 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:24:31 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(guardrails):=20match-level=20Shared=20e?= =?UTF-8?q?xclusion=20=E2=80=94=20keep=20other=20violations=20on=20the=20s?= =?UTF-8?q?ame=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: the widened Shared exclusion was a line-level grep -v, so a line holding both a bare Shared path and a user-specific path dropped entirely, silently passing the real violation (the old separator-form exclusion did not trigger on the bare Shared value, so the second path was reported). Each candidate line is now re-tested with its Shared tokens defanged under boundary guards (a SharedStuff segment stays flagged); only lines whose sole matches are Shared drop out, and the original line is reported. Suite 62/0. Co-Authored-By: Claude Fable 5 (200k context) --- .../hooks/hardcoded-path-check.test.sh | 13 +++++++++ .../path-detection/hardcoded-path-patterns.sh | 29 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/hooks/hardcoded-path-check.test.sh b/plugins/guardrails/hooks/hardcoded-path-check.test.sh index 4a058e7a05..6f8d225a51 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.test.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.test.sh @@ -187,6 +187,19 @@ OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" 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" + 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" diff --git a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh index d0d9ec0231..203fe9ed1f 100644 --- a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh +++ b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh @@ -119,14 +119,31 @@ hpp::scan_text() { [[ -n "$match" ]] && violations="${violations}Windows user path detected:${nl}${match}${nl}${nl}" fi - # macOS user home paths: /Users/ - # Exclusions via pipe (replaces Perl lookbehind/lookahead): - # Shared exclusion — legitimate shared directory; the body matches it bare - # (no trailing separator required), so the exclusion accepts EOL, - # whitespace, quote, or slash after the segment + # 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 (boundary-guarded, so a real segment like SharedStuff is + # untouched); only lines whose SOLE matches are Shared drop out. Three + # 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 -vE '/Users/Shared($|[[:space:]"/])' | 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 "$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}/|${_shared_defused}/|g" \ + -e "s|${_shared}\([[:space:]\"]\)|${_shared_defused}\1|g" \ + -e "s|${_shared}\$|${_shared_defused}|") + printf '%s' "$_defanged" | grep -qE "$HPP_MACOS_USER_BODY" && printf '%s\n' "$_line" + done | head -3) [[ -n "$match" ]] && violations="${violations}macOS user path detected:${nl}${match}${nl}${nl}" fi From bb91732e4760aa021d74ae9a2cfe2367667b0944 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:31:15 -0400 Subject: [PATCH 3/4] fix(guardrails): Shared defang boundary = any non-dirname-continuation char MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 2: the segment class admits shell/prose punctuation, so "cd /Users/Shared;" and a single-quoted Shared value matched the body while the enumerated defang boundaries (separator, whitespace, double quote, EOL) missed them — new false positives on common Shared-directory command shapes. The defang boundary is now any character that cannot continue a real directory name (word chars, dot, hyphen) instead of an enumerated delimiter list; SharedStuff stays flagged. Two boundary regression cases added; suite 64/0. Co-Authored-By: Claude Fable 5 (200k context) --- .../guardrails/hooks/hardcoded-path-check.test.sh | 10 ++++++++++ .../lib/path-detection/hardcoded-path-patterns.sh | 15 +++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/hooks/hardcoded-path-check.test.sh b/plugins/guardrails/hooks/hardcoded-path-check.test.sh index 6f8d225a51..6b222c6ca1 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.test.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.test.sh @@ -200,6 +200,16 @@ OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" 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" + 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" diff --git a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh index 203fe9ed1f..7671a7bd73 100644 --- a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh +++ b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh @@ -126,10 +126,14 @@ hpp::scan_text() { # 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 (boundary-guarded, so a real segment like SharedStuff is - # untouched); only lines whose SOLE matches are Shared drop out. Three - # basic-regex sed expressions (no in-group anchors) keep macOS stock sed - # compatibility; the ORIGINAL line is reported, never the defanged copy. + # 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. @@ -139,8 +143,7 @@ hpp::scan_text() { _shared_defused='/Use''rs-Shared' match=$(printf '%s' "$content" | grep -nE "$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}/|${_shared_defused}/|g" \ - -e "s|${_shared}\([[:space:]\"]\)|${_shared_defused}\1|g" \ + _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 "$HPP_MACOS_USER_BODY" && printf '%s\n' "$_line" done | head -3) From 9b04ed3d823859eb9584d292757561acbde4bbfd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:37:27 -0400 Subject: [PATCH 4/4] fix(guardrails): driver-owned left boundary for slash-rooted home bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 3: the pattern lib has always assigned the macOS/Linux bodies a driver-side left-boundary prefix, and the ci-workflows/medley verification drivers carry one — this hook driver never did. With separator-bounded bodies the gap mostly hid (a URL needed a trailing slash after the home root to false-match); the bare-value form widened it to any URL suffix, e.g. an https URL ending in /home/alice flagged as a Linux user path. The driver now prefixes both slash-rooted bodies with a boundary (line start, whitespace, quote, backtick, paren, "=", ":" for yaml/docker value position, or file://), mirroring the verification drivers' set plus ":". URL negatives and a colon-prefixed positive pinned; suite 68/0. Co-Authored-By: Claude Fable 5 (200k context) --- .../hooks/hardcoded-path-check.test.sh | 16 ++++++++++++++++ .../path-detection/hardcoded-path-patterns.sh | 17 +++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/plugins/guardrails/hooks/hardcoded-path-check.test.sh b/plugins/guardrails/hooks/hardcoded-path-check.test.sh index 6b222c6ca1..18b34257cb 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.test.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.test.sh @@ -210,6 +210,22 @@ OUT=$(CLAUDE_PROJECT_DIR="$TEST_TMPDIR" bash "$HOOK" <<<"$(write_json "$FIXTURE" 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" diff --git a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh index 7671a7bd73..a6eaae10b8 100644 --- a/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh +++ b/plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh @@ -119,6 +119,15 @@ hpp::scan_text() { [[ -n "$match" ]] && violations="${violations}Windows user path detected:${nl}${match}${nl}${nl}" fi + # 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) @@ -141,18 +150,18 @@ hpp::scan_text() { local _shared _shared_defused _shared='/Use''rs/Shared' _shared_defused='/Use''rs-Shared' - match=$(printf '%s' "$content" | grep -nE "$HPP_MACOS_USER_BODY" 2>/dev/null | grep -vE '[A-Za-z]:[/\\]' | + 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 "$HPP_MACOS_USER_BODY" && printf '%s\n' "$_line" + 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