Skip to content

Usage-policy matcher never canonicalizes: a symlinked spelling of an ignored directory escapes its .hypignore (all adapters, hyp purge, query visibility) #479

Description

@philcunliffe

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

  1. 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).
  2. 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).
  3. 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.
  4. 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.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    neutral:fixDelegate this issue to neutral for an autonomous fix attempt (reproduce -> fix -> PR)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions