Skip to content

fix(phi-scan): refuse a scan root the walk never observed - #38

Merged
NSchatz merged 3 commits into
mainfrom
phi-scan-refuse-an-unobserved-scan-root
Aug 7, 2026
Merged

fix(phi-scan): refuse a scan root the walk never observed#38
NSchatz merged 3 commits into
mainfrom
phi-scan-refuse-an-unobserved-scan-root

Conversation

@NSchatz

@NSchatz NSchatz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What was wrong

pnpm phi-scan with no arguments, which is what CI runs, printed OK, no hits and exited 0 over a corpus it never opened. A PHI tripwire that has never been pointed at an input has not cleared that input.

Six states, every one measured on this tree and every one previously exiting 0 with OK, no hits:

state before after
test/__fixtures__ missing 0 2
test/__fixtures__ emptied 0 2
test/__fixtures__ a dangling symbolic link 0 2
test/__fixtures__ a live link to an outside directory 0 2
one tracked fixture removed from the working tree 0 2
src moved away 0 2

What it does

Each scan root's walk is reconciled against git ls-files, and two independent conditions refuse at exit 2: the root contributed nothing, or git tracks an in-scope file under the root that the walk did not open.

  • Neither condition subsumes the other, so both ship. An emptied root opens nothing; a root swapped for another directory opens plenty. Refusing only a missing root leaves the other half open, because existence is not observation.
  • The dangling case is why a kind check cannot stand in. existsSync FOLLOWS the link and answers false, so walk() returns before readdirSync and the existing not-a-regular-file refusal never fires. Nothing about the entry is ever inspected.
  • A denominator was deliberately not added. A count counts the roots and files that DID exist, so a healthy-looking total is exactly what a starved root produces.
  • Exit 2 was derived from this scanner's own contract, not ported. 1 means "hits found" here; walk() already raises an unreadable root as an invocation failure, and a root replaced by a regular file already exits 2 through readdirSync (re-measured, not assumed). Sibling scanners disagree on this code.
  • git ls-files failing REFUSES rather than answering the empty set, which would be indistinguishable from "this root tracks nothing".
  • One-directional: a tracked file the walk missed refuses; an untracked one it found does not.
  • All-mode only. --staged is a diff and not a corpus, and widening it would change what a commit is blocked on.

Also fixed: a present-but-unreadable phi-scan-overrides.md threw a raw EACCES past every handler and node exited 1, this scanner's code for "hits found". It now exits 2, matching the allow-list reader beside it. The two other minors a sibling item named were re-derived and measured not open here.

What is NOT closed, and is disclosed on four surfaces

The live-link scan-root escape is narrowed, not closed. The reconciliation compares path sets, not the bytes git carries at those paths, so a target directory mirroring the tracked names clears both conditions with decoy contents, at exit 0. A test pins that so the disclosure cannot quietly revert to the absolute wording. An ancestor of a scan root is still out of the staged route's scope, and paths mode still follows a link a caller names.

Measurement

As of cd221a0: 122 tracked files, 34 opened by the all-mode walk (7 fixtures + 27 sources), 88 scanned by neither route, and 6 of those carry an inline PID| literal. All six were read by hand and are placeholder shapes (DOE^JANE, X^^^H^MR, 123^^^HOSP); no real identifier. That 88 is an enumeration gap belonging to a different item; this change widens no root, so the recogniser needed no widening and none was added.

9 of test/scripts/phi-scan.test.ts's 47 tests run red against cd221a0's scanner and green against this one.

Gates

scripts/verify.sh cli green on every step except the pre-existing pnpm audit advisories under @modelcontextprotocol/sdk transitives (this branch touches neither package.json nor pnpm-lock.yaml). 403 tests, coverage 97.7%.

Conformance gate: pass 1 REFUTED (a false residual disclosure, stale counts, an unmerged path named once per stage), pass 2 NOT REFUTED, pass 3 narrow on the remedy diff.

NSchatz added 3 commits August 7, 2026 18:02
…BSERVED-NOTHING-IS-GLOBAL)

`pnpm phi-scan` with no arguments, which is what CI runs, printed `OK, no hits` and
exited 0 over a corpus it never opened. Each scan root's walk is now reconciled
against `git ls-files`, and two independent conditions refuse at exit 2: the root
contributed nothing, or git tracks an in-scope file under it that the walk did not
open.

Six states measured on this tree, every one of them previously exiting 0 with
`OK, no hits`: the fixture root missing; the fixture root emptied; the fixture root
a dangling symbolic link; the fixture root a live symbolic link to a directory
outside the repository; one tracked fixture removed from the working tree with the
rest of the root still opened; and `src` moved away. 8 of the suite's tests run red
on cd221a0 and green here.

Neither condition subsumes the other, so both ship: an emptied root opens nothing, a
swapped one opens plenty, and refusing only a MISSING root leaves the other half
open, because existence is not observation. The dangling case is why a kind check
cannot stand in: `existsSync` FOLLOWS the link and answers false, so `walk()` returns
before `readdirSync` and the not-a-regular-file refusal never fires, leaving nothing
about the entry ever inspected.

