refactor(hooks): hoist the repo-relative path helper into lib/hook-utils.sh - #3463
Conversation
A nine-line FILE_REL / cygpath block was hand-copied into 12 hook payload scripts across 10 plugins. Only actionlint's copy carried the #1133 fix: when the repo-root prefix strip does not match (mount/symlink mismatch, or a cygpath that answers for one side and not the other), the "repo-relative" value is still the whole absolute path, which embeds the developer's username and breaks the telemetry schema's data.file contract. Guardrails' three copies had grown an equivalent redaction independently, under a different variable name. The other eight leaked. lib/hook-utils.sh now owns it as hook::repo_relative_path, propagated to all 17 carrying plugins by scripts/sync-hook-utils.sh. It answers on the same three channels the neighboring hook::repo_root and hook::physical_path use: stdout, a return code, and HOOK_REPO_RELATIVE_DEGRADED. That status matters at one call site. typos-format feeds FILE_REL to the tool itself (TYPOS_ARG), not only to telemetry, and typos runs from the repo root, so a redacted bare basename there names a different file or none at all. That hook now reads the return status and keeps the absolute path when the helper degraded. Measured on a symlinked-repo fixture with a file one directory deep: before, data.file carried the absolute path and the typo was reported; with the helper but no status branch, data.file was redacted and the scan went silent; with both, data.file is "deep.md" and the typo is still reported. The helper also redacts UNC paths, which the guardrails case patterns did not match, and drops actionlint's basename subprocess. Every carrying plugin's version and CHANGELOG are bumped: the plugin version is the update cache key, so an unbumped carrier never delivers the lib change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
…ions Review found that ruff-format feeds FILE_REL to Ruff itself, behind the same guard typos-format had, so the new basename redaction pointed Ruff at a nonexistent path and real findings vanished from an advisory hook. actionlint consumes FILE_REL the same way, and has since it grew the degrade on main. Audited all 12 converted sites for tool-argument consumption: 3 feed a tool (actionlint, ruff-format, typos-format), 9 are telemetry-only. All three now branch on FILE_REL_DEGRADED and fall back to the absolute path. Adds symlinked-root regression cases to the actionlint, ruff-format, and typos-format suites; each fails without its guard. The lib cases now drive both helper arms in child shells with a curated PATH (empty, or a stub cygpath), so the Windows normalization arm is exercised on every host rather than skipped. Also: an empty repo root no longer shaves the leading slash and returns a near-absolute path with a success status, and the contract block documents the set -e hazard of taking the status from a bare assignment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
…file-rel # Conflicts: # plugins/actionlint/CHANGELOG.md # plugins/autonomy/CHANGELOG.md # plugins/claude-ops/CHANGELOG.md # plugins/context-guard/CHANGELOG.md # plugins/go-format/CHANGELOG.md # plugins/guardrails/CHANGELOG.md # plugins/powershell-format/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md # plugins/source-control/CHANGELOG.md # plugins/typos-format/CHANGELOG.md
…ases The child-shell probe is invoked as "$BASH" so it resolves off the deliberately near-empty PATH; shellcheck cannot then tell the quoted argument is a bash script and reads its child positional parameters as unexpanded by mistake, so the site carries a reasoned SC2016 waiver. The symlink fixture's intentional misspelling gets the repo's spellchecker:disable-line directive, the same convention the sibling fixtures in that file already use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
…file-rel # Conflicts: # plugins/claude-ops/CHANGELOG.md # plugins/desktop-notification/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md # plugins/source-control/CHANGELOG.md
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
…not flag The two new lib fixtures spelled their absolute paths as C:/Users/dev/... and /home/dev/..., which is exactly the shape the hygiene lane's machine-specific-paths detector hunts. They are synthetic, but the detector cannot tell, and it is one of the ungated whole-repo scanners, so it blocked the merge. The cases only need a drive-letter absolute path and a POSIX absolute path, so they now use C:/proj/app/a/b.md and /srv/proj/repo/a.txt. Confirmed against the detector this repo actually pins (ci-workflows @ c265418): its five bodies key on Users, /home/, and a literal `repos` checkout parent, none of which the new spellings contain. Running that exact script over the tree is clean, and restoring the old spellings reproduces both failures. Assertions are unchanged: same 12 cases, PASS=221, and disabling the empty-root guard still fails the empty-root case on the new path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkMx972GYEQ3vCb98pLw8m
# Conflicts: # plugins/claude-ops/CHANGELOG.md # plugins/instruction-placement/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 2m 8s —— View job Security review complete
No security issues found. What I checked:
This PR is itself a security fix (closing the absolute-path/username leak on 16 of 17 carriers that lacked the degrade) applied uniformly through the existing vendoring mechanism, and it correctly identifies and fixes the follow-on defect (the |
|
Claude finished @kyle-sexton's task in 2m 53s —— View job Code review complete
No blocking findings. This is a clean, well-verified refactor:
Nothing here rises to a reportable finding under this lane's high-signal bar. |
|
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. |
# Conflicts: # plugins/claude-ops/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 57s —— View job Security review — skip gate hit
Skip gate: this head already has a successful security review that still applies. The last completed security review covered
Per the skip-gate rule, posting nothing further beyond this note. |
|
Claude finished @kyle-sexton's task in 59s —— View job Code review complete
Skip gate: this lane's prior review still applies. This run was triggered by a I confirmed:
Per the skip gate in |
#3463 certified its hoist complete with a grep scoped to the cygpath spelling, so two hand-rolled copies of the same computation survived in guardrails in a different spelling. secret-pattern-detection.sh's redaction tested only the POSIX-absolute and drive-letter spellings, never UNC, and this guard deliberately scans on when no project dir is set, so a \\server\share\file path matched neither arm and the whole share path, server name included, landed in the telemetry envelope's data.file. Both hooks now call hook::repo_relative_path, which carries the UNC arm. 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 basename. hardcoded-path-check.sh's scope guard made the leaking shape unreachable there, so its emitted value is unchanged and the copy is removed only so a third divergent one cannot reappear. Both computations move inside emit_tel, behind the telemetry gate, so an unwired run pays nothing. A trailing-separator trim the copies carried was dropped in the first revision and restored after review: the helper strips "$root/", so a root ending in a separator forms the prefix /repo// and degrades every in-project file to its basename. Trimmed at these two call sites rather than in the shared helper, since every other caller passes git rev-parse --show-toplevel, which never returns one. All 17 selected suites pass; secret-pattern-detection 65/0 and hardcoded-path-check 100/0, each demonstrated failing first against the unfixed hooks.
No linked issue
Summary
A ~9-line
FILE_REL/cygpathblock was hand-copied into 12 hook payload scripts across 10 plugins, and only four of the twelve copies carried the #1133 absolute-path-leak fix. Second-ranked route-lane finding from the/coupling:reducepass merged in #3457.grep -rl '_file_lm=$(cygpath -lm "$FILE"' plugins/now returns nothing.Fix
hook::repo_relative_pathadded tolib/hook-utils.sh— the repo's existing single source of truth for shared hook shell code — implementing the cygpath handling and the basename degrade that eight of the twelve copies lacked.scripts/sync-hook-utils.shpropagates it to all 17 carrying plugins under the existinghook-utils-syncCI lane, so no new script, lane, or registry entry was needed.The four that already had the degrade are
actionlint-check.shand guardrails'cli-flag-verify.sh,skill-reference-verify.shandstale-path-verify.sh. An earlier revision of this description said "only one copy" and named onlyactionlint; that was wrong, and contradicted the CHANGELOG entries this PR ships. Corrected here after an independent verifier measured the twelve copies atorigin/main.All 17 carriers get a version bump and CHANGELOG entry, not just the 10 touched:
--check-bumprequires it for every carrier of a changed lib.The finding had behavioral reach, and fixing it exposed a second defect
In
typos-formatthe value feeds the tool invocation, not just telemetry. Its guard was[[ -n "$root" && -n "$FILE_REL" && "$FILE_REL" != "$FILE" ]]. Under the old code a failed strip leftFILE_REL == FILE, so the guard fell through to the absolute path. Introducing a basename degrade makes it!= FILE— so a naive conversion would have silently pointedtyposat a nonexistent path and dropped real findings. The hook now branches onFILE_REL_DEGRADED.Exactly three of the twelve sites feed a tool rather than telemetry —
ruff-format.sh,typos-format.sh,actionlint-check.sh— and all three carry that branch. Independent verification enumerated all twelve and confirmed no fourth tool-feeding site exists.The helper returns 0 when the path is genuinely repo-relative and 1 (plus
HOOK_REPO_RELATIVE_DEGRADED=1) when it degraded, matching the existinghook::repo_root/hook::physical_pathidiom — the return status, not the global, is the usable channel under command substitution.Verification
Fixture: a real git repo plus a symlink to it, so
file_patharrives in the symlink spelling whilegit rev-parse --show-toplevelanswers with the physical path, and the prefix strip cannot match.eol-normalizertelemetrydata.file:typos-format, file one directory deep, three variants — the middle row is the change with the degrade branch removed, showing the regression the branch prevents:An independent verifier reproduced that guard-removal experiment from its own fixture across all three tool-feeding sites, and found
actionlintthe worst case of the three: with the branch removed it emits no output at all and exits 0, a completely silent findings loss rather than a tool-break message.F401+F821reportedE902 No such file or directoryargument note.md is not foundThe new cases are not vacuous: mutating each hook in place makes its suite fail precisely on the new symlinked-root case (ruff
FAIL=2, typosFAIL=1, actionlintFAIL=1), and pass unmutated.Gates:
sync-hook-utils.sh --checkand--check-bump origin/main,check-changelog-parity.sh --checkand--check-bump,validate-plugins.sh,check-purged-em-dashes.sh,shellcheck -xon all changed shell,check-shell-portability.sh,markdownlint-cli2on 17 CHANGELOGs,typos,editorconfig-checker— all pass. All 17 vendored copies independently confirmed to share one sha256 withlib/hook-utils.sh, and the set of plugins that source it is exactly the set that carries a copy (17 = 17, nothing on either side of thecomm).Suites:
affected-tests.sh --run122/122 shell suites pass after the merge withmainwidened the diff, 0 FAIL. The 7 Python suites it names as NOT RUN all pass.lib/hook-utils.test.shPASS=221 FAIL=0, with all 12repo_relative_pathcases running in both thecygandnocygarms.git diff -- '*.test.sh' | grep '^-[^-]'is empty: no test line was deleted or loosened anywhere in the diff.Stated limits, not glossed:
powershell-format's suite skips its behavioral half (no PSScriptAnalyzer), confirmed a genuine pre-existing environment gate untouched by this diff.go-format's suite self-skipped on the authoring machine for want ofgoimports, but ran in full during verification on a machine that has it:PASS=54 FAIL=0, including thedata.filerepo-relative assertion. Oneportability-ok:annotation was added to a newhook-utils.test.shUNC fixture, becausecheck-shell-portability.shreads the literal\s/\binside'\\srv\share\b.md'as GNU regex escapes; verified a genuine false positive by reproducing the report on a minimal fixture carrying only that line. No test was weakened or skipped to get green.Merge with
mainThree CHANGELOG version collisions were resolved by keeping
main's shipped entry verbatim and renumbering this PR's above it (claude-ops0.38.18, instruction-placement0.11.12, rate-limit-guard0.7.20), with each manifest updated to match. Verified as a clean union: exactly six files differ from both parents, and they are precisely those three CHANGELOGs and their three manifests, so nothing else could have been silently resolved. Each CHANGELOG's diff againstorigin/mainis a pure insertion at the top.Known gap, deliberately not widened into here
Verification found two further hand-rolled copies of this computation in
guardrails—secret-pattern-detection.shandhardcoded-path-check.sh— that the completeness grep missed because they use a different spelling (CLAUDE_PROJECT_DIRplus backslash folding, nocygpath). Their redactioncaselacks the UNC arm, sosecret-pattern-detection.shstill emits a full\\srv\share\...path indata.filewhen the root is unset. Pre-existing, not a regression from this change, andhardcoded-path-check.shis unreachable (its scope guard guarantees the root is set). Filed as a follow-up rather than expanding a 68-file PR.Related
Follow-up to #3457. Refs ADR 0019 (
docs/adr/0019-share-code-across-plugins-by-vendoring-with-a-sync-gate.md), issue #1133.Generated by Claude Code