The shared usage-policy matcher never canonicalizes, so a symlinked spelling of an ignored directory escapes its .hypignore
Found in PR #477's round-2 review. This is a master defect, not PR #477's,
and it is reachable today through several call sites. PR #477 changes which
spelling reaches the Codex gate but neither creates nor closes the class.
The defect
src/core/usage-policy/matcher.js (the resolve entry point):
function resolve(cwd) {
const key = path.resolve(cwd)
...
const dotfileResult = walk(key)
path.resolve is lexical. It normalizes ./.. and makes the path
absolute, but it does not follow symlinks. The ancestor walk therefore climbs
the symlink's parents, not the real directory's, so it never meets a
.hypignore that governs the real location. grep -rn realpath src/core/usage-policy/
returns nothing: no part of this subsystem canonicalizes, on either side of any
comparison.
Because this is the single shared matcher (LLP 0050), every caller inherits it:
the four adapter capture seams, hyp purge, the query-seam visibility filter,
hyp ignore --check, and the local-only list membership test.
Verified by execution (real on-disk symlink, real node:fs, no injection)
$T/work/ignored/.hypignore (class ignore), $T/home/me/link a real
symlink(2) to $T/work/ignored/a/b/c:
resolve($T/work/ignored/a/b/c) -> ignore governedBy $T/work/ignored/.hypignore
resolve($T/home/me/link) -> full governedBy null
Same directory, opposite privacy verdicts. Through the Codex live projector on
origin/master (1555f13) and on PR #477's head, with the same real symlink:
| case |
in-band cwd |
workspaces key |
master |
PR #477 |
| A |
symlink |
canonical |
DROP |
RECORDED |
| B |
canonical |
symlink |
RECORDED |
DROP |
| C |
canonical |
canonical |
DROP |
DROP |
| D |
none |
symlink |
RECORDED |
RECORDED |
Case D is the widest one and neither tree closes it: on the
ChatGPT-subscription route the request states no cwd at all (the route LLP 0083
exists for), so a declared symlinked workspace key is the only value there is and
it goes to the gate uncanonicalized. Cases A and B are a symmetric swap, which is
why PR #477 is not a net regression here.
The @hypaware/claude adapter has no compensating second source at all:
hypaware-core/plugins-workspace/claude/src/projector.js:206 is
const policy = cwd ? resolver.resolve(cwd) : null on the transcript's cwd
verbatim, so a symlinked Claude session cwd under an ignored tree is recorded
unconditionally.
Why the obvious one-line fix is wrong
Canonicalizing only the incoming cwd closes all four cases above (verified:
A/B/C/D all DROP) and causes zero new test failures on origin/master
(2917 pass / 8 fail, the 8 being the pre-existing test/core/leave-command.test.js
set, identical names on a pristine tree). It is still not a safe patch,
because it silently breaks the other resolver source.
matchList compares the canonicalized cwd against path.resolve(entry.dir),
and local_only.js stores whatever path the user supplied (path.resolve, lines
73 and 193, no realpath). So an entry the user marked by its symlink spelling
stops governing. Verified, entry dir = $T/link (a symlink to $T/real/proj):
pristine master one-sided canonicalize
resolve($T/link) -> local-only full
resolve($T/real/proj) full full
local-only to full means the directory starts forwarding. A one-sided
canonicalization trades one leak for another.
What the real fix needs
- Canonicalize both sides: the incoming
cwd and every local-only entry
dir (readListEntriesSync, and the exported isEqualOrDescendant the
hyp ignore --local-only CLI shares per LLP 0069 R8).
- Decide the fate of already-stored list entries written in a non-canonical
spelling (migrate on read, or canonicalize on write in local_only.js, or
both).
- A
realpathSync that fails safe: it throws ENOENT for a directory that
does not exist, which is the normal case for the injected-fs unit tests and
for a cwd that has since been deleted. Falling back to the lexical path is
the behaviour-preserving choice.
- Keep it inside the TTL cache so the cost stays at one
realpath per cwd
per window, the same bound LLP 0049 R6 already sets for the ancestor walk.
- Amend LLP 0049
#scope (the walk is over real paths) and LLP 0071's "Match
semantics" (membership is over real paths), and note it in LLP 0050.
Not in PR #477
PR #477's review deliberately keeps exchange-projector.js byte-identical to
limit its conflict surface against #462 and #474. A fix here is a multi-file
change to the shared privacy matcher with a stored-data migration question, in a
different subsystem from PR #477's diff, so it belongs on its own.
The shared usage-policy matcher never canonicalizes, so a symlinked spelling of an ignored directory escapes its
.hypignoreFound in PR #477's round-2 review. This is a
masterdefect, not PR #477's,and it is reachable today through several call sites. PR #477 changes which
spelling reaches the Codex gate but neither creates nor closes the class.
The defect
src/core/usage-policy/matcher.js(theresolveentry point):path.resolveis lexical. It normalizes./..and makes the pathabsolute, but it does not follow symlinks. The ancestor walk therefore climbs
the symlink's parents, not the real directory's, so it never meets a
.hypignorethat governs the real location.grep -rn realpath src/core/usage-policy/returns nothing: no part of this subsystem canonicalizes, on either side of any
comparison.
Because this is the single shared matcher (LLP 0050), every caller inherits it:
the four adapter capture seams,
hyp purge, the query-seam visibility filter,hyp ignore --check, and thelocal-onlylist membership test.Verified by execution (real on-disk symlink, real
node:fs, no injection)$T/work/ignored/.hypignore(classignore),$T/home/me/linka realsymlink(2)to$T/work/ignored/a/b/c:Same directory, opposite privacy verdicts. Through the Codex live projector on
origin/master(1555f13) and on PR #477's head, with the same real symlink:cwdworkspaceskeymasterCase D is the widest one and neither tree closes it: on the
ChatGPT-subscription route the request states no
cwdat all (the route LLP 0083exists for), so a declared symlinked workspace key is the only value there is and
it goes to the gate uncanonicalized. Cases A and B are a symmetric swap, which is
why PR #477 is not a net regression here.
The
@hypaware/claudeadapter has no compensating second source at all:hypaware-core/plugins-workspace/claude/src/projector.js:206isconst policy = cwd ? resolver.resolve(cwd) : nullon the transcript'scwdverbatim, so a symlinked Claude session cwd under an ignored tree is recorded
unconditionally.
Why the obvious one-line fix is wrong
Canonicalizing only the incoming
cwdcloses all four cases above (verified:A/B/C/D all DROP) and causes zero new test failures on
origin/master(2917 pass / 8 fail, the 8 being the pre-existing
test/core/leave-command.test.jsset, identical names on a pristine tree). It is still not a safe patch,
because it silently breaks the other resolver source.
matchListcompares the canonicalizedcwdagainstpath.resolve(entry.dir),and
local_only.jsstores whatever path the user supplied (path.resolve, lines73 and 193, no
realpath). So an entry the user marked by its symlink spellingstops governing. Verified, entry
dir=$T/link(a symlink to$T/real/proj):local-onlytofullmeans the directory starts forwarding. A one-sidedcanonicalization trades one leak for another.
What the real fix needs
cwdand everylocal-onlyentrydir(readListEntriesSync, and the exportedisEqualOrDescendantthehyp ignore --local-onlyCLI shares per LLP 0069 R8).spelling (migrate on read, or canonicalize on write in
local_only.js, orboth).
realpathSyncthat fails safe: it throwsENOENTfor a directory thatdoes not exist, which is the normal case for the injected-fs unit tests and
for a
cwdthat has since been deleted. Falling back to the lexical path isthe behaviour-preserving choice.
realpathpercwdper window, the same bound LLP 0049 R6 already sets for the ancestor walk.
#scope(the walk is over real paths) and LLP 0071's "Matchsemantics" (membership is over real paths), and note it in LLP 0050.
Not in PR #477
PR #477's review deliberately keeps
exchange-projector.jsbyte-identical tolimit its conflict surface against #462 and #474. A fix here is a multi-file
change to the shared privacy matcher with a stored-data migration question, in a
different subsystem from PR #477's diff, so it belongs on its own.