Skip to content

Commit c9b2e5e

Browse files
philcunliffetestclaudetest
authored
hyp session states that the control responder is never authenticated (#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>
1 parent 2ef9fc5 commit c9b2e5e

5 files changed

Lines changed: 513 additions & 5 deletions

File tree

hypaware-core/plugins-workspace/ai-gateway/src/session_command.js

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,38 @@ const FOLDER_GOVERNOR_NOTE = 'folder: see `hyp policy show` (this verb reports
3838
const EPHEMERAL_NOTE =
3939
'this opt-out is in-memory only: a gateway restart drops it, and a fork (`claude --fork-session`, `codex fork`) mints a new session id it no longer covers. Re-check with `hyp session status`.'
4040

41+
/**
42+
* The control plane's authenticity contract, printed beside every **confirmed**
43+
* answer, by the writer and the reader alike.
44+
*
45+
* `validateControlResponse` proves the responder saw our token; nothing proves
46+
* the responder IS the gateway. A local process that binds the resolved port and
47+
* echoes the token back yields a confident `ignored: true` for a session nothing
48+
* is dropping (issue #451). Authenticating it would need peer-process identity,
49+
* which has no portable form, and a gateway-written secret defends nothing
50+
* because whoever can bind that port runs as the same uid and can read the same
51+
* file ([LLP 0067 §cli-response-check](../../../../llp/0067-session-opt-out.design.md#cli-response-check)).
52+
*
53+
* So the guarantee is stated rather than proved, and the statement is
54+
* **unconditional**: the verb cannot tell the gateway from the impostor, so a
55+
* note printed only "when spoofed" would be a claim it cannot make, and its
56+
* absence would read as proof of authenticity.
57+
*
58+
* The endpoint is named in the note rather than left as "that port": on the
59+
* `daemon_status` path this is the only line about the endpoint at all, and a
60+
* reader pasting the output into a support thread should not have to reconstruct
61+
* which address was trusted.
62+
*
63+
* @ref LLP 0166#stated-not-proved [implements]: the responder is never
64+
* authenticated, and every confirmed answer says so.
65+
*
66+
* @param {string} endpoint
67+
* @returns {string}
68+
*/
69+
function responderTrustNote(endpoint) {
70+
return `trust: nothing proves the responder at ${endpoint} is the HypAware gateway - any process on this machine could bind that port and answer. This answer is only as trustworthy as this machine.`
71+
}
72+
4173
/**
4274
* `hyp session status` exit code for a **confirmed** "this session is NOT
4375
* being dropped" read. Distinct from `SESSION_EXIT_UNKNOWN` on purpose: the
@@ -276,6 +308,9 @@ async function runMutation(argv, ctx, method, usage) {
276308
total,
277309
endpoint: endpoint.endpoint,
278310
endpoint_source: endpoint.source,
311+
// Same field, same constant, on the verbs whose output reads as done.
312+
// @ref LLP 0166#stated-not-proved [implements]
313+
endpoint_authenticated: false,
279314
}) + '\n'
280315
)
281316
return 0
@@ -295,6 +330,7 @@ async function runMutation(argv, ctx, method, usage) {
295330
idSource: resolvedId.source,
296331
idEvidence: resolvedId.evidence ?? null,
297332
threadId: resolvedId.threadId ?? null,
333+
endpoint: endpoint.endpoint,
298334
endpointSource: endpoint.source,
299335
})) {
300336
ctx.stdout.write(`${note}\n`)
@@ -313,7 +349,19 @@ async function runMutation(argv, ctx, method, usage) {
313349
*/
314350
function writeStatus(ctx, json, report) {
315351
if (json) {
316-
ctx.stdout.write(JSON.stringify({ ...report, folder_policy: 'hyp policy show' }) + '\n')
352+
ctx.stdout.write(
353+
JSON.stringify({
354+
...report,
355+
// The human note's machine-readable twin, so a JSON consumer does not
356+
// have to parse prose (or, worse, infer authenticity from silence).
357+
// Constant by contract, `unknown` reports included: it is `false`
358+
// because no answer this verb can obtain is authenticated, not because
359+
// this particular one failed a check.
360+
// @ref LLP 0166#stated-not-proved [implements]
361+
endpoint_authenticated: false,
362+
folder_policy: 'hyp policy show',
363+
}) + '\n'
364+
)
317365
} else if (report.status === 'unknown') {
318366
const who = report.session_id ?? '(unresolved)'
319367
ctx.stdout.write(`session ${who}: UNKNOWN - cannot confirm the opt-out is in effect\n`)
@@ -327,6 +375,7 @@ function writeStatus(ctx, json, report) {
327375
idSource: report.session_id_source,
328376
idEvidence: report.session_id_evidence,
329377
threadId: report.thread_id,
378+
endpoint: report.endpoint,
330379
endpointSource: report.endpoint_source,
331380
})) {
332381
ctx.stdout.write(`${note}\n`)
@@ -339,6 +388,7 @@ function writeStatus(ctx, json, report) {
339388
idSource: report.session_id_source,
340389
idEvidence: report.session_id_evidence,
341390
threadId: report.thread_id,
391+
endpoint: report.endpoint,
342392
endpointSource: report.endpoint_source,
343393
})) {
344394
ctx.stdout.write(`${note}\n`)
@@ -368,9 +418,12 @@ function writeStatus(ctx, json, report) {
368418
* bound, and qualifying it too would train the reader to skip the caveat on the
369419
* paths where it is load-bearing.
370420
*
371-
* A live daemon's `status.json` proves the second; a
372-
* pinned `listen` only asserts it, and `validateControlResponse` can prove the
373-
* responder saw our token but not that it is the gateway. Naming the weaker
421+
* The second claim is never proved, only graded. A live daemon's `status.json`
422+
* says the gateway bound that port; a pinned `listen` says only that it was
423+
* asked to, so the weaker source gets its own note. Neither says who answers
424+
* there NOW, and `validateControlResponse` can prove the responder saw our
425+
* token but not that it is the gateway, so `responderTrustNote` rides every
426+
* confirmed answer under both sources (issue #451, LLP 0166). Naming the
374427
* evidence in the output is the only remedy available at this layer, and it is
375428
* this change's own thesis: a control that can be wrong must at least say so.
376429
*
@@ -388,12 +441,13 @@ function writeStatus(ctx, json, report) {
388441
* idSource: SessionStatusReport['session_id_source'],
389442
* idEvidence: string | null,
390443
* threadId: string | null,
444+
* endpoint: string | null,
391445
* endpointSource: SessionStatusReport['endpoint_source'],
392446
* }} args
393447
* @returns {string[]}
394448
*/
395449
function provenanceNotes(args) {
396-
const { idSource, idEvidence, threadId, endpointSource } = args
450+
const { idSource, idEvidence, threadId, endpoint, endpointSource } = args
397451
/** @type {string[]} */
398452
const notes = []
399453
if (idSource === 'codex_rollout') {
@@ -416,6 +470,10 @@ function provenanceNotes(args) {
416470
'endpoint: from the pinned `listen`, not a live daemon - nothing proved the gateway still owns that port.'
417471
)
418472
}
473+
// Last, and on every confirmed answer: the weaker of the two endpoint
474+
// sources gets the extra note above, but neither of them authenticates the
475+
// responder, so the contract is stated whichever one produced the port.
476+
if (endpoint) notes.push(responderTrustNote(endpoint))
419477
return notes
420478
}
421479

hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,22 @@ export type SessionEndpointResolution =
211211
* gateway" - and only some of the ways of establishing them are authoritative.
212212
* Hiding which one was used is how a confident answer about the wrong session
213213
* reads as a confident answer about yours.
214+
*
215+
* **The `--json` envelope is this record plus two fields the writer adds and
216+
* this interface deliberately does not carry**, because they are constants
217+
* rather than results: `folder_policy` (the other governor's verb) and
218+
* `endpoint_authenticated`, which is always `false`. The second is `false` **by
219+
* contract, not by outcome** - no answer this verb can obtain is authenticated,
220+
* on `unknown` reports included - so a peer-identity check, if one is ever
221+
* adopted, needs a NEW field rather than flipping this one: a consumer that
222+
* learned "false means nobody checked" must not have to relearn "false now
223+
* means the check ran and failed". Anything that adds a report shape here owes
224+
* it the same constant.
225+
*
214226
* @ref LLP 0066#readable [implements]: R10 and R12 shape this record - `ignored`
215227
* is nullable so an unconfirmable read cannot render as `false`.
228+
* @ref LLP 0166#stated-not-proved [constrained-by]: the `--json` envelope states
229+
* the responder was never authenticated, on every shape.
216230
*/
217231
export interface SessionStatusReport {
218232
status: 'ignored' | 'not_ignored' | 'unknown'

llp/0067-session-opt-out.design.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ dependency for one check makes it a separate design decision rather than a
293293
hardening tweak. Recorded, not adopted; the residual stays mitigated by the
294294
`endpoint_source` disclosure ([§cli-provenance](#cli-provenance)).
295295
296+
> **Extended-by: [LLP 0166 §stated-not-proved](./0166-session-control-plane-states-its-guarantees.decision.md#stated-not-proved).**
297+
> Issue #451 settled the deferred question as **accept and document**: the
298+
> residual is not closed, and every confirmed answer now states that the
299+
> responder was never authenticated (`trust:` in the human output,
300+
> `endpoint_authenticated: false` in `--json`) beside the `endpoint_source`
301+
> disclosure this section points at.
302+
296303
### Endpoint resolution: disk, then config, never a guess {#cli-endpoint}
297304
298305
The daemon's live bound port from `status.json` wins
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# LLP 0166: the session control plane states its guarantees rather than proving them
2+
3+
**Type:** Decision
4+
**Status:** Accepted
5+
**Systems:** Gateway, Plugins
6+
**Author:** Phil / Claude
7+
**Date:** 2026-07-31
8+
**Related:** LLP 0066, LLP 0067, LLP 0086
9+
10+
> Two questions were asked of the `hyp session` control plane in the same week,
11+
> and they have the same answer. **#451:** can the CLI prove that whatever
12+
> answers on the resolved port *is* the gateway? No, and it never will at this
13+
> layer. **#460:** can a caller learn from `ignored: true` that the opt-out will
14+
> actually match live traffic? No, only that the token it sent is in the drop
15+
> set. Both are **accept and document**: the control plane keeps the behaviour
16+
> it has and states the limit of the guarantee in its own output, in human form
17+
> and in `--json`, rather than letting silence read as assurance.
18+
>
19+
> @ref LLP 0067#cli-response-check [constrained-by]: extends the recorded dead
20+
> end into a stated contract - the residual is disclosed, not closed.
21+
22+
## Context
23+
24+
`hyp session status | ignore | unignore` resolves a control endpoint
25+
(`status.json` from a live daemon, else the pinned `listen`), POSTs / GETs
26+
`/_hypaware/ignore/session`, and reports the answer. It is a **privacy**
27+
control: the answer a user acts on is "this session is not being recorded".
28+
29+
Two independent things stand between that answer and the truth, and neither is
30+
a defect in the code that was written:
31+
32+
1. **Nobody authenticated the responder** (issue #451). PR #439's
33+
`validateControlResponse` refuses a reply that is not an object, whose
34+
`ignored` is not a boolean, or whose `session_id` is not echoed
35+
byte-for-byte. A local process that binds the port and *echoes the token
36+
back* satisfies every one of those checks by construction, and the user is
37+
told they are opted out while nothing is dropping anything.
38+
2. **The route confirms a write, not a match** (issue #460). `control.js` adds
39+
the token to a `Set` and answers `ignored: true`; the drop consumer is a bare
40+
`Set.has`. A caller that registered the wrong key - a Codex *thread* id
41+
rather than the session container, say - gets the identical happy answer. A
42+
`GET` afterwards does not help: it asks the same `Set` the same question.
43+
44+
## Why authentication is not the fix for #451 {#why-not-authenticate}
45+
46+
Costed in [LLP 0067 §cli-response-check](./0067-session-opt-out.design.md#cli-response-check)
47+
and unchanged by this document:
48+
49+
- **A gateway-written secret defends nothing.** Any process able to bind that
50+
port runs as the same uid as the daemon, and can therefore read whatever file
51+
the secret lives in. It raises the cost of the attack by one `read()`.
52+
- **Peer-process identity is the only real signal, and it is not portable.**
53+
`status.json` already carries a liveness-gated daemon pid, so the check would
54+
be "is the process on the other end of this socket that pid" -
55+
`/proc/net/tcp` plus `/proc/<pid>/fd` on Linux, `lsof` on macOS,
56+
`GetExtendedTcpTable` (native addon or `netstat -o` scraping) on Windows.
57+
Three implementations and a native dependency for one check.
58+
- **A non-squattable transport is a redesign.** A unix socket in a
59+
daemon-created directory would carry the property in the namespace itself, but
60+
it changes the control-plane transport, the skills' shell path, and Windows
61+
support all at once.
62+
63+
**The trigger condition is what settles it.** The attack requires a process on
64+
this machine that can bind the gateway's port before or instead of the daemon,
65+
which means the attacker already has local code execution as this user. At that
66+
point they can read the cache, the config, and the credentials directly; a
67+
spoofed opt-out answer is not the marginal capability worth a native dependency
68+
in three platform flavours. The guarantee `hyp session` can honestly offer is
69+
bounded by the machine it runs on, so that is the guarantee it states.
70+
71+
## The contract, stated in the output {#stated-not-proved}
72+
73+
**The responder is never authenticated, and every confirmed answer says so.**
74+
75+
- **Human output** carries a `trust:` note beside the answer, on `status` and on
76+
both mutation verbs: nothing proves the responder **at the named endpoint** is
77+
the HypAware gateway, any process on this machine could bind that port and
78+
answer, and the answer is only as trustworthy as this machine. The endpoint is
79+
named in the note itself because on the `daemon_status` path it is the only
80+
line about the endpoint at all.
81+
- **`--json`** carries `endpoint_authenticated: false`, so a consumer acts on
82+
the guarantee without parsing prose.
83+
- The existing `endpoint_source` disclosure (`daemon_status` / `config_listen`)
84+
**stays** and keeps its own note for the weaker source. The two say different
85+
things: `endpoint_source` grades the evidence that the *port* is the
86+
gateway's, `endpoint_authenticated` reports that the *responder* was never
87+
checked at all.
88+
89+
Three properties of the statement are load-bearing:
90+
91+
**It is unconditional.** The verb cannot tell the gateway from the impostor, so
92+
a note printed only "when spoofed" would be a claim it cannot make. It rides
93+
the `daemon_status` path too, where a live daemon reported the port it bound:
94+
that is evidence about a bind in the past, not about who answers now.
95+
96+
**`endpoint_authenticated` is `false` by contract, not by outcome.** It is not a
97+
check result that might come back `true` on a good day, and it is `false` on
98+
`unknown` reports as well. Should a peer-identity check ever be adopted, that is
99+
a new decision superseding this section, and it would need a distinct field
100+
rather than quietly flipping this one - a consumer that has learned "false means
101+
unauthenticated" must not have to relearn "false now means the check ran and
102+
failed".
103+
104+
**Fail-closed behaviour is untouched.** `validateControlResponse` keeps every
105+
refusal it has (LLP 0067 §cli-response-check): a malformed answer, an answer
106+
about a different session, a non-200, an oversized body are all still `unknown`.
107+
This document adds a disclosure to the answers that *are* believed; it does not
108+
believe anything new.
109+
110+
## The companion contract: membership is not a match {#membership-not-grain}
111+
112+
The same decision was taken on issue #460, and the two read as one story: the
113+
control plane answers exactly what it knows and names what it does not.
114+
115+
**`ignored: true` means "this token is in the drop set", and nothing more.** It
116+
is not a statement that live traffic will match it. **The caller is responsible
117+
for resolving the correct key before calling** - which is the shape PR #458
118+
already established for `hyp session` ([LLP 0067
119+
§cli-session-id](./0067-session-opt-out.design.md#cli-session-id): the session
120+
container, never the thread id, and a refusal rather than a guess). This makes
121+
that responsibility the stated contract rather than an accident of who resolved
122+
first, and it is why option 1 there (the gateway resolving and echoing the
123+
grain) was declined: the route treats the token as opaque
124+
([LLP 0066 §enforcement](./0066-session-opt-out.spec.md#enforcement)), and
125+
teaching it Codex grain would move provider knowledge into the one component
126+
that has deliberately never had any.
127+
128+
Two consequences carried by that decision, realized in the change set for #460
129+
rather than here:
130+
131+
- The skills' shell path gains the echo check the JS resolver already has
132+
(`validateControlResponse`'s equivalent), for as long as that path exists.
133+
- When #435 lands, the skills stop resolving independently and the verification
134+
question narrows to the single CLI resolver, which is the durable shape.
135+
136+
## Scope of this change set
137+
138+
This document is the joint record; the code lands in two places, and this half
139+
is the **#451** half:
140+
141+
- **Here (#451):** `responderTrustNote(endpoint)` and
142+
`endpoint_authenticated: false` in
143+
`hypaware-core/plugins-workspace/ai-gateway/src/session_command.js`, on
144+
`status` and both mutation verbs, human and `--json`. The note is a function
145+
of the endpoint rather than a constant string because it names the address it
146+
trusted, which on the `daemon_status` path is the only line about the endpoint
147+
at all.
148+
- **In its own change set (#460):** the membership-not-grain wording on the CLI
149+
and skill surfaces, and the shell-path echo check.
150+
151+
## What would reopen this
152+
153+
A change in the trigger condition, not a cheaper mitigation. If the gateway ever
154+
serves a control plane reachable by a process that is *not* already running as
155+
this user - a shared or multi-user host, a container boundary, a network-exposed
156+
listener - the "local code execution is a precondition" argument stops holding
157+
and authentication becomes load-bearing again. The disclosure fields make that
158+
reopening cheap to spot: anything consuming `endpoint_authenticated` is a caller
159+
that already knows to ask.
160+
161+
## Test plan {#tests}
162+
163+
`test/plugins/ai-gateway-session-responder-trust.test.js` stands up an
164+
**impostor** responder - a listener that reads the session id off the query
165+
string or body and echoes it back with `ignored: true` - and pins both halves of
166+
the accepted outcome:
167+
168+
- the impostor's answer **is** believed (exit 0, `ignored: true`), including on
169+
the `daemon_status` path with a live pid file and a `status.json` naming the
170+
impostor's port, so the residual cannot change by accident, and
171+
- every such answer discloses that the responder was not authenticated, in the
172+
human text and as `endpoint_authenticated: false`, on `status`, on `ignore`,
173+
on the genuine control route, and on `unknown` reports.

0 commit comments

Comments
 (0)