Uh oh!
There was an error while loading. Please reload this page.
Give the session opt-out a reader: hyp session ignore/unignore/status, fail-closed - #439
Conversation
The ephemeral per-session opt-out (LLP 0066/0067) could only be written.
`POST` and `DELETE /_hypaware/ignore/session` toggled an in-memory set and
nothing could read it back, so both ways the opt-out stops applying - a
gateway restart dropping the set (LLP 0066 #ephemeral) and a session id
that changes under the client - were invisible. The user believed they were
not being recorded, they were, and nothing told them. That matters most
where the mechanism is load-bearing: LLP 0100 R3 has the privacy review opt
this session out before surveying the most sensitive content on the machine.
Root cause: a write-only control surface. Not the enforcement path, which
is correct, and not ephemerality, which is deliberate.
- `control.js` serves `GET /_hypaware/ignore/session?session_id=<id>`,
reporting `{ session_id, ignored, total }` without mutating anything. The
id rides `URLSearchParams` so the R5 raw-token discipline holds for reads.
- New `session_command.js` contributes `hyp session ignore | unignore |
status`. `status` fails closed: an unreachable gateway, an unresolvable
endpoint or an unresolvable session id all report `unknown` with
`ignored: null` and exit 3, never `ignored: false`. Confirmed-ignored is
0, confirmed-not-ignored is 1, so the three answers stay distinguishable.
Every output mode names `hyp policy show`, the independent folder governor
this verb does not cover (LLP 0066 R7).
- Endpoint resolution reads the daemon's proven bound port from status.json
(LLP 0086/0114) then a pinned `listen`, never a guessed default port.
- Session-id resolution: explicit argument, then `CLAUDE_CODE_SESSION_ID`,
then the Codex rollout whose `payload.cwd` matches the invocation cwd.
It refuses on ambiguity rather than guessing newest-by-mtime, which would
opt out the wrong session while reporting the user covered.
- The verbs live on the gateway plugin, which owns the route (LLP 0003), so
one client-agnostic group serves Claude and Codex; Codex previously had
working enforcement and no front door at all.
LLP 0066 gains #readable and R9-R11; LLP 0067 gains #status-endpoint, #cli,
#exit-codes, #cli-endpoint and #cli-session-id.
Also fixes a latent bug in the new client: Node's HTTP client applies no
chunked framing to a DELETE, so a body written without an explicit
content-length is silently dropped and the route 400s.
Regression coverage in test/plugins/ai-gateway-session-status.test.js: 8 of
its 16 tests fail on the parent commit.Review of #439 found the fail-closed contract (LLP 0066 R10) still had a hole: a 200 with JSON in it was accepted as an authoritative membership answer without checking that it was a control response, or that it was about the session that was asked about. Both endpoint-discovery paths can land on a port another local process now owns (a pinned `listen` whose gateway is gone, a recycled ephemeral port). Against such a responder, `status` reported: 200 {} -> ignored: false, exit 1 200 {"session_id":"other","ignored":true} -> ignored: true, exit 0 200 [] -> ignored: false, exit 1 200 {"ignored":"true"} -> ignored: false, exit 1 The second is the dangerous direction: a confident "you are covered" that nothing established. The client now accepts a control response only when it is a JSON object with a boolean `ignored`, a numeric `total`, and a `session_id` echoed back byte-identical to the token sent (the route echoes it verbatim, R5). Anything else is `unknown` / exit 3. The mutation verbs apply the same check, so `hyp session ignore` cannot print a success it did not get. Also: the bounded Codex rollout walk now reports truncation and refuses rather than resolving, because "exactly one cwd match" over a partial listing is an artefact of the bound, not a fact. 9 new regression tests; LLP 0066 R10 and LLP 0067 gain #cli-response-check. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 29, 2026
Verdict: sound design, one real hole in the headline claim. Fixed and pushed.The reader is the right fix for #432 and the LLP work is honest. But the fail-closed Everything else I attacked held up. Mutation-testing confirms the new tests are real FindingsHIGH-1 (fixed) - a |
| response on the resolved port | status reported | exit |
|---|---|---|
200 {"ok":true} | not_ignored, ignored: false | 1 |
200 {"session_id":"someone-else","ignored":true,"total":9} | ignored, ignored: true | 0 |
200 {"session_id":"sess-real","ignored":"true"} | not_ignored, ignored: false | 1 |
200 [] | not_ignored, ignored: false | 1 |
Row 2 is the dangerous one: exit 0, "you are opted out", off a reply that was
about a different session. Row 1 is the plain R10 violation the PR's own spec text
forbids: ignored absent silently became false. runSessionIgnore /runSessionUnignore had the identical shape - they would printsession X: ignored - the gateway will drop this session and exit 0 off 200 {}.
Also: typeof [] === 'object' and ![] is false, so the array guard did not
actually reject arrays.
Fixed by validating the answer before believing it: object (not array), ignored
a real boolean, total a real number, and session_id echoed back byte-identical
to the token sent. The route already echoes it verbatim (R5, andai-gateway-session-status.test.js asserts byte-exactness), so a mismatch means the
reply describes a different session and establishes nothing. All four rows above now
report unknown / exit 3, with a reason naming what was wrong. The mutation verbs
share the check.
MEDIUM-1 (fixed) - a truncated rollout scan made the "refuses on ambiguity" claim unsound
session_command.js:376 (at 221788c): rolloutFiles stops at MAX_ROLLOUT_SCAN
(5000) and returns a bare string[], so the caller cannot tell a complete listing
from a partial one. candidates.length === 1 over a partial listing is an artefact of
the bound, not a fact - the rollout that would have made it ambiguous may be one of
the files never looked at. The verb would then resolve, act on, and report a
confidently wrong session.
Fixed: rolloutFiles now reports truncated (bound hit or unvisited
directories left on the stack) and resolveSessionIdForCli refuses on truncation,
naming the bound and asking for an explicit id. An optional maxScan makes it
testable without writing 5000 files.
MEDIUM-2 (NOT fixed - needs a design call, flagging only)
A cwd with exactly one historical Codex rollout resolves confidently to that
rollout's session id even when that session is long dead and the user is currently in
some other client with no CLAUDE_CODE_SESSION_ID. The ambiguity refusal only fires
at >= 2 candidates, so the single-stale-rollout case is precisely the one that slips
through, and a rollout file carries no liveness signal to key on. It is bounded in
blast radius (status reports about the wrong session; ignore opts out a dead one),
but it is a confident answer about the wrong session, which the PR body itself rates
worse than unknown. Left for the author: the honest options are a recency bound on
the rollout, or requiring an explicit id whenever the id came off disk rather than the
environment.
LOW-1 (fixed as part of HIGH-1) - the non-happy transport paths were untested
The suite covered "connection refused", "no endpoint", "no session id" but not
non-200, unparseable body, or wrong-shaped body. The first two paths were already
correct; the wrong-shape path was HIGH-1. All are now covered.
LOW-2 (NOT fixed) - no -- terminator in parseArgv
session_command.jsparseArgv rejects any --prefixed argument as an unknown flag,
so a session id beginning with - cannot be passed at all. Vanishingly unlikely for
UUID-shaped ids; noting for completeness.
What I checked and found correct
- Exit codes match the contract end to end:
0ignored /1not-ignored /2
usage /3unknown, andSESSION_EXIT_UNKNOWNis asserted distinct from both0
andSESSION_EXIT_NOT_IGNOREDin the suite. - Endpoint resolution is not guessy.
resolveLiveGatewayEndpointFromStatusgates
onreadPidFile+processIsAlivebefore trustingstatus.json
(src/core/daemon/status.js:145-166), so a stale snapshot from a dead daemon is not
handed back. No hardcoded:8787anywhere in the new code. - GET round-trip is byte-exact.
control.jsreturns the rawsearchParamsvalue
and looks up the raw token;' sess pad+plus 'survives intact. Trim is used only
for the emptiness check, matching the existing R5 discipline on the write path. - 405
allowheader isGET, POST, DELETE, andtest/plugins/ai-gateway-control-route.test.js:120asserts it. - The DELETE
content-lengthfix is real and is tested. Mutation-verified: I
removed thecontent-lengthheader andhyp session ignore / unignore round-trip through the control routefails. Node
applies no chunked framing to DELETE, exactly as the comment says. - The new tests genuinely fail without the fix. Reverting
control.jsto its
parent makes 9 tests fail across the two suites (8 in the new file plus theallowassertion). Mutating the fail-closed guard to emitstatus: 'not_ignored', ignored: falsefailshyp session status FAILS CLOSED when the gateway is unreachable. My new guards are
load-bearing too: disabling the boolean check and the session-id echo check fails 2
of the new tests. @refannotations all resolve.LLP 0066#control-path,#readable,#ephemeral,#requirements;LLP 0067#cli,#status-endpoint;LLP 0086#manual-attach-reads-the-live-portand#endpoint-discovery(both anchors
exist atllp/0086-attach-tracks-ephemeral-port.decision.md:78).- House style: no semicolons, no em dashes, no inline
import('...')types, no@typedef, root-anchored.jstype specifiers for cross-tree imports (./types.js
for same-directory is the established pattern in this plugin, e.g.api.js:5).export typefor the two unions is precedented attypes.d.ts:137. Two-word
command names are precedented (graph project,claude-desktop status).
Scope
Honest answer: I would have split it, but the PR's argument is defensible.
The fix for #432 is the 32-line GET branch in control.js. The 516-line CLI group is
a new user-facing surface carrying endpoint discovery and a Codex rollout scanner -
that is where essentially all the risk lives, and it is where both of my
substantive findings landed. Against that: a GET route with no client leaves the user
holding curl and a port they cannot discover, which does not actually close the issue
for a human, so "the state is readable, there is just no reader" is a fair rebuttal.
If it were mine I would have landed control.js plus its tests first, thenhyp session status, then session-id resolution as a third change - the rollout
resolver in particular is separable and is the piece a reviewer most needs to see on
its own. Not splitting it here, as instructed. Flagging it so the author can decide.
Fail-closed contract: my judgement after the fix
It holds now for everything I could construct: connection refused, no endpoint
resolvable, no session id resolvable, timeout (req.destroy(err) surfaces through theerror handler), non-200, unparseable body, wrong-shaped body, and an answer about
another session. What it still cannot detect is the issue's failure mode 2 - the
client re-minting the session id under you - since the CLI resolves the current id
and asks about that one. The PR body says so explicitly and defers it, which I agree is
right: it needs recorded evidence, not code. MEDIUM-2 above is the one remaining path
to a confident-but-wrong answer.
Pushed
0fe0989e0ef7dddae5b4b3e5275494cd6322e901 on fix/issue-432 - validator plus
truncation refusal plus 9 regression tests, with LLP 0066 R10 and LLP 0067#cli-response-check updated in the same commit.
npm test: 2817 pass / 8 fail, the 8 being the pre-existing, unrelatedtest/core/leave-command.test.js failures that reproduce identically onorigin/master. npx tsc --noEmit clean.npm run smoke -- session_optout_capture_drop passes.
Unresolved
- MEDIUM-2: a single stale Codex rollout resolves to a dead session id.
- LOW-2: no
--terminator inparseArgv. - Scope: the CLI surface could be a separate PR (author's call; not split here).
…me from Round 2 review of #439. The ambiguity refusal only fires at two or more cwd-matching Codex rollouts, so a cwd where Codex ran exactly once, days ago, resolved confidently to a DEAD session id. `hyp session ignore` would then opt out that dead id and print "the gateway will drop this session" while the session the user is actually in kept being recorded - the same confident-answer-about-the-wrong- session defect the response validation removed on the other input. A single match is now only usable when its rollout was written to recently; a stale-only match is an error naming the file and its age, with the explicit-id escape hatch. The bound narrows that window; it cannot close it (a session that ended minutes ago is still inside it, and mtime is a liveness proxy, not proof). So the residual is made visible instead of silent: every output mode now reports the provenance of both inputs an answer rests on - whether the session id was stated (argument / CLAUDE_CODE_SESSION_ID) or INFERRED from a named rollout, and whether the endpoint was proven bound by a live daemon or merely pinned in config. The write verbs print it too, where "ignored" reads as done. Also: `--` ends flag parsing, so a session id beginning with `-` is reachable at all (the id is an opaque provider token the verb never interprets, so there is no shape it may be refused for); a control response is cut off at a 64 KiB bound rather than buffered whole, so whatever owns the port cannot grow the CLI at will; and the request deadline is wall-clock, since `timeout` is socket inactivity and a trickling responder resets it forever. LLP 0066 gains R12 and extends R10 to cover an id that could only be inferred; LLP 0067 gains #cli-provenance and the staleness rule under #cli-session-id. Every new guard is pinned by a test that fails when the guard is removed.
philcunliffe
commented
Jul 29, 2026
Round 2 (final) - reviewed |
| Attack | Result |
|---|---|
200 {"__proto__":{"ignored":true,"total":1,"session_id":"sess-real"}} | refused, exit 3. JSON.parse creates __proto__ as an own data property, so the prototype is untouched and body.ignored stays undefined |
session_id echoed as the number 12345 against the string id "12345" | refused - !== is strict, no coercion |
session_id echoed NFD against an NFC id (visually identical) | refused - byte comparison, not normalization |
session id containing &session_id=, trying to inject a duplicate query param | no injection. URLSearchParams percent-encodes it (?session_id=a%26session_id%3Db), and the server's searchParams.get takes the first anyway. Round-trips correctly |
201 / 204 / 302 / 400 carrying a perfectly valid body | refused - only 200 is accepted, and no redirect is followed |
mutation verbs (POST/DELETE) against a 200 {"ok":true} responder | refused, nothing printed as opted out |
All six behave. Round 1's fix is sound and the shape/echo check is doing real work - mutating if (body.session_id !== sessionId) to if (false) fails test 15 by name, so it is pinned.
Residual, and it is inherent, not a defect in the fix: a listener that simply echoes the token it received still yields a confident answer.
ECHO ROGUE status -> exit 0 {"status":"ignored","ignored":true,"total":42}
ECHO ROGUE ignore -> exit 0 "session sess-real: ignored - the gateway will drop this session"
Nothing at this layer can distinguish that from the gateway: the echo proves the responder saw our token, not that it is the gateway, and no proof-of-identity field would help since it would be public. The benign case (some other dev server on a stale pinned port) is fully covered by the shape check. The remaining case needs a local process deliberately impersonating the control route. I did not treat that as a blocker, but I did make the weaker evidence visible - see MEDIUM-2's fix below, which covers this too.
Findings
MEDIUM-2 (carried from round 1) - a single stale rollout resolved to a dead session id. FIXED.
session_command.js:367-369 (at 0fe0989e): the ambiguity refusal only fires at >=2 cwd matches, so a cwd where Codex ran exactly once, days ago, had exactly one match and resolved confidently to a finished session's id. hyp session ignore would then opt out the dead id and print "the gateway will drop this session" while the session the user is actually in kept being recorded. That is the same wrong-session defect as believing an unvalidated control reply, arriving through the other input.
I judged this fixable within the design, and fixed it. Two parts:
A staleness refusal. A unique cwd match is only usable when its rollout was written to within 30 minutes. The justification is not arbitrary: a running Codex session appends to its rollout on every turn, and the tool call that invokes
hypis itself preceded by rollout writes, so the legitimate case is seconds-to-minutes old. A stale-only match is now an error naming the file and its age (... rollout-2026-01-01-aaa.jsonl) was last written 3d ago, so it is a finished session rather than this one), with the same explicit-id escape hatch the ambiguity path uses. This is strictly a narrowing: it can only turn a confident answer into a refusal, never the reverse.Provenance, because the bound narrows the window but cannot close it. A session that ended five minutes ago is still inside it, and mtime is a liveness proxy, not proof. So the verb now says where each of its two claims came from - this is my session id and that endpoint is the gateway.
--jsoncarriessession_id_source/session_id_evidence(the rollout filename) andendpoint_source; the human form printssession id: INFERRED from rollout-....jsonl on disk, not stated by the clientand, when the port came from a pinnedlistenrather than a live daemon'sstatus.json,endpoint: from the pinned listen, not a live daemon - nothing proved the gateway still owns that port. The write verbs print it too, where "ignored" reads as done. That second line is also the only available mitigation for the echo-rogue residual above.
This is the PR's own thesis turned on its weakest inputs: a privacy control that can be wrong must at least say where it can be wrong, so a user handed the wrong session sees it instead of discovering it in the cache. Spec'd as LLP 0066 R12 and LLP 0067 §cli-provenance; the staleness rule extends R10 and §cli-session-id.
LOW-2 (carried from round 1) - no -- terminator. FIXED.
Confirmed: hyp session status -- -weird-id exited 2 with unknown flag --. The session id is an opaque provider token the verb never interprets (LLP 0066 R5), so there is no shape it may legitimately be refused for - without a terminator an id beginning with - was simply unreachable, and that session could never be checked or opted out at all. parseArgv now ends flag parsing at a bare --.
LOW-3 (new) - unbounded response buffering. FIXED.
controlRequest's res.on('data') accumulated without limit. A rogue on the resolved port streaming 40 MB grew the CLI's heap by ~85 MB before the parse failed. The route's own answers are a few dozen bytes, so the client now cuts off at 64 KiB (mirroring the server's MAX_BODY_BYTES) and reports it as "not the HypAware control route". Fail-closed direction was already correct; this is about not letting whatever owns the port grow the process at will.
LOW-4 (new) - REQUEST_TIMEOUT_MS did not bound the request. FIXED.
timeout in http.request is socket inactivity, so a responder trickling a byte every 1.5 s resets it forever. Measured: a 5000 ms timeout let a request run 10.5 s, bounded only by when my test server chose to stop. Added a wall-clock deadline alongside it. Again fail-closed throughout - it eventually returned unknown - but a privacy check needs an answer or a refusal in bounded time.
Confirmed sound, no action
- Round 1's MEDIUM-1 truncation refusal is pinned: mutating
if (scan.truncated)toif (false)fails test 24 by name. - Control-route
GETbranch (control.js:73-86) mutates nothing, 400s on blank/missing id, round-trips the token verbatim, and joinsallow: GET, POST, DELETE. Duplicatesession_idparams resolve to the first, which the echo check then makes moot. - The
content-length-on-DELETE fix is correct and load-bearing.
Tests have teeth (mutation-verified)
I removed each guard in turn and confirmed a named test fails. Not one guard is unpinned:
| Mutation | Test that fails |
|---|---|
| neuter the staleness bound | 25 a SINGLE STALE rollout refuses..., 27 the staleness bound is what refuses... |
drop the evidence field | 26 a fresh rollout still resolves..., 28 a disk-inferred id is never presented... |
provenanceNotes returns [] | 28 a disk-inferred id..., 29 an endpoint nothing proved is the gateway... |
drop the -- terminator | 30 `--` ends flag parsing... |
| drop the response-size cap | 31 an oversized control response is refused rather than buffered |
accept any session_id echo (round 1) | 15 ...fails closed when the answer is about a DIFFERENT session |
| drop the truncation refusal (round 1) | 24 a truncated rollout scan refuses... |
Test 27 is deliberately paired with 25: the same rollout resolves under a wider maxAgeMs, which proves the refusal is about staleness and not about the cwd match. Test 31 asserts the reason names the byte bound, so it fails if the body is merely swallowed whole and only then found unparseable.
Verification
npm test: 2824 pass / 8 fail, and those 8 are the same pre-existing test/core/leave-command.test.js failures that reproduce unchanged on origin/master (7 net new tests vs 0fe0989e's 2817). npm run typecheck clean. npm run smoke -- session_optout_capture_drop passes. LLP 0066 and 0067 edits landed in the same commit as the code, per the repo convention.
What remains UNRESOLVED (for triage)
One item, and it needs a follow-on issue rather than a decision on this PR.
A Codex session that ended within the last 30 minutes can still be resolved from its rollout and produce a confident answer about the wrong session. The staleness bound narrows the window from days to minutes and the provenance line makes the inference visible, but it does not close it, because mtime is a proxy for liveness, not proof of it.
Actually closing it needs a liveness signal Codex does not currently expose - either a session id in the environment (as Claude has via CLAUDE_CODE_SESSION_ID, which is why the Claude path has none of this exposure) or a terminal record in the rollout that marks a session finished. Both are upstream facts to establish empirically, not choices to make here. It sits naturally beside the PR's own deferred item ("does claude --resume mint a new session id?") - the same "settle it with recorded evidence" investigation.
This is not a blocker and I am not asking triage to park anything. The failure mode is now bounded, refused in the common case, and visible in the residual case, which is a strict improvement over 0fe0989e and over the pre-PR state where the control had no reader at all. If triage wants a decision recorded: the alternative was to refuse every disk-derived id and require an explicit argument for Codex, which would delete the Codex path of all three verbs - Codex users would be back to having working enforcement and no front door, which is the gap this PR exists to close. I judged the narrowed-plus-visible option clearly better and took it.
philcunliffe
commented
Jul 29, 2026
Triage (LLP 0017): non-blocking, follow-up filedReviewed head Classification: all four residual items are PREFERENCE / follow-on, not TRUE BLOCKERs.
Follow-up issue: #442 (enumerates A-D with file:line and rationale). Not marking ready for review or merging - that's the author's / next rung's call. This PR is a strict improvement over |
Uh oh!
There was an error while loading. Please reload this page.
…me guess (#442 A, D) (#450) * hyp session: take Codex's stated CODEX_THREAD_ID over the rollout mtime guess Issue #442 item A. `resolveSessionIdForCli` fell back to a Codex rollout whose `payload.cwd` matched the invocation cwd, treating an mtime inside a 30-minute bound as evidence the session was live. mtime is a proxy, so a session that ended inside the window still resolved, and `hyp session ignore` would opt out the finished session, print "the gateway will drop this session", and leave the session the user is actually in recording. PR #439 deferred this on the ground that Codex exposes no liveness signal. That is no longer true: openai/codex#10096 (merged 2026-02-03, closing openai/codex#8923) injects `CODEX_THREAD_ID` into the environment of every shell/exec tool subprocess, exempt from `shell_environment_policy` filtering. Its presence is proof rather than proxy - a session that has ended cannot have spawned this process - and its value is `session.conversation_id`, the same identifier the rollout's `session_meta.payload.id` carries and its filename embeds. So this is the id the disk scan already produced, from a source that states it instead of inferring it. The disk scan stays as the fallback for a Codex predating the variable and for a hand invocation no client spawned, keeping its staleness bound and its `INFERRED from <rollout>` provenance line. A stated id carries neither, because neither applies to it. Two clients each stating an id (environments nest: Codex runs `claude`, or the reverse) is ambiguity and refuses naming both, rather than preferring one and being wrong half the time. Strictly narrowing: it can only turn a confident answer into a refusal. Also settles issue #442 item D in LLP 0066: `--resume` / `--continue` reuse the session id and `--fork-session` mints a new one, per Claude Code 2.1.215's own flag reference, so an opt-out survives a resume and is dropped by a fork. And records in LLP 0067 why item B (a local listener that echoes the token) has no cheap fix: a shared secret is readable by any same-uid process, and peer-pid verification has no portable form. Items B and C remain deferred; see the PR body. * LLP 0067: name the thread-vs-session-container gap, and call the env var proof of provenance not liveness Review of #450. The CODEX_THREAD_ID premise holds: verified against the merged openai/codex#10096 diff (the insert is step 6 of core/src/exec_env.rs, AFTER the include_only retain, so the exemption is structural) and against codex-rs/protocol/src/protocol.rs, where `SessionMeta { session_id: SessionId, id: ThreadId }` confirms the rollout's `payload.id` is the same ThreadId the variable carries. Two claims around it were tighter than the evidence supports. 1. "Presence is proof of liveness" is proof of PROVENANCE. It is liveness for a `hyp` run inside a tool call the client is blocked on, which is the path that matters, but a process that outlives its spawn (a server or tmux pane started from a tool call) inherits the variable and keeps it after the session ends. Strictly narrower than the mtime bound it replaces, but not zero. 2. The identity caveat understated its own consequence. The drop keys on the session container (LLP 0066 §scope); CODEX_THREAD_ID is the thread. Codex derives the session id from the thread id for a ROOT thread (same uuid), but a SUBAGENT thread keeps the root's session id and mints its own, so an opt-out taken inside a subagent tool call states an id the drop never matches and the verb reports success for a suppression that suppresses nothing. Pre-existing and shared with the disk scan, so not introduced here, but now on a live path and now closable: current Codex writes `session_meta.session_id` beside `session_meta.id`, so the drop key itself is on disk. Also corrects §cli-response-check's "neither on Windows", which reads as "no mechanism on Windows"; Windows has GetExtendedTcpTable, it is just a third implementation and a native dependency. The argument is unchanged. Docs and comments only. No behaviour change: 2850 pass / 8 known pre-existing leave-command failures, typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com> * Finish round 1's liveness correction: two passages still carried the retracted claim Round 2 review of #450. Round 1 downgraded "CODEX_THREAD_ID is proof of liveness" to "proof of provenance" in LLP 0067 §cli-session-id and in the STATED_SESSION_ID_VARS comment, naming the residual (a process that outlives its spawn inherits the variable and keeps it after the session ends). It missed two other places that state the retracted version, one of them normative. 1. LLP 0066 R11 justified "a stated id carries no staleness bound" with "only a running session can have spawned the process it is set in" - the liveness claim itself, in a MUST-shaped requirement. The requirement is right (an env var has no timestamp to bound), only its rationale overclaimed. Restated as provenance, with the outlives-its-spawn residual and a pointer to LLP 0067 §cli-session-id. 2. `provenanceNotes` justified qualifying only the disk inference with "because only it can name a session that has already ended", which LLP 0067 now says is false: a hyp run from a detached descendant of a tool call can name a finished session too. Replaced with the reason LLP 0067 §cli-provenance actually gives - the stated id's residual is far narrower, and qualifying both would train the reader to skip the caveat on the one path where it is load-bearing. Docs and comments only, no behaviour change. Round 1's premise re-verified against openai/codex main: SessionMeta { session_id: SessionId, id: ThreadId } with neither field carrying a serde attribute, root threads take SessionId::from(thread_id) (a straight uuid copy) while a non-root agent takes agent_control.session_id() with an independently minted ThreadId, and PR 10096's insert is post-retain, post-exclude and post-set in exec_env.rs. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: neutral-loop <neutral-loop@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
…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>
The defect
The ephemeral per-session opt-out (LLP 0066 / LLP 0067) was write-only.
POSTandDELETE /_hypaware/ignore/sessiontoggled an in-memory set; nothing could read it back. So both ways the opt-out stops applying were invisible:Neither is a bug in the mechanism. Both were a bug in the surface: the user believed they were not being recorded, they were, and nothing told them. That matters most where the mechanism is load-bearing (LLP 0100 R3 opts the session out before surveying the most sensitive content on the machine).
Root cause: a write-only control surface. Not the adapter drop, which is correct. Not ephemerality, which is deliberate and is not changed here (LLP 0066 non-goal 2 stands: nothing is persisted).
Regression test (the ground-truth gate)
test/plugins/ai-gateway-session-status.test.js(new, 16 tests). 8 of them fail on the parent commit, including:the ignored-session set is readable: GET reports current membership(405 before)a gateway restart no longer fails open SILENTLY: the reader reports the resumed recording- opt out, then rebuild the set the way a daemon restart does, then read: the resumed recording is now observable instead of silenthyp session status FAILS CLOSED when the gateway is unreachable: unknown, never ignored:falsehyp session status names the folder governor rather than omitting it (R7)It also covers
not_ignoredas a distinct answer, an unresolvable endpoint, an unresolvable session id, verbatim token round-trip (R5), and the Codex rollout resolver (unique cwd match resolves; several or none refuses).The fix
control.jsservesGET /_hypaware/ignore/session?session_id=<id>returning the same{ session_id, ignored, total }shape, mutating nothing. The id ridesURLSearchParamson both ends so the R5 raw-token discipline holds for reads exactly as for writes.GETjoinsallow: GET, POST, DELETE.session_command.js(new) contributeshyp session ignore | unignore | status.statusfails closed. Unreachable gateway, unresolvable endpoint, unresolvable session id all reportunknownwithignored: nulland exit3- neverignored: false. Confirmed-ignored exits0, confirmed-not-ignored exits1, so "I could not ask" and "I asked, and you are being recorded" stay different answers.hyp policy showas the independent folder governor this verb does not cover (LLP 0066 R7): a user in a.hypignored repo must not read "not ignored" as "I am being recorded".status.json(LLP 0086, liveness-gated), then a pinnedlisten, then errors. Never the stale hardcodedhttp://127.0.0.1:8787the skill bodies carry (hyp initwrites an explicitlisten: 127.0.0.1:8787, forfeiting LLP 0114's fixed default and its EADDRINUSE fallback #431), and never a guessed port under LLP 0114's ephemeral fallback.CLAUDE_CODE_SESSION_ID, then the Codex rollout under$CODEX_HOME/sessions/**whosepayload.cwdmatches the invocation cwd. It refuses on ambiguity (several matches, or none) naming the candidates, rather than taking newest-by-mtime the way thehypaware-privacyskill body does - guessing would opt out the wrong session while reporting the user covered, which is the same fail-open shape this PR removes.DELETE, so a body written without an explicitcontent-lengthis silently dropped and the route 400s.Why the CLI surface is in scope
It is the mechanism, not an add-on: "the state is readable, there is just no reader". The gateway plugin owns the route so it owns the verb (LLP 0003), and one client-agnostic group serves both clients - Codex had working enforcement and no front door at all. Deliberately not
hyp ignore --session: LLP 0110 diagnosed exactly that shape. As a plugin-contributed group it inherits the LLP 0098/0099 inactive-pluginrepair:line (manifestcontributes.commandsadded).Deliberately not in this PR
.hypignore. This is observability of an ephemeral control, not durability.hypaware-ignore/hypaware-unignore/hypaware-privacyStep 1) onto the new verbs, and havinghypaware-privacyre-checkhyp session status. The issue files these as follow-on; they are skill-content edits with their own review surface.claude --resume/--continue/--fork-sessionmint a new session id?). The issue says this is unsettled, and it needs recorded evidence, not code. This PR does not decide it - it makes the failure detectable whichever way it turns out, which is what the corpus was missing. Worth its own investigation issue.Docs
LLP 0066 gains
#readableand R9-R11 (readable set; fail-closed read; name the other governor). LLP 0067 gains#status-endpoint,#cli,#exit-codes,#cli-endpoint,#cli-session-id, plus test-plan and annotation-map rows.Verification
npm test: 2808 pass / 8 fail, and the same 8leave-commandfailures reproduce unchanged onorigin/master(pre-existing, unrelated).npm run typecheckclean.npm run smoke -- session_optout_capture_droppasses.Fixes#432