Skip to content

Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate - #477

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-476
Jul 30, 2026
Merged

Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate#477
philcunliffe merged 3 commits into
masterfrom
fix/issue-476

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Root cause

hypaware-core/plugins-workspace/codex/src/exchange-projector.js

selectCodexWorkspace picks a workspaces turn-metadata key, falling back to
the first key when none matches the request's cwd:

const workspacePath = workspacePaths.find((key) => pathsEqual(key, cwd)) ?? workspacePaths[0]

resolveCodexContext stamped that result as codexContext.cwd, which is the
one resolved cwd (LLP 0083
"One resolved cwd, used twice") that feeds both the .hypignore gate and the
row. So when the request's real cwd matched no declared workspace, the privacy
verdict was computed for a substituted, unrelated directory, and because
workspacePaths[0] is normally absolute, PR #474's usableInBandCwd predicate
accepts it and never sees the problem. The substitution also runs one layer
ahead of that predicate, so an unusable in-band cwd never reaches it.

The design question, answered

A substituted workspace key is a guess about which directory the session ran
in, and a guess must not decide a privacy verdict.
But it is not worthless: on
the ChatGPT-subscription route the request often carries no cwd at all, and the
key is then the only in-band source of one. Deleting the substitution would
remove real .hypignore coverage.

So the two roles are separated, which is the distinction the defect conflates:
"may we record this row at all" versus "what do we record about it".

Option chosen: option 1 (separate the roles)

An explicit in-band cwd outranks the workspace key for the gate and the
stamp
; the key keeps its enrichment role in full and still supplies the
cwd when the request states none.

cwd: firstString(inBandCwd, workspace?.path),

Chosen over the alternatives:

  • Refuse substitution entirely would delete subscription-route .hypignore
    coverage, which is a net privacy loss. Rejected.
  • Allow the substitution only when it is an ancestor of the real cwd gives
    the same answer in all three reported cases but is worse in the common
    benign one: it keeps gating on the workspace root and so keeps missing a
    deeper .hypignore under the actual cwd. Using the real cwd gates on the more
    specific directory. It also needs a new ancestor helper for no benefit.
  • Option 3, accept and document, leaves a demonstrated leak open.

