Skip to content

Codex lineage: only a namespaced transport signal may trust the flat client_metadata pair (#473) - #517

Draft
philcunliffe wants to merge 4 commits into
masterfrom
fix/issue-473
Draft

Codex lineage: only a namespaced transport signal may trust the flat client_metadata pair (#473)#517
philcunliffe wants to merge 4 commits into
masterfrom
fix/issue-473

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Addresses both deferred, non-blocking review findings from PR #467, following the maintainer decisions recorded on issue #473 (A: tighten, B: proceed). Numbering note: the doc reviewed as LLP 0144 on the #467 branch landed on master as LLP 0151 (renumbered per LLP 0156); all references below use the merged number.

A. A Codex user-agent could steer the LLP 0030 partition key - BEHAVIOURAL

Root cause. hasCodexTransportSignal was one predicate answering two different questions. It decided both may this exchange be called Codex (a label: revisable, cheap if wrong) and may this exchange's unnamespaced client_metadata identity pair be trusted (an identity: it clusters rows on disk and scopes the fallback message_id). Its fourth branch is a codex-prefixed user-agent product string, which any local process can copy without knowing anything Codex-specific, unlike the other three, which require Codex's proprietary route or header vocabulary. So a forged user-agent: codex_cli_rs/1.0 plus a body client_metadata: { session_id, thread_id } carrying no x-codex-* key let that pair dictate conversation_id and session_id, the LLP 0030 partition key.

Fix. Split the predicate exactly as the PR author sketched (LLP 0151 lines ~95-104):

  • hasCodexNamespaceSignal - the chatgpt upstream, the /backend-api/codex/ namespace, an x-codex-* compatibility header. This is now the only thing passed to readCodexClientMetadata as corroboration.
  • hasCodexTransportSignal - that union plus the user-agent branch, unchanged in effect for client naming and for whether a codex context resolves at all.

Real Codex loses nothing: its map carries x-codex-installation-id and x-codex-window-id on every request, which is the self-naming branch that never needed corroboration. The accepted cost, per the maintainer decision, is that a hypothetical future Codex build reachable only by user-agent and writing no x-codex-* map key loses lineage until this file is updated. That build surfaces as an absent lineage_source rather than as silence.

Regression test evidence. New test a Codex user-agent alone does not let a flat client_metadata pair dictate row identity asserts both halves of the asymmetry in one place: the row is still client_name: 'codex' (loose predicate), and the flat pair contributes nothing, with the row byte-identical to the same request sent without the map (strict predicate).

Before the fix, on the test change alone:

not ok 57 - a Codex user-agent alone does not let a flat client_metadata pair dictate row identity
  error: |-
    Expected "actual" to be strictly unequal to:
    'thread-ua'
  actual: 'thread-ua'
  operator: 'notStrictEqual'
  stack: test/plugins/codex-exchange-projector.test.js:1722
# tests 60
# pass 59
# fail 1

After the fix:

ok 56 - a namespace-corroborated Codex request still resolves lineage from a flat-only client_metadata
ok 57 - a Codex user-agent alone does not let a flat client_metadata pair dictate row identity
ok 58 - a Codex user-agent request keeps its lineage when the map carries a Codex-owned key
# tests 60
# pass 60
# fail 0

Two companion tests pin what must NOT change: a flat-only map still resolves its lineage when a Codex-namespaced header corroborates it, and a user-agent-only request keeps full lineage whenever its map carries an x-codex-* key (the shape all real Codex traffic has). The existing pinned-literal identity test (part_id and message_id stay byte-identical...) is untouched and still passes, so no recorded shape re-keys.

B. LLP 0151's header audit overstated "nothing emits them" - NON-BEHAVIOURAL

No regression test is possible for this finding, and none is included. It is a documentation-accuracy defect only: the code's behaviour was already correct and is unchanged by this PR. The claim corrected is prose about an upstream project (openai/codex), which is not observable from HypAware's own test surface.

Root cause. LLP 0151 audited the header names the projector reads against Codex's turn-streaming path (build_responses_request / stream_responses_api) and generalised the result to every path. codex-rs/codex-api/src/requests/headers.rs::build_session_headers inserts headers literally named session-id and thread-id, and has two further call sites in codex-rs/core/src/client.rs: compact_conversation_history (the /responses/compact endpoint, which the projector's isOpenAiResponsesPath matches) and build_websocket_headers.

Fix. The audit's verdict survives its reason being wrong, so the correction is to the justification only. Both names remain correctly unread: compaction requests are CodexResponsesRequestKind::Compaction, which Codex marks has_turn_identity = true, so their x-codex-turn-metadata blob independently states the same session_id and thread_id that readCodexTurnMetadata already reads. Memory requests state identity on no surface at all by Codex's own design. Reading the two bare names would add no id HypAware does not already have, while reopening exactly what LLP 0151 closed. parent-thread-id is fictional as originally stated.

Corrected at every echo, not only the two sites the issue names:

  • llp/0151-...md (Context consequence 2, and the audit table row) - via forward-refs, see below
  • test/plugins/codex-exchange-projector.test.js (the comment the issue flags)
  • hypaware-core/plugins-workspace/codex/src/exchange-projector.js (resolveConversationId)
  • hypaware-core/smoke/flows/gateway_codex_capture.js
  • test/plugins/codex-rollout-cwd.test.js

LLP handling

LLP 0151 is Active, and the house rule is that such a doc is a record, not a worksheet. Finding A changes what its #body-is-a-codex-signal section decided, and finding B corrects a factual claim rather than a typo, so neither is the permitted editorial fix. Both land in new LLP 0164 (llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md), following the precedent of LLP 0133 carrying corrections for the immutable LLP 0115. LLP 0151 receives only Extended-by forward-refs at the two affected sections, which the convention explicitly allows. New @ref anchors #flat-pair-corroboration and #header-audit-correction are annotated on the code and tests that realize them.

Checks

  • npm test: 3080 pass, 8 fail. All 8 failures (leave after join..., tests 892-900) are pre-existing on origin/master, verified by stashing this branch's changes and re-running: identical 8 failures, # pass 3078. The +2 is this PR's net new tests.
  • npm run typecheck: clean.
  • npm run smoke -- gateway_codex_capture: ok. Its fixture carries an x-codex-installation-id map key on a namespaced route, so it exercises the unchanged self-naming branch.

Fixes #473

test and others added 2 commits July 31, 2026 04:10
Deferred review findings from PR #467, per the maintainer decisions on
issue #473 (A: tighten, B: proceed).

A. `hasCodexTransportSignal` answered both "may this exchange be called
Codex" and "may its unnamespaced `client_metadata` identity pair be
trusted". A forged `user-agent: codex_cli_rs/1.0` therefore let a flat
`session_id` / `thread_id` pair dictate `conversation_id` and the LLP 0030
partition key. Split the predicate: `hasCodexNamespaceSignal` (chatgpt
upstream, `/backend-api/codex/` namespace, `x-codex-*` header) is what
corroborates the pair; the user-agent branch stays only in the loose
predicate, which names the client. Real Codex is unaffected, since its map
always carries `x-codex-installation-id` and `x-codex-window-id`.

B. LLP 0151's header audit claimed nothing emits `thread-id` /
`session-id`. Both are real names built by `build_session_headers` on
Codex's `/responses/compact` and websocket-handshake paths. They remain
correctly unread, since the turn-metadata blob states the same ids for the
one request kind that carries them. Corrected the claim at every echo.

LLP 0151 is Active and immutable, so both land in new LLP 0164 with
`Extended-by` forward-refs on the two affected sections.

Co-Authored-By: Claude <noreply@anthropic.com>
PR #502 (fix/issue-421) already mints llp/0164-status-names-recent-clients-
from-gateway-entrypoints.decision.md and is held+approved awaiting a human
merge, so it has the prior claim on 0164. This branch's decision doc and
every @ref pointing at it move to 0165, which is free across master and all
open PR branches.

No content change: the file is renamed and the number updated at all 13
reference sites (5 source, 3 test, 3 in LLP 0151's Extended-by forward
refs, the doc title, and the smoke flow). Both anchors still resolve.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: renumbered LLP 0164 -> 0165 (cross-branch collision)

This branch minted llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md, but PR #502 (fix/issue-421) already mints llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md. Neither branch could see the other, so both picked 0164 off the same master high-water mark.

#502 is held, approved, and awaiting your merge, so it has the prior claim. This branch moves to 0165, which is free across master and every open PR branch (surveyed: 0160 on fix/issue-481, 0161-0162 on integration/openclaw-full-capture, 0163 on master, 0164 on fix/issue-421).

Pushed as 53ef099. No content change - the file is renamed and the number updated at all 13 reference sites: 5 in exchange-projector.js, 3 in codex-exchange-projector.test.js, 3 Extended-by forward refs in LLP 0151, the doc title, and the gateway_codex_capture smoke flow. Both anchors (#flat-pair-corroboration, #header-audit-correction) still resolve.

npm test after the rename: 3080 pass / 8 fail - the 8 being the pre-existing test/core/leave-command.test.js baseline (tracked in #512), identical to this branch's own pre-rename count.

philcunliffe pushed a commit that referenced this pull request Jul 31, 2026
Three open branches independently minted 0164 off the same master
high-water mark, none able to see the others:

  fix/issue-421 (PR #502)  status-names-recent-clients-from-gateway-entrypoints
  fix/issue-473 (PR #517)  codex-flat-pair-needs-a-namespace-signal -> 0165
  fix/issue-451 (PR #520)  session-control-plane-states-its-guarantees -> 0166

PR #502 is held and approved awaiting a human merge, so it keeps 0164.
This branch takes 0166; 0165 went to PR #517.

No content change: the file is renamed and the number updated at every
reference site (3 in session_command.js, 1 in the new responder-trust
test, the Extended-by forward-ref in LLP 0067, and the doc title). The
#stated-not-proved and #membership-not-grain anchors still resolve.
PR #517 corrected five echoes of "nothing emits `thread-id` / `session-id`".
Two more survive.

`readCodexClientMetadata`'s JSDoc still calls `thread-id` a "fictional"
header, three lines above the block this PR rewrote. Per LLP 0165
#header-audit-correction only `parent-thread-id` is fictional; the defect
class the sentence names is unchanged, so only the adjective moves.

LLP 0083 repeats the claim twice, in a doc the correction sweep did not
reach: `codex-rs` "defines ... and nothing else", and the bare names
"appear nowhere in it". LLP 0083 is Accepted and immutable, so both take
`Extended-by` forward-refs to LLP 0165, the same shape this PR gave 0151.

npm test: 3080 pass, 8 fail, the pre-existing leave-command failures
(issue #512) and no others.

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

Copy link
Copy Markdown
Contributor Author

Verdict: approve with fixes applied (2 low-severity findings, both fixed in e1d5009)

The security-relevant core of finding A is correct and the split is complete. Both findings below are documentation-accuracy defects in finding B's correction sweep; neither is behavioural.

1. Finding A - the predicate split (verified sound)

Every call site got the right predicate. hasCodexTransportSignal has exactly one call site outside its own definition: resolveCodexContext:756, where it feeds transportIsCodex, which reaches only if (!transportIsCodex && clientMetadata === undefined) return undefined - whether a codex context resolves at all. hasCodexNamespaceSignal:761 feeds readCodexClientMetadata:766, and clientMetadata is the sole gate on every identity field: thread_id, session_id, turn_id, x-codex-parent-thread-id, window_id, lineage_source. I traced conversation_id and the LLP 0030 session_id back through resolveConversationId and project() and found no second path by which a user-agent-only request can put body content on a row's identity. readCodexTurnMetadata falls back to the map, but only through the same gated clientMetadata. No loose-predicate leak.

Real Codex keeps full lineage, and the companion test at test/plugins/codex-exchange-projector.test.js:1741 genuinely pins it: user-agent: codex_cli_rs/0.55.0 on /v1/responses with x-codex-installation-id in the map still resolves thread-ua-owned / session-ua-owned and lineage_source: 'body_client_metadata'. That is the self-naming hasCodexKey branch, which never consults corroborated.

Non-blocking observation on the strict set. Of the three namespace signals, x-codex-* headers and the chatgpt upstream are the strong ones. isCodexNamespacePath('/backend-api/codex/...') is a weaker bar than the other two read as: a route is more guessable than a header name. It is still meaningfully stronger than a user-agent, because forging it actually routes the request to ChatGPT's backend rather than merely labelling it. Recording this as a note, not a request: it is the maintainer-decided A: tighten shape and closing it further would be a new decision.

2. Test quality (verified by reverting the source, not the test)

git checkout origin/master -- hypaware-core/plugins-workspace/codex/src/exchange-projector.js then node --test test/plugins/codex-exchange-projector.test.js reproduces the claimed failure exactly:

not ok 57 - a Codex user-agent alone does not let a flat client_metadata pair dictate row identity
  actual: 'thread-ua'
# tests 60 / # pass 59 / # fail 1

With the branch source restored: # tests 60 / # pass 60 / # fail 0, with ok 56, ok 57, ok 58 as claimed. The pinned-literal identity test (part_id and message_id stay byte-identical for the turn-metadata shape already recorded, line 1801) is untouched by the diff and passing, so no recorded shape re-keys. The control-row assertion inside test 57 (projection.conversation_id === control.conversation_id) is the right shape: it proves the map contributes nothing, not merely that it contributes something different.

npm test on the branch: 3080 pass / 8 fail, the 8 being exactly the known pre-existing leave-command failures (issue #512) and no others.

3. Finding B - the header-audit correction

All five claimed echoes verified corrected: llp/0151 (both sections, via forward-ref), test/plugins/codex-exchange-projector.test.js:1488, exchange-projector.js:1120 (resolveConversationId), hypaware-core/smoke/flows/gateway_codex_capture.js:125, test/plugins/codex-rollout-cwd.test.js:59.

FINDING 1 (low) - hypaware-core/plugins-workspace/codex/src/exchange-projector.js:964: a sixth echo, inside the JSDoc block this PR rewrote. readCodexClientMetadata's doc comment still read "the same defect class as the fictional thread-id header this document removed", three lines above the sentence the PR edited to add the namespace caveat. Per LLP 0165 #header-audit-correction, only parent-thread-id is fictional. The defect class the sentence names is unaffected, so the fix is the adjective alone: now "the bare thread-id header read this document removed".

FINDING 2 (low) - llp/0083-codex-live-cwd-from-rollout.decision.md:97 and :207-214: a seventh echo, in a doc the sweep did not reach. LLP 0083 states the same corrected claim twice and more strongly than LLP 0151 did: "codex-rs defines x-codex-turn-metadata, x-codex-window-id, x-codex-parent-thread-id and x-openai-subagent, and nothing else", and "the bare thread-id, session-id and parent-thread-id this document once relied on appear nowhere in it". build_session_headers falsifies both. A reader arriving at LLP 0083 first would take away exactly the claim LLP 0165 exists to correct. LLP 0083 is Accepted and immutable, so both sites now carry Extended-by forward-refs to LLP 0165#header-audit-correction - the same shape this PR gave LLP 0151, and its prose is otherwise untouched. LLP 0083 was already in LLP 0165's Related list, so no header edit was needed.

4. LLP handling (verified)

The LLP 0133 precedent is real and near-verbatim: LLP 0133's abstract reads "Also carries the live-test corrections to LLP 0115, which is Accepted and immutable; corrections land here, as new findings that ref it, not as edits to it." LLP 0165's framing mirrors it. The Extended-by forward-ref convention is well established across the corpus (LLPs 0027, 0033, 0062, 0070, 0071, 0080, 0103), in both the inline-parenthetical and blockquote forms this PR uses. Correct shape.

5. Renumber 0164 -> 0165 (verified complete)

Zero residual 0164 references anywhere in the tree. Both anchors exist and are unique: llp/0165-...md:47 <a id="flat-pair-corroboration"> and :76 <a id="header-audit-correction">. All nine code/test @ref LLP 0165#... annotations resolve, as do all three Extended-by forward-refs in LLP 0151 (lines 46, 98, 136). The renumber was warranted and is collision-free: origin/fix/issue-421 holds LLP 0164, and no branch other than this one holds 0165. The llp-ref-hygiene test passes.

6. House rules

Clean. No semicolons, no U+2014 em dashes in the diff (or in my additions), JSDoc-only types, no @typedef, no inline import('...'), no new type-import specifiers introduced. npm run typecheck clean.


Fixed in e1d5009 (pushed to fix/issue-473): both findings above. npm test after the fix: 3080 pass / 8 fail, the same pre-existing leave-command set and no others.

The rollout-cwd fallback's JSDoc still asserted the corrected claim: the
bare `session-id` header name "is not one Codex emits or this file
reads". Per LLP 0165 #header-audit-correction it is a real Codex name,
emitted by `build_session_headers` on the `/responses/compact` and
websocket paths. Only the "or this file reads" half survives, and it
survives for a stated reason, so the sentence now says the file reads it
on no path and refs LLP 0165 for why that stays right.

Rounds 1 and 2 of review have now found three echoes past the five the
PR corrected; this is the last occurrence in the tree.

npm test: 3080 pass, 8 fail, the pre-existing leave-command failures
(issue #512) and no others. npm run typecheck clean.

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

Copy link
Copy Markdown
Contributor Author

Verdict: approve with fixes applied (round 2, 1 low-severity finding, fixed in e5610e3)

Round 2 re-verified round 1's two fixes in the committed tree, re-checked the branch against the moved master, and swept the whole tree for further echoes. The predicate split still holds up on a fresh read. An eighth echo did exist and is now fixed. This is the last round before triage and I do not think anything blocks.

1. Round-1 fixes verified in the tree (not from the comment)

Both landed in e1d5009 and are present at HEAD:

  • hypaware-core/plugins-workspace/codex/src/exchange-projector.js:967 now reads "the bare thread-id header read this document removed". The word fictional survives in this file only at :744, where it correctly describes parent-thread-id, the misspelling the named constants exist to prevent. Correct as written.
  • llp/0083-codex-live-cwd-from-rollout.decision.md:99 and :223 carry the Extended-by forward-refs to LLP 0165#header-audit-correction, with LLP 0083's own prose untouched. Both survive the merge with master intact.

2. Branch vs. the moved master (#491, #502, #505 merged)

git merge --no-commit --no-ff origin/master (at 71c6231) merges cleanly, no conflicts, both before and after this round's commit. Four files auto-merge, llp/0083 among them: #491's edits to that doc and round 1's Extended-by refs land in different regions and do not overlap. I verified the merged text reads correctly rather than only that git exited 0.

LLP 0165 is still collision-free. Enumerating llp/016[456] across every remote branch: 0164 (status-names-recent-clients, from #502) is on master, fix/issue-478, fix/issue-480 and integration/openclaw-full-capture; 0166 is on fix/issue-451; 0165 is held by this branch only. The renumber remains correct and no new collision has appeared.

3. FINDING 1 (low) - exchange-projector.js:256: the eighth echo

The rollout-cwd fallback's JSDoc still asserted the corrected claim outright. The resolveRolloutCwd doc block read:

That was once the common subscription-route shape, a bare session-id header; it is not any more, and that header name is not one Codex emits or this file reads (@ref LLP 0151#real-header-names).

session-id is a name Codex emits, from build_session_headers on the /responses/compact and websocket paths. That is precisely what LLP 0165#header-audit-correction exists to say. This block predates the PR (it arrived with #462) and sits ~700 lines above the sites the correction sweep touched, which is why five, then seven, passes missed it.

Only the "or this file reads" half of the sentence survives, and per LLP 0165 it survives for a stated reason rather than by accident, so the fix carries the reason across:

...that header name is not one this file reads on any path (@ref LLP 0151#real-header-names). The name is real on Codex's compaction and websocket paths, so leaving it unread is a decision rather than an oversight: those requests state the same ids in the turn-metadata blob (@ref LLP 0165#header-audit-correction).

Sweep evidence that this is the last one. I grepped the full tree (and separately the merged-with-master tree, so newly landed files from #491/#502/#505 were in scope) for every occurrence of session-id / thread-id / parent-thread-id and for the claim phrasings (fictional, nothing emits, never emits, does not emit, appear nowhere, not one Codex emits). After this fix, every surviving occurrence is one of: a correct statement about parent-thread-id alone; an x-codex--prefixed name; an unrelated session-id sense (hyp session status <session-id> usage strings, x-claude-code-session-id, mcp-session-id, LLP 0030/0066/0067 partition-key prose); or a corrected site already carrying its Extended-by ref. The two remaining LLP 0151 statements at :37 and :134 are the audit's own record and are covered by the forward-refs at :46 and :136, which is the intended shape.

4. Fresh review of the predicate split at this head

Re-traced independently of round 1 and reached the same conclusion. hasCodexTransportSignal (:945) is the union of hasCodexNamespaceSignal plus the user-agent branch, and its only call site is :759 feeding transportIsCodex, which reaches exactly one expression: if (!transportIsCodex && clientMetadata === undefined) return undefined. hasCodexNamespaceSignal (:920) feeds readCodexClientMetadata at :769, and inside that function corroborated gates the flat-pair branch only, after the x-codex-* self-naming branch has already returned. Every identity field hangs off clientMetadata. No second path puts body content on row identity under a user-agent alone.

One thing I checked that round 1 did not raise: resolveProvider:328 sets provider = 'chatgpt' from isCodexNamespacePath(path), so the provider === 'chatgpt' branch of the strict predicate can be derived from the route rather than stated by the gateway. That is not a widening - it collapses two strict signals into one and reaches the same verdict either way, and input.provider is gateway-resolved rather than client-stated.

Observation, not a request (carried forward from round 1, plus one new). First, isCodexNamespacePath remains the weakest of the three strict signals, for the reason round 1 gave; tightening it further would exceed the maintainer's A: tighten. Second, hasCodexNamespaceSignal's JSDoc and LLP 0165 :53 both summarise the header branch as "an x-codex-* header", while the code checks two specific names (x-codex-turn-metadata, x-codex-window-id) and not x-codex-parent-thread-id. The shorthand is slightly wider than the code, but it fails closed, the precise set is four lines below the sentence, and no real Codex request depends on it (Codex writes x-codex-installation-id / x-codex-window-id into the map, so the self-naming branch returns before corroboration is consulted). Recording it rather than churning the PR for it.

5. House rules

Clean at this head and in my addition. No semicolons introduced, no U+2014 em dashes anywhere in the diff, JSDoc-only types, no @typedef, no inline import('...') types, no new type-import specifiers. Both @ref anchors in the edited block resolve (llp/0165:76 and LLP 0151's #real-header-names), and test/core/llp-ref-hygiene.test.js passes.

6. Checks


Fixed in e5610e3 (pushed to fix/issue-473): finding 1 above. Nothing outstanding from my side.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Jul 31, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Rung: TRIAGE (LLP 0017), round-2 review-cap judgement call on PR #517 (fix/issue-473, closes #473).

What neutral was doing: Both review rounds verified the core fix (every call
site traced; no user-agent-only request can seat body content on row identity;
real Codex keeps full lineage) and swept branch + merged-with-master trees for
further echoes of the LLP 0151 header-audit error, finding none beyond the eight
already fixed. Three residuals were left open at the review cap and handed to
TRIAGE to classify as true blocker vs. preference. Per instruction, residual 1
was re-judged against the concrete threat model this PR exists to close
(row-identity spoofing by "any local process").

Why it cannot proceed — residual 1 is a TRUE BLOCKER, verified by reproduction:

hasCodexNamespaceSignal (hypaware-core/plugins-workspace/codex/src/exchange-projector.js:920-925)
treats isCodexNamespacePath(path) — i.e. the request path starting with
/backend-api/codex/ (exchange-projector.js:354-357) — as strict corroboration
sufficient, on its own, to let a request body's flat client_metadata.thread_id
/session_id pair dictate conversation_id and session_id, the LLP 0030
partition key (via readCodexClientMetadata, exchange-projector.js:984-993).

That route string is not something "the client had to learn from Codex" (the bar
LLP 0165 itself sets, llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md
under "Decision"). It is published verbatim in this repo's own source
(hypaware-core/plugins-workspace/codex/src/index.js:79-84, the chatgpt
upstream preset) and in OpenAI's public codex-rs. Any local process that can
reach the gateway's local listener can send it — no Codex installation, no
ChatGPT credentials, no network access to chatgpt.com required for the
capture itself.

I reproduced this directly against the PR's own projector at head e5610e3
(script + output below). A forged request to /backend-api/codex/responses,
carrying no x-codex-* header, no Codex user-agent, and a
client_metadata pair naming an arbitrary victim thread/session, with the
upstream call failing (status_code: 401, invalid_api_key — i.e. the
attacker has no real ChatGPT credentials at all) still produces a captured row
keyed on the forged identity:

match(): true
projection.conversation_id: victim-thread-id
projection.session_id: victim-session-id
projection.attributes.codex.lineage_source: body_client_metadata
rows written: 1
row session_id: victim-session-id
row conversation_id: victim-thread-id

Root cause of why the failed upstream call doesn't help: onExchangeFinished
(hypaware-core/plugins-workspace/ai-gateway/src/source.js:122-131) runs
projector.projectExchange on every finished exchange regardless of response
status or error (ai-gateway/src/proxy.js finalizes on upstreamRes.on('end')
and on upstreamReq.on('error')), and responsesInputMessages
(exchange-projector.js:462-484) derives the projected user message purely from
reqBody.input, independent of the response. So messageRows.length > 0 is
satisfied by the request alone — a 401 from ChatGPT's real backend never stops
the forged identity from landing on disk.

This means the "weaker bar" flagged in round 1 is not a stylistic gap: it lets
exactly the row-identity spoofing this PR exists to close continue to work,
via a public path string instead of a copyable user-agent string — comparably
easy for the threat actor the PR names ("any local process"). Landing this as-is
would make the LLP record the hole as closed when it is not, which is worse than
not having fixed it (per the task's own framing).

Also checked, NOT a blocker — residual 2 (resolveProvider,
exchange-projector.js:323-331, and hasCodexNamespaceSignal's
provider === 'chatgpt' branch, exchange-projector.js:921): for every
request that actually reaches this projector through the real gateway,
input.provider is set by proxy.js:143-149 from the upstream that
matchUpstream (proxy.js:235-251) selected, and the only chatgpt upstream
registered by this plugin (codex/src/index.js:79-84) matches purely by
path_prefix: '/backend-api/codex' — the exact same string
isCodexNamespacePath checks. Confirmed: provider === 'chatgpt' and
isCodexNamespacePath(path) are the same underlying fact restated through two
derivations for this plugin's traffic; the branch adds no independent forgery
surface beyond residual 1. input.provider is gateway-derived, not
client-stated (a user's ai-gateway.upstreams config entries are also
path_prefix-only, ai-gateway/src/config.js:39-58) — round 2's reasoning
holds. Whatever resolution is chosen for residual 1 should apply to this branch
too, since it inherits the same weakness rather than adding a new one.

Also checked, NOT a blocker — residual 3 (JSDoc/LLP 0165:53 say "an
x-codex-* header" where the code checks two specific header names,
X_CODEX_TURN_METADATA and X_CODEX_WINDOW_ID): a documentation-shorthand nit.
The shorthand fails closed (real Codex requests satisfy the actual, narrower
check) and no request depends on the imprecise wording. Style/doc-nicety, no
production impact.

What neutral needs from you: a decision on how to close the route-namespace
gap without exceeding "tighten" into a rewrite. Options, not exhaustive:

  1. Require upstream success as part of corroboration — only trust the
    namespace-path signal when the exchange actually completed against the real
    chatgpt.com upstream with a non-error response (e.g. gate on
    status_code in the 2xx range, or on a shape only ChatGPT's backend would
    return). Keeps the route check but ties it to something a local-only forger
    cannot produce, since the local gateway proxies the request outbound.
  2. Drop the bare path branch from hasCodexNamespaceSignal, leaving
    provider === 'chatgpt' (or just the header/upstream signals) as corroboration
    only when it is itself backed by something client-unforgeable — which,
    per residual 2's finding, it currently is not either, so this likely needs
    pairing with (1) or (4).
  3. Fold in the real client_metadata x-codex-* requirement more strongly
    — i.e. only accept the namespace-path/provider signal as corroboration when
    the flat pair search also confirms an x-codex- prefixed key elsewhere in
    the payload, rather than trusting the bare flat pair once any namespace
    signal fires. (This changes readCodexClientMetadata's corroborated
    branch, not just hasCodexNamespaceSignal.)
  4. Explicitly accept the residual risk and downgrade the LLP's claim — if
    the maintainer judges the exposure acceptable for V1 (e.g. because a
    forged row is low-value without a genuine response, or downstream
    consumers are trusted to cross-check), say so explicitly and have LLP 0165
    record the route-namespace signal as a known-weaker corroboration rather
    than describing the split as closing the hole. This still needs a decision
    on whether that framing is acceptable given the PR's stated purpose.

Reproduction script used above is available on request (not committed; it lives
only in this triage run's scratch worktree).

reply with a comment on this PR (or push to the branch); neutral monitors this
thread and will re-engage with your guidance on its next tick.

philcunliffe added a commit that referenced this pull request Jul 31, 2026
…451) (#520)

* hyp session states that the control responder is never authenticated (#451)

`validateControlResponse` (PR #439) proves the responder echoed our token;
it cannot prove the responder IS the gateway. A local process that binds the
resolved port and echoes the token back still yields `exit 0, ignored: true`,
so the privacy control tells a user "you are opted out" when nothing recorded
the decision.

Per the maintainer's direction on #451, this is accept-and-document: the
attack presupposes local code execution as this user, a gateway-written secret
is readable by whoever can bind the port, and peer-process identity has no
portable form (three platform implementations plus a native dependency). So
the guarantee is stated rather than proved.

- human output gains a `trust:` note beside every confirmed answer, on
  `status` and on both mutation verbs, naming the endpoint that was trusted
- `--json` gains `endpoint_authenticated: false`, constant by contract rather
  than by outcome, so a consumer never infers authenticity from silence
- the disclosure is unconditional and rides the `daemon_status` path too,
  which said nothing at all before: a live daemon's status.json is evidence
  about a past bind, not about who answers now
- `endpoint_source` and every existing fail-closed refusal are unchanged

LLP 0164 records the decision and carries the companion #460 contract
statement (membership is not a match) so the control-plane guarantees read as
one story; LLP 0067 §cli-response-check gets the forward-ref.

test/plugins/ai-gateway-session-responder-trust.test.js stands up an impostor
that echoes the token, pins that its answer is still believed (the accepted
residual), and asserts the disclosure on every surface.

Fixes #451

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

* Renumber LLP 0164 -> 0166 to resolve a cross-branch number collision

Three open branches independently minted 0164 off the same master
high-water mark, none able to see the others:

  fix/issue-421 (PR #502)  status-names-recent-clients-from-gateway-entrypoints
  fix/issue-473 (PR #517)  codex-flat-pair-needs-a-namespace-signal -> 0165
  fix/issue-451 (PR #520)  session-control-plane-states-its-guarantees -> 0166

PR #502 is held and approved awaiting a human merge, so it keeps 0164.
This branch takes 0166; 0165 went to PR #517.

No content change: the file is renamed and the number updated at every
reference site (3 in session_command.js, 1 in the new responder-trust
test, the Extended-by forward-ref in LLP 0067, and the doc title). The
#stated-not-proved and #membership-not-grain anchors still resolve.

* review: document endpoint_authenticated where the report shape lives

Three review fixes on top of #451's disclosure work, no behaviour change:

- `types.d.ts`: `SessionStatusReport` bills itself as "what `hyp session
  status` reports, in `--json` field order", and a maintainer adding a report
  shape reads it. It said nothing about `endpoint_authenticated`, so the
  constant-by-contract rule lived only inside `writeStatus`. State the
  envelope, the by-contract choice, and the "a real check needs a new field"
  consequence there, with the LLP 0166 ref.
- LLP 0166 named `RESPONDER_TRUST_NOTE`, a symbol that does not exist; the
  code has `responderTrustNote(endpoint)`, and the reason it is a function
  (it names the endpoint) is the point.
- Drop the unused `@import { IncomingMessage, ServerResponse }` copied into
  the new test from its sibling suite.

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

* review: point the LLP 0067 link at the section its label names

The new `responderTrustNote` docblock links `[LLP 0067 §cli-response-check]`
at the file with no fragment, so a reader who follows the label lands at the
top of a 600-line design doc. Every other fragment-bearing LLP link in the
tree carries its anchor (`sync.js`, `verb_codec.js`, `remote_commands.js`,
`first_sync_hold.js`); this one was the outlier.

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:stuck neutral attempted this but cannot complete it autonomously — needs a human

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #467

1 participant