A denominator was deliberately NOT added. A count counts the roots and the files that
did exist, so a healthy-looking total is exactly what a starved root produces.

Exit 2 was derived from this scanner's own contract, not ported from a sibling: `1`
means "hits found" here, `walk()` already raises an unreadable root as an invocation
failure, and a root replaced by a regular file already exits 2 through `readdirSync`
(re-measured, not assumed). Sibling scanners disagree on this code.

The rule is one-directional (a tracked file the walk missed refuses, an untracked one
it found does not) and scoped to all mode: `--staged` is a diff rather than a corpus,
and widening it would change what a commit is blocked on. `git ls-files` failing
refuses rather than answering the empty set.

Two of the three disclosed escapes are narrowed, not closed, and the module header
says so: a scan root that is itself a live link is still followed and survives only
where git tracks nothing under it, and an ancestor of a scan root is still out of the
staged route's scope. Paths mode is untouched.

Also fixed: a present-but-unreadable `phi-scan-overrides.md` threw a raw EACCES past
every handler and node exited 1, this contract's code for "hits found". It now exits
2, matching the allow-list reader beside it. The two other minors a sibling named were
re-derived and measured NOT open here.

Measured for the record: 122 tracked files, 34 opened by the all-mode walk, 88 scanned
by neither route, 6 of those carrying an inline `PID|` literal. All six were read by
hand and are placeholder shapes, no real identifier. That 88 is an enumeration gap and
a different item; this change widened no root, so the recogniser needed no widening.
…ed path once

Three findings from the conformance gate on e72dba6, all INTRODUCED by that commit
and none of them a change to what the rule refuses.

1. THE RESIDUAL DISCLOSURE WAS FALSE AND WAS FALSIFIED IN ONE RUN. `CLAUDE.md`,
   `CHANGELOG.md` and `documentation/agent-notes.md` all said the live-link scan-root
   escape "survives only where git tracks nothing under it". It does not. The
   reconciliation compares PATH SETS, not the bytes git carries at those paths, so a
   target directory mirroring this repo's own seven tracked fixture NAMES satisfies
   both conditions and the gate prints `OK, no hits` at exit 0 over decoy contents.
   Measured. The module header had it right; the three restatements dropped the
   qualifier and inverted it. All four surfaces now name the surviving shape, and a
   root git tracks nothing under is stated as the degenerate case of it rather than
   the whole of it. An inaccurate disclosure is a defect in its own right here, so
   the remedy is the claim and deliberately not a bigger guard.

2. THE COUNTS WERE STALE IN THE COMMIT THAT SHIPPED THEM. 122 tracked, 88 in neither
   route and 6 carrying an inline `PID|` literal are all correct as of cd221a0 and
   all move the moment this slice adds a file, including the note that states them,
   which contains the literal it counts. They are now anchored to that sha, with the
   reason written next to them.

3. AN UNMERGED PATH WAS NAMED ONCE PER STAGE. `git ls-files` emits three records for
   a conflicted path, so one missing fixture was reported as three. `trackedUnder`
   de-duplicates. The refusal and its exit code were right either way; only the
   diagnostic was wrong.

Two tests added: one pins the surviving live-link shape so the disclosure cannot
quietly revert to the absolute wording, and one pins the unmerged count, asserting
the three stages exist before asserting the one line.
Two minors from the second gate pass, both stale text and neither a change to
behaviour.

The red-test count went stale INSIDE the paragraph that had just been added to stop
counts going stale. It read "8 of the suite's tests run red on cd221a0", true at
e72dba6 when the suite held 45 tests; the remedy's own added test moved it. Re-derived
against a clean tree: 9 of 47 red under cd221a0's scanner, 38 green. The denominator is
now named, and so is the reason, because this is the second time in one slice.

The module header's residual list said TWO while shape (1) sixty lines above it had
just been corrected to describe a third: the reconciliation compares path sets rather
than the bytes git carries at those paths, so a directory mirroring the tracked names
clears both conditions with decoy contents. That is now listed as a residual in its own
right, with the tracks-nothing case stated as its degenerate form, and with the note
that comparing blobs would be a different and larger rule that is deliberately not
taken here.
@NSchatz

NSchatz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Merging on pass 2's NOT REFUTED. Flagging plainly: commit a84099b (comment and markdown only, the remedy for pass 2's two minors) is UNGRADED. A narrow third pass was launched against that diff alone and never reported after two requests, so both of its claims were re-derived by hand instead. Neither changes what the scanner refuses.

Separately, and NOT fixed here: .github/workflows/ci.yml's banner says the ruleset requires four contexts. It requires seven, confirmed against gh api repos/cosyte/cli/rulesets/19907924. That is a different item.

@NSchatz
NSchatz merged commit 2f9400d into main Aug 7, 2026
8 checks passed
@NSchatz
NSchatz deleted the phi-scan-refuse-an-unobserved-scan-root branch August 7, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant