Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions hypaware-core/plugins-workspace/ai-gateway/src/session_command.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ const FOLDER_GOVERNOR_NOTE = 'folder: see `hyp policy show` (this verb reports
const EPHEMERAL_NOTE =
'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`.'

/**
* The control plane's authenticity contract, printed beside every **confirmed**
* answer, by the writer and the reader alike.
*
* `validateControlResponse` proves the responder saw our token; nothing proves
* the responder IS the gateway. A local process that binds the resolved port and
* echoes the token back yields a confident `ignored: true` for a session nothing
* is dropping (issue #451). Authenticating it would need peer-process identity,
* which has no portable form, and a gateway-written secret defends nothing
* because whoever can bind that port runs as the same uid and can read the same
* file ([LLP 0067 §cli-response-check](../../../../llp/0067-session-opt-out.design.md#cli-response-check)).
*
* So the guarantee is stated rather than proved, and the statement is
* **unconditional**: the verb cannot tell the gateway from the impostor, so a
* note printed only "when spoofed" would be a claim it cannot make, and its
* absence would read as proof of authenticity.
*
* The endpoint is named in the note rather than left as "that port": on the
* `daemon_status` path this is the only line about the endpoint at all, and a
* reader pasting the output into a support thread should not have to reconstruct
* which address was trusted.
*
* @ref LLP 0166#stated-not-proved [implements]: the responder is never
* authenticated, and every confirmed answer says so.
*
* @param {string} endpoint
* @returns {string}
*/
function responderTrustNote(endpoint) {
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.`
}

/**
* `hyp session status` exit code for a **confirmed** "this session is NOT
* being dropped" read. Distinct from `SESSION_EXIT_UNKNOWN` on purpose: the
Expand Down Expand Up @@ -276,6 +308,9 @@ async function runMutation(argv, ctx, method, usage) {
total,
endpoint: endpoint.endpoint,
endpoint_source: endpoint.source,
// Same field, same constant, on the verbs whose output reads as done.
// @ref LLP 0166#stated-not-proved [implements]
endpoint_authenticated: false,
}) + '\n'
)
return 0
Expand All @@ -295,6 +330,7 @@ async function runMutation(argv, ctx, method, usage) {
idSource: resolvedId.source,
idEvidence: resolvedId.evidence ?? null,
threadId: resolvedId.threadId ?? null,
endpoint: endpoint.endpoint,
endpointSource: endpoint.source,
})) {
ctx.stdout.write(`${note}\n`)
Expand All @@ -313,7 +349,19 @@ async function runMutation(argv, ctx, method, usage) {
*/
function writeStatus(ctx, json, report) {
if (json) {
ctx.stdout.write(JSON.stringify({ ...report, folder_policy: 'hyp policy show' }) + '\n')
ctx.stdout.write(
JSON.stringify({
...report,
// The human note's machine-readable twin, so a JSON consumer does not
// have to parse prose (or, worse, infer authenticity from silence).
// Constant by contract, `unknown` reports included: it is `false`
// because no answer this verb can obtain is authenticated, not because
// this particular one failed a check.
// @ref LLP 0166#stated-not-proved [implements]
endpoint_authenticated: false,
folder_policy: 'hyp policy show',
}) + '\n'
)
} else if (report.status === 'unknown') {
const who = report.session_id ?? '(unresolved)'
ctx.stdout.write(`session ${who}: UNKNOWN - cannot confirm the opt-out is in effect\n`)
Expand All @@ -327,6 +375,7 @@ function writeStatus(ctx, json, report) {
idSource: report.session_id_source,
idEvidence: report.session_id_evidence,
threadId: report.thread_id,
endpoint: report.endpoint,
endpointSource: report.endpoint_source,
})) {
ctx.stdout.write(`${note}\n`)
Expand All @@ -339,6 +388,7 @@ function writeStatus(ctx, json, report) {
idSource: report.session_id_source,
idEvidence: report.session_id_evidence,
threadId: report.thread_id,
endpoint: report.endpoint,
endpointSource: report.endpoint_source,
})) {
ctx.stdout.write(`${note}\n`)
Expand Down Expand Up @@ -368,9 +418,12 @@ function writeStatus(ctx, json, report) {
* bound, and qualifying it too would train the reader to skip the caveat on the
* paths where it is load-bearing.
*
* A live daemon's `status.json` proves the second; a
* pinned `listen` only asserts it, and `validateControlResponse` can prove the
* responder saw our token but not that it is the gateway. Naming the weaker
* The second claim is never proved, only graded. A live daemon's `status.json`
* says the gateway bound that port; a pinned `listen` says only that it was
* asked to, so the weaker source gets its own note. Neither says who answers
* there NOW, and `validateControlResponse` can prove the responder saw our
* token but not that it is the gateway, so `responderTrustNote` rides every
* confirmed answer under both sources (issue #451, LLP 0166). Naming the
* evidence in the output is the only remedy available at this layer, and it is
* this change's own thesis: a control that can be wrong must at least say so.
*
Expand All @@ -388,12 +441,13 @@ function writeStatus(ctx, json, report) {
* idSource: SessionStatusReport['session_id_source'],
* idEvidence: string | null,
* threadId: string | null,
* endpoint: string | null,
* endpointSource: SessionStatusReport['endpoint_source'],
* }} args
* @returns {string[]}
*/
function provenanceNotes(args) {
const { idSource, idEvidence, threadId, endpointSource } = args
const { idSource, idEvidence, threadId, endpoint, endpointSource } = args
/** @type {string[]} */
const notes = []
if (idSource === 'codex_rollout') {
Expand All @@ -416,6 +470,10 @@ function provenanceNotes(args) {
'endpoint: from the pinned `listen`, not a live daemon - nothing proved the gateway still owns that port.'
)
}
// Last, and on every confirmed answer: the weaker of the two endpoint
// sources gets the extra note above, but neither of them authenticates the
// responder, so the contract is stated whichever one produced the port.
if (endpoint) notes.push(responderTrustNote(endpoint))
return notes
}

Expand Down
14 changes: 14 additions & 0 deletions hypaware-core/plugins-workspace/ai-gateway/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,22 @@ export type SessionEndpointResolution =
* gateway" - and only some of the ways of establishing them are authoritative.
* Hiding which one was used is how a confident answer about the wrong session
* reads as a confident answer about yours.
*
* **The `--json` envelope is this record plus two fields the writer adds and
* this interface deliberately does not carry**, because they are constants
* rather than results: `folder_policy` (the other governor's verb) and
* `endpoint_authenticated`, which is always `false`. The second is `false` **by
* contract, not by outcome** - no answer this verb can obtain is authenticated,
* on `unknown` reports included - so a peer-identity check, if one is ever
* adopted, needs a NEW field rather than flipping this one: a consumer that
* learned "false means nobody checked" must not have to relearn "false now
* means the check ran and failed". Anything that adds a report shape here owes
* it the same constant.
*
* @ref LLP 0066#readable [implements]: R10 and R12 shape this record - `ignored`
* is nullable so an unconfirmable read cannot render as `false`.
* @ref LLP 0166#stated-not-proved [constrained-by]: the `--json` envelope states
* the responder was never authenticated, on every shape.
*/
export interface SessionStatusReport {
status: 'ignored' | 'not_ignored' | 'unknown'
Expand Down
7 changes: 7 additions & 0 deletions llp/0067-session-opt-out.design.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,13 @@ dependency for one check makes it a separate design decision rather than a
hardening tweak. Recorded, not adopted; the residual stays mitigated by the
`endpoint_source` disclosure ([§cli-provenance](#cli-provenance)).

> **Extended-by: [LLP 0166 §stated-not-proved](./0166-session-control-plane-states-its-guarantees.decision.md#stated-not-proved).**
> Issue #451 settled the deferred question as **accept and document**: the
> residual is not closed, and every confirmed answer now states that the
> responder was never authenticated (`trust:` in the human output,
> `endpoint_authenticated: false` in `--json`) beside the `endpoint_source`
> disclosure this section points at.

### Endpoint resolution: disk, then config, never a guess {#cli-endpoint}

The daemon's live bound port from `status.json` wins
Expand Down
173 changes: 173 additions & 0 deletions llp/0166-session-control-plane-states-its-guarantees.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# LLP 0166: the session control plane states its guarantees rather than proving them

**Type:** Decision
**Status:** Accepted
**Systems:** Gateway, Plugins
**Author:** Phil / Claude
**Date:** 2026-07-31
**Related:** LLP 0066, LLP 0067, LLP 0086

> Two questions were asked of the `hyp session` control plane in the same week,
> and they have the same answer. **#451:** can the CLI prove that whatever
> answers on the resolved port *is* the gateway? No, and it never will at this
> layer. **#460:** can a caller learn from `ignored: true` that the opt-out will
> actually match live traffic? No, only that the token it sent is in the drop
> set. Both are **accept and document**: the control plane keeps the behaviour
> it has and states the limit of the guarantee in its own output, in human form
> and in `--json`, rather than letting silence read as assurance.
>
> @ref LLP 0067#cli-response-check [constrained-by]: extends the recorded dead
> end into a stated contract - the residual is disclosed, not closed.

## Context

`hyp session status | ignore | unignore` resolves a control endpoint
(`status.json` from a live daemon, else the pinned `listen`), POSTs / GETs
`/_hypaware/ignore/session`, and reports the answer. It is a **privacy**
control: the answer a user acts on is "this session is not being recorded".

Two independent things stand between that answer and the truth, and neither is
a defect in the code that was written:

1. **Nobody authenticated the responder** (issue #451). PR #439's
`validateControlResponse` refuses a reply that is not an object, whose
`ignored` is not a boolean, or whose `session_id` is not echoed
byte-for-byte. A local process that binds the port and *echoes the token
back* satisfies every one of those checks by construction, and the user is
told they are opted out while nothing is dropping anything.
2. **The route confirms a write, not a match** (issue #460). `control.js` adds
the token to a `Set` and answers `ignored: true`; the drop consumer is a bare
`Set.has`. A caller that registered the wrong key - a Codex *thread* id
rather than the session container, say - gets the identical happy answer. A
`GET` afterwards does not help: it asks the same `Set` the same question.

## Why authentication is not the fix for #451 {#why-not-authenticate}

Costed in [LLP 0067 §cli-response-check](./0067-session-opt-out.design.md#cli-response-check)
and unchanged by this document:

- **A gateway-written secret defends nothing.** Any process able to bind that
port runs as the same uid as the daemon, and can therefore read whatever file
the secret lives in. It raises the cost of the attack by one `read()`.
- **Peer-process identity is the only real signal, and it is not portable.**
`status.json` already carries a liveness-gated daemon pid, so the check would
be "is the process on the other end of this socket that pid" -
`/proc/net/tcp` plus `/proc/<pid>/fd` on Linux, `lsof` on macOS,
`GetExtendedTcpTable` (native addon or `netstat -o` scraping) on Windows.
Three implementations and a native dependency for one check.
- **A non-squattable transport is a redesign.** A unix socket in a
daemon-created directory would carry the property in the namespace itself, but
it changes the control-plane transport, the skills' shell path, and Windows
support all at once.

**The trigger condition is what settles it.** The attack requires a process on
this machine that can bind the gateway's port before or instead of the daemon,
which means the attacker already has local code execution as this user. At that
point they can read the cache, the config, and the credentials directly; a
spoofed opt-out answer is not the marginal capability worth a native dependency
in three platform flavours. The guarantee `hyp session` can honestly offer is
bounded by the machine it runs on, so that is the guarantee it states.

## The contract, stated in the output {#stated-not-proved}

**The responder is never authenticated, and every confirmed answer says so.**

- **Human output** carries a `trust:` note beside the answer, on `status` and on
both mutation verbs: nothing proves the responder **at the named endpoint** is
the HypAware gateway, any process on this machine could bind that port and
answer, and the answer is only as trustworthy as this machine. The endpoint is
named in the note itself because on the `daemon_status` path it is the only
line about the endpoint at all.
- **`--json`** carries `endpoint_authenticated: false`, so a consumer acts on
the guarantee without parsing prose.
- The existing `endpoint_source` disclosure (`daemon_status` / `config_listen`)
**stays** and keeps its own note for the weaker source. The two say different
things: `endpoint_source` grades the evidence that the *port* is the
gateway's, `endpoint_authenticated` reports that the *responder* was never
checked at all.

Three properties of the statement are load-bearing:

**It is unconditional.** The verb cannot tell the gateway from the impostor, so
a note printed only "when spoofed" would be a claim it cannot make. It rides
the `daemon_status` path too, where a live daemon reported the port it bound:
that is evidence about a bind in the past, not about who answers now.

**`endpoint_authenticated` is `false` by contract, not by outcome.** It is not a
check result that might come back `true` on a good day, and it is `false` on
`unknown` reports as well. Should a peer-identity check ever be adopted, that is
a new decision superseding this section, and it would need a distinct field
rather than quietly flipping this one - a consumer that has learned "false means
unauthenticated" must not have to relearn "false now means the check ran and
failed".

**Fail-closed behaviour is untouched.** `validateControlResponse` keeps every
refusal it has (LLP 0067 §cli-response-check): a malformed answer, an answer
about a different session, a non-200, an oversized body are all still `unknown`.
This document adds a disclosure to the answers that *are* believed; it does not
believe anything new.

## The companion contract: membership is not a match {#membership-not-grain}

The same decision was taken on issue #460, and the two read as one story: the
control plane answers exactly what it knows and names what it does not.

**`ignored: true` means "this token is in the drop set", and nothing more.** It
is not a statement that live traffic will match it. **The caller is responsible
for resolving the correct key before calling** - which is the shape PR #458
already established for `hyp session` ([LLP 0067
§cli-session-id](./0067-session-opt-out.design.md#cli-session-id): the session
container, never the thread id, and a refusal rather than a guess). This makes
that responsibility the stated contract rather than an accident of who resolved
first, and it is why option 1 there (the gateway resolving and echoing the
grain) was declined: the route treats the token as opaque
([LLP 0066 §enforcement](./0066-session-opt-out.spec.md#enforcement)), and
teaching it Codex grain would move provider knowledge into the one component
that has deliberately never had any.

Two consequences carried by that decision, realized in the change set for #460
rather than here:

- The skills' shell path gains the echo check the JS resolver already has
(`validateControlResponse`'s equivalent), for as long as that path exists.
- When #435 lands, the skills stop resolving independently and the verification
question narrows to the single CLI resolver, which is the durable shape.

## Scope of this change set

This document is the joint record; the code lands in two places, and this half
is the **#451** half:

- **Here (#451):** `responderTrustNote(endpoint)` and
`endpoint_authenticated: false` in
`hypaware-core/plugins-workspace/ai-gateway/src/session_command.js`, on
`status` and both mutation verbs, human and `--json`. The note is a function
of the endpoint rather than a constant string because it names the address it
trusted, which on the `daemon_status` path is the only line about the endpoint
at all.
- **In its own change set (#460):** the membership-not-grain wording on the CLI
and skill surfaces, and the shell-path echo check.

## What would reopen this

A change in the trigger condition, not a cheaper mitigation. If the gateway ever
serves a control plane reachable by a process that is *not* already running as
this user - a shared or multi-user host, a container boundary, a network-exposed
listener - the "local code execution is a precondition" argument stops holding
and authentication becomes load-bearing again. The disclosure fields make that
reopening cheap to spot: anything consuming `endpoint_authenticated` is a caller
that already knows to ask.

## Test plan {#tests}

`test/plugins/ai-gateway-session-responder-trust.test.js` stands up an
**impostor** responder - a listener that reads the session id off the query
string or body and echoes it back with `ignored: true` - and pins both halves of
the accepted outcome:

- the impostor's answer **is** believed (exit 0, `ignored: true`), including on
the `daemon_status` path with a live pid file and a `status.json` naming the
impostor's port, so the residual cannot change by accident, and
- every such answer discloses that the responder was not authenticated, in the
human text and as `endpoint_authenticated: false`, on `status`, on `ignore`,
on the genuine control route, and on `unknown` reports.
Loading
Loading