diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/control.js b/hypaware-core/plugins-workspace/ai-gateway/src/control.js index eab91b1a..b3480711 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/control.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/control.js @@ -33,6 +33,19 @@ const MAX_BODY_BYTES = 64 * 1024 * `.total`. The `session_id` is an opaque token: the gateway never * interprets it, keeping the LLP 0050 provider-agnostic boundary exact. * + * **`ignored: true` is set membership, and is not a verified drop.** The + * gateway holds tokens, not traffic: the drop happens in the client adapter, + * keyed on the `session_id` it stamps on the row (LLP 0066 R5), so this route + * cannot tell a live session id from a Codex thread id or a typo and answers + * `ignored: true` for all three. Making it able to would mean teaching a + * deliberately provider-agnostic route about client grain, which is the + * boundary above. So the contract is the narrow one and the CALLER owns + * resolving the right key before it posts; responses that read as more than + * that are what LLP 0066 R14 forbids. + * @ref LLP 0066#receipt-is-membership [constrained-by]: the route confirms the + * write only, so callers must resolve the key rather than expect an echo to + * prove the drop. + * * @ref LLP 0066#control-path [implements]: the reserved `/_hypaware/` * prefix is a local control surface; this handler owns the routes served * under it, holding only opaque session-id tokens. diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js b/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js index c038b657..5acbbe1e 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/session_command.js @@ -38,6 +38,45 @@ 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`.' +/** + * What a confirmed `ignored` establishes, printed next to it by the writer and + * the reader alike, for the same no-drift reason as `EPHEMERAL_NOTE`. + * + * The control route is a `Set` over opaque tokens: `POST` adds whatever it was + * handed and answers `ignored: true`, `GET` is the same `Set.has`. Neither verb + * sees a single exchange, so neither can establish that the id is one live + * traffic carries - the drop happens later, in the client adapter, keyed on the + * `session_id` it stamps on the row (LLP 0066 R5). "the gateway will drop this + * session" was therefore a promise the receipt could not support: an id no + * exchange will ever carry (a Codex thread id, a finished session's id, a typo) + * prints exactly the same line and suppresses nothing. + * + * Naming the caller's own responsibility is the other half. The gap is closed + * by resolving the right key BEFORE the call (`resolveSessionIdForCli`), not by + * anything the route can answer afterwards, so the receipt says which of the + * two claims it is making. + * + * @ref LLP 0066#receipt-is-membership [implements]: R14 - the receipt states + * set membership and names who owns the key, rather than reporting a drop + * nothing verified. + */ +const MEMBERSHIP_NOTE = + 'what this proves: the gateway holds this exact id in its drop set, and nothing more. It never inspects traffic, so an exchange is dropped only where the client adapter stamps it with this same session_id - an id this session does not carry prints this same line and suppresses nothing. Naming the right id is on the caller, which is why this verb resolves it (or takes it explicitly) rather than asking the gateway to confirm it afterwards.' + +/** + * The machine-readable form of `MEMBERSHIP_NOTE`, carried by the write verbs' + * `--json` receipt. `status: 'ok'` on its own reads as "done" to the skills + * that parse it, which is the same overclaim in the shape an agent actually + * consumes. + * + * The reader's `--json` needs no equivalent: it already reports `status` as + * `ignored` / `not_ignored` / `unknown` beside a tri-state `ignored`, which is + * a membership answer on its face. + * + * @ref LLP 0066#receipt-is-membership [implements]: R14 + */ +const MEMBERSHIP_GUARANTEE = 'set_membership' + /** * The control plane's authenticity contract, printed beside every **confirmed** * answer, by the writer and the reader alike. @@ -300,6 +339,8 @@ async function runMutation(argv, ctx, method, usage) { ctx.stdout.write( JSON.stringify({ status: 'ok', + // What the `ok` above is an `ok` about, for the agent parsing this. + guarantee: MEMBERSHIP_GUARANTEE, session_id: resolvedId.sessionId, session_id_source: resolvedId.source, session_id_evidence: resolvedId.evidence ?? null, @@ -315,13 +356,19 @@ async function runMutation(argv, ctx, method, usage) { ) return 0 } + // The headline states the write that happened, not a drop nobody verified: + // the route added an opaque token to a set (LLP 0066#receipt-is-membership). + // The removal says no more than that either. "recording resumed" was the same + // overclaim mirrored: a token nothing carried suppressed nothing to resume, + // and the folder governor below is a separate reason a session stays unrecorded. ctx.stdout.write( ignored - ? `session ${resolvedId.sessionId}: ignored - the gateway will drop this session (${total} ignored)\n` - : `session ${resolvedId.sessionId}: not ignored - recording resumed (${total} ignored)\n` + ? `session ${resolvedId.sessionId}: ignored - this id is in the gateway drop set (${total} ignored)\n` + : `session ${resolvedId.sessionId}: not ignored - this id is out of the gateway drop set, so this opt-out suppresses nothing now (${total} ignored)\n` ) if (ignored) { ctx.stdout.write(`${EPHEMERAL_NOTE}\n`) + ctx.stdout.write(`${MEMBERSHIP_NOTE}\n`) } // The write verbs carry the same provenance caveats as the read: "ignored" // printed off an inferred id is a claim about a session the user may not be @@ -371,6 +418,7 @@ function writeStatus(ctx, json, report) { } else if (report.status === 'ignored') { ctx.stdout.write(`session ${report.session_id}: ignored (${report.total} ignored in total)\n`) ctx.stdout.write(`${EPHEMERAL_NOTE}\n`) + ctx.stdout.write(`${MEMBERSHIP_NOTE}\n`) for (const note of provenanceNotes({ idSource: report.session_id_source, idEvidence: report.session_id_evidence, diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-ignore/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-ignore/SKILL.md index 6861a9e5..824b782f 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-ignore/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-ignore/SKILL.md @@ -29,12 +29,34 @@ response="$(curl --fail-with-body --silent --show-error \ -H 'content-type: application/json' \ --data "$(printf '{"session_id":"%s"}' "$CLAUDE_CODE_SESSION_ID")")" -total="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["total"])')" +# Check the reply before believing it, the same three ways `hyp session ignore` +# does: `ignored` a real boolean true, `total` a real number, and `session_id` +# echoed back byte-for-byte. The route echoes the token verbatim, so a reply +# about a different session establishes nothing about this one - and reaching +# *something* on that port is not the same as reaching the gateway. +total="$(printf '%s' "$response" | python3 -c ' +import json, sys +expected = sys.argv[1] +try: + r = json.load(sys.stdin) +except Exception: + sys.exit("opt-out NOT confirmed: the reply was not JSON, so it is not the control route") +# bool is excluded because isinstance(True, int) is True in Python: the CLI +# check this mirrors is `typeof total !== "number"`, which a JSON true fails. +if not isinstance(r, dict) or r.get("ignored") is not True or isinstance(r.get("total"), bool) or not isinstance(r.get("total"), int): + sys.exit("opt-out NOT confirmed: " + json.dumps(r)) +if r.get("session_id") != expected: + sys.exit("opt-out NOT confirmed: the reply is about session %s, not %s" % (json.dumps(r.get("session_id")), json.dumps(expected))) +print(r["total"]) +' "$CLAUDE_CODE_SESSION_ID")" printf 'Ignored session %s. Total ignored: %s\n' "$CLAUDE_CODE_SESSION_ID" "$total" ``` +If that check fails, say the session is **still being recorded**; do not report a partial success. + ## Notes +- **What the confirmation proves.** `ignored: true` means the id is in the gateway's in-memory drop set, and nothing more. The gateway holds the id as an opaque token and never inspects traffic, so it cannot confirm the id is one this session's exchanges carry; the match happens later, in the client adapter, against the `session_id` it stamps on the row. For Claude the session *is* the conversation and `CLAUDE_CODE_SESSION_ID` is that same id, which is what makes the opt-out real - the reply is a receipt for the write, not a verified drop. - The opt-out is held in-memory by the running AI gateway. A gateway restart drops the entry; if a long-running gateway is restarted mid-session, re-run `/hypaware-ignore`. - This only affects the *current* Claude session. Concurrent sessions in the same working directory continue to record unless covered by a `.hypignore` file. - For committable / team-wide opt-out, drop an empty `.hypignore` file at the top of the repo instead. diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md index 3250e215..fa29fa53 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md @@ -40,18 +40,33 @@ response="$(curl --fail-with-body --silent --show-error \ -H 'content-type: application/json' \ --data "$(printf '{"session_id":"%s"}' "$CLAUDE_CODE_SESSION_ID")")" -# Verify the gateway reports this session as ignored. `ignored` must be true. +# Verify the gateway accepted the opt-out, and that the reply is about THIS +# session. Same three checks `hyp session ignore` applies (validateControlResponse +# in ai-gateway/src/session_command.js): `ignored` a real boolean true, `total` a +# real number, and `session_id` echoed back byte-for-byte. The route echoes the +# token verbatim, so a reply naming a different session establishes nothing about +# this one, and reaching *something* on the port is not reaching the gateway. printf '%s' "$response" | python3 -c ' import json, sys -r = json.load(sys.stdin) -if r.get("ignored") is not True: +expected = sys.argv[1] +try: + r = json.load(sys.stdin) +except Exception: + sys.exit("opt-out NOT confirmed: the reply was not JSON, so it is not the control route") +# bool is excluded because isinstance(True, int) is True in Python: the CLI +# check this mirrors is `typeof total !== "number"`, which a JSON true fails. +if not isinstance(r, dict) or r.get("ignored") is not True or isinstance(r.get("total"), bool) or not isinstance(r.get("total"), int): sys.exit("opt-out NOT confirmed: " + json.dumps(r)) -print("opt-out confirmed for session %s (total ignored: %s)" % (r.get("session_id"), r.get("total"))) -' +if r.get("session_id") != expected: + sys.exit("opt-out NOT confirmed: the reply is about session %s, not %s" % (json.dumps(r.get("session_id")), json.dumps(expected))) +print("opt-out confirmed for session %s (total ignored: %s)" % (expected, r["total"])) +' "$CLAUDE_CODE_SESSION_ID" ``` If the `curl` fails (gateway not running, wrong port) or the verification line does not print `opt-out confirmed`, **stop and tell the user the review session is still being recorded**. Only proceed if they explicitly accept that risk. +**What `opt-out confirmed` proves, exactly.** The gateway holds the id as an opaque token: `ignored: true` means the id is in its drop set, and nothing more. It never inspects traffic, so it cannot tell you the id is one your exchanges carry - that match happens later, in the client adapter, against the `session_id` it stamps on the row. For Claude the session *is* the conversation and `CLAUDE_CODE_SESSION_ID` is that same id, so sending it is what makes the opt-out real; the reply is a receipt for the write, not a verified drop. Do not report it to the user as more than that, and never treat a follow-up `GET` as extra proof: it is the same set lookup answering the same question. + The opt-out is held in memory by the running gateway and keyed on that one session id, so two things drop it: a **gateway restart**, and a **new session id** minted under what the user experiences as the same conversation (`claude --fork-session`; a plain `--resume` / `--continue` reuses the id). If the review spans either, re-run this step. `hyp session status` reports the current answer for the session you are in at any point. Reverse later with `/hypaware-unignore`. ## Step 2 - Check that backfill has settled (before surveying) diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-unignore/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-unignore/SKILL.md index 3c2b7085..39f85e20 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-unignore/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-unignore/SKILL.md @@ -24,16 +24,39 @@ fi BASE="${ANTHROPIC_BASE_URL:-http://127.0.0.1:8787}" URL="${BASE%/}/_hypaware/ignore/session" -curl --fail-with-body --silent --show-error \ +response="$(curl --fail-with-body --silent --show-error \ -X DELETE "$URL" \ -H 'content-type: application/json' \ - --data "$(printf '{"session_id":"%s"}' "$CLAUDE_CODE_SESSION_ID")" \ - > /dev/null - -printf 'Recording re-enabled for session %s\n' "$CLAUDE_CODE_SESSION_ID" + --data "$(printf '{"session_id":"%s"}' "$CLAUDE_CODE_SESSION_ID")")" + +# Check the reply before believing it, the same three ways `hyp session unignore` +# does (`validateControlResponse` in ai-gateway/src/session_command.js): `ignored` +# a real boolean - `false` here, since removal is what was asked for - `total` a +# real number, and `session_id` echoed back byte-for-byte. The route echoes the +# token verbatim, so a reply about a different session establishes nothing about +# this one, and reaching *something* on that port is not reaching the gateway. +total="$(printf '%s' "$response" | python3 -c ' +import json, sys +expected = sys.argv[1] +try: + r = json.load(sys.stdin) +except Exception: + sys.exit("removal NOT confirmed: the reply was not JSON, so it is not the control route") +# bool is excluded because isinstance(True, int) is True in Python: the CLI +# check this mirrors is `typeof total !== "number"`, which a JSON true fails. +if not isinstance(r, dict) or r.get("ignored") is not False or isinstance(r.get("total"), bool) or not isinstance(r.get("total"), int): + sys.exit("removal NOT confirmed: " + json.dumps(r)) +if r.get("session_id") != expected: + sys.exit("removal NOT confirmed: the reply is about session %s, not %s" % (json.dumps(r.get("session_id")), json.dumps(expected))) +print(r["total"]) +' "$CLAUDE_CODE_SESSION_ID")" +printf 'Session %s is out of the gateway drop set, so this opt-out suppresses nothing now. Total ignored: %s\n' "$CLAUDE_CODE_SESSION_ID" "$total" ``` +If that check fails, do not report the opt-out as lifted; say the gateway did not confirm the removal. + ## Notes +- **What the confirmation proves.** `ignored: false` means the id is no longer in the gateway's in-memory drop set, and nothing more. The gateway holds the id as an opaque token and never inspects traffic, so it cannot tell you recording resumed: an id this session's exchanges never carried was suppressing nothing to resume, and a `.hypignore` ancestor is an independent reason the session stays unrecorded. The reply is a receipt for the removal, not a verified resumption. - Only the *temporary, in-memory* opt-out is reversed. Recording stays suppressed if the working directory is covered by a `.hypignore` ancestor file. Remove those by deleting the marker file. - The CLI is idempotent: it returns success even when the session was not currently ignored. diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md index 78be12ee..a85d2003 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md @@ -127,22 +127,38 @@ response="$(curl --fail-with-body --silent --show-error \ -H 'content-type: application/json' \ --data "$(printf '{"session_id":"%s"}' "$SESSION_ID")")" -# Verify the gateway accepted the opt-out. `ignored` must be true. Note the -# bound of this check: the control route holds the id as an opaque token, so a -# true here proves the id is in the drop set, not that it is the id this +# Verify the gateway accepted the opt-out, and that the reply is about THIS +# session. Same three checks `hyp session ignore` applies (validateControlResponse +# in ai-gateway/src/session_command.js): `ignored` a real boolean true, `total` a +# real number, and `session_id` echoed back byte-for-byte. The route echoes the +# token verbatim, so a reply naming a different session establishes nothing about +# this one, and reaching *something* on the port is not reaching the gateway. +# +# Note the bound of all three: the control route holds the id as an opaque token, +# so a true here proves the id is in the drop set, not that it is the id this # session's exchanges carry. That is why the id above is established from the # rollout's container rather than guessed. printf '%s' "$response" | python3 -c ' import json, sys -r = json.load(sys.stdin) -if r.get("ignored") is not True: +expected = sys.argv[1] +try: + r = json.load(sys.stdin) +except Exception: + sys.exit("opt-out NOT confirmed: the reply was not JSON, so it is not the control route") +# bool is excluded because isinstance(True, int) is True in Python: the CLI +# check this mirrors is `typeof total !== "number"`, which a JSON true fails. +if not isinstance(r, dict) or r.get("ignored") is not True or isinstance(r.get("total"), bool) or not isinstance(r.get("total"), int): sys.exit("opt-out NOT confirmed: " + json.dumps(r)) -print("opt-out confirmed for session %s (total ignored: %s)" % (r.get("session_id"), r.get("total"))) -' +if r.get("session_id") != expected: + sys.exit("opt-out NOT confirmed: the reply is about session %s, not %s" % (json.dumps(r.get("session_id")), json.dumps(expected))) +print("opt-out confirmed for session %s (total ignored: %s)" % (expected, r["total"])) +' "$SESSION_ID" ``` If the session id cannot be resolved (the script refuses on ambiguity, staleness, or a missing container by design), the `curl` fails, or the verification line does not print `opt-out confirmed`, **stop and tell the user the review session may still be recorded**. Only proceed if they explicitly accept that risk. +**What `opt-out confirmed` proves, exactly.** `ignored: true` means the id is in the gateway's drop set, and nothing more. The gateway never inspects traffic, so it cannot tell a live session container from a thread id or a finished session's id: it answers `ignored: true` for whatever it was handed. The drop happens later, in the client adapter, against the `session_id` it stamps on the row. Everything that makes this opt-out real therefore happened *before* the POST, in resolving `payload.session_id` above - the reply is a receipt for the write, not a verified drop. Report it to the user that way, and never treat a follow-up `GET` as extra proof: it is the same set lookup answering the same question. + The opt-out is held in memory by the running gateway and keyed on that one session id, so two things drop it: a **gateway restart**, and a **new session id** minted under what the user experiences as the same conversation (`codex fork `; a plain `codex resume ` reuses the id). If the review spans either, re-run this step. `hyp session status` reports the current answer for the session you are in at any point. ## Step 2 - Check that backfill has settled (before surveying) diff --git a/llp/0066-session-opt-out.spec.md b/llp/0066-session-opt-out.spec.md index 8930d3cd..df925c82 100644 --- a/llp/0066-session-opt-out.spec.md +++ b/llp/0066-session-opt-out.spec.md @@ -189,6 +189,44 @@ being recorded." This is observability of an ephemeral control, not durability. Persisting the set remains [non-goal 2](#non-goals). +## What `ignored: true` proves, and what it does not {#receipt-is-membership} + +**Settled, 2026-07-31 ([issue #460](https://github.com/hyparam/hypaware/issues/460)): +`ignored: true` means "this token is in the drop set", and nothing more.** It is +a receipt for a write, not a verified drop. + +The route holds opaque tokens ([enforcement](#enforcement)) and the drop happens +elsewhere, in the client adapter, keyed on the `session_id` that adapter resolves +and stamps (R5). Nothing in between compares the two. So a `POST` of a Codex +**thread** id, of a finished session's id, or of a typo is added to the set and +answered `ignored: true` exactly like a live container id, and the caller learns +only that the gateway is now holding the string it sent. + +**A read-back does not close this.** `GET` performs the same `ignoredSessions.has(raw)` +over the same set, so calling it after the `POST` confirms the write a second +time; it is tautological about the grain. Neither verb has ever seen an exchange, +which is what a drop would have to be checked against. + +**Closing it properly would be a different route.** The gateway would have to +resolve client grain (which key covers which threads) to say whether the id it +recorded is one live traffic carries, and that is precisely the provider +awareness [LLP 0050](./0050-ignore-enforced-in-adapters.decision.md) keeps out of +the gateway ([enforcement](#enforcement)). Rejected on that ground, not on cost. + +**So the contract is the narrow one, and the caller owns the key.** Every surface +that names a session to the route MUST resolve the container first (R13) and +MUST NOT present the reply as evidence that it did (R14). The two rules are the +same rule from both ends: the resolution is where correctness lives, and the +receipt must not read as though it re-checked it. The durable shape is one +resolver with one caller ([issue #435](https://github.com/hyparam/hypaware/issues/435)), +at which point the verification question narrows to that resolver; until it +lands, each surface that resolves independently carries the same obligation. + +This is the same class of claim as [readable](#readable) and R12: where a control +cannot prove something, it says so instead of implying it. There the unprovable +part was *which* session was answered about; here it is *whether the answer is +about live traffic at all.* + ## Non-goals {#non-goals} 1. **Per-thread (`conversation_id`) granularity.** Deferred. `conversation_id` @@ -306,6 +344,26 @@ set remains [non-goal 2](#non-goals). - Because the acted-on key is coarser than the thread the user is in, the surface MUST disclose that grain alongside the answer (R12): "ignored" means the whole session, sibling threads included. +- **R14.** No surface may present a control-route reply as evidence that the id + it sent is the one live traffic carries. `ignored: true` is **set membership** + (see [receipt-is-membership](#receipt-is-membership)), so a confirmed opt-out + MUST be reported as the write it is - "this id is in the drop set" - and MUST + NOT be phrased as a drop the gateway verified ("the gateway will drop this + session"). The surface MUST also name where the guarantee actually comes from: + the caller resolving the container (R13) before it calls. + - A **read-back does not upgrade the claim.** `GET` is the same `Set.has` over + the same set, so a surface MUST NOT present a `POST`-then-`GET` sequence as + verification of anything beyond the write. + - Machine-readable receipts are covered too, since that is what the skills + parse: a bare `status: "ok"` reads as "done", so the write verbs' `--json` + states the guarantee (`guarantee: "set_membership"`) alongside it. + - Any surface that calls the route directly rather than through the CLI MUST + apply the same response validation the CLI does + ([LLP 0067 §cli-response-check](./0067-session-opt-out.design.md#cli-response-check)), + including the byte-exact `session_id` echo. That check does not prove the + drop (nothing here does), but without it a reply about a *different* session + is read as this session's success, which is a second overclaim stacked on + the first. ## `@ref` annotations code will carry {#refs} @@ -316,3 +374,6 @@ set remains [non-goal 2](#non-goals). `@ref LLP 0050` on the same drop site. - The read route and the fail-closed CLI reader: `@ref LLP 0066#readable [implements]`. +- The confirmation the write verbs print, and the route JSDoc stating what its + reply is a reply about: `@ref LLP 0066#receipt-is-membership` (`[implements]` + on the CLI receipt, `[constrained-by]` on the route that cannot say more). diff --git a/llp/0067-session-opt-out.design.md b/llp/0067-session-opt-out.design.md index 4632ee8b..ebb4be1d 100644 --- a/llp/0067-session-opt-out.design.md +++ b/llp/0067-session-opt-out.design.md @@ -253,6 +253,60 @@ must never render as `false` mode also names `hyp policy show` as the folder governor this verb does not cover (R11 / R7). +### The write verbs report a write, not a drop {#cli-receipt} + +`hyp session ignore` used to print "ignored - the gateway will drop this +session". The gateway cannot know that +([LLP 0066 §receipt-is-membership](./0066-session-opt-out.spec.md#receipt-is-membership), +issue #460): it added an opaque token to a set, and whether any exchange carries +that token is settled later, in the adapter. The claim was load-bearing in the +wrong direction, because the surfaces most likely to be handed the wrong key +(a Codex thread id, a dead session) are exactly the ones that read the line as +proof they got it right. + +So the receipt states the write and names where the guarantee comes from: + +``` +session : ignored - this id is in the gateway drop set (N ignored) + +what this proves: the gateway holds this exact id in its drop set, and nothing +more. It never inspects traffic, so an exchange is dropped only where the client +adapter stamps it with this same session_id ... +``` + +`unignore` is held to the same bound, since it is the same `Set` answer read the +other way. It used to print "recording resumed", which the route cannot know +either: a token no exchange carried was suppressing nothing to resume, and +`.hypignore` is an independent governor (R7) that can keep the session +unrecorded whatever this verb just removed. It now reports the removal - "this +id is out of the gateway drop set, so this opt-out suppresses nothing now". + +The `hypaware-unignore` skill is bound by the same two rules, being the one +surface in the family that calls the route directly for the removal. It printed +`Recording re-enabled for session ` off `--fail-with-body` alone, with the +response body sent to `/dev/null`: the mirrored overclaim, and R14's last bullet +unapplied entirely rather than merely weakly +([§cli-response-check](#cli-response-check)). It now reads the reply, asserts +`ignored` is a real boolean `false` with the `session_id` echoed byte-exactly, +and reports the removal in the CLI's words. + +`--json` carries `guarantee: "set_membership"` beside its `status: "ok"`, since +an agent parsing the receipt reads a bare `ok` as "done" (the skills are the +consumer here). The reader prints the same qualifier next to a confirmed +`ignored`, for the reason `EPHEMERAL_NOTE` is shared: two statements of one +contract drift, one constant does not. Its `--json` needs no new field - it +already reports `status` as `ignored` / `not_ignored` / `unknown` beside a +tri-state `ignored`, which is a membership answer on its face. + +**This is accept-and-document, deliberately.** The alternative considered was +having the route resolve and echo the *grain* it recorded (which container, which +threads), so a caller could compare it against what it meant. That is the most +informative answer and it is the one thing this route may not do: it would put +client-grain knowledge in a deliberately provider-agnostic control surface +([LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)). Correctness stays +where [§cli-session-id](#cli-session-id) puts it - the caller resolves the +container before it calls - and the receipt's job is to not obscure that. + ### The answer is validated before it is believed {#cli-response-check} Reaching *something* on the resolved endpoint is not the same as reaching the @@ -336,8 +390,10 @@ drop matches the `session_id` the adapter stamps the session container. A thread id is the same uuid on a **root** thread and a different one on a **subagent** thread, so resolving to a thread id is a control that works everywhere it is casually tested and silently does nothing where it -matters: `hyp session ignore` from inside a subagent tool call printed "the -gateway will drop this session" for an id the gateway never sees. +matters: `hyp session ignore` from inside a subagent tool call printed a +confirmed opt-out for an id the gateway never sees. (The wording it printed then, +"the gateway will drop this session", was the second half of the same defect and +is gone: [§cli-receipt](#cli-receipt).) **Liveness versus the correct key.** `CODEX_THREAD_ID` is the better *liveness* signal (Codex sets it on the process it spawns for a tool call, so a finished @@ -514,9 +570,9 @@ now. The ambiguity rule only fires at two or more matches, so a cwd where Codex ran exactly once, days ago, has exactly one match and would otherwise resolve confidently to a **dead** session id. That is the wrong-session failure of [§cli-response-check](#cli-response-check) arriving -through the other input: `hyp session ignore` would opt out the dead id, print -"the gateway will drop this session", and the session the user is actually in -would keep being recorded. A rollout is therefore only usable as "the session I +through the other input: `hyp session ignore` would opt out the dead id, print a +confirmed opt-out, and the session the user is actually in would keep being +recorded. A rollout is therefore only usable as "the session I am in" when it was written to recently (30 minutes): a running Codex session appends on every turn, and the tool call invoking `hyp` is itself preceded by rollout writes, so the legitimate case is seconds-to-minutes old. A stale-only @@ -610,6 +666,18 @@ against live rows. Traditional tests (root `test/`, alongside the existing suites): +- `test/plugins/ai-gateway-session-ignore-receipt.test.js` (new, R14, issue + #460): the false-confidence case end to end rather than a string in + isolation - `hyp session ignore ` succeeds, the id genuinely lands + in the set, and `codex/src/exchange-projector.js` is then shown RECORDING the + session's traffic anyway (the row is stamped with the container), so the + receipt printed over that outcome is required not to have promised a drop. It + must state the membership it does establish, `--json` must carry + `guarantee: "set_membership"`, and the reader must print the same qualifier so + the two cannot drift. The skills that call the route directly are pinned on + the echo check ([§cli-response-check](#cli-response-check)): each compares the + returned `session_id` against the one it posted rather than printing whatever + came back. - `test/plugins/ai-gateway-control-route.test.js` (new): POST adds + idempotent re-POST, DELETE removes + idempotent, `.total` correct across a sequence; 400 malformed/missing `session_id`; 405 wrong method (`allow: diff --git a/test/plugins/ai-gateway-session-ignore-receipt.test.js b/test/plugins/ai-gateway-session-ignore-receipt.test.js new file mode 100644 index 00000000..4ee03209 --- /dev/null +++ b/test/plugins/ai-gateway-session-ignore-receipt.test.js @@ -0,0 +1,313 @@ +// @ts-check + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { createControlHandler } from '../../hypaware-core/plugins-workspace/ai-gateway/src/control.js' +import { createCodexExchangeProjector } from '../../hypaware-core/plugins-workspace/codex/src/exchange-projector.js' +import { USAGE_POLICY_DROP } from '../../src/core/usage-policy/index.js' +import { runSessionIgnore, runSessionStatus, runSessionUnignore } from '../../hypaware-core/plugins-workspace/ai-gateway/src/session_command.js' + +/** + * @import { IncomingMessage, ServerResponse } from 'node:http' + */ + +/** + * Regression suite for issue #460: the `POST` receipt claimed a drop it could + * not have verified. + * + * `control.js` adds an opaque token to a `Set` and answers `ignored: true` for + * whatever it was handed; the drop happens later, in the client adapter, keyed + * on the `session_id` that adapter stamps on the row. Nothing compares the two, + * so the receipt is evidence of a write and of nothing else - yet + * `hyp session ignore` printed "the gateway will drop this session", which is + * a statement about live traffic the gateway never sees. + * + * The tests below pin the false-confidence case end to end rather than + * asserting a string in isolation: an id that live traffic does not carry is + * registered successfully, the REAL projector is then shown recording that + * traffic anyway, and the receipt printed over that outcome is required not to + * have promised otherwise. + * + * @ref LLP 0066#receipt-is-membership [tests]: R14 - `ignored: true` is set + * membership, so the receipt reports the write and names who owns the key. + * @ref LLP 0067#cli-receipt [tests] + */ + +/* ------------------------------------------------------------------ */ +/* 1. The receipt must not promise a drop the gateway cannot verify */ +/* ------------------------------------------------------------------ */ + +test('a successful ignore receipt does not claim a drop, for an id live traffic never carries', async () => { + // The exact false-confidence case. A Codex SUBAGENT thread inherits the + // root's session container and mints its own thread id, and the two are + // easy to confuse (they are the same uuid on a root thread). A user - or a + // skill resolving on its own - hands the THREAD id to `hyp session ignore`. + const threadId = 'thread-subagent' + const containerId = 'session-root' + + const set = /** @type {Set} */ (new Set()) + await withControlServer(set, async (base) => { + const ctx = fakeCtx({ endpoint: base }) + const code = await runSessionIgnore([threadId], ctx.ctx) + + // The write itself genuinely succeeded: the route holds the token now. + assert.equal(code, 0) + assert.ok(set.has(threadId), 'the id is in the set - that much is true') + + // And it drops nothing. Proven against the code that performs the drop, + // not against a restatement of it: the session's live exchanges are + // stamped with the CONTAINER, so the registered thread id matches none of + // them and the turn is recorded exactly as if no opt-out had been made. + const projector = createCodexExchangeProjector() + const recorded = /** @type {any} */ ( + projector.project(codexExchange({ sessionId: containerId, threadId }), dropContext(set)) + ) + assert.ok( + recorded && recorded !== USAGE_POLICY_DROP, + 'precondition: the registered id matches no live traffic, so nothing is dropped' + ) + assert.equal(recorded.session_id, containerId) + + // So the receipt printed over that outcome must not have claimed a drop. + const out = ctx.stdout() + assert.doesNotMatch( + out, + /will drop this session/, + 'the gateway never saw an exchange; it cannot promise a drop, and here there is none' + ) + assert.doesNotMatch(out, /gateway will drop/, 'no phrasing of the same promise') + + // What it may claim - the write - it must claim plainly. + assert.match(out, /in the gateway drop set/, 'report the membership that IS established') + + // And it must state the bound, next to the success, where a caller reading + // "ignored" as done would see it. + assert.match(out, /what this proves/, 'the receipt names what it is a receipt for') + assert.match(out, /never inspects traffic/, 'why the gateway cannot say more') + assert.match(out, /suppresses nothing/, 'the failure this receipt cannot rule out') + assert.match(out, /the caller/, 'and where the guarantee actually comes from (R13)') + }) +}) + +test('the --json receipt states its guarantee, so `status: ok` cannot read as "dropped"', async () => { + // The skills parse this form, and an agent reads a bare `ok` as done. The + // machine-readable receipt therefore says what kind of ok it is. + const set = /** @type {Set} */ (new Set()) + await withControlServer(set, async (base) => { + const ctx = fakeCtx({ endpoint: base }) + assert.equal(await runSessionIgnore(['thread-subagent', '--json'], ctx.ctx), 0) + const out = JSON.parse(ctx.stdout()) + assert.equal(out.status, 'ok') + assert.equal(out.ignored, true) + assert.equal( + out.guarantee, + 'set_membership', + 'the receipt names the claim it is making, rather than leaving `ok` to be read as a verified drop' + ) + }) +}) + +test('the unignore receipt reports the removal, not a resumption it cannot verify', async () => { + // R14 mirrored. "recording resumed" is the same inference from the same + // `Set` answer, read the other way: a token nothing carried was suppressing + // nothing to resume, and `.hypignore` is an independent governor (R7) that + // can keep the session unrecorded regardless of what this verb just removed. + const set = new Set(['thread-subagent']) + await withControlServer(set, async (base) => { + const ctx = fakeCtx({ endpoint: base }) + assert.equal(await runSessionUnignore(['thread-subagent'], ctx.ctx), 0) + assert.ok(!set.has('thread-subagent'), 'the removal itself happened - that much is true') + const out = ctx.stdout() + assert.match(out, /out of the gateway drop set/, 'report the membership that IS established') + assert.doesNotMatch(out, /recording resumed/, 'the gateway cannot know recording resumed') + }) +}) + +test('the reader carries the same qualifier, so writer and reader cannot drift', async () => { + // `status` answers the same `Set.has` question, so a confirmed `ignored` + // there rests on the identical bound. One shared constant, as with the + // ephemerality caveat: two statements of one contract drift apart. + const set = new Set(['sess-live']) + await withControlServer(set, async (base) => { + const ctx = fakeCtx({ endpoint: base, env: { CLAUDE_CODE_SESSION_ID: 'sess-live' } }) + assert.equal(await runSessionStatus([], ctx.ctx), 0) + assert.match(ctx.stdout(), /what this proves/) + assert.match(ctx.stdout(), /never inspects traffic/) + }) +}) + +/* ------------------------------------------------------------------ */ +/* 2. The skills that call the route directly validate the reply */ +/* ------------------------------------------------------------------ */ + +const SKILLS = [ + 'claude/skills/hypaware-privacy/SKILL.md', + 'claude/skills/hypaware-ignore/SKILL.md', + 'codex/skills/hypaware-privacy/SKILL.md', + // The removal verb calls the same route directly, so R14's last bullet binds + // it identically: it used to discard the body (`> /dev/null`) and print off + // the exit code alone, which is the echo check missing entirely rather than + // merely weak. + 'claude/skills/hypaware-unignore/SKILL.md', +] + +/** @param {string} rel */ +function skillText(rel) { + return fs.readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../hypaware-core/plugins-workspace', rel), + 'utf8' + ) +} + +for (const rel of SKILLS) { + test(`${rel} checks the control reply is about the session it posted`, () => { + // R14's second half. The JS resolver refuses a reply naming a different + // session (`validateControlResponse`); the shell paths in the skills + // printed `opt-out confirmed` off whatever came back, so a reply about + // another session - or from whatever else now owns that port - read as + // this session's success. That does not prove the drop (nothing here + // does), but without it a second overclaim stacks on the first. + const text = skillText(rel) + + // The verifier compares the echoed id against the one that was posted, + // rather than printing the value the responder chose to send back. + assert.match( + text, + /r\.get\("session_id"\) != expected/, + 'the echoed session_id must be compared with the id that was sent' + ) + assert.match(text, /isinstance\(r\.get\("total"\), int\)/, 'and `total` must be a real number') + // `isinstance(True, int)` is True in Python, so the int test alone is + // weaker than the CLI's `typeof total !== "number"` it claims to mirror: + // a responder answering `total: true` would pass here and fail there. + assert.match( + text, + /isinstance\(r\.get\("total"\), bool\)/, + 'and a JSON `true` must not satisfy the numeric check the CLI applies' + ) + assert.doesNotMatch( + text, + /opt-out confirmed for session %s[^\n]*% \(r\.get\("session_id"\)/, + 'the confirmation must not echo the responder-supplied id as if it were verified' + ) + + // And the reply is described as the membership receipt it is. + assert.match(text, /drop set, and nothing more/, 'state the narrow contract plainly') + }) +} + +test('the unignore skill reports the removal it verified, not a resumption', () => { + // The CLI receipt was held to R14 mirrored; the skill that DELETEs the same + // route was printing "Recording re-enabled" off `--fail-with-body` alone, + // with the response body sent to /dev/null. Same overclaim, and the one + // surface in the family with no response validation at all. + const text = skillText('claude/skills/hypaware-unignore/SKILL.md') + + assert.doesNotMatch(text, /> \/dev\/null/, 'the reply must be read, not discarded') + assert.doesNotMatch( + text, + /Recording re-enabled for session/, + 'the gateway cannot know recording resumed - `.hypignore` alone can keep it suppressed' + ) + assert.match( + text, + /r\.get\("ignored"\) is not False/, + 'the removal must be asserted as a real boolean false, as the CLI does' + ) + assert.match(text, /out of the gateway drop set/, 'report the membership that IS established') +}) + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +/** + * @param {Set} set + * @param {(base: string) => Promise} fn + */ +async function withControlServer(set, fn) { + const handler = createControlHandler({ ignoredSessions: set }) + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + handler(/** @type {IncomingMessage} */ (req), /** @type {ServerResponse} */ (res), url) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined))) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + try { + await fn(`http://127.0.0.1:${port}`) + } finally { + await new Promise((resolve) => server.close(() => resolve(undefined))) + } +} + +/** + * Minimal `CommandRunContext` stand-in, with the endpoint threaded through the + * gateway plugin's configured `listen` (how the CLI resolves it when no live + * daemon status file is present). + * + * @param {{ endpoint: string, env?: Record }} args + */ +function fakeCtx(args) { + let out = '' + let err = '' + const hypHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hyp-receipt-home-')) + const ctx = { + stdout: { write: (/** @type {string} */ s) => { out += s; return true } }, + stderr: { write: (/** @type {string} */ s) => { err += s; return true } }, + env: { HYP_HOME: hypHome, ...(args.env ?? {}) }, + cwd: '/repo/here', + config: { + version: 2, + plugins: [{ name: '@hypaware/ai-gateway', config: { listen: args.endpoint.replace(/^https?:\/\//, '') } }], + }, + } + return { ctx: /** @type {any} */ (ctx), stdout: () => out, stderr: () => err } +} + +/** + * One live Codex exchange carrying the turn metadata a subagent turn has: its + * own thread id, and the session container holding it. This is the input the + * drop sees, so the test asserts against the real projector rather than a + * restatement of what it matches. + * + * @param {{ sessionId: string, threadId: string }} ids + */ +function codexExchange(ids) { + return /** @type {any} */ ({ + exchange_id: 'ex-460', + ts_start: '2026-07-31T10:00:00.000Z', + ts_end: '2026-07-31T10:00:00.250Z', + provider: 'chatgpt', + method: 'POST', + path: '/backend-api/codex/responses', + status_code: 200, + is_sse: false, + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ session_id: ids.sessionId, thread_id: ids.threadId }), + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_headers: JSON.stringify({}), + response_body: JSON.stringify({ output_text: 'done' }), + stream_events: [], + }) +} + +/** + * The projector context the gateway dispatcher supplies, with the + * ignored-session predicate backed by the live control-route set. + * + * @param {Set} ignored + */ +function dropContext(ignored) { + return { + log: { debug() {}, info() {}, warn() {}, error() {} }, + isSessionIgnored: (/** @type {string} */ id) => ignored.has(id), + } +} diff --git a/test/plugins/ai-gateway-session-responder-trust.test.js b/test/plugins/ai-gateway-session-responder-trust.test.js index 08ff6484..f1095b23 100644 --- a/test/plugins/ai-gateway-session-responder-trust.test.js +++ b/test/plugins/ai-gateway-session-responder-trust.test.js @@ -84,15 +84,17 @@ test('an impostor that echoes the token is believed - and every answer discloses }) test('`hyp session ignore` carries the disclosure too, where a spoofed success reads as done', async () => { - // The louder half of the harm in #451: `ignore` against an impostor prints - // "the gateway will drop this session" while nothing recorded the decision. + // The louder half of the harm in #451: `ignore` against an impostor prints a + // confirmed `ignored` while nothing recorded the decision. The receipt no + // longer promises a drop (LLP 0066 R14), but "this id is in the gateway drop + // set" still reads as done, and the set it names is the impostor's. await withImpostorServer(async (base) => { const home = daemonHome(base) const env = { HYP_HOME: home, CLAUDE_CODE_SESSION_ID: 'sess-spoofed-write' } const human = fakeCtx({ env }) assert.equal(await runSessionIgnore([], human.ctx), 0) - assert.match(human.stdout(), /ignored - the gateway will drop this session/) + assert.match(human.stdout(), /ignored - this id is in the gateway drop set/) assert.match(human.stdout(), /nothing proves the responder .* is the HypAware gateway/) const json = fakeCtx({ env })