Skip to content

Give the session opt-out a reader: hyp session ignore/unignore/status, fail-closed - #439

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-432
Jul 29, 2026
Merged

Give the session opt-out a reader: hyp session ignore/unignore/status, fail-closed#439
philcunliffe merged 3 commits into
masterfrom
fix/issue-432

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The defect

The ephemeral per-session opt-out (LLP 0066 / LLP 0067) was write-only. POST and DELETE /_hypaware/ignore/session toggled an in-memory set; nothing could read it back. So both ways the opt-out stops applying were invisible:

  1. Gateway restart drops the set (LLP 0066 §ephemeral, accepted by design).
  2. The session id changes under the client (resume / fork): the entry no longer matches and recording resumes.

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 silent
  • hyp session status FAILS CLOSED when the gateway is unreachable: unknown, never ignored:false
  • hyp session status names the folder governor rather than omitting it (R7)

It also covers not_ignored as 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.js serves GET /_hypaware/ignore/session?session_id=<id> returning the same { session_id, ignored, total } shape, mutating nothing. The id rides URLSearchParams on both ends so the R5 raw-token discipline holds for reads exactly as for writes. GET joins allow: GET, POST, DELETE.
  • session_command.js (new) contributes hyp session ignore | unignore | status.
    • status fails closed. Unreachable gateway, unresolvable endpoint, unresolvable session id all report unknown with ignored: null and exit 3 - never ignored: false. Confirmed-ignored exits 0, confirmed-not-ignored exits 1, so "I could not ask" and "I asked, and you are being recorded" stay different answers.
    • Every output mode names hyp policy show as 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".
    • Endpoint resolution reads the daemon's proven bound port from status.json (LLP 0086, liveness-gated), then a pinned listen, then errors. Never the stale hardcoded http://127.0.0.1:8787 the skill bodies carry (hyp init writes an explicit listen: 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.
    • Session-id resolution: explicit argument, then CLAUDE_CODE_SESSION_ID, then the Codex rollout under $CODEX_HOME/sessions/** whose payload.cwd matches the invocation cwd. It refuses on ambiguity (several matches, or none) naming the candidates, rather than taking newest-by-mtime the way the hypaware-privacy skill body does - guessing would opt out the wrong session while reporting the user covered, which is the same fail-open shape this PR removes.
  • 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.

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 nothyp ignore --session: LLP 0110 diagnosed exactly that shape. As a plugin-contributed group it inherits the LLP 0098/0099 inactive-plugin repair: line (manifest contributes.commands added).

Deliberately not in this PR

  • Persisting the set. LLP 0066 non-goal 2 stands; the durable mechanism is .hypignore. This is observability of an ephemeral control, not durability.
  • Collapsing the skill bodies (hypaware-ignore / hypaware-unignore / hypaware-privacy Step 1) onto the new verbs, and having hypaware-privacy re-check hyp session status. The issue files these as follow-on; they are skill-content edits with their own review surface.
  • Settling failure mode 2 empirically (does claude --resume / --continue / --fork-session mint 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 #readable and 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 8 leave-command failures reproduce unchanged on origin/master (pre-existing, unrelated). npm run typecheck clean. npm run smoke -- session_optout_capture_drop passes.

Fixes#432

neutral-loopand others added 2 commits July 29, 2026 04:07
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

Copy link
Copy Markdown
ContributorAuthor

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
contract (LLP 0066 R10) did not hold at 221788c: I found five concrete paths where
hyp session status returned a confident membership answer that nothing had
established, including one that reports ignored: true / exit 0 for a session the
gateway never confirmed. That is the same fail-open shape the PR exists to remove,
moved one layer out. Fixed in 0fe0989.

Everything else I attacked held up. Mutation-testing confirms the new tests are real
gates, not decoration.


Findings

HIGH-1 (fixed) - a 200 was treated as evidence, so status could report a confident answer nothing established

hypaware-core/plugins-workspace/ai-gateway/src/session_command.js:494-503 (at 221788c):

constparsed=JSON.parse(raw)if(!parsed||typeofparsed!=='object'){/* error */}resolve({ok: true,body: parsed})

and then session_command.js:139 / :187:

constignored=result.body.ignored===true

Any JSON object from the resolved endpoint became an authoritative membership answer.
This matters because neither discovery path proves the thing on that port is the
gateway. resolveLiveGatewayEndpointFromStatus is liveness-gated (good, I checked
src/core/daemon/status.js:145), but it returns undefined whenever the gateway
source recorded no bound port, and the fallback is then configuredGatewayEndpoint -
a pinned listen that any other local process may now own, or a recycled
ephemeral port.

I ran the CLI against a rogue local listener on the resolved port. Actual behaviour at
221788c:

