fix(guardrails): hoist the Shared defang out of the per-candidate loop - #1840
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 920a21f7c6
ℹ️ 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 macOS block defanged Shared tokens inside a per-candidate `while read` loop, spawning a sed and a grep per line. The loop's only escape was the trailing `head -3`, which fires when candidates SURVIVE the defang — so a block whose candidates are all legitimate Users/Shared references never short-circuited and ran to completion. The guard was slowest on exactly the innocent content the exclusion exists to serve, and a guard killed at its hook timeout fails OPEN. The defang now runs once over the whole candidate block. sed is line-oriented here (no N/H commands, and `$` anchors per line in both shapes), so hoisting cannot change any individual line's result. A grep -nE over the defanged block gives the block-relative survivor indices, and awk selects those lines from the ORIGINAL block by NR, so the reported entry keeps its original line number and original un-defanged text. grep -E stays the sole matcher; awk does no regex work, so the shared HPP_* bodies remain the single source of truth. A block with no Shared token skips the pipeline via a bash-builtin substring test, where the defang is a provable no-op. The survivor re-test strips grep -n's `<n>:` line-number prefix before matching. Hoisting made that necessary: the re-test runs over the NUMBERED candidate lines, so a violation at column 0 arrives as `<n>:/Users/…` and can no longer satisfy the left boundary's `^` alternative. It matched anyway only because that class also accepts ":", a member added for yaml/docker value position which owes this pipeline nothing — narrowing the class for its own stated purpose would have silently dropped a violation the first pass had already flagged. Rerunning the pipeline with ":" removed from the class takes the column-0 survivor set from [2] to [] unstripped, and leaves it [2] stripped. The strip is one more expression on the sed the defang already runs, so it adds no process, and a column-0 case pins it. The candidate assignment gains an explicit `|| true`: its trailing grep -v exits non-zero whenever nothing survives the Windows exclusion, and this library is sourced by commit-time hooks whose shell options it does not control. Aborting there under `set -e` would fail open too. Subprocess count is now constant rather than proportional to the candidate count, and the regression case pins that count directly rather than timing the run: grep/sed shims on PATH tally every spawn the hook makes, and the tally must not move when the input quadruples. Measured through the hook on Git Bash over the same corpora, swapping only the pattern lib: 210 spawns at 100 Shared-only lines per-line (100 sed + 110 grep) against 12 at either size hoisted, with wall clock rising 22s to 288s across a 4x input increase against a flat ~10s. A wall-clock ratio could not carry that pin. Elapsed time here is dominated by process-spawn latency, and repeats of the identical 400-line corpus measured 5.0s and 15.2s — a spread wider than the 4x signal the ratio existed to detect, which made the timing assertion report a scaling failure on unchanged code. Widening its tolerance would have left a gate that cannot discriminate the bug it guards; a count is exact and load-invariant. A companion assertion fails if the shims never fire, so the equality cannot pass vacuously. Fixes #1792 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVoZoMYXqf8ZVbQYixPVPW
920a21f to
30e7842
Compare
|
Claude finished @kyle-sexton's task in 3m 37s —— View job Security review
Scope reviewed: the 4 files changed in this PR ( Findings: noneThis is a shell-guard hook (
Non-issue noted for completeness (not reported as a finding)
No CRITICAL, IMPORTANT, or SUGGESTION-severity security findings for this PR. |
|
Claude finished @kyle-sexton's task in 3m 35s —— View job Code review complete ✅
No blocking issues found. This is a correctness fix (fail-open under hook timeout) with strong test coverage, and I traced the actual regex/pipeline mechanics rather than taking the PR description at face value. Details below, scoped to correctness/architecture/tests/maintainability per this lane's remit (security is the dedicated security lane's scope). Core logic (
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
Summary
hpp::scan_text's macOS block defangedSharedtokens inside a per-candidatewhile readloop,spawning a
sedand agrepfor every candidate line. The loop's only escape was the trailinghead -3, and that short-circuit fires when candidates survive the defang. On a block whereevery candidate is a legitimate
Users/Sharedreference, none survives, nothing is ever written,head -3never closes the pipe, and the loop runs to completion.So the guard was slowest on precisely the innocent content the exclusion exists to serve, and
fastest on violations — the wrong way round for something with a hook timeout. A
PreToolUseguardkilled at its timeout fails open, which makes this a correctness bug, not a speed one. This
plugin has been bitten by that before (#1345).
The defang now runs once over the whole candidate block:
sedover the candidate block.sedis line-oriented in this pipeline — noN/Hmultilinecommands, and
$anchors per line in both shapes — so hoisting cannot change any individualline's result.
grep -nEover the defanged block yields the block-relative indices of the survivors;awkthen selects those lines from the original block by
NR. The reported entry therefore stillcarries its original line number and original un-defanged text, never the defanged copy.
Sharedtoken at all skips the pipeline entirely via a bash-builtinsubstring test. The defang is a provable no-op there, so the common case costs nothing.
grep -Eremains the sole matcher andawkdoes no regex work, so no second regex dialect entersand the shared
HPP_*bodies stay the single source of truth.The survivor re-test also strips
grep -n's<n>:line-number prefix before matching. Hoistingmade that necessary and it is easy to miss: the re-test runs over the numbered candidate lines,
so a violation at column 0 arrives as
<n>:/Users/…and can no longer satisfy the leftboundary's
^alternative. It matched anyway only because that class also accepts:— a memberadded for yaml/docker value position, which owes this pipeline nothing. Narrowing the class for its
own stated purpose would therefore have silently dropped a violation the first pass had already
flagged. Verified by rerunning the pipeline with
:removed from the class: the column-0 survivorset goes from
[2]to[]unstripped, and stays[2]stripped. The strip is one more expression onthe
sedthe defang already runs, so it adds no process, and a column-0 case now pins it.Detection semantics are otherwise unchanged. Only the hoisting was ported — this repo's
_posix_boundaryand its[^A-Za-z0-9._-]defang boundary class are untouched.One adjacent fail-open fix: the candidate assignment gains an explicit
|| true. Its trailinggrep -vexits non-zero whenever nothing survives the Windows exclusion (the common clean case),and this library is sourced by commit-time hooks whose shell options it does not control — aborting
there under
set -ewould fail open in the same way.Measured
A matched pair: one machine, one harness, the same two corpora driven through the hook, with
only
lib/path-detection/hardcoded-path-patterns.shswapped between the sides. Spawn counts comefrom
grep/sedshims onPATH; wall clock isEPOCHREALTIMEaround the hook invocation with thepayload precomputed outside the timed region.
grep/sedspawns at 100sed+ 110grep)grep/sedspawns at 400Spawn count is the exact figure; wall clock is its consequence. The per-candidate shape grew 13x
for a 4x input increase — super-linear, because fork pressure compounds — while the hoisted shape
is flat and its spread is machine noise. A control corpus of the same size carrying no
Sharedtoken cost 1.9–4.0s on both sides, which places the delta in the defang rather than in payload
size.
The figures in the issue (108s per-line against 0.92s hoisted) are #1792's own measurement on a
different machine, not this pair.
Test plan
Regression cases in
hardcoded-path-check.test.sh, plus the existing suite:Bounded, not per-candidate — the headline pin. It counts subprocesses, not seconds.
grep/sedshims onPATHtally every spawn the hook makes, and the tally must not move whenthe input quadruples. A companion assertion fails if the shims never fire, so the equality cannot
pass vacuously.
This replaces a wall-clock ratio assertion, and the reason is the most reviewer-relevant fact in
the PR: the timing assertion failed on unchanged code. Repeats of the identical 400-line
corpus measured 5.0s and 15.2s, so the noise floor was wider than the 4x signal the ratio existed
to detect, and the gate reported
FAIL: Shared defang scales with candidate count: 4s at 100 lines, 18s at 400on a green branch. Widening the tolerance would have left a gate that cannotdiscriminate the bug it guards. A count is exact, load-invariant, and pins the property the fix
actually establishes — a constant subprocess count.
Shared-padded block — four Shared-only lines then a real user path at line 5, so the violation
sits beyond the first three candidates. Asserts the reported entry carries the original file
line number (
5:cd …) and that the defanged spelling never appears in output. This is exactlywhere a block-relative index would leak through in place of the file-relative one.
Violation at column 0 inside a Shared block — pins the prefix strip described above. It passes
both with and without the strip today, which is the point: it guards the coupling rather than a
live defect, and it fails the moment
:leaves the boundary class if the strip is ever reverted.All candidates excluded as Windows paths — the candidate block is empty after the
-vstage,which under
pipefailreports failure. Asserts exit 0 and silence, distinguishing a clean passfrom a silent abort.
Existing Shared cases (bare
Sharedat EOL,Shared+ user path on one line,SharedStuff,trailing punctuation, quoted forms) all still pass, pinning that the exclusion stays match-level.
Results on this branch:
Version bumped 0.18.4 → 0.18.5 with a matching
## [0.18.5]CHANGELOG entry. (The branchoriginally claimed 0.18.4; #1821 released that version on
mainwhile this was open, so it wasrenumbered when this branch rebased rather than co-owning a released version.)
Gates, all run from the worktree root after the rebase:
bash plugins/guardrails/hooks/hardcoded-path-check.test.shshellcheck(lib + test)shfmt -d(lib + test)scripts/check-shell-portability.sh origin/mainscripts/check-changed-skills.sh origin/mainscripts/check-changelog-parity.sh --checkscripts/check-changelog-parity.sh --check-bump origin/mainscripts/check-changelog-parity.sh --check-orderscripts/check-cross-plugin-source-drift.sh --checkscripts/check-silent-skips.shscripts/validate-plugins.shmarkdownlint-cli2 "plugins/guardrails/**/*.md"Related
machine-path-patterns.shbodies. Porting it here reconverges the two drivers rather than leavingthem forked. That PR also carries a derived
_seg_endand a:-boundary addition which arenot included here — those are separate semantic changes belonging to their own issues.
Overlap with concurrent guardrails work
Sibling lanes are working #1814/#1811/#1810 (git wrapper argv parsing) on other branches. Different
code path — those touch the commit-guard argv helpers, this touches the path-detection driver — so
no functional overlap is expected. Both land in the same plugin, so
CHANGELOG.mdandplugin.json's version are the likely textual conflict points; whichever merges second rebases theversion bump. Rebased onto current
origin/main.🤖 Generated with Claude Code
https://claude.ai/code/session_01FVoZoMYXqf8ZVbQYixPVPW