fix(guardrails): gate hardcoded-path repo branch on real non-home checkout - #759
Conversation
…ckout The repo-path branch matched PROJECT_ROOT as a bare substring and was never OS-context-suppressed, so a session rooted at the user home hard-denied every absolute path under home (AppData, Desktop, ...) as a checkout-root leak. Engage the branch only when the resolved project root is a real git checkout that is neither home nor a home ancestor; missing home resolution leaves the branch active (fail toward detection). First MUST-FIRE regression case for the branch plus two stay-quiet repros. Guardrails 0.9.1 -> 0.9.2. The percent-env false positive (F2 of the same issue) lives in the upstream-owned pattern library and ships via the standards distribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 55s —— View job Security Review — PR #759
No critical or important security issues found. Four files changed:
|
| State | SCAN_ROOT | Branch behaviour |
|---|---|---|
PROJECT_ROOT empty |
"" |
repo-path branch inactive (pre-existing) |
git -C "$PROJECT_ROOT" fails |
"" |
branch suppressed → false-negative risk, but matches stated design intent |
HOME and USERPROFILE both empty |
PROJECT_ROOT |
branch stays active — "fail toward detection" ✓ |
Ancestor-suppression logic — the [[ ]] glob pattern is directionally correct:
"$_pr_norm" == "$_home_norm"→ checkout is HOME → suppress ✓"$_home_norm" == "$_pr_norm"/*→ HOME is under the project root → project is an ancestor of HOME → suppress ✓
hook::normalize_path is a lexical-only transform (backslash→slash, drive-letter case fold) — no external commands, no injection risk. Symlinks are not resolved, which is consistent with how the function is used elsewhere in hook-utils.sh and is a pre-existing behaviour unmodified by this PR.
TOCTOU note (SUGGESTION, low confidence): The new code runs git -C "$PROJECT_ROOT" rev-parse as a second git check after PROJECT_ROOT was already resolved from a prior git call (line 121). If .git were removed between the two invocations, the second check fails and SCAN_ROOT stays empty — the repo-path branch goes silent rather than blocking. This is an intentional fail-safe posture per the comment, and the race is not exploitable in practice (it requires an attacker to be able to delete .git mid-hook-execution, which implies they already own the filesystem).
hardcoded-path-check.test.sh — new test cases
No security concerns. Tests run in isolated $TEST_TMPDIR subdirectories with a proper trap ... EXIT cleanup. New git init calls are scoped inside throwaway directories and don't touch any shared state.
Verdict: safe to merge from a security perspective. The change narrows an overly broad detection gate using well-quoted shell code and documented fail-safe defaults. No new injection surfaces, no authorization gaps, no credential exposure.
|
Claude finished @kyle-sexton's task in 2m 47s —— View job PR Review
SummaryThis is a clean, targeted fix. The new A few observations below, all minor. Logic correctnessGate condition — if [[ -n "$_home_norm" && ( "$_pr_norm" == "$_home_norm" || "$_home_norm" == "$_pr_norm"/* ) ]]; thenThe two suppress conditions are:
Condition 2 is correct but has no dedicated test. An edge case like Symlinks not resolved — the SCAN_ROOT comparison uses Second Test coverageF1a (test line 126) tests the non-git gate: F1b (test line 135) covers the git-checkout-equals-home case. MUST-FIRE (test line 64) guards against over-suppression. This is the most important addition — the branch previously had zero test coverage. The gap is the missing "project root is a proper ancestor of home" case (condition 2 above). F1a + F1b + MUST-FIRE together give confidence the common scenarios are solid.
|
…k comment The version bump had rewritten plugin.json wholesale (2-space indent to tabs via an external formatter that does not own this file); restore the original bytes with only the version field changed. Reword the new hook comment to drop the issue-number back-reference per comment hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 33s —— View job PR Review #759
|
| Scenario | SCAN_ROOT |
Expected |
|---|---|---|
Non-git dir (CLAUDE_PROJECT_DIR is home without a .git) |
"" |
suppress ✓ |
Git checkout == $HOME (dotfiles-as-home) |
"" |
suppress ✓ |
Git checkout is an ancestor of $HOME (e.g. root = /home, home = /home/user) |
"" |
suppress ✓ |
| Git checkout is a normal subdirectory below home | "$PROJECT_ROOT" |
detect ✓ |
$HOME/$USERPROFILE both unset |
"$PROJECT_ROOT" (fail toward detection) |
detect ✓ |
The "$_home_norm" == "$_pr_norm"/* glob correctly anchors on the path-segment boundary — /* requires a literal / separator, so /homework does not match /home/*. That's the same anchoring pattern used elsewhere in hook-utils.sh (e.g. hook::read_file_path line 206).
Pre-filter interaction (hardcoded-path-patterns.sh line 74): The F1a Windows test still reaches the pre-filter (Users matches), the OS-context and home-path branches are all suppressed or miss, and project_root="" skips the repo-path block — exit 0 as expected.
Findings
1. Missing test — ancestor-of-home suppression (minor gap)
The "$_home_norm" == "$_pr_norm"/* branch (project root is an ancestor of $HOME) has no dedicated test. F1b only covers the equal-case. A test for e.g. PROJECT_ROOT=/tmp/anc, HOME=/tmp/anc/user would complete the matrix. The failure mode if the suppression didn't work would be false positives (annoying, not a security regression), but coverage of a novel branch is low.
2. Windows USERPROFILE fallback is CI-untestable (acknowledged limitation)
The ${USERPROFILE:-} path in "$(hook::normalize_path "${HOME:-${USERPROFILE:-}}")" is not exercised by any test because CI runs Linux. F1a uses a Windows-format string but the git check fails for a filesystem reason, not because of the home comparison. This is a known cross-platform testing gap (the test suite notes "runtime-assembled machine paths") — not a blocker, but worth tracking against a future Windows runner.
Non-issues
- Double git invocation:
git check-ignoreruns on$CLAUDE_PROJECT_DIR; the newgit rev-parseruns on$PROJECT_ROOT. These are the same variable whenCLAUDE_PROJECT_DIRis set, and the second call is gated onPROJECT_ROOTbeing non-empty. Minor redundancy, not a correctness concern. PROJECT_ROOTvs actual git root mismatch: WhenCLAUDE_PROJECT_DIRis a subdirectory of a repo,SCAN_ROOTis set toCLAUDE_PROJECT_DIR(not the git root). This is the same behavior as before this PR — the branch always searched forPROJECT_ROOTas a literal substring, not the actual toplevel. Existing, pre-existing, not introduced here.- Comment length: The 12-line block comment is detailed but warranted given the subtlety of the invariant.
CHANGELOG.md/plugin.json: Correct semver bump (bug fix → patch), clean changelog entry, description unchanged. ✓
Verdict
Approve pending the ancestor-of-home test gap (item 1, minor). The core fix is sound, the must-fire regression case guards against over-suppression, and the fail-toward-detection semantics on missing HOME are correct. CHANGELOG and version are clean.
|
Claude finished @kyle-sexton's task in 2m 23s —— View job Security Review — PR #759
No security issues found. Two of the four changed files are noise (
|
| Condition | _home_norm |
Result |
|---|---|---|
PROJECT_ROOT empty |
— | SCAN_ROOT="" — repo-path branch stays inactive (pre-existing) |
git -C "$PROJECT_ROOT" rev-parse fails |
— | SCAN_ROOT="" — repo-path branch suppressed; documented intent (fail-safe open on git absence) |
Both HOME and USERPROFILE unset |
"" |
[[ -n "$_home_norm" ]] is false → else branch → SCAN_ROOT="$PROJECT_ROOT" — branch stays active ("fail toward detection") ✓ |
The third case is the critical one: a missing home resolution never silently disables the guard.
Gate logic:
if [[ -n "$_home_norm" && ( "$_pr_norm" == "$_home_norm" || "$_home_norm" == "$_pr_norm"/* ) ]]; then- Condition 1 (
_pr_norm == _home_norm): project root equals home → suppress ✓ - Condition 2 (
_home_norm == _pr_norm/*): home is inside the project root (project root is an ancestor of home) → suppress ✓ (e.g.PROJECT_ROOT=/home,HOME=/home/user— suppressing/homeas a "repo path" is correct)
Env-var manipulation: an attacker who can set HOME to an arbitrary value could influence the gate, but the worst reachable outcome is false-positive suppression (the repo-path branch goes quiet), not detection bypass of the home-path branches (Linux user path, macOS user path, Windows user path) which are independent and unaffected by SCAN_ROOT. The repo-path branch is also the least critical of the detection families — it catches repo-root strings that leak, not raw home directories.
git -C TOCTOU: if .git is deleted between the earlier CLAUDE_PROJECT_DIR assignment and the new git rev-parse check, SCAN_ROOT stays "" and the repo-path branch is silently disabled. This matches the documented fail-safe posture. The race is not exploitable: deleting .git mid-hook-execution requires filesystem write access, which already implies full ownership.
hardcoded-path-check.test.sh — new test cases
Test scaffolding is clean: git init calls are scoped to $TEST_TMPDIR subdirectories and cleaned up by the existing trap 'rm -rf "$TEST_TMPDIR"' EXIT. No shared state, no credential exposure, no unsafe invocations.
Verdict: safe to merge. The gate narrows overly broad detection using well-quoted shell code, correct fail-toward-detection defaults, and no new command execution surfaces.
…est comments Two tracker-ref comment-hygiene violations survived the earlier sweep; reworded to describe the behavior instead of the tracker item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 25s —— View job Security Review — PR #759
No security issues found. Two of the four changed files are noise (
|
| Condition | _home_norm |
SCAN_ROOT |
Expected |
|---|---|---|---|
PROJECT_ROOT empty |
— | "" |
repo-path branch inactive (pre-existing) |
git -C "$PROJECT_ROOT" rev-parse fails |
— | "" |
branch suppressed; documented intent |
Both HOME and USERPROFILE unset |
"" |
"$PROJECT_ROOT" (else branch) |
branch armed — "fail toward detection" ✓ |
The third case is the critical one from a security standpoint: a missing home env var never silently disables the guard.
Gate logic correctness (L146):
if [[ -n "$_home_norm" && ( "$_pr_norm" == "$_home_norm" || "$_home_norm" == "$_pr_norm"/* ) ]]; thenThe /* glob anchors on a path-segment boundary — /homework cannot match /home/*. Both suppress conditions are directionally correct and are not bypassable without controlling HOME/USERPROFILE (environment-level access, already beyond this hook's threat model).
Env-var manipulation impact ceiling — an attacker who can set HOME to an arbitrary value could force SCAN_ROOT="", silencing the repo-path branch only. The home-path detection branches (Linux user path, macOS user path, Windows user path) run independently and are entirely unaffected by SCAN_ROOT. The worst reachable outcome is a false-negative on the repo-path branch, not a bypass of the primary home-path detections.
TOCTOU note — if .git is removed between the earlier CLAUDE_PROJECT_DIR resolution and the new git rev-parse check, SCAN_ROOT stays "" and the repo-path branch goes silent. Matches the documented fail-safe posture; the race requires filesystem write access, which already implies full host ownership.
hardcoded-path-check.test.sh — new test cases
Test scaffolding is clean: git init calls are scoped to $TEST_TMPDIR subdirectories and cleaned up by the existing trap 'rm -rf "$TEST_TMPDIR"' EXIT. HOME overrides are constrained to those subdirectories. No shared state, no credential exposure, no unsafe invocations introduced.
Verdict: safe to merge from a security perspective. The gate narrows overly broad detection using well-quoted shell code, correct fail-toward-detection defaults for missing HOME, and no new command-execution surfaces.
|
Claude finished @kyle-sexton's task in 1m 47s —— View job PR Review #759
SummaryThree commits, two of which are cleanup-only. The functional fix (5f50c3a) is correct and the two follow-up commits (79733e5, f6a30dd) address feedback from the prior review rounds: plugin.json formatting noise is resolved and issue-number back-references have been removed from comments. One gap from the prior reviews is still open (ancestor-of-home test case); everything else is solid. Commit Breakdown5f50c3a — core fix: 79733e5 — restores f6a30dd — drops two surviving Logic CorrectnessGate at
Open Gap — Ancestor-of-Home Test CaseBoth prior review rounds flagged that the The failure mode is a false positive (the repo-path branch stays active when it should suppress), not a security regression. But this is the third review cycle and the branch still has zero coverage for that condition. No Issues With
VerdictApprove with the ancestor-of-home test still pending. The fix is correct, the follow-up commits cleanly addressed the prior review feedback, and the fail-toward-detection stance means the missing test is a coverage gap rather than a correctness or security issue. The PR is safe to merge; the test can follow as a small separate commit or on the next bump. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f6a30dd6c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
When home is itself a git checkout (chezmoi dotfiles) and the project dir is a subdirectory of it, rev-parse discovers the parent checkout at home but the gate compared the subdirectory against home — neither equal nor an ancestor — re-enabling the repo-path branch and hard-denying paths under the subdirectory. Capture the discovered toplevel and run the home comparison against it. Adds the discriminating side-door case (fails before, passes after) and a subdir-of-non-home-checkout MUST-FIRE case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 1s —— View job Security Review — PR #759
No security issues found. Two files are noise (
|
| Condition | _toplevel |
SCAN_ROOT |
Behaviour |
|---|---|---|---|
PROJECT_ROOT empty |
— | "" |
repo-path branch inactive (pre-existing) |
git rev-parse fails (non-git dir) |
"" |
"" |
branch suppressed; documented intent |
checkout == $HOME; or $HOME is inside checkout |
set | "" |
suppress (F1b and new F1c case) ✓ |
| normal repo below home | set | "$PROJECT_ROOT" |
detect ✓ |
HOME/USERPROFILE both unset |
set | "$PROJECT_ROOT" (else branch) |
branch armed — "fail toward detection" ✓ |
The critical invariant — unset home never silently disables the guard — is confirmed correct.
Env-var manipulation ceiling — an attacker who controls HOME can force SCAN_ROOT="", silencing only the repo-path branch. The home-path detection branches (Linux user path, macOS user path, Windows user path) run independently and are unaffected by SCAN_ROOT. The worst reachable outcome is a false negative on the least-critical detection family, not a bypass of primary home-path detection.
git safe.directory on a1a879b's second git rev-parse — if PROJECT_ROOT is owned by a different user, modern git refuses the call and writes to stderr (suppressed by 2>/dev/null); _toplevel is empty; SCAN_ROOT stays "". The repo-path branch goes silent — matches the documented fail-safe posture and is not exploitable.
TOCTOU — if .git is deleted between the PROJECT_ROOT assignment and the new rev-parse, _toplevel is empty and SCAN_ROOT="". Requires filesystem write access to .git mid-hook-execution, which implies full host ownership. Not exploitable in practice.
hardcoded-path-check.test.sh — new test scaffolding
New git init calls are scoped to $TEST_TMPDIR subdirectories (F1b uses $TEST_TMPDIR/githome, F1c uses $TEST_TMPDIR/homerepo, etc.) and cleaned up by the existing trap 'rm -rf "$TEST_TMPDIR"' EXIT. HOME overrides are constrained to those throwaway paths. No shared state, no credential exposure, no unsafe invocations introduced.
Verdict: safe to merge from a security perspective. The a1a879b change tightens the gate to compare against the git toplevel rather than PROJECT_ROOT, correctly closing the chezmoi/home-is-checkout side-door identified by Codex. Quoting is sound throughout; fail-toward-detection semantics on missing home are preserved; no new command-execution surfaces introduced.
|
Claude finished @kyle-sexton's task in 2m 22s —— View job PR Review #759 —
|
| Scenario | _toplevel |
SCAN_ROOT |
Expected |
|---|---|---|---|
PROJECT_ROOT empty |
— | "" |
branch inactive ✓ |
| Non-git dir (git fails) | "" |
"" |
branch suppressed ✓ |
| Checkout equals home | $HOME |
"" |
suppress ✓ |
Checkout is ancestor of home (e.g. /home, HOME=/home/user) |
/home |
"" |
suppress ✓ |
| Home is checkout, project is subdir of home (Codex case) | $HOME |
"" |
suppress ✓ (was broken before) |
| Real non-home checkout, project = repo root | non-home path | $PROJECT_ROOT |
detect ✓ |
| Real non-home checkout, project = subdir of repo | non-home toplevel | $PROJECT_ROOT (subdir) |
detect ✓ |
HOME/USERPROFILE both unset |
non-home path | $PROJECT_ROOT |
detect (fail-toward-detection) ✓ |
Test Coverage
Three new/updated test cases in hardcoded-path-check.test.sh:
F1b update (L153–157): now derives HOME from git ... --show-toplevel output rather than the raw path. Correct and important — on Git Bash/macOS, git may return a canonicalized form (e.g. /private/tmp/...) that differs from what was passed in. Setting HOME to the same form git returns ensures the string comparison in the hook isn't trivially bypassed by a path-form mismatch. The added comment at L142–146 explains this well.
F1c (L164–171): the exact Codex scenario. HOMEREPO is a git checkout, HOME=$HOMEREPO_TL, CLAUDE_PROJECT_DIR=$HOMEREPO/Desktop. The hook discovers _toplevel=$HOMEREPO_TL = $HOME → suppressed → exit 0. This was the zero-coverage blind spot that the Codex finding identified; it now has a direct repro.
SUBREPO MUST-FIRE (L73–79): guards against over-suppression from the toplevel check. CLAUDE_PROJECT_DIR=$SUBREPO/pkg, checkout at $SUBREPO, HOME=$TEST_TMPDIR/elsewhere2. _toplevel=$SUBREPO ≠ home → SCAN_ROOT="$SUBREPO/pkg" → content "at $SUBREPO/pkg/x" fires → exit 2. This verifies that using _toplevel for the home gate doesn't accidentally suppress real subdirectory checkouts.
Remaining Gap (pre-existing, not introduced here)
The ancestor-of-home suppression ("$_home_norm" == "$_tl_norm"/*) — where the checkout root is an ancestor of $HOME, e.g. _toplevel=/home, HOME=/home/user — still has no dedicated test. This is the same gap flagged in prior review rounds. It remains fail-toward-detection (false positive) rather than a security regression, and the condition itself is not touched by this commit.
No Issues With
- Shell quoting: all new expansions are double-quoted;
_tl_norm/_home_normland only in[[ ]]comparisons, never in command positions. SCAN_ROOT=PROJECT_ROOTnot_toplevel: correct — the scanner looks for the project dir literal, not the repo root. The SUBREPO test confirms this._toplevelempty on git failure: the innerif [[ -n "$_toplevel" ]]gates correctly;SCAN_ROOTstays""(fail-safe open, documented intent).- Fail-toward-detection on missing HOME: unchanged —
[[ -n "$_home_norm" ]]is false when both env vars are unset, else-branch fires,SCAN_ROOT="$PROJECT_ROOT".
Verdict
Approve. The fix is correct, directly addresses the Codex P2 finding, and the test suite now has explicit coverage for all five meaningful scenarios (non-git, checkout=home, home-is-checkout+subdir, real-non-home-root, real-non-home-subdir). PASS=38 FAIL=0. The ancestor-of-home test gap is pre-existing and not blocking.

No linked issue — partial fix: this lands F1 of #389 (repo-path branch false positive); #389 stays open until F2 (percent-env token false positive) distributes back from
melodic-software/standards, whosecomponents/path-detection/machine-path-patterns.shis the upstream owner of the affected pattern library (sync-manifest.ymlmarks the guardrails copymanaged— not editable downstream).F1:
hardcoded-path-check.shengages the repo-path branch only for a real git checkout (git -C ... rev-parse --show-toplevel) that is neither${HOME:-%USERPROFILE%}nor a home ancestor; missing home resolution fails toward detection. Repro-first test discipline: both stay-quiet repros failed (exit 2) against unmodified code, pass after; new MUST-FIRE case (real non-home checkout root in content → exit 2) guards against over-suppression — the branch previously had zero test coverage.Verification:
hardcoded-path-check.test.shPASS=34 FAIL=0; shellcheck clean;machine-path-patterns.shverified byte-identical to the standards source (no downstream skew shipped).Related
🤖 Generated with Claude Code