response on the resolved portstatus reportedexit
200 {"ok":true}not_ignored, ignored: false1
200 {"session_id":"someone-else","ignored":true,"total":9}ignored, ignored: true0
200 {"session_id":"sess-real","ignored":"true"}not_ignored, ignored: false1
200 []not_ignored, ignored: false1

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 print
session 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, and
ai-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: 0 ignored / 1 not-ignored / 2
    usage / 3 unknown, and SESSION_EXIT_UNKNOWN is asserted distinct from both 0
    and SESSION_EXIT_NOT_IGNORED in the suite.
  • Endpoint resolution is not guessy.resolveLiveGatewayEndpointFromStatus gates
    on readPidFile + processIsAlive before trusting status.json
    (src/core/daemon/status.js:145-166), so a stale snapshot from a dead daemon is not
    handed back. No hardcoded :8787 anywhere in the new code.
  • GET round-trip is byte-exact.control.js returns the raw searchParams value
    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 allow header is GET, POST, DELETE, and
    test/plugins/ai-gateway-control-route.test.js:120 asserts it.
  • The DELETE content-length fix is real and is tested. Mutation-verified: I
    removed the content-length header and
    hyp session ignore / unignore round-trip through the control route fails. Node
    applies no chunked framing to DELETE, exactly as the comment says.
  • The new tests genuinely fail without the fix. Reverting control.js to its
    parent makes 9 tests fail across the two suites (8 in the new file plus the
    allow assertion). Mutating the fail-closed guard to emit
    status: 'not_ignored', ignored: false fails
    hyp 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.
  • @ref annotations all resolve.LLP 0066#control-path, #readable,
    #ephemeral, #requirements; LLP 0067#cli, #status-endpoint;
    LLP 0086#manual-attach-reads-the-live-port and #endpoint-discovery (both anchors
    exist at llp/0086-attach-tracks-ephemeral-port.decision.md:78).
  • House style: no semicolons, no em dashes, no inline import('...') types, no
    @typedef, root-anchored .js type specifiers for cross-tree imports (./types.js
    for same-directory is the established pattern in this plugin, e.g. api.js:5).
    export type for the two unions is precedented at types.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, then
hyp 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 the
error 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, unrelated
test/core/leave-command.test.js failures that reproduce identically on
origin/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 in parseArgv.
  • 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

Copy link
Copy Markdown
ContributorAuthor

Round 2 (final) - reviewed 0fe0989e, fixes pushed as 4984cf2

Verdict: approve after this round's fixes. Round 1's two guards hold up under adversarial re-testing, MEDIUM-2 is fixed as far as the design admits, LOW-2 is fixed, and three new findings are fixed. One residual is documented rather than closed, and it needs no decision to land - it needs a follow-on issue. Nothing here parks the PR.


Adversarial re-test of round 1's HIGH-1 fix (validateControlResponse)

I stood up rogue local listeners against runSessionStatus / runSessionIgnore / runSessionUnignore and tried to defeat the check. session_command.js:477-495 (at 0fe0989e).

AttackResult
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 paramno 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 bodyrefused - only 200 is accepted, and no redirect is followed
mutation verbs (POST/DELETE) against a 200 {"ok":true} responderrefused, 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:

  1. 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 hyp is 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.

  2. 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. --json carries session_id_source / session_id_evidence (the rollout filename) and endpoint_source; the human form prints session id: INFERRED from rollout-....jsonl on disk, not stated by the client and, when the port came from a pinned listen rather than a live daemon's status.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) to if (false) fails test 24 by name.
  • Control-route GET branch (control.js:73-86) mutates nothing, 400s on blank/missing id, round-trips the token verbatim, and joins allow: GET, POST, DELETE. Duplicate session_id params 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:

MutationTest that fails
neuter the staleness bound25 a SINGLE STALE rollout refuses..., 27 the staleness bound is what refuses...
drop the evidence field26 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 -- terminator30 `--` ends flag parsing...
drop the response-size cap31 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

Copy link
Copy Markdown
ContributorAuthor

Triage (LLP 0017): non-blocking, follow-up filed

Reviewed head 4984cf268a9418abc8ba895e83165618c6ea88cf independently of both review rounds' severity labels, per the triage rung's mandate after the 2-round fix-loop cap. Read both review comments in full, re-read session_command.js and control.js at head, ran npm test (2824 pass / 8 fail, all 8 the pre-existing leave-command failures, confirmed unchanged), and independently mutation-tested the staleness guard (if (ageMs > maxAgeMs) -> if (false) fails tests 25 and 27 by name, matching round 2's claim).

Classification: all four residual items are PREFERENCE / follow-on, not TRUE BLOCKERs.

  • (A) 30-minute stale-rollout window. Verified directly in code, not taken on faith: provenanceNotes() (session_command.js:320-334) is called from both writeStatus's ignored/not_ignored branches (lines 282, 289) and from runMutation (line 255) - so the INFERRED from <rollout> caveat prints on statusand on ignore/unignore, in both human and --json form (session_id_source/session_id_evidence fields). This is confirmed by passing tests (ai-gateway-session-status.test.js tests 25-29), which assert the caveat text on both the read and write verb, both output modes. The failure mode is strictly narrower than master (which has no reader at all), refused in the common case, and disclosed whenever it resolves. It also does not decide the residual as "acceptable forever" - it's a real gap that needs a liveness signal Codex doesn't expose. Filed as item A in the follow-up.
  • (B) Echo-rogue. Requires a local process already able to bind the resolved port and deliberately impersonate the response shape - inherent to any localhost control-API trust boundary, not a defect this PR introduces or could reasonably close here. endpoint_source gives the user a way to sanity-check the claim. Non-blocking.
  • (C) Scope. Packaging preference (round 1 said so explicitly); not a behavioral risk. Noted for future PRs of this shape.
  • (D) claude --resume/--fork-session session-id investigation. Explicitly deferred by the PR body pending recorded evidence, paired naturally with (A) in the follow-up.

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 master's status quo (a write-only control with no reader at all) for the harm model in #432, and both review rounds' fixes hold up under independent re-verification.

@philcunliffe
philcunliffe marked this pull request as ready for review July 29, 2026 04:38
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 29, 2026
@philcunliffe
philcunliffe merged commit 3aff41f into masterJul 29, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-432 branch July 29, 2026 18:51
philcunliffe added a commit that referenced this pull request Jul 29, 2026
…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>
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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session opt-out fails open and cannot be checked: restart and session-resume both silently resume recording; add hyp session ignore/unignore/status

1 participant

@philcunliffe