Repository navigation
disk-hygiene: _engine_gate_relevant samefile-scans every token of every command, not just separator-carrying words as documented #3527
Description
Activity
- addedpriority: highSignificant impact, or blocks an imminent release; staff this cycle.Significant impact, or blocks an imminent release; staff this cycle.area: securitySecurity-relevant: vulnerability, hardening, or disclosure follow-up.Security-relevant: vulnerability, hardening, or disclosure follow-up.work-class: structuralRefactors, migrations, contract changes; cross-cutting and hard to reverse.Refactors, migrations, contract changes; cross-cutting and hard to reverse.
on Aug 31, 2026 This was generated by AI during triage.
Claiming for triage-only evaluation (no execution).
Generated by Claude Code
This was generated by AI during triage.
Cross-reference: #3349 measures the symptom this issue root-causes
Related, not duplicate. Recommend linking, not consolidating.
#3349 ("PreToolUse gate runs on every Bash/PowerShell call in every session, ~2.4 s median") is the symptom-side filing of the same cost. This issue is the mechanism:
_engine_gate_relevant's marker-free fallback callingos.path.samefileon every whitespace token, not just separator-carrying words as its own docstring and inline comment claim. #3349's own triage comment already points at this fallback as a surviving residual behind the Bash predicate, but it does not carry the doc/code mismatch or the "every token, not every separator-carrying word" finding, which is what makes the latency unbounded rather than merely large.Why they should not be merged:
- disk-hygiene: PreToolUse gate runs on every Bash/PowerShell call in every session (~2.4 s median) #3349 uniquely holds a threat-model option this issue does not cover. Its fourth suggestion, making the always-on gate conditional on the plugin having been invoked at least once in the session, changes what the gate protects against. Its triage comment explicitly rules that out of the delegable scope and flags it as a maintainer decision. Folding disk-hygiene: PreToolUse gate runs on every Bash/PowerShell call in every session (~2.4 s median) #3349 into this issue would lose that thread.
- disk-hygiene: PreToolUse gate runs on every Bash/PowerShell call in every session (~2.4 s median) #3349's remaining scoped work is the PowerShell matcher predicate, plus a fresh measurement with the plugin-disabled control the original lacked. That is registration-level work in
hooks/hooks.json. - This issue is a semantics change to the security core, narrowing what the gate sees, and its own body says it should not ride along in a performance PR. Different review bar, different acceptance criteria.
Suggested shape: #3527 as the root-cause child of #3349, with #3349 retained as the parent measurement-and-registration item. A human should set the edge; no labels, state, or links were changed by this pass.
No comment was posted on #3349 itself: it showed activity inside the last 30 minutes, which this lane skips.
Generated by Claude Code
This was generated by AI during triage.
Triage: verified, routed human-gated
The report is confirmed, the cost is larger than reported, and the suggested fix as written opens a hole. That last point is why this is human-gated rather than delegable.
Verification
Instrumented
os.path.samefileand called_engine_gate_relevantdirectly on ordinary commands:Command Words samefilecallsGate echo hello2 8 False git log --oneline --graph --decorate origin/main6 24 False npm run build -- --watch --verbose6 24 False rg -n pattern src/ tests/ docs/ --glob !node_modules8 32 False Confirmed: no separator filter is applied on either the
_literal_shell_wordspath or thecommand.split()path, while the docstring and the inline comment both describe one. Probed values include--oneline,--verbose,-n, and--— flags, not paths.The multiplier is 4x the word count, not 1x. Two independent doublings the report does not mention:
- The candidate list is
marker_candidates + words, and for a normally-parsing command those two sets are the same tokens. Nothing dedupes them, so every token is probed twice. _same_file_as_bundledperforms twosamefilecalls per candidate: the word as written, then the word joined onto the engine's own directory.
The suggested fix loses a shape the guard deliberately covers
Hard-linked the engine under a non-marker name and probed both readings:
Invocation Link location Head has separator Gates today <tmp>/cleanup applyoutside engine dir yes True <tmp>/cleanup;echo doneoutside engine dir yes True cleanup applyoutside engine dir no False ./alias applyinside engine dir yes True alias applyinside engine dir no True The last row is the one a separator filter removes. It is not the already-accepted
PATH-installed alias with no separatorresidual: it is the shape_same_file_as_bundled's second reading exists to catch, and the docstring names the reason directly, that the engine's directory is "the directory such a command mustcdinto for the alias to run." On a shell that searches the current directory beforePATH(Windowscmd),cd <scripts> && alias applyexecutes the engine and would no longer gate. So the report's closing assumption, that a separator filter "appears to add nothing beyond" an accepted residual, does not hold, and the answer is the one the report asked someone to confirm rather than assume.What the latency actually tracks
The two readings have different risk profiles, and only one of them is unbounded:
- As-written (
samefile(word, bundled)) takes an arbitrary user-supplied token. This is the reading that can block on a dead drive letter, a disconnected UNC path, or a stale mount. Unbounded. - Engine-dir-relative (
samefile(<engine dir>/word, bundled)) always resolves under the plugin's own directory. Local, bounded, and it is the reading that carries the coverage in the table above.
A fix that filters the two readings independently can drop nearly all of the unbounded cost without narrowing the gate. A single separator filter over both readings cannot.
Agent Brief
Type: Bug
Summary: The engine gate's marker-free fallback performs unbounded filesystem identity checks against every token of every shell command; cut that cost without narrowing what the gate detects.Current behavior:
In engine-gate mode, for any command that carries no engine marker, the gate builds a candidate list from both the metacharacter-split token set and the literal-shell-word set, does not dedupe them, and submits every candidate to a two-reading filesystem identity check. Cost is foursamefilecalls per command word, paid on every shell tool call in every session. Each as-written reading takes a user-supplied token and can block for as long as the OS takes to fail on an unreachable path.Desired behavior:
Marker-free relevance costs a bounded, small number of filesystem probes per command, and no probe is issued against an arbitrary user-supplied path token that could be unreachable. The set of commands the gate deems relevant is unchanged, including the case where a link to the engine sits in the engine's own directory under a non-marker name and is invoked as a bare word with no separator.Key interfaces:
- The marker-free branch of the engine-gate relevance predicate: candidate construction and the identity check it feeds.
- The two-reading identity helper. Its as-written reading and its engine-directory-relative reading have different cost and different coverage and should be filterable independently rather than as one unit.
- The marker-carrying branches are not part of this change.
Acceptance criteria:
- A differential over a command corpus shows allow/ask/deny parity with current behavior on every non-alias shape. The harness added by PR perf(disk-hygiene): cut the destructive guard's per-call spawns 4 to 1, and stop its watchdog blocking commands it never judged #3523 is the intended starting point.
- A regression test covers a hard link to the engine placed in the engine's own directory under a non-marker name, invoked both bare and with a leading
./, and asserts both still gate. The test skips explicitly, rather than silently degrading into a copy test, whenos.linkcannot create the link, since a cross-volume failure on Windows turns it into a different and already-accepted residual. - A regression test covers a hard link outside the engine directory invoked by absolute path, both alone and beside a shell operator, and asserts both still gate.
- A counted measurement of identity probes per invocation, before and after, on a representative multi-token command, recorded in the PR.
- No probe is issued against a token that is neither separator-carrying nor resolvable under the engine's own directory. Demonstrated by instrumenting the probe on a command whose tokens are all flags.
- The plugin CHANGELOG records the semantics decision and its rationale.
Out of scope:
- The marker-carrying branches of the relevance predicate.
- The watchdog expiry behavior PR perf(disk-hygiene): cut the destructive guard's per-call spawns 4 to 1, and stop its watchdog blocking commands it never judged #3523 changed. This item removes the stall; it does not revisit what happens when the deadline fires.
- The copy-evasion residual class. A byte copy is a different file and remains out of reach.
Why human-gated
Not a capability blocker, a genuinely open decision on a security surface:
- The fix named in the report is demonstrably not parity-preserving, so someone has to choose the actual approach. The independent-readings split above is a candidate, not a decision.
- Deciding it means ruling on whether the bare-word-inside-engine-directory shape is worth keeping. That is a judgment about the guard's accepted-residual set, which is maintainer territory.
Separable, if the maintainer wants partial relief without settling the above: deduping the candidate list is provably semantics-free, because the check is a pure predicate under
any(), so duplicate candidates cannot change the result. That alone halves the probe count. It still edits the security core, so it is recorded here rather than split into its own item; one decision can authorize both stages.Labels: keeping
priority: high,area: security,work-class: structural; addingneeds-human.Triage claim released.
Generated by Claude Code
- The candidate list is
- addedneeds-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.Human-in-the-loop required; autonomous sessions must not resolve items carrying this.
on Sep 6, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Sep 29, 2026 This was generated by AI during fix work on the 2026-09-29 audit of the unattended Cursor run.
Done in #5336 (docstring-only, no behavior change, no park record). The module and function docstrings and the branch comment now state that the marker-free fallback identity-checks every token.
Owner decision still open. Either fix it (a separator filter, which triage found loses the engine-directory bare-word alias parity, so it needs the corpus differential, hard-link cases and counted probes from this issue) or accept and document the over-scan (the closed PR #5013 "Option A").
Recommended (not a decision): fund the parity-preserving fix as its own PR reusing the #3523 harness. Basis: the unbounded samefile stall on a dead drive or stale UNC mount is still present, and this issue's own verification list. The differential corpus tests from closed PR #5051 can be reused by that fix.
Owner decision (2026-09-29): Option 2. Split the two readings: apply a separator filter to the as-written probe only, keep the engine-directory reading for bare words, and dedupe candidates. Prove parity with a differential over a command corpus, the hard-link cases and counted probes listed in this issue. Owner's words: "Accept recommendation: Option 2, with the differential over a command corpus, hard-link cases and counted probes the issue lists".
Next: an agent implements this per the triage Agent Brief (six acceptance criteria), reusing the #3523 harness and the closed #5051 tests, and opens a draft PR. Before merge, check whether os.path.join(engine_dir, 'D:foo') on Windows still probes a dead drive in the engine-directory reading.
- addedagent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.Fully specified and briefed; eligible for autonomous pickup from the frontier.and removedneeds-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.Human-in-the-loop required; autonomous sessions must not resolve items carrying this.
on Sep 29, 2026 - added a commit that references this issue
on Sep 30, 2026 Owner decision (2026-09-30): Option A on the IMPORTANT security-review finding on PR #5526 (
destructive_guard.py, engine gate). Restore origin/main's unconditional_samefile(word)reading for every marker-free candidate, keep the PR's other changes, and accept an empty candidate drive as well as the engine's drive. Add regression cases for a hard link at a bare name outside the engine directory and a bare relative word on Windows. The owner accepted the recommendation on 2026-09-30 ("Go with recommendations").Owner decision (2026-10-01): approved the recommendation ("go with recommended", the owner's reply to the batch decision page that listed this question and its recommendation).
Question: PR #5526 shipped everything except one acceptance criterion, and the 2026-09-30 Option A decision means that criterion cannot be met. Close and waive it, fund a bounded probe, or close with a separate follow-up?
Chosen: Option A. Close #3527 as completed and waive the criterion "no probe is issued against a token that is neither separator-carrying nor resolvable under the engine's own directory". PR #5526 (merged 2026-09-30) delivered the dedupe, the drive filter on the engine-directory reading, the hard-link regression tests, the probe count (24 down to 12) and the CHANGELOG entry. The unconditional as-written samefile probe stays as an accepted residual: it can stall on a dead drive or a stale UNC mount, bounded only by the PR #3523 watchdog.
Next: closing this issue as completed. No further work is planned.
Summary
_engine_gate_relevant's marker-free fallback callsos.path.samefileon every whitespace token of everyBash/PowerShellcommand in every session. Its own docstring says it scans separator-carrying words; the implementation applies no such filter. This is both a doc/code mismatch and the identified source of the unbounded stall that #1423 investigated and that PR #3523 works around rather than fixes.Evidence
plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py, marker-free branch of_engine_gate_relevant:Every candidate reaches
_same_file_as_bundled, which callsos.path.samefile(word, bundled)and, for relative words, a secondsamefileagainst the engine's own directory. So an ordinarygit log --oneline --graph --decorate origin/mainperforms a filesystem identity check on each of its words.The function's docstring describes something narrower:
and the branch's own inline comment says:
Neither the whitespace-token path nor the
_literal_shell_wordspath filters on a separator.Why it matters
The module docstring already names this as the strongest candidate for the one observed 17-second hang:
Note that sentence says "every separator-containing word", which is what the code was believed to do. It scans every word.
The latency is unbounded, not merely large:
samefileon a dead drive letter, a disconnected UNC path, or a stale network mount blocks for as long as the OS takes to fail. No deadline value fixes that — it is why PR #3523 changed the watchdog's expiry action instead of tuning its timeout. The stall itself is still here.Two distinct costs:
Suggested fix
Filter the marker-free candidate set to separator-carrying words, which is what both the docstring and the inline comment already describe.
This is a semantics change to the security core and should not ride along in a performance PR. It needs its own review of the residual set, because it narrows what the gate sees. The relevant question for a reviewer: the function's contract already lists
as accepted residuals of the copy-evasion class. A separator filter appears to add nothing beyond the first of those, which is already accepted — but that needs confirming, not assuming, and the marker-carrying branches must stay untouched.
Verification expected of a fix
os.linkcannot cross volumes on Windows; a cross-volume failure silently degrades such a test into a copy test, which exercises a different, already-accepted residual and reports no gap.samefilecalls per invocation, before and after, on a representative command.Related
_carries_markercompares basenames rather than substrings.