fix(guardrails): stop a UNC file path reaching telemetry whole - #3479
Conversation
#3463 hoisted the repo-relative path computation into hook::repo_relative_path, but its completeness check grepped for the cygpath spelling of the block. Two copies written in a different spelling survived in plugins/guardrails/hooks/, and their redaction case tested only /* and [A-Za-z]:*, never the \\\\* UNC arm the helper carries. secret-pattern-detection.sh scans on when CLAUDE_PROJECT_DIR is unset, by design. On that path the copy did no separator folding either, so a file_path of the \\server\share\file shape matched neither redaction arm and the whole share path, server name included, reached data.file. Measured against the pre-fix script: data.file came back as the full UNC path where the helper answers with the basename. Both hooks now call the helper. secret-pattern-detection.sh pairs it with hook::repo_root so a file with no project dir is reported relative to its own checkout rather than collapsing to a bare basename, which is the second divergence the copies carried. hardcoded-path-check.sh exits at its scope guard whenever the project dir is unset, so the leaking shape was never reachable there and its emitted data.file is unchanged; the copy goes anyway so a third divergent one cannot reappear. file_rel feeds only the jq telemetry payload in both hooks, never a tool argument, so neither needs the degrade branch three of #3463's twelve sites required. Both now resolve it inside emit_tel, behind the start-stamp and sink gates, so an unwired run does not pay for it. Tests: the UNC leak and the no-project-dir repo-relative case are both discriminating, failing against the pre-fix script and passing against this one, as is the symlinked-checkout case. hardcoded-path-check has no reachable behavioral delta on a POSIX host, so its new coverage pins the absence of the duplicate block plus the value that shape still produces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
…inment "UNC/no-project: data.file is the basename" used assert_contains, and the leaked value `\\srv\share\secrets.env` ENDS in `secrets.env`, so it passed against the pre-fix hook for the wrong reason. It now asserts equality and discriminates on its own: pre-fix the suite reports FAIL=7 with `expected 'secrets.env', got '\\srv\share\secrets.env'`, post-fix FAIL=0. assert_eq is added to guardrails-test-helpers.sh in the shape the claude-ops helpers already use, with a note on when to reach for it over assert_contains. The hardcoded-path-check data.file assertion is labelled for what it is: a regression pin on a computation that MOVED into emit_tel in a suite that asserted nothing about data.file before, not evidence of the UNC fix. It passes against the pre-helper hook too, and the comment says so, so the next reader is not misled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
|
Claude finished @kyle-sexton's task in 3m 53s —— View job Claude finished the code review
ScopeThis is the code-review lane; a What I checked
FindingsNone that clear the high-signal bar. The fix is narrowly scoped, the root-cause explanation in the PR body matches what the code does, and the new tests exercise the actual gap (UNC with no project dir) rather than a proxy for it. |
|
Claude finished @kyle-sexton's task in 1m 31s —— View job Security review
No security issues found. This PR is itself a security fix, not a source of new risk. It closes an information-disclosure gap: Checked specifically for regressions this kind of refactor could introduce:
No CRITICAL/IMPORTANT/SUGGESTION findings. |
|
Last security-reviewed head: |
|
Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ab6f01760f
ℹ️ 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".
The helper strips "$root/", so a CLAUDE_PROJECT_DIR that already ends in a
separator forms the prefix "/repo//", matches nothing, and degrades every
in-project file to its basename. The hand-rolled copies trimmed the
separator before stripping and the move to the helper dropped that trim,
so this was a regression introduced by the previous commit rather than a
pre-existing defect.
A trailing slash is a supported spelling: this plugin's own scope test
already passes CLAUDE_PROJECT_DIR="/repo/". hook::repo_root never returns
one, so the helper's other call sites were never exposed and the trim
belongs at these two rather than in the shared helper, which would
otherwise cascade a version bump to all seventeen carriers.
Reproduced before fixing:
hook::repo_relative_path /repo/src/config.env "/repo/" -> config.env
hook::repo_relative_path /repo/src/config.env "/repo" -> src/config.env
Fail-then-pass, both suites, against the pre-trim hooks with the new
cases in place: secret-pattern-detection PASS=64 FAIL=1 ("expected
'src/config.env', got 'config.env'") and hardcoded-path-check PASS=99
FAIL=1 ("expected 'src/run.sh', got 'run.sh'"); against the fix, 65/0 and
100/0. affected-tests.sh --run: all 17 selected suites pass.
Reported by Codex on the pull request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
|
Claude finished @kyle-sexton's task in 1m 8s —— View job Security review
No security issues found. This PR closes an information-disclosure gap (a UNC path leaking whole, share host included, into the telemetry envelope's New since the last review on this PR:
This is a correctness fix for the Codex P2 finding on the prior commit (a Re-checked the points from the prior review and they still hold against this head:
No CRITICAL/IMPORTANT/SUGGESTION findings. |
|
Claude finished @kyle-sexton's task in 2m 4s —— View job Code review
ScopeCODE-REVIEW lane; a Codex's P2 finding is already fixed on this headCodex flagged that a That review was against What else I checked
FindingsNone that clear the high-signal bar on this head. The one substantive issue raised in review (Codex's trailing-separator finding) is already fixed and test-covered as of |
No linked issue
Summary
#3463 hoisted a hand-copied repo-relative path computation into
hook::repo_relative_pathand certified completeness withgrep -rl '_file_lm=$(cygpath -lm "$FILE"' plugins/. That grep is scoped to the cygpath spelling. Two further copies survived inguardrailsin a different spelling (CLAUDE_PROJECT_DIRplus${FILE//\\//}folding, no cygpath), and one of them still leaks.secret-pattern-detection.sh's redactioncasewas/* | [A-Za-z]:*). It tested two of the three absolute spellings and never UNC. This guard deliberately scans on when no project dir is set, and on that path the copy did no separator folding either, so afile_pathof the\\server\share\fileshape matched neither arm and the whole share path, server name included, landed in the envelope'sdata.file.Found by independent adversarial verification of #3463, which is also where the scope was established: this is pre-existing, not a regression from that PR, and it is the exact bug class that PR exists to eliminate, left behind while its summary read as if the class were closed.
Fix
Both hooks now call
hook::repo_relative_path, which carries the UNC arm.secret-pattern-detection.shpairs it withhook::repo_rooton the file's own directory when no project dir is set, so an ordinary in-repo file is reported relative to its own checkout instead of collapsing to a bare basename. That is the second divergence the verifier measured, fixed in the same change.hardcoded-path-check.shcarried the same duplicated block but its scope guard exits before the computation whenever the project dir is unset, so the leaking shape was never reachable there and its emitteddata.fileis unchanged. The copy is removed so a third divergent one cannot reappear.Both computations move inside
emit_tel, behind the start-stamp andhook::telemetry_enabledgates, so a run with no telemetry sink wired does not pay for the new command substitution at all (.claude/rules/hook-budget.md).Neither site feeds a tool. Read rather than grepped: in both hooks
file_relhas exactly one consumer,jq -n --arg file.secret-pattern-detection.sh's stderr block andsecrets::scan_text "$CONTENT"use$FILE/$CONTENT;hardcoded-path-check.sh'shpp::scan_text "$CONTENT" "$SCAN_ROOT" "$FILE"takes neither. So neither needs theFILE_REL_DEGRADEDbranch that three of #3463's twelve sites required, and there is no findings-loss regression to prevent. The bare-assignment idiom matches the telemetry-only siblings; both scripts runset -uo pipefailwith no-e.A regression this PR introduced, then fixed
The first revision dropped a trailing-separator trim the hand-rolled copies carried (
_root="${_root%/}", in both). The helper strips"$root/", so a root already ending in a separator forms the prefix/repo//, matches nothing, and degrades every in-project file to its basename. Caught by Codex on review, reproduced, and fixed in07c22e41:A trailing slash is a supported spelling:
secret-pattern-detection.test.sh:95already passedCLAUDE_PROJECT_DIR="/repo/". That case asserted only the exit code, so it exercised the shape without pinning the value, which is why it stayed green through the regression. Both suites now assert the value.Trimmed at the two call sites rather than in the helper, deliberately. Every other caller passes
$REPO_ROOTfromhook::repo_root, which isgit rev-parse --show-topleveland never returns a trailing separator; these two are the only ones handing the helper a caller-suppliedCLAUDE_PROJECT_DIR. Normalizing insidelib/hook-utils.shis defensible on its own merits but would cascade a version bump and CHANGELOG entry to all seventeen carriers under thehook-utils-synclane, a much larger diff than the defect warrants. Recording the trade rather than leaving it implicit.Verification
Measured before any change, by running the real hooks and reading
data.fileout of the envelope:data.file\\srv\share\secrets.env, no project dir\\srv\share\secrets.env(leaks, share host and all)secrets.envconfig.envplugins/…/config.envplugins/…/config.envconfig.envsrc/config.envsub/config.envFail-then-pass, hooks reverted to
origin/mainwith the new tests kept, reproduced independently of the authoring run:And for the trailing-slash regression, against the pre-trim hooks with those cases in place:
Against the current head:
secret-pattern-detection65/0,hardcoded-path-check100/0.Two disclosures rather than a clean claim:
assert_eqwas added toguardrails-test-helpers.shin the shapeclaude-ops-test-helpers.shalready uses.hardcoded-path-check.test.shpasses both ways and is labelled in-file as a regression pin, not a fail-then-pass case. It exists because that suite asserted nothing aboutdata.filebefore and the computation just moved intoemit_tel, so without it a relocation mistake would pass every other case there.The symlinked-root fixture #3463 used does not discriminate for these two hooks, and is not shipped as a standalone case: when the strip fails, a POSIX-absolute path matches the old
casearm too, so both sides land on the same basename. The entire behavioral delta is the missing UNC arm plus the missing root resolution.Gates, all exit 0:
check-changelog-parity.sh --checkand--check-bump origin/main,validate-plugins.sh,sync-hook-utils.sh --check("All 17 plugin copies match"),shellcheck -x,check-shell-portability.sh --paths("No unexcused GNU-only constructs"),check-purged-em-dashes.sh,typos,editorconfig-checker,markdownlint-cli2.affected-tests.sh --run: all 17 selected suites passed, zeroFAILand zeroNOT RUN:lines. Selection widened from 6 to 17 because touching the sharedguardrails-test-helpers.shfans out to every suite sourcing it.Not verified, stated plainly: everything was measured on Linux, so the cygpath branch of
hook::repo_relative_pathis exercised by reading rather than running, and the real UNC-mount trigger is reproduced by driving the code path with a UNC-shaped string rather than on a Windows share. The old copies folded\to/before stripping and the helper does not; on a shell with no cygpath and a genuine backslash-separated path below the root, the helper degrades where the copy stripped. That shape is not constructible as a real Linux path, and #3463's twelve sites accepted the same trade, so the helper's behavior was not forked for these two.Related
Follow-up to #3463, which hoisted the helper and whose completeness grep missed these two copies. Refs #1133 (the original absolute-path leak) and ADR 0019. Refs
.claude/rules/hook-budget.md.Generated by Claude Code