attributes.codex.workspace, git_remote, git_commit and has_changes are
untouched and still come from the selected key, so no enrichment or graph-bridge
data (LLP 0032#capture) is lost.
selectCodexWorkspace itself is not modified.

Behaviour in the three reported cases

.hypignore of class ignore at /work/ignored.

workspaces request cwd before after
(a) {'/work/clean/proj':{}} /work/ignored/real RECORDED, row cwd=/work/clean/proj DROPPED
(b) {'/work/ignored/proj':{}} /work/clean/real DROPPED RECORDED, row cwd=/work/clean/real
(c) {'/work/ignored/proj':{}} sub DROPPED, silently RECORDED, row cwd=sub, refusal logged

(a) is the privacy leak and it is closed. (b) is the false drop and it is closed.

Fail-open / fail-closed, explicitly

This fix fails OPEN, in case (c), and that is a trade, not a strict
improvement.
Case (c) changes DROP to RECORD. The old drop was not a verdict,
it was luck: the guessed directory happened to be ignored. Refusing the guess
means the honest but unusable sub reaches the gate, where (on this base)
path.resolve measures it against the daemon's own cwd. So this fix trades an
accidental drop for a fail-open record
in the one case where the client states
a cwd we cannot use.

That is precisely the hole PR #474 closes, and the two compose: with #474
also landed, case (c) becomes "substitution refused, sub refused as unusable,
rollout fallback consulted, row recorded with cwd = NULL plus a
usage_policy_cwd_unusable warn". This PR does not duplicate #474's
predicate, to keep the diff off the same lines.

Cases (a) and (b) are strictly better. One further consequence, deliberate and
recorded in the LLP: metadata.cwd from the turn-metadata header now reaches the
gate where only the workspace key did before, so a header-declared cwd under an
ignored tree will now correctly drop. And for a session running in a
subdirectory of its workspace the row stamps the subdirectory rather than the
workspace root, which is the directory the policy is actually scoped to.

I make no claim that this dominates every alternative.

Not silent

A refused substitution emits, at warn level, symmetric with #474's sibling
signal:

plugin.codex.usage_policy_workspace_cwd_refused
  component=codex  operation=usage_policy_workspace_cwd_refused
  error_kind=workspace_cwd_mismatch
  workspace_sha256=<16>  cwd_sha256=<16>  exchange_id=<id>

Paths are hashed, never raw: this seam sees LLM traffic. A test asserts the raw
path does not appear in the fields.

Reproducing tests

All in test/plugins/codex-exchange-projector.test.js:

  1. the .hypignore gate uses the request cwd, not a substituted workspace key (#476 case a)
  2. an unrelated ignored workspace key does not drop a session it never covered (#476 case b)
  3. a refused workspace substitution is logged with hashed paths, not silently applied (#476 case c)
  4. a refused workspace substitution still enriches the row from the workspace key (#476)
  5. the workspace key still supplies the gate cwd when the request states none (#476)
  6. no workspace-cwd refusal is logged when the key matches or the request states no cwd (#476)

1-4 fail on origin/master (# tests 42 / # pass 38 / # fail 4; case (a)
fails with a full projection where USAGE_POLICY_DROP was expected) and pass
after. 5 and 6 encode behaviour that must survive the fix and are the
negative-branch guards.

Mutation checks

Every branch of the new logic was reverted individually:

mutation reddens
cwd: workspace?.path (restore the substitution) tests 1, 2, 3, 4
cwd: inBandCwd (drop the workspace fallback) test 5, plus the pre-existing Codex turn metadata + headers project into first-class columns
drop the !pathsEqual guard test 6
drop the inBandCwd && guard test 6
rename the log message (silence the signal) test 3

Verification

  • npm test: # tests 2885 / # pass 2876 / # fail 8. The 8 failures are all
    test/core/leave-command.test.js and were verified to fail identically on
    a pristine origin/master worktree with the same node_modules symlink
    (baseline: # tests 2879 / # pass 2870 / # fail 8, same 8 names).
  • npm run typecheck: clean.
  • npm run smoke -- gateway_codex_capture: ok.
  • npm run smoke -- session_optout_capture_drop: ok.

Diff size and conflict surface

3 files, +223 / -6. Of that, 38 lines in exchange-projector.js across 4
hunks
, and 16 of those 38 are comment/@ref lines. The functional change is
two expressions plus one log block. 177 lines are tests, 14 are the LLP 0083
amendment.

exchange-projector.js is concurrently modified by PRs #462, #467 and #474,
so the conflict surface was deliberately minimised: selectCodexWorkspace is
not touched, nothing was refactored, reordered or renamed, #474's
usableInBandCwd is not duplicated or moved, and no line either of the held
PRs is likely to rewrite was reformatted. Per the issue, this should land
after #467 and #462 are resolved.

Fixes #476

…e key at the .hypignore gate (#476)

`selectCodexWorkspace` falls back to the first `workspaces` turn-metadata key
when none matches the request's `cwd`, and `resolveCodexContext` stamped that
result as the one resolved `cwd` (LLP 0083) that feeds the `.hypignore` gate. So
when the request's real cwd matched no declared workspace, the privacy verdict
was computed for an unrelated directory: an opted-out session could be recorded
(the leak), and a session no `.hypignore` covered could be dropped.

An explicit in-band `cwd` now outranks the workspace key for the gate and the
stamp. The key keeps its enrichment role and still supplies the `cwd` on the
subscription route, where the request states none and the key is the only
in-band source there is. A refused substitution is reported as
`plugin.codex.usage_policy_workspace_cwd_refused` with hashed paths.

`selectCodexWorkspace` itself is untouched to keep the conflict surface with
PRs #462, #467 and #474 as small as possible.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…view)

Review of PR #477 verified by execution that the fix closes #476 case (a) and
several unreported variants of it, and found two consequences the amendment did
not state:

- the workspace key still outranks the rollout fallback, so a subscription-route
  session that declares a `workspaces` map never consults `session_meta.cwd`
  and a first-key guess can still decide its verdict (true on `master` too);
- because the key keeps enriching, a row recorded where it used to drop (clean
  in-band cwd, ignored declared workspace) carries that ignored workspace's
  identity.

Doc only: no code change, so the projector's contended lines are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…view)

Review of PR #477 verified by execution that the fix closes #476 case (a) and
several unreported variants of it, and found two consequences the amendment did
not state:

- the workspace key still outranks the rollout fallback, so a subscription-route
  session that declares a `workspaces` map never consults `session_meta.cwd`
  and a first-key guess can still decide its verdict (true on `master` too);
- because the key keeps enriching, a row recorded where it used to drop (clean
  in-band cwd, ignored declared workspace) carries that ignored workspace's
  identity.

Doc only: no code change, so the projector's contended lines are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Review of PR #477 verified by execution that the fix closes #476 case (a) and
several unreported variants of it, and found three consequences the amendment
did not state:

- the workspace key still outranks the rollout fallback, so a subscription-route
  session that declares a `workspaces` map never consults `session_meta.cwd`
  and a first-key guess can still decide its verdict (true on `master` too);
- because the key keeps enriching, a row recorded where it used to drop (clean
  in-band cwd, ignored declared workspace) carries that ignored workspace's
  identity;
- the gate does not canonicalize, so a symlinked in-band spelling of an ignored
  directory is now recorded where the key's canonical spelling used to drop it.

Doc only: no code change, so the projector's contended lines are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 1 of 2

Reviewed head d3a6afc. Everything below was verified by execution against the
real projector and the real shared matcher (createUsagePolicyResolver), in a
detached worktree with a node_modules symlink, not by reading the diff.

Verdict: findings. The central claim holds: case (a) is closed, and closed
more broadly than the PR body advertises. Nothing found is a reason to hold the
PR, and the land order it names (after #467 and #462) is right. One doc-only
commit pushed; four items are left for a human, each a design decision, spelled
out at the end.

1. Case (a) is closed, and so are four variants the body does not mention

.hypignore of class ignore at /work/ignored, one exchange each, injected
existsSync/readFileSync, ttlMs: 0:

scenario workspaces in-band cwd master d3a6afc
(a) /work/clean/proj /work/ignored/real RECORDED cwd=/work/clean/proj DROP
(b) /work/ignored/proj /work/clean/real DROP RECORDED cwd=/work/clean/real
(c) /work/ignored/proj sub DROP, silent RECORDED cwd=sub + refusal warn
nested keys, cwd under the ignored one /work/clean/outer, /work/ignored /work/ignored/deep/dir RECORDED DROP
trailing slash on the real cwd /work/clean/proj /work/ignored/real/ RECORDED DROP
dot segments /work/clean/proj /work/ignored/real/../real RECORDED DROP
turn-metadata header cwd /work/clean/proj header cwd=/work/ignored/real RECORDED DROP
body metadata.cwd /work/clean/proj metadata.cwd=/work/ignored/real RECORDED DROP
non-first key matches /work/clean/a, /work/ignored/real /work/ignored/real DROP DROP
prefix-not-ancestor /work/ignored /work/ignored-other/real DROP (false) RECORDED

Four rows besides (a) flip from a leak to a correct drop, because pathsEqual
is byte equality after a trailing-slash trim, so every non-identical spelling of
the real cwd used to hand the gate the substituted key. The
prefix-not-ancestor row confirms the matcher's walk is segment-aware
(path.dirname), so /work/ignored-other was a false drop and is now correct.

On the header-cwd question (undisclosed change 1): it cannot cause a wrong
drop that the body cwd would not also cause.
readRecordedCwd and the
header cwd feed one firstString, body first, so the header only decides when
the body states nothing; before, that case fell to the key. Every value that now
reaches the gate is a directory the client itself declared for this turn.

Row-stamp change (undisclosed change 2) is contained. attributes.codex.workspace,
git_remote, head_sha, has_changes still come from the selected key
(confirmed on a projected row, not only via the new test 4); repo_root is
deliberately not derived (LLP 0032 #codex-repo-root). The only cwd-keyed
consumer is listCapturedDirectories (src/core/commands/local_only.js:22),
which groups by exact cwd with no ancestor collapsing, so a subdir session now
surfaces in the privacy survey as the subdir instead of the workspace root. That
is how @hypaware/claude rows already behave, so this makes Codex consistent
rather than novel. No existing test encoded the old value: the only test pinning
the pair ("Codex workspace selection prefers recorded cwd over first metadata
key") uses cwd == key, and gateway_codex_capture's
r.cwd === codexWorkspace assertion survives because that fixture states no
in-band cwd.

2. Findings

F1. MEDIUM (privacy, not closed by this PR, also true on master).
hypaware-core/plugins-workspace/codex/src/exchange-projector.js:123-124,
with :733.
The refusal only outranks the key with an explicit in-band
cwd. The key still outranks the rollout fallback, because it is folded into
codexContext.cwd before the ??. So on the subscription route, the route
LLP 0083 exists for, a session that declares a workspaces map never consults
session_meta.cwd, and a first-key guess still decides the verdict:

workspaces in-band cwd rollout session_meta.cwd master d3a6afc
/work/clean/proj none /work/ignored/real RECORDED cwd=/work/clean/proj RECORDED cwd=/work/clean/proj
none none /work/ignored/real DROP DROP
/work/clean/a, /work/ignored/proj none (none) RECORDED cwd=/work/clean/a RECORDED cwd=/work/clean/a

Row 1 versus row 2 is the finding: adding a workspaces header to an otherwise
identical, correctly-dropped exchange turns the drop into a record. This is the
same class as (a) reached through the one door the fix leaves open, and it is
pre-existing, so it is a gap rather than a regression. Recorded in LLP 0083 by
the commit below; the code decision is D1.

F2. MEDIUM (privacy, new in this PR, a net loss versus master for this
input). exchange-projector.js:733.
The gate does not canonicalize, so which
spelling reaches it now matters. Real filesystem, .hypignore at
$T/work/ignored, $T/home/me/link a symlink to $T/work/ignored/a/b/c:

in-band cwd workspaces key master d3a6afc
$T/home/me/link (symlink) $T/work/ignored/a/b/c (canonical) DROP RECORDED
$T/work/ignored/a/b/c $T/home/me/link RECORDED DROP

The symlink jumps depth, so the ancestor walk from the symlinked spelling never
passes $T/work/ignored and misses the .hypignore; the row is recorded with a
cwd that resolves into the opted-out tree. It is the same shape as the case (c)
trade the PR does own (refusing a guess costs the drops the guess reached by
luck), but it is a leak rather than a NULL-cwd record, and the body does not name
it. Row 2 shows it is symmetric, not one-directional: neither spelling is
authoritative today. Decision D2.

F3. LOW-MEDIUM (privacy, new). exchange-projector.js:715 with :733.
Keeping enrichment "in full" while taking the gate cwd back means a row that is
now recorded where it used to drop can carry an ignored directory's
identity. Case (b), projected row, verbatim:

cwd: "/work/clean/real"
git_remote: "https://github.com/a/b.git"     <- from /work/ignored/proj
attributes.codex.workspace: "/work/ignored/proj"

The gate is scoped by cwd (LLP 0049 #scope), not by enrichment source, so
this is consistent with the design; it is simply a consequence nobody wrote down,
and the PR's own test 4 avoids it by using a clean workspace key. Decision D3.

F4. LOW (observability). exchange-projector.js:128-137. The refusal
predicate is "not byte-equal", so it fires for the ordinary shape "session
running in a subdirectory of its workspace" and emits a warn on every
turn
of such a session (verified: cwd=/work/proj/sub, key /work/proj,
recorded, warn emitted). #474's sibling signal fires only on genuinely
unusable values, so the claimed symmetry does not extend to frequency. Decision
D4.

F5. INFO. A whitespace-only in-band cwd (" ") is a second instance of
the case (c) fail-open that the body does not list: master dropped it on the
ignored key, d3a6afc records it with cwd=" ". Same argument as (c), and
#474's usableInBandCwd refuses it as cwd_blank, so it composes away.

F6. INFO. Once both land, PR #474's LLP 0083 paragraph still says the #476
gap is open ("an absolute-but-unrelated directory can still reach the gate
(#476)"). Whoever merges second should trim that sentence. Not resolved here, per
the brief.

F7. INFO, not this PR's. llp/0083-...decision.md still contains 11 U+2014
characters in pre-existing prose, on lines 12, 21, 33, 36, 44, 54, 55, 68, 101
and 106. #468 only normalized @ref glosses, and the merged hygiene gate only
checks those, so this is a standing CLAUDE.md violation on master. The 16 lines
this PR adds contain none.

3. Claims I re-derived

  • Mutation table: all five reproduce exactly as stated. cwd: workspace?.path
    reddens tests 1-4; cwd: inBandCwd reddens test 5 plus "Codex turn metadata +
    headers project into first-class columns"; dropping !pathsEqual reddens test
    6; dropping inBandCwd && reddens test 6; renaming the log message reddens
    test 3. Baseline 43/43.
  • #474 composes, and the two merge cleanly. Merging fix/issue-471 into
    this head auto-merged with no conflict, including both LLP 0083 amendments
    to the same decision list. Case (c) on the merged tree: RECORDED,
    cwd = undefined (NULL column), with both
    plugin.codex.usage_policy_cwd_unusable and
    plugin.codex.usage_policy_workspace_cwd_refused. That is precisely the
    composed behaviour the body claims. Cases (a) and (b) are unchanged by the
    merge, and F1's row 1 still records, so the composition does not close it.
  • Case (c)'s reasoning holds. The old drop was the guessed key's verdict, not
    the session's; sub resolves against the daemon's own cwd, so the old drop was
    correct only while the daemon happened to run under an ignoring tree.
    Fail-open here is the honest answer, and Codex live projector: an unusable in-band cwd is a miss, not a path (#471) #474 turns it into a NULL cwd.
  • New logging is clean. The refused path appears nowhere raw: not in the
    log fields (asserted over the serialized fields), not in the projected row
    (refused_workspace_cwd is never spread into the projection; every field is
    picked by name). Digest idiom is identical to the adjacent drop log
    (sha256Hex(...).slice(0, 16)), and both carry cwd_sha256 for the same
    resolved cwd, so the two correlate. No prompt or payload text reaches the
    fields.
  • Ref hygiene passes. test/core/llp-ref-hygiene.test.js is on master but
    not on this branch's base (c551d6e), so I ran it on master merged into this
    branch: 8 pass, 1 skip, 0 fail, and the merge itself was clean.
    LLP 0083#decision resolves (## Decision).
  • npm test: 2885 / 2876 pass / 8 fail, the 8 all test/core/leave-command.test.js,
    identical names on a pristine origin/master worktree with the same symlink.
    npm run typecheck clean. npm run smoke -- gateway_codex_capture ok.
    npm run smoke -- session_optout_capture_drop ok.
  • Style: the diff adds no U+2014, no trailing semicolons, no @typedef, no
    inline import('...') types.

4. What I changed

One commit, doc only, pushed to fix/issue-476: 0db67e1,
llp/0083-codex-live-cwd-from-rollout.decision.md +16 lines, recording F1, F2
and F3 as three named limits of the amended decision. Deliberately no code:
exchange-projector.js is byte-identical to d3a6afc, so the conflict surface
against #462/#467/#474 is unchanged (re-verified: fix/issue-471 still
auto-merges into the pushed head with no conflict). Verified with
git diff d3a6afc..0db67e1 --stat (one file, +16, 0 deletions), the hygiene test
re-run on the merged tree, and 43/43 on the projector suite.

5. Decisions for a human

  • D1 (F1). Should the workspace-key guess rank below the rollout, not
    above it? The change is cwd: inBandCwd at :733, a new
    workspace_cwd: workspace?.path field, and a third ?? codexContext?.workspace_cwd
    term on the gate expression at :123-124, so the order becomes in-band,
    rollout, key. Test 5 stays green (it configures no rollout). Not done here for
    two reasons, both for you to overrule: it is outside the option-1 menu issue
    Codex live projector: the workspace-key substitution can feed the .hypignore gate a directory the session never ran in #476 offered (the rollout is not "an explicit request cwd"), and :123-124
    is the exact line Codex live projector: an unusable in-band cwd is a miss, not a path (#471) #474 rewrites and One reader for the Codex session_meta header, in core (#465) #466 will predicate. Either take it as a
    follow-up issue, or accept and leave LLP 0083 saying so.
  • D2 (F2). Canonicalize, or accept? Canonicalizing belongs in the shared
    matcher (LLP 0050, one matcher for all four adapters), costs a realpath per
    cache miss, and would change Claude's behaviour too. Either file it against
    the matcher, or accept the spelling trade as documented.
  • D3 (F3). Should enrichment from a workspace key that is itself
    .hypignore-ignored be suppressed on a recorded row? Correct-looking but costs
    a second resolver lookup per exchange and drops graph-bridge identity (LLP 0032
    #capture) for multi-root sessions. Suppress, or accept as documented.
  • D4 (F4). Keep the refusal at warn for symmetry with Codex live projector: an unusable in-band cwd is a miss, not a path (#471) #474, or demote to
    info because the common subdirectory session trips it on every turn? Demoting
    is one word plus moving test 3's assertion to the info channel.

None of D1-D4 blocks landing. F1 and F2 are the two I would not leave
undocumented, and they now are documented.

…ssues (#476 review round 2)

Round 2 of the #477 review filed each residual as its own issue so it does not
live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd,
pre-existing), #481 (a newly-recorded row carries an ignored workspace's
identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling
of an ignored directory escapes its .hypignore).

Also corrects the third limit. It was written as a trade this amendment makes;
execution against a real on-disk symlink shows it is a property of the shared
matcher that predates the amendment, that the amendment swaps which of two
symmetric spellings trips it rather than opening a new leak, and that the widest
case (a declared symlinked key with no in-band cwd at all) is identical before
and after. Notes that the matcher fix must canonicalize the local-only list
entries too, since canonicalizing only the incoming cwd un-governs an entry a
user marked by its symlink spelling.

Doc only: exchange-projector.js stays byte-identical, so the conflict surface
against #462 and #474 is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 2 of 2 (final)

Reviewed head 0db67e18. Everything below was established by execution, not
by reading: the real projector, the real shared matcher
(createUsagePolicyResolver), and for the symlink question a real symlink(2)
on disk
with real node:fs, no injection. Three worktrees throughout:
origin/master (1555f13) as control, 0db67e18, and the two merged.

Verdict: findings. Nothing found is a reason to hold the PR, and the central
claim holds: an explicit in-band cwd now outranks a substituted workspace key
at the .hypignore gate, closing case (a) plus the four spelling variants round 1
found. One doc-only commit pushed (fc521d0). Round 1's headline concern is
withdrawn on the evidence
: F2 is not a net loss versus master. Every residual
is now filed as its own issue (#479, #480, #481) rather than living only in an LLP
paragraph, since triage classifies but does not fix.


1. F2, the symlink leak: reachable, but NOT a net regression

Reachable: yes. $T/work/ignored/.hypignore (class ignore),
$T/home/me/link a real symlink to $T/work/ignored/a/b/c. The matcher alone,
real fs:

resolve($T/work/ignored/a/b/c) -> ignore   governedBy $T/work/ignored/.hypignore
resolve($T/home/me/link)       -> full     governedBy null

Same directory, opposite verdicts. path.resolve is lexical, so the ancestor
walk climbs the symlink's own parents and never meets the governing file. Through
the projector, same symlink:

case in-band cwd workspaces key origin/master 0db67e18
A symlink canonical DROP RECORDED
B canonical symlink RECORDED DROP
C canonical canonical DROP DROP
D none symlink RECORDED RECORDED

Round 1 measured A only, and concluded "a net loss versus master". That is
wrong.
A and B are a symmetric swap: this PR closes the case where the
key held the non-canonical spelling and opens the case where the request
does. And case D, the widest of the three, is identical before and after: on
the ChatGPT-subscription route the request states no cwd at all (the route LLP
0083 exists for), so a declared symlinked key is the only value there is and it
reaches the gate uncanonicalized on both trees. Net across the class this PR is a
wash, not a regression.

Which of A/B is likelier is the real question, and it favours the PR. POSIX
getcwd(2) always returns a fully canonical path, so any cwd a process reads
from its own kernel state is canonical. Non-canonical spellings reach this code
only from declared paths: workspaces turn-metadata keys, local-only list
entries a user types, hyp ignore targets. That is case B (fixed here) and case
D (untouched). Case A needs the client to state a cwd that did not come from
getcwd, such as a --cd-style flag echoed verbatim. The one thing I could
not verify by execution:
whether Codex ever sends such a value, since there is
no Codex binary here. That is the crux of round 1's D2 and it is now stated in
the issue rather than guessed at.

Not fixable in this PR, and now demonstrably so. I built the fix and measured
it. Canonicalizing the incoming cwd in matcher.js:resolve closes all four
cases (A/B/C/D all DROP) with zero new test failures on origin/master. It is
still the wrong patch, because it breaks the resolver's other source.
matchList compares the canonicalized cwd against path.resolve(entry.dir),
and nothing in src/core/usage-policy/ calls realpath on either side
(grep -rn realpath is empty). Verified, entry dir a symlink to $T/real/proj:

                        pristine master      one-sided canonicalize
resolve($T/link)   ->   local-only           full

local-only to full means the directory starts forwarding. A one-sided fix
trades one leak for another. The correct fix canonicalizes both sides, touches
readListEntriesSync and the exported isEqualOrDescendant the hyp ignore --local-only CLI shares (LLP 0069 R8), carries a stored-data migration question,
and needs LLP 0049 #scope / 0071 / 0050 amendments. Different subsystem from
this PR's diff, so it does not belong here.

Filed as #479, neutral:fix only, deliberately not neutral:stuck: the
subject code (src/core/usage-policy/matcher.js) is on master and leaks there
today, both via cases B and D and via @hypaware/claude, whose
projector.js:206 is resolver.resolve(cwd) on the transcript cwd verbatim with
no compensating second source. So it is immediately actionable and the
branch-only rule does not apply. Overrule me if you disagree.

2. F1: confirmed pre-existing, byte-identical, now tracked

exchange-projector.js:130-131 with :723. Injected rolloutCwd that counts
its calls, session_id present so the fallback is genuinely eligible:

# workspaces in-band cwd rollout session_meta.cwd verdict rollout consulted
1 {/work/clean/proj} none /work/ignored/real RECORDED cwd=/work/clean/proj false
2 none none /work/ignored/real DROP true

Identical on origin/master, on 0db67e18, and on the two merged. Rows 1 and 2
differ in nothing but the presence of a workspaces header, so adding one to a
correctly-dropped exchange turns the drop into a record. rollout_consulted=false
is the mechanism: the key is folded into codexContext.cwd before the ??, so
the rollout is never asked. Not created and not widened by this PR. Round 1's
D1 patch shape is carried into the issue. Filed as #480, neutral:fix (the
defect is on master), with the #477/#474/#462 overlap on those exact lines noted
in prose so a worker sequences rather than opening a fourth concurrent edit.

3. D3 and D4: verified, left unfixed, filed as #481

Both confirmed by execution, both branch-only, so #481 carries
neutral:fix and neutral:stuck naming #477 as the blocker.

D3, LOW-MEDIUM, privacy (identity, not content). :715 with :733. The case
this PR newly records produces a row naming the opted-out tree:
cwd=/work/clean/real, attributes.codex.workspace=/work/ignored/proj,
git_remote=git@github.com:acme/SECRET-REPO.git, head_sha=cafebabe, where
origin/master DROPPED and no row existed. Documenting it is not quite
enough
, which is why it is filed: only the identity leaks, never content, and
it is defensible by design (the gate is scoped by cwd, LLP 0049 #scope, not by
enrichment source), but the row is new surface. Decision in #481: suppress
enrichment from a key that itself resolves to ignore, or accept. Note this PR's
own test 4 pins the opposite behaviour using a clean key, so it does not
constrain the choice.

D4, LOW, observability. :125-137. Confirmed: workspaces={/work/proj},
in-band cwd=/work/proj/sub, both clean, no .hypignore anywhere, nothing
privacy-relevant happening, and the branch emits 1 refusal warn per turn
against 0 on master. I did not demote it, despite it being one word. #474
modifies this file and was mid conflict-resolution during this review, and #462
touches it too; round 1 bought a byte-identical exchange-projector.js
specifically to keep that surface small, and a log-level change on a non-leak does
not justify spending it. #481 offers two fixes: demote to info, or keep warn
and skip the benign ancestor case using isEqualOrDescendant rather than a second
copy of the path logic.

4. Semantic drift against the moved master

master advanced 64 commits (#466, #467 among them). Checked, all clean:

5. Gates

6. What I changed

One commit, doc only: fc521d0,
llp/0083-codex-live-cwd-from-rollout.decision.md, +23 / -14.
exchange-projector.js is byte-identical to both 0db67e18 and d3a6afc
(git diff --stat empty for that path against both), so the conflict surface
against #462 and #474 is unchanged from round 1. The commit makes each stated
limit cite its issue (#479, #480, #481) and corrects the third limit, which round
1 had written as a trade this amendment makes; on the evidence above it is a
pre-existing matcher property, the amendment swaps which spelling trips it, and
the widest case is unchanged. Verified by
git diff 0db67e18..fc521d0, re-running the hygiene test and the full suite on
the merged tree, and the smoke.

7. What a human still has to decide

Nothing blocks landing. The land order round 1 named (after #467 and #462) still
holds. Three decisions, each now an issue with the options and the measurements:

Not re-verified, needs a recheck: the composition with #474. Round 1
verified it by a real merge, but #474 was being conflict-resolved in parallel
while I worked and its head is moving, so re-checking against it would have
measured a branch that no longer exists. Whoever settles #474 should re-run the
composed case (c) and confirm the NULL-cwd-plus-both-signals behaviour, and trim
#474's LLP 0083 sentence that still says the #476 gap is open.

This was round 2 of 2. The PR is still a draft; I did not mark it ready, did
not merge, and did not edit the PR body.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage of PR #477 at head fc521d06 (LLP 0017 rung; 2-round review budget exhausted, draft, CI green, MERGEABLE).

Round 2's commit (fc521d06, "LLP 0083: the stated limits of the workspace-key refusal cite their issues") is doc-only (llp/0083-codex-live-cwd-from-rollout.decision.md, +23/-14; confirmed by git diff d3a6afc fc521d06 -- .../exchange-projector.js = empty) and had never been reviewed. I read and independently verified it.

Verdict on the round-1-vs-round-2 reversal: round 2 is correct. I did not take its word for it — I built real on-disk symlinks (fs.symlinkSync, real node:fs, no injected stubs) and ran the actual createCodexExchangeProjector end to end on both origin/master (1555f13) and this PR's head:

scenario master PR #477
symlinked workspace key, canonical request cwd (real subdir of the ignored root) RECORD (leak) DROP
canonical workspace key, symlinked request cwd DROP RECORD (leak)
widest case: symlinked key, no in-band cwd at all (subscription route) RECORD (leak) RECORD (leak)

This is exactly the symmetric swap the doc claims, not a net loss: the PR closes the case where the declared workspace key carries a non-canonical spelling and opens the case where the request's own cwd does. The reason this favors the branch is structural, not hopeful: readRecordedCwd/turn-metadata cwd are populated from the client's own process cwd, and POSIX getcwd(3) is documented to return the resolved path with no symlink components — a real Codex client's in-band cwd should already be canonical, so the "opens" row above requires a client that reports its cwd through some path other than a real getcwd() call, which is not how codex-tui/std::env::current_dir() behaves. Workspace keys, by contrast, come from user/config-declared paths and have no such guarantee. The widest case (symlinked key, subscription route with no request cwd) leaks identically before and after — that's the pre-existing shared-matcher defect (#479), not this PR's.

I also spot-checked the four round-1 "master leaks" variants by direct execution (real projector, stub resolver): trailing-slash, dot-segment, and metadata.cwd-header mismatches all RECORD (leak) on master and DROP correctly on this PR's head; case (a)/(b)/(c) tests (all 43 in test/plugins/codex-exchange-projector.test.js) pass at the head.

LLP 0083 text at fc521d06: accurate. No "net loss" language remains (removed vs the round-1 framing). It correctly cites all three filed follow-ups (#479 shared-matcher canonicalization, #480 workspace-header-preempts-rollout, #481 enrichment identity + refusal-warn noise) with descriptions that match each issue's actual content and labels.

Residuals — confirmed accurately described and correctly labelled, not re-filed:

None of the three is mislabelled.

D3 / enrichment-identity leak (#481 D1) — judged non-blocking, tracking in #481 is sufficient. It leaks only identity (a workspace path string, a redacted-userinfo git remote, a commit sha), never session content, and only in a narrow multi-workspace shape (a clean real cwd alongside a different, ignore-flagged declared workspace key). On master this exact row never existed at all — but only because master over-drops it (case (b): master would have wrongly suppressed a legitimate, non-ignored session). The counterfactual that matters here: holding this PR back to close a low-severity identity leak leaves case (a) — a full content record of a session that should have been dropped — shipping in production. That trade isn't close.

#474 composition re-verified at its current head (ce76b56c, moved since round 1's merge check): merged fc521d06 with origin/master then with ce76b56cbce4cf33e77a5419c034564c655a0275 — auto-merge, zero conflicts, all 58 tests in test/plugins/codex-exchange-projector.test.js pass. Directly executed the composed case (c): a relative in-band cwd + a non-matching declared workspace key produces a recorded row with cwd = undefined and both warns (plugin.codex.usage_policy_cwd_unusable error_kind=cwd_not_absolute, and plugin.codex.usage_policy_workspace_cwd_refused error_kind=workspace_cwd_mismatch) — matches round 1's verified composition. The two LLP 0083 amendments (#474's usableInBandCwd bullet and #476/#477's substituted-workspace-key bullet) coexist as separate bullets with no contradiction. (Note for whoever merges #474 second: its own LLP 0083 paragraph still says "an absolute-but-unrelated directory can still reach the gate (#476)" — stale once #477 is in; #481 already flags this as cleanup for the second PR to land, not this one's problem.)

Ref hygiene: test/core/llp-ref-hygiene.test.js merged with current origin/master (1555f13): 8 pass, 1 skip (pre-existing, unrelated to #476/#477 — issue #463 item 1), 0 fail.

test/core/leave-command.test.js: 3 pass / 8 fail identically on this PR's head and on a pristine origin/master worktree with the same node_modules symlink — not this PR's, matches the PR's own verification claim.

Every residual finding here is non-blocking. Follow-ups already exist (#479/#480/#481); no new issue filed.

@philcunliffe
philcunliffe marked this pull request as ready for review July 30, 2026 05:42
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 30, 2026
@philcunliffe
philcunliffe merged commit bcad4a4 into master Jul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-476 branch July 30, 2026 17:12
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
Three files conflicted. Each resolution is a union of both intents, not a
side-picking, because the two sides changed different things about the same
subject (Codex session identity and the opt-out caveat).

session_command.js: keep this branch's EPHEMERAL_NOTE constant (#455: the
caveat names the fork as well as the restart) and master's object-argument
provenanceNotes({ idSource, idEvidence, threadId, endpointSource }) call. Only
one of the two call sites conflicted; the other already carried the combination,
so this makes the two branches of the if/else identical again.

types.d.ts: keep master's prose, which is the correct one now - it explains
sessionId as the session container versus threadId, and the merged union already
carries master's rename of codex_env to codex_env_rollout, which this branch's
prose still called codex_env. Re-attached this branch's gloss on the
@ref LLP 0067#cli-session-id annotation, which master had left bare. Anchor
{#cli-session-id} verified present at llp/0067:305.

codex privacy skill SKILL.md: this branch's rollout-selection policy wins (match
payload.cwd, refuse on zero/ambiguous/stale, never newest-by-mtime - issue #452),
since master's side still said "pick the newest rollout". Folded master's
readRolloutMeta-mirroring guards into that same walk rather than dropping them:
the session_meta type check and payload-dict check (as skips, since this side
walks many files), plus the non-string and whitespace-in-id refusals, which this
side needs too because it reads the result through `read -r`. Took master's
`hyp session ignore --json` over the bare verb.

Semantics checked, not just textual cleanliness:

- The claim "the gateway's drop keys on the container" is STILL TRUE after
  PR #477. exchange-projector.js line 113 is unchanged:
  `const sessionId = stringValue(codexContext?.session_id) ?? conversationId`,
  i.e. metadata.session_id falling back to the conversation (thread) id, exactly
  as the skill prose and the test comment describe. #477 (bcad4a4) only changed
  the separate .hypignore gate, which keys on a cwd path, not on any session id.
  So no correction was needed there.
- The three-way agreement still holds between the CODEX_THREAD_ENV comment in
  session_command.js (master renamed it from STATED_SESSION_ID_VARS), the skill's
  Step 1 prose, and the drop code: thread id is a selector, the container is the
  answer.
- Corrected one thing that HAD gone stale: this branch's prose said issue #453
  "puts CODEX_THREAD_ID to its real use" in the future tense, but #453 shipped on
  master (5d270a5, c551d6e). The prose now describes the codex_env_rollout source
  as live, matching llp/0067:317 ("selector, not an answer") and llp/0067:585.
- The fork caveat agrees across all three surfaces it appears on: EPHEMERAL_NOTE,
  the Codex skill, and the Claude skill (`claude --fork-session` / `codex fork`).

The usage-policy unification (#482/#484, fold(realpath(p))) touches no file on
this branch and needed no reconciliation here.

Checks: npm test 3041 tests, 8 failures, all of them the pre-existing
test/core/leave-command.test.js "leave ..." set, name-for-name identical to a
pristine origin/master run (3037 tests, same 8). Test count rose by 4, which is
this branch's new tests passing. npm run typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
A semantic merge, not a textual one: master reworked the same Codex cwd path
under this branch's feet (#466 LLP 0150, #467 LLP 0151, #474, #477), so the
conflicts had to be resolved on what the combined behaviour means, not on which
side's hunk looked cleaner.

Four files, and what each side wanted:

- exchange-projector.js: master wrapped the in-band cwd in `usableInBandCwd`
  (#474) and added the refused-workspace warn (#477), both around the very
  expression this branch replaced. Kept both, with this branch's
  `resolveRolloutCwd` as the fallback rather than master's inline
  `rolloutCwd.resolve(session_id)`, which is the container key #459 is about.

- rollout-cwd.js: master replaced the local first-line read with core's one
  `readRolloutSessionMeta` (LLP 0150); this branch added a thread-identity guard
  on top of that read. Composed rather than chosen: the guard now compares
  `meta.threadId` from the shared reader. The two fit exactly, because LLP 0150
  rule 1 (raw JSONL line, never Codex's `Deserialize`) is the property the guard
  depends on to see an absent `payload.id` as absent. `meta.cwd` also arrives
  pre-predicated by `sessionMetaCwd`, so a blank or relative rollout cwd is now
  refused here too. Cache key stays the thread id.

- LLP 0083: took master's Context correction and its unusable-in-band bullet,
  kept this branch's thread-keying thesis over master's superseded "keyed on the
  codex session id" bullet, and reconciled the prose that #467 falsified: the
  thread now comes from the body's `client_metadata`, not from `thread-id` /
  `session-id` header names Codex never emitted. The Consequences bullet
  promising a shared-reader follow-up was stale (that fold has landed) and now
  says so.

- test/plugins/codex-rollout-cwd.test.js: git merged this file cleanly and the
  result was wrong in both directions, which is the part worth reading.
  Master's #257 fixtures key the fake resolver on the session id while stating a
  distinct thread id, so thread keying missed; rekeyed onto the thread id, which
  keeps master's deliberately-distinct pair. More seriously, this branch's #459
  fixtures state identity through the bare `session-id` / `thread-id` /
  `parent-thread-id` headers, which #467 established are names no Codex version
  emits and removed the reads for. Left alone, four leak-direction tests failed
  outright and the refusal tests would have passed VACUOUSLY, for want of any id
  rather than because a refusal fired, silently gutting the gate. Ported the
  fixtures to the body `client_metadata` surface (LLP 0151), assertions
  unchanged.

Checked, not assumed:

- Regression gate still bites: master's two source files under this merged test
  file fail 11 of 23, including every #459 leak-direction case and all four
  refusal cases, so the ported fixtures are not vacuous.
- `npm test`: 3039 pass / 8 fail, exactly the `leave-command` 8 that fail
  identically on a pristine `origin/master` worktree (73b4618), by name.
- `npm run typecheck`: clean. No em dashes, no semicolons in changed lines. The
  LLP anchors cited (0150#usable-cwd, 0151#body-is-authority,
  0083#container-fallback-gap) all resolve.

Not touched, deliberately: the open `subagent_signal` finding at
`resolveRolloutCwd`. The refusal is still value-blind and its shape is
unchanged, but #467 narrowed its reachability, since a turn now has to carry
neither a Codex-owned `client_metadata` map nor a turn-metadata blob to reach
the container fallback at all. LLP 0083 records that narrowing without
pretending it closes the question.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Jul 31, 2026
… finding 2 of 2) (#491)

* Codex workspace-cwd refusal is an ancestor test, not a byte test (#481)

The refusal predicate was "a `workspaces` key was substituted and it is not
byte-equal to the in-band cwd", so the completely ordinary shape "a session
running in a subdirectory of its declared workspace" emitted
`plugin.codex.usage_policy_workspace_cwd_refused` at `warn` on every single
turn, with both directories clean and no `.hypignore` anywhere. A privacy warn
that fires constantly on the common case is read as noise, and the signals
beside it are read as noise with it.

Narrow it to keys off the in-band cwd's ancestor chain. The justification is
not "close enough": when the key is an ancestor, the cwd's `.hypignore` walk
passes through the key and every machine-local entry governing the key also
governs the cwd, so resolving the cwd is at least as restrictive as resolving
the key would have been. The refusal can only tighten, so there is nothing to
report. Off the chain the two walks are incomparable, in both directions - a
sibling tree, and a key BELOW the cwd whose own walk covers strictly more - and
those still warn.

The test is the shared `isEqualOrDescendant` (LLP 0069 R8), not a second copy
of the path rule. Lexical rather than spelling-agnostic on purpose:
`scopeGoverns` buys its extra reach with realpath syscalls this per-exchange
seam must not spend (LLP 0049 R6), and the residue errs toward reporting.

LLP 0160 records the decision, the ancestor-monotonicity argument, and what is
now stale in LLP 0083's #476 limit sentence; LLP 0083 gains the forward-ref.

Does NOT address the other finding deferred from PR #477: a row recorded where
it used to drop still carries an ignored workspace's identity through the key's
surviving enrichment role. That is a privacy-relevant default the corpus does
not settle, and it stays open under #481.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review: the ancestor test's justification was a false monotonicity proof

The behaviour is kept; the reason given for it was wrong and is replaced.

LLP 0160, the `workspaceCoversCwd` JSDoc and the warn-site comment all
claimed that when the key is an ancestor of the in-band `cwd`,
`resolve(cwd)` is at least as restrictive as `resolve(key)`, so refusing
the substitution "can only tighten". That is false on this resolver.
Nearest-governs means a declaration between the key and the `cwd`
overrides the key's and may be less restrictive. Swept against the real
`createUsagePolicyResolver` over `.hypignore` bodies and machine-local
list classes at four depths on one ancestor chain: 131 of 576
arrangements resolve the `cwd` LESS restrictively than the key, spanning
ignore->local-only, local-only->full and ignore->full.

Restated on the ground that actually holds: an ancestor key is not a
guess about where the session ran, it is a less specific name for the
same tree, and nearest-governs makes the `cwd`'s own declaration
authoritative. So the signal reports the location inference, not a
verdict change - and the residue (an ancestor key resolving more
restrictively than its `cwd`, now silent) is disclosed rather than
implicitly denied, and pinned by a test.

Also corrected:
- the symlink residue cited #479 as an open gap; #479 was closed for the
  gate by #482/#484. It is knowingly retained here, at this
  reporting-only predicate, and the converse direction (a lexical
  descendant that is really a symlink out of the tree reads as covered,
  so stays silent) was undisclosed.
- LLP 0160 and LLP 0083 pointed the undecided enrichment question at
  #481, which this PR closes. It is split out to #492.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review round 2: purge the disproved monotonicity claim from the places round 1 missed, and ground the retained justification by execution

Round 1 replaced the false "an ancestor key can only tighten" proof in LLP 0160
§decision and in the `workspaceCoversCwd` JSDoc, but the same claim survived in
three other places, one of them LLP 0160's own abstract, where it contradicted
the §decision body directly:

- `llp/0160-...md:13` - the summary blockquote still said "An ancestor key
  cannot have changed the `.hypignore` verdict".
- `llp/0083-...md:144` - the `Extended-by` block said the same.
- `test/plugins/codex-exchange-projector.test.js` - the `@ref LLP 0160#decision
  [tests]` block said "taking its own `cwd` over an ancestor key can only
  tighten the verdict". A ref that states a disproved premise is exactly what
  CLAUDE.md's "keep refs honest" rule is for.

All three now state the ground that actually holds, and say explicitly that it
is NOT a monotonicity argument.

Round 1 also carried the disposition on a reasoned claim: "in every one of the
131 cases the loosening is the user's own nested declaration". Two changes:

1. A better, EXECUTED ground is now stated and pinned by a test. The warn this
   PR narrows carries no usage class at all. On `origin/master` it fires with an
   identical field set on a subdirectory turn with no `.hypignore` anywhere, on
   one where key and `cwd` both resolve `ignore`, and on the loosening
   arrangement. It could never have distinguished them, so narrowing it removes
   a constant, not privacy information. The genuine-refusal test now asserts the
   warn carries no `class`, `declared` or `governed_by`.

2. The provenance claim is corrected where it was too strong. The machine-local
   list, the only source that reaches an explicit `full`, has exactly two
   writers, both behind explicit `hyp ignore`/`unignore`/`policy set` verbs, and
   LLP 0071 §not-central forbids anything central writing one - so the
   `->full` transitions really are the user's own. But a `.hypignore` is a
   COMMITTABLE file by design (LLP 0071 §not-dotfiles) and the ancestor walk has
   no vendored-tree exclusion, so the `ignore->local-only` transition can come
   from a dependency's own file. Bounded (the walk only goes up, and
   `.hypignore` cannot express `full`), unchanged by this PR, and now disclosed
   rather than asserted away.

Also notes that the 131/576 count's mixed slice is enumeration-dependent; the
two source-pure slices, 50 and 75, reproduce exactly.

No behaviour change: predicate, gate, row and drop path untouched.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: test <test@test.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: test <test@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex live projector: the workspace-key substitution can feed the .hypignore gate a directory the session never ran in

2 participants