fix(runner): redact the public Phase 7 stream and bind sessions to a capability

Both findings from the exact-build security review (PAP-16983) against
`b73524ec2ac5`.

Sensitive information disclosure: every projected view carried raw
`provider_event` records and unredacted tool results, so one ordinary turn
handed an admitted browser provider protocol traffic, the echoed prompt,
provider thread/turn/item identifiers, model and token metadata, and complete
tool payloads. Redaction now happens where evidence is recorded, not where it
is rendered — `redactPhase7EvidenceData` is the only way an entry enters a live
session, so a raw payload never reaches the snapshot, the store, a frame, or a
log. `toPhase7PublicThreadView` then publishes an allowlisted DTO on every
response path, aliases provider-authored identifiers consistently, and scrubs
withheld provider identity. A streamed failure answers with a code and fixed
operator copy instead of the underlying message.

Cross-session BOLA: the only capability was minted once per gateway process, so
possession of a session id authorized reads and mutations of it. Each surface
now mints a per-browser capability, stores only its SHA-256 with the session,
and compares it in constant time on every session-scoped route. A valid id
without its capability is answered 404, exactly like an id that never existed.
`New chat`, a scenario POST, and reset rotate the capability after revoking the
caller's prior bindings.

Also normalises the stream frame cap to encoded UTF-8 bytes rather than UTF-16
code units, which the review flagged as low-severity hardening.

Each new test was observed failing against the reinstated rejected behaviour
(19 failed, 18 pre-existing passed) before passing against the fix.

Refs PAP-16991.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5 committed 2026-08-10 22:40:32 +00:00
1 parent 6c1dda072d
commit b0f8b1e91e
19 files changed
+2176 -90

No files matched your search

@@ -22,6 +22,14 @@ import {
const BASE = "/api/phase7/ui";
/**
* Every route is session-scoped and mediated by the per-browser capability
* cookie the server sets, so the cookie has to ride along. Stated rather than
* left to the default so a future change to this client cannot silently drop
* the capability and fall back to session-id-only access (track 7U).
*/
const CREDENTIALS = "same-origin" as const;
export interface Phase7CleanRoomIdentity {
token: string;
sequence: number;
@@ -74,6 +82,7 @@ async function post(path: string, body: unknown): Promise<Phase7LiveResponse> {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
credentials: CREDENTIALS,
});
if (!response.ok) throw await readError(response, path);
return (await response.json()) as Phase7LiveResponse;
@@ -97,6 +106,7 @@ async function postTurnStream(
method: "POST",
headers: { "content-type": "application/json", accept: PHASE7_TURN_STREAM_ACCEPT },
body: JSON.stringify(body),
credentials: CREDENTIALS,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Admission failures still answer with a JSON body and a real status code,
@@ -118,7 +128,7 @@ async function postTurnStream(
export const phase7LiveClient = {
async load(sessionId: string | null): Promise<Phase7LiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/session${query}`);
const response = await fetch(`${BASE}/session${query}`, { credentials: CREDENTIALS });
if (!response.ok) throw await readError(response, "/session");
return (await response.json()) as Phase7LiveResponse;
},
@@ -128,7 +138,7 @@ export const phase7LiveClient = {
/** Reconnect to a clean room, or open one when there is nothing to resume. */
async loadCleanRoom(sessionId: string | null): Promise<Phase7LiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/cleanroom/session${query}`);
const response = await fetch(`${BASE}/cleanroom/session${query}`, { credentials: CREDENTIALS });
if (!response.ok) throw await readError(response, "/cleanroom/session");
return (await response.json()) as Phase7LiveResponse;
},
@@ -40,6 +40,61 @@ No provider, runner, or control-plane credential reaches the page. Redacted
fields render as `••• redacted` with the redaction rule name, mock issues use
the reserved `MCK-` prefix, and no real Paperclip URL is ever rendered.
## What the browser is allowed to see
The projection is an internal shape. What ships is the published DTO,
`toPhase7PublicThreadView` (`src/issue-thread/public-view.ts`), which copies the
view field by field — so a field added to the projection, or to any record it
passes through, cannot reach a browser until it is listed there. Every response
path uses it: interim stream frames, the settled payload, and reconnect or
replay replies alike.
Three narrowings stack, so no single omission opens a disclosure path:
1. **At record time.** `redactPhase7EvidenceData`
(`src/phase7/evidence-redaction.ts`) is the only way an evidence entry enters
a live session. Provider notifications are reduced to a coarse category,
provider diagnostics to the fact that one occurred, tool arguments to the
catalog-declared field names they used, and tool results to the outcome,
revisions, and mock entity refs the UI resolves into cards. Provider thread
and session identity, model and token metadata, and raw tool payloads are
never retained, so no later reader, frame, or log can republish them.
2. **In the projection.** `Runner & events` details are composed from the
redacted record rather than stringified from it, and `Calls & results` names
the operation and its field count instead of echoing arguments.
3. **In the DTO.** Provider-authored turn and call identifiers are replaced by
in-view aliases (`turn-1`, `call-1`) that stay consistent across anchors,
evidence refs, and successive frames of one turn, and any value the caller
declares withheld is scrubbed from the encoded result.
A streamed turn that fails answers with a code and fixed operator copy; the
underlying message stays server side, because provider text can quote prompts
and paths.
## Session capability
Every session-scoped route is bound to a per-browser capability. The server
mints one on session creation, stores only its SHA-256 with the session record,
sets it as an `HttpOnly; SameSite=Strict` cookie, and compares it in constant
time on every read and mutation — message, reconnect, interaction, stop, reset,
and new chat. A valid session id presented without its capability is answered
`404`, exactly like an id that never existed, so an unauthorized caller cannot
tell a live session from a dead one.
Rotation belongs to the actions that start something new — `New chat`, a scenario
POST, reset. Each revokes the caller's existing bindings before issuing the
replacement cookie. Reopening a page whose stored id is simply gone mints a
session under the capability the browser already holds instead, because rotating
there would make two tabs of one surface revoke each other on every load while
protecting nothing: one browser is one principal, and cross-browser denial rests
on the binding rather than on how often the value changes.
The two surfaces use separate cookie names (`paperclip_phase7_issue`,
`paperclip_phase7_chat`) because they are separate pages of one origin: a single
name would make opening the explorer revoke the clean room. A script that drives
these routes has to behave like one browser — `scripts/phase7-cookie-jar.mjs` is
what the smoke scripts use for that.
## Surfaces
- **Header** — three identity chips (`Real Codex` / `Fake agent` / `Replay`,
@@ -0,0 +1,190 @@
---
type: Verification Evidence
title: Phase 7U streamed-evidence disclosure and session capability remediation verification
description: Command-and-result record for the two exact-build security findings — allowlisted public stream DTO with redaction applied before evidence persistence, and per-browser session capability binding — including the observed failure of each new regression test against the rejected behaviour.
tags: [native-runner, phase-7, clean-room, streaming, security, redaction, authorization, verification, evidence]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-10T22:40:00Z }
entry_kind: evidence
phase: "7"
---
# Scope
Track 7U (PAP-16991) remediates both findings the exact-build security review
(PAP-16983) raised against source `b73524ec2ac5c9f796f4067a85d8848801e2735b`,
build `phase7r-b73524ec2ac5-index-CXqou55h.js-f97f184f34e448c2`, bundle
`index-CXqou55h.js`, SHA-256
`fa0960a14279bb72941b2ece623d92124dffa1116974edfec3ad485a6e8dbd41`.
The runtime remains mock-Paperclip only. No ACPX, real Paperclip bridge, HTTPS
promotion, Tailscale Serve/cert/operator change, sudo work, node, or port is
introduced.
Finding 1 (High) was streamed sensitive-information disclosure: every projected
view carried raw `provider_event` records and unredacted tool results, so an
admitted browser received provider protocol traffic, the echoed prompt, provider
thread/turn/item identifiers, model and token metadata, and complete tool
payloads. Finding 2 (Medium) was cross-session BOLA: the only capability was
minted once per gateway process, so possession of a session id — not ownership of
it — authorized reads and mutations.
# What changed
* `src/phase7/evidence-redaction.ts` (new) is the single gate every live evidence
record passes through before it is retained. Provider notifications reduce to a
coarse category, provider diagnostics to the fact that one occurred, tool
arguments to the catalog-declared field names they used, and tool results to
the outcome, our own denial copy, the revisions, and the mock entity refs the
projection resolves into cards. Unlisted kinds and unlisted fields produce
nothing. `Phase7LiveSession.#appendEvidence` calls it, so redaction happens at
record time — a raw payload never enters the snapshot, the store, a frame, or a
log.
* `src/issue-thread/public-view.ts` (new) publishes the DTO. It copies the view
field by field, so a field added to the projection cannot ship; replaces
provider-authored turn and call identifiers with in-view aliases that stay
consistent across anchors, evidence refs, and successive frames of one turn;
and scrubs caller-declared withheld values (provider thread and session
identity) from the encoded result.
* `src/issue-thread/live-projection.ts` composes `Runner & events` details from
the redacted record instead of stringifying it, names the operation and its
field count in `Calls & results` instead of echoing arguments, and re-narrows
tool results on the way out.
* `scripts/phase7-issue-thread-server.mjs` publishes the DTO on every response
path — interim frames, settled payloads, reconnect and replay replies — mints a
per-browser capability per surface, stores only its SHA-256, compares it in
constant time on every session-scoped route, answers `404` for a session owned
by another capability, rotates on reset and `New chat` after revoking the old
binding, and reports a streamed failure as a code with fixed operator copy
rather than the underlying provider text.
* `src/phase7/turn-stream.ts` enforces the named byte cap on encoded UTF-8 bytes
via `phase7Utf8ByteLength` instead of UTF-16 code units.
* `devtools/issue-thread/src/live-client.ts` states `credentials: "same-origin"`
on every request, and `scripts/phase7-cookie-jar.mjs` (new) gives the smoke
scripts one-browser behaviour.
# Observed failure against the rejected behaviour, then the fix
The rejected behaviour was reinstated in place (evidence redaction and the DTO
reduced to passthroughs, projection details stringified, capability comparison
forced true, raw error text restored, byte cap back to `String#length`), the
package rebuilt, and the new tests run.
```text
$ npx vitest run src/phase7/clean-room-server.test.ts \
src/issue-thread/public-view.test.ts src/phase7/turn-stream.test.ts
Tests 19 failed | 18 passed (37)
```
All 19 new assertions failed; all 18 pre-existing tests in those files still
passed, so the new coverage — not a broken harness — is what fails. The
end-to-end canary sweep failed on the exact disclosure the review reported:
```text
AssertionError: expected '{"schema":"paperclip.phase7.issue-thr…' not to contain 'codex-thread-clean-room'
runner[].detail = "{\"action\":\"started\",\"sessionId\":\"…\",
\"providerThreadId\":\"codex-thread-clean-room\",
\"providerSessionId\":\"codex-provider-clean-room\",…}"
```
With the fix restored:
```text
$ pnpm run test:phase7
Test Files 16 passed (16)
Tests 188 passed (188)
```
# New coverage
`src/phase7/clean-room-server.test.ts` drives the real middleware, live session,
semantic dispatcher, authorization engine, and mock `ControlPlanePort` over HTTP.
* One turn plants a canary prompt echo, a credential in provider request
headers, an environment value inside a provider diagnostic, model and token
metadata, a provider-shaped item id, and an argument no catalog schema
declares. The sweep scans every interim frame, the settled payload, the
reconnect payload, the replay payload, the persisted public evidence, and
captured `stdout`/`stderr`, and asserts none of the canaries, the provider
thread id, the provider session id, the browser's own capability, the
undeclared argument name, or `inputTokens` appears in any of them. The user's
own message card is removed before scanning rather than exempted, so the one
legitimate echo of the prompt cannot mask a second copy. The same test asserts
the evidence panel still reports what happened, so the fix is a summary rather
than a blank section.
* Two independent cookie jars prove reciprocal read denial and reciprocal
mutation denial across `message`, `interrupt`, `reconnect`, `reset`, and
`interaction`, on both the clean-room and the issue surface, plus denial to a
cookie-less caller, while the owner keeps access throughout.
* Reset and `New chat` rotate the capability, and the previous cookie is proved
dead against the replacement session as well as the retired one, on both
surfaces.
* Two tabs of one browser each hold their own session without revoking the
other, and a different browser still reaches neither. Rotation belongs to the
actions that start something new — `New chat`, a scenario POST, reset — not to
reopening a page whose stored id is simply gone, because rotating there would
make two tabs ping-pong while protecting nothing: one browser is one
principal, and cross-browser denial rests on the binding rather than on how
often the value changes.
`src/issue-thread/public-view.test.ts` pins the two components: allowlist copying
drops injected fields, tool payloads narrow to the rendered summary, provider
identifiers alias consistently, withheld values are scrubbed from free text, and
each redaction rule is asserted against a canary.
`src/phase7/turn-stream.test.ts` proves the frame cap trips on encoded bytes for
multi-byte text that is under the cap by code units, and that
`phase7Utf8ByteLength` agrees with `TextEncoder` including surrogate pairs and a
lone surrogate left by a chunk boundary.
# Preserved invariants
Incremental NDJSON streaming, monotonic sequencing, stop and abandonment
cleanup, the turn/session/frame/body limits, mock-only enforcement, the
real-Paperclip API block, and the clean room's live-only guard are all still
asserted by the pre-existing tests in the same files, which pass unchanged.
# Commands and results
```text
$ npx tsc -p tsconfig.json --noEmit # clean
$ npx tsc -p tsconfig.browser.json --noEmit # clean
$ pnpm run test:phase7 # 16 files, 188 tests passed
$ pnpm run test:phase5 # 3 files, 22 tests passed
$ npx vitest run # 41 files, 384 tests passed
$ pnpm run test:browser:issue-thread # 62 passed
$ npx playwright test --config examples/playwright.phase7.config.ts
# 43 passed
$ pnpm run check:phase7-inventory # passed
$ pnpm run check:phase-07-contract # passed
$ pnpm run check:forbidden-imports # passed
$ pnpm run docs:validate # 94 files, 35 concepts, 8 indexes
```
`test:phase7` was widened from `src/issue-thread/issue-thread.test.ts` to
`src/issue-thread` so the new suite is inside the gate rather than beside it.
The browser gates need the vendored aarch64 Chromium libraries on
`LD_LIBRARY_PATH`; without them Playwright's bundled `headless_shell` cannot load
`libatk-1.0.so.0` and every browser test fails to launch. The
`examples/playwright.phase7.config.ts` gate does not read
`PAPERCLIP_RUNNER_CHROMIUM_PATH`, so it needs the library environment rather than
the pin.
# Known pre-existing failure, not caused by this change
`pnpm run check:phase7:ui` reports all 24 committed PNGs as not byte-stable in
this environment, with the pinned Chromium 151.0.7922.34. It is pre-existing:
reverting the only two bundle inputs this track touches
(`devtools/issue-thread/src/live-client.ts`, `src/phase7/turn-stream.ts`) to
`HEAD` and re-running reproduces the same 24-file mismatch. A pixel diff shows a
whole-page difference (for example `denial-optional-tool--desktop.png`, 207,794
differing pixels, bounding box covering the page) rather than a localised copy
change, which is the environment-difference signature the recorder itself warns
about. The re-recorded captures were discarded and the committed PNGs restored
unchanged, because that acceptance set is gated UX evidence and must not be
replaced with bytes from a different rendering environment.
One unhandled rejection surfaces in the full suite from
`src/drivers/codex/app-server-transport.test.ts` ("codex app-server transport
closed"). That file is untouched here and all 384 tests still pass.
@@ -21,3 +21,4 @@
* [Phase 7 final evidence manifest (Revision 3/4)](2026-08-10-phase-07i-final-evidence.md) - Authoritative Phase 7I manifest for the final candidate build `865d1a72`: offline and live command results, conformance and approved security results, screenshot eligibility, and the Revision 2 ineligibility record.
* [Phase 7M clean-room live chat verification](2026-08-10-phase-07m-clean-room-verification.md) - Blank-seed enforcement, deterministic exposure profile, allowed/denied semantic calls, durable multi-turn session, identity rotation on reset and new chat, the real-API block record, browser and axe coverage, and the live Codex smoke.
* [Phase 7Q live turn streaming verification](2026-08-10-phase-07q-turn-streaming-verification.md) - Live turn events, the NDJSON turn stream, incremental browser rendering on one growing assistant card, stop and abandonment behaviour, the preserved security properties, and a measured real Codex turn.
* [Phase 7U streamed-evidence disclosure and session capability verification](2026-08-10-phase-07u-disclosure-and-capability-verification.md) - Allowlisted public stream DTO, redaction applied before evidence persistence, per-browser session capability binding with rotation, the canary and two-cookie-jar regression sets observed failing against the rejected behaviour, and the byte-accurate stream frame cap.
+1 -1
View File
@@ -46,7 +46,7 @@
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
"test:phase5": "vitest run src/browser/client.test.ts src/browser/transcript-model.test.ts src/react/public-contract.test.tsx",
"test:phase7": "vitest run src/phase7 src/semantic-tools/semantic-tools.test.ts src/mock-core/phase7-mock-control-plane-adapter.test.ts src/issue-thread/issue-thread.test.ts devtools/issue-thread/src examples/phase7-explorer",
"test:phase7": "vitest run src/phase7 src/semantic-tools/semantic-tools.test.ts src/mock-core/phase7-mock-control-plane-adapter.test.ts src/issue-thread devtools/issue-thread/src examples/phase7-explorer",
"test:browser:phase5": "pnpm run build:typescript && playwright test --config examples/playwright.config.ts",
"test:browser:phase7": "playwright test --config examples/playwright.phase7.config.ts && pnpm run test:browser:issue-thread",
"test:browser:issue-thread": "pnpm run build:typescript && pnpm run build:issue-thread && playwright test --config devtools/issue-thread/playwright.config.ts",
@@ -17,6 +17,7 @@
import { createServer } from "node:http";
import { once } from "node:events";
import { createPhase7CookieJar } from "./phase7-cookie-jar.mjs";
import { createPhase7IssueThreadMiddleware } from "./phase7-issue-thread-server.mjs";
const { readPhase7TurnStream, PHASE7_TURN_STREAM_ACCEPT } = await import(
@@ -76,10 +77,13 @@ async function main() {
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
const get = async (path) => (await fetch(`${origin}${path}`)).json();
// One jar for the whole run: the smoke drives the routes as a single browser
// would, so it carries the capability the server minted for it.
const jar = createPhase7CookieJar(origin);
const get = async (path) => (await jar.fetch(path)).json();
const post = async (path, body) =>
(
await fetch(`${origin}${path}`, {
await jar.fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
@@ -91,7 +95,7 @@ async function main() {
* than only the settled reply.
*/
const turn = async (body) => {
const response = await fetch(`${origin}/api/phase7/ui/message`, {
const response = await jar.fetch("/api/phase7/ui/message", {
method: "POST",
headers: { "content-type": "application/json", accept: PHASE7_TURN_STREAM_ACCEPT },
body: JSON.stringify(body),
@@ -158,12 +162,20 @@ async function main() {
fresh.identity.companyId !== opened.identity.companyId &&
fresh.identity.taskId !== opened.identity.taskId &&
fresh.view.turns.length === 0;
const retired = await fetch(`${origin}/api/phase7/ui/message`, {
const retired = await jar.fetch("/api/phase7/ui/message", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "still there?" }),
});
assertions.priorAuthorityRetired = retired.status === 404;
// A second browser must not reach the room this one owns.
const stranger = createPhase7CookieJar(origin);
const foreign = await stranger.fetch(
`/api/phase7/ui/cleanroom/session?sessionId=${fresh.sessionId}`,
);
await foreign.text();
assertions.crossSessionDenied = foreign.status === 404;
assertions.capabilityBound = typeof jar.value("paperclip_phase7_chat") === "string";
summary = {
schema: "paperclip.phase7.clean-room-smoke.v1",
@@ -0,0 +1,41 @@
/**
* Cookie jar for the Phase 7 smoke scripts.
*
* Every session-scoped route is bound to a per-browser capability cookie
* (track 7U), so a script that drives those routes has to behave like one
* browser: carry the capability it was given, and adopt the replacement when a
* reset or a new chat rotates it. A browser does this for free; `fetch` does
* not, which is what this small jar supplies.
*/
export function createPhase7CookieJar(origin) {
const cookies = new Map();
function capture(response) {
for (const raw of response.headers.getSetCookie()) {
const pair = raw.split(";", 1)[0] ?? "";
const separator = pair.indexOf("=");
if (separator < 1) continue;
cookies.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
}
}
function header() {
if (cookies.size === 0) return {};
return { cookie: [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") };
}
return {
/** One request through this jar, capturing any rotated capability. */
async fetch(path, init = {}) {
const response = await fetch(`${origin}${path}`, {
...init,
headers: { ...(init.headers ?? {}), ...header() },
});
capture(response);
return response;
},
value(name) {
return cookies.get(name);
},
};
}
@@ -1,3 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -39,6 +40,78 @@ const MAX_MESSAGE_BYTES = 8 * 1024;
*/
const MAX_TURN_STREAM_FRAMES = 600;
/**
* Per-browser session capability (track 7U).
*
* A session id used to be the only thing a route checked, so possession of
* another browser's id authorized reading and mutating that browser's session.
* Each surface now mints its own high-entropy capability, stores only its
* SHA-256 with the session record, and compares in constant time on every read
* and mutation. A valid id presented without its capability is answered `404`,
* the same as an id that never existed: an unauthorized caller learns nothing
* about which ids are live.
*
* The two surfaces use separate cookie names on purpose. They are separate
* pages of one origin, so a single name would make opening the explorer revoke
* the clean room (and the reverse) instead of keeping two independent tenants.
*/
const CAPABILITY_COOKIES = Object.freeze({
issue: "paperclip_phase7_issue",
cleanroom: "paperclip_phase7_chat",
});
const CAPABILITY_BYTES = 32;
const CAPABILITY_MAX_AGE_SECONDS = 30 * 60;
/**
* A streamed turn fails with a code, never with the underlying text. Provider
* and driver messages can quote prompts, paths, or protocol detail, and a frame
* is browser surface, so the operator-facing copy is fixed here.
*/
const PUBLIC_TURN_ERROR_MESSAGE =
"The turn could not be completed. Nothing further was sent to the provider.";
function sha256(value) {
return createHash("sha256").update(value, "utf8").digest();
}
function mintCapability() {
return randomBytes(CAPABILITY_BYTES).toString("base64url");
}
function capabilityMatches(presented, expectedHash) {
if (typeof presented !== "string" || presented.length < 32) return false;
const digest = sha256(presented);
// Length is compared first because `timingSafeEqual` throws on a mismatch;
// both operands are fixed-width digests, so this never short-circuits on a
// secret-dependent branch.
return digest.length === expectedHash.length && timingSafeEqual(digest, expectedHash);
}
function parseCookies(request) {
const header = request.headers.cookie;
const source = typeof header === "string" ? header : "";
const cookies = new Map();
for (const segment of source.split(";")) {
const separator = segment.indexOf("=");
if (separator < 1) continue;
cookies.set(segment.slice(0, separator).trim(), segment.slice(separator + 1).trim());
}
return cookies;
}
function presentedCapability(request, surface) {
return parseCookies(request).get(CAPABILITY_COOKIES[surface]) ?? "";
}
function capabilityCookie(request, surface, value, maxAgeSeconds = CAPABILITY_MAX_AGE_SECONDS) {
const forwardedProtocol = request.headers["x-forwarded-proto"];
const secure = String(Array.isArray(forwardedProtocol) ? forwardedProtocol[0] : forwardedProtocol ?? "")
.toLowerCase() === "https";
return `${CAPABILITY_COOKIES[surface]}=${value}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly${
secure ? "; Secure" : ""
}; SameSite=Strict`;
}
async function loadRunner() {
return import(new URL("../dist/index.js", import.meta.url).href);
}
@@ -110,6 +183,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
* ownsWorkingDirectory: boolean,
* turns: number,
* createdAt: number,
* capabilityHash: Buffer,
* connection: { state: string, attempt: number },
* }>}
*/
@@ -133,13 +207,25 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
return bootstrap;
}
/**
* The published view.
*
* The projection is an internal shape; `toPhase7PublicThreadView` is what the
* browser is allowed to see. Every response path calls this — never the
* projection directly — so interim frames, terminal payloads, and reconnect
* replies cannot disagree about what is public (track 7U).
*/
function view(runner, entry) {
return runner.projectPhase7IssueThread({
snapshot: entry.session.snapshot(),
const snapshot = entry.session.snapshot();
const projected = runner.projectPhase7IssueThread({
snapshot,
connection: entry.connection,
mode: "live",
fixtureProfile: entry.scenario,
});
return runner.toPhase7PublicThreadView(projected, {
withheldValues: [snapshot.providerThreadId, snapshot.providerSessionId ?? ""],
});
}
/**
@@ -179,7 +265,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
response.end(JSON.stringify(payload));
}
async function createSession(runner, service, scenario, workingDirectory) {
async function createSession(runner, service, scenario, workingDirectory, capabilityHash) {
const session = await service.create({
seed: issueThreadSeed(runner, scenario),
workingDirectory,
@@ -197,6 +283,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
ownsWorkingDirectory: false,
turns: 0,
createdAt: Date.now(),
capabilityHash,
connection: { state: "connected", attempt: 0 },
};
sessions.set(session.id, entry);
@@ -214,7 +301,21 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
}
}
async function createCleanRoomSession(runner, service) {
/**
* Revokes every session already bound to the presented capability before a
* replacement is minted, so a rotation deletes the old binding rather than
* leaving a second reachable session behind it.
*/
async function revokeBoundSessions(service, surface, presented) {
if (presented.length === 0) return;
for (const [sessionId, entry] of [...sessions.entries()]) {
if (entry.surface !== surface) continue;
if (!capabilityMatches(presented, entry.capabilityHash)) continue;
await retire(service, sessionId, "capability rotated");
}
}
async function createCleanRoomSession(runner, service, capabilityHash) {
const open = [...sessions.values()].filter((entry) => entry.surface === "cleanroom");
// Bounded concurrency: the oldest clean room yields rather than refusing a
// new board user, because an abandoned chat is the likelier tenant here.
@@ -239,6 +340,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
ownsWorkingDirectory: true,
turns: 0,
createdAt: Date.now(),
capabilityHash,
connection: { state: "connected", attempt: 0 },
};
sessions.set(session.id, entry);
@@ -365,11 +467,13 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
});
} catch (error) {
finished = true;
// The code identifies the failure; the underlying message stays server
// side because it can quote provider text (track 7U).
write({
schema: runner.PHASE7_TURN_STREAM_SCHEMA,
type: "error",
error: error instanceof RouteError ? error.code : "turn_failed",
message: String(error instanceof Error ? error.message : error),
message: PUBLIC_TURN_ERROR_MESSAGE,
});
} finally {
finished = true;
@@ -396,44 +500,114 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
const scenario =
typeof body.scenario === "string" ? body.scenario : url.searchParams.get("scenario") ?? "hb-baseline";
if (route === CLEAN_ROOM_ROUTE && request.method === "GET") {
const existing = requestedId === null ? undefined : sessions.get(requestedId);
// A stale id from localStorage opens a fresh room rather than a dead
// end; a live id reconnects to the same durable chat.
/**
* Resolves the caller's own session, or `undefined`.
*
* A session that exists but belongs to another capability is reported as
* `denied` rather than as absent-so-make-a-new-one: the caller must not be
* handed a fresh session under an id it does not own, and must not be able
* to tell a live id from a dead one.
*/
const ownedSession = (surface) => {
if (requestedId === null) return { state: "absent" };
const existing = sessions.get(requestedId);
if (existing === undefined) return { state: "absent" };
if (
existing.surface !== surface ||
!capabilityMatches(presentedCapability(request, surface), existing.capabilityHash)
) {
return { state: "denied" };
}
return { state: "owned", entry: existing };
};
/**
* Mints a session for this browser.
*
* `rotate` separates the two reasons a session gets created. Starting
* something new — `New chat`, a scenario POST, a reset — rotates: the old
* bindings are revoked first so the cookie the caller arrived with stops
* working. Opening a page whose stored id is simply gone does not rotate;
* it reuses the capability the browser already holds. Two tabs of one
* surface would otherwise revoke each other's session on every load and
* ping-pong, and rotating there protects nothing: the browser is the same
* principal either way, and cross-browser denial rests on the binding, not
* on how often the value changes.
*/
const mint = async (surface, { rotate }) => {
const presented = presentedCapability(request, surface);
const reuse = !rotate && presented.length >= 32;
if (rotate) await revokeBoundSessions(service, surface, presented);
const capability = reuse ? presented : mintCapability();
const capabilityHash = sha256(capability);
const entry =
existing !== undefined && existing.surface === "cleanroom"
? existing
: await createCleanRoomSession(runner, service);
surface === "cleanroom"
? await createCleanRoomSession(runner, service, capabilityHash)
: await createSession(runner, service, scenario, workingDirectory, capabilityHash);
if (!reuse) {
response.setHeader("set-cookie", capabilityCookie(request, surface, capability));
}
return entry;
};
if (route === CLEAN_ROOM_ROUTE && request.method === "GET") {
// A stale id from localStorage opens a fresh room rather than a dead
// end; a live id reconnects to the same durable chat; another browser's
// id is refused.
const owned = ownedSession("cleanroom");
if (owned.state === "denied") {
send(response, 404, { error: "unknown_session" });
return;
}
const entry = owned.state === "owned" ? owned.entry : await mint("cleanroom", { rotate: false });
send(response, 200, cleanRoomPayload(runner, entry));
return;
}
if (route === CLEAN_ROOM_ROUTE && request.method === "POST") {
// `New chat`: retire the caller's room before minting the next one, so
// the prior session authority is cleared rather than left running.
if (requestedId !== null) await retire(service, requestedId, "new clean-room chat");
const entry = await createCleanRoomSession(runner, service);
// `New chat`: the caller's own room is retired and its capability
// rotated before the next one is minted, so the prior authority is
// cleared and the prior cookie stops working.
const owned = ownedSession("cleanroom");
if (owned.state === "denied") {
send(response, 404, { error: "unknown_session" });
return;
}
if (owned.state === "owned") {
await retire(service, owned.entry.session.id, "new clean-room chat");
}
const entry = await mint("cleanroom", { rotate: true });
send(response, 201, cleanRoomPayload(runner, entry));
return;
}
if (route === "session" && request.method === "GET") {
const existing = requestedId === null ? undefined : sessions.get(requestedId);
const entry =
existing !== undefined && existing.surface === "issue"
? existing
: await createSession(runner, service, scenario, workingDirectory);
const owned = ownedSession("issue");
if (owned.state === "denied") {
send(response, 404, { error: "unknown_session" });
return;
}
const entry = owned.state === "owned" ? owned.entry : await mint("issue", { rotate: false });
send(response, 200, payload(runner, entry));
return;
}
if (route === "session" && request.method === "POST") {
const entry = await createSession(runner, service, scenario, workingDirectory);
const entry = await mint("issue", { rotate: true });
send(response, 201, payload(runner, entry));
return;
}
const entry = requestedId === null ? undefined : sessions.get(requestedId);
// Shared session-scoped routes. The surface comes from the stored record,
// and the capability is then checked against that record — so `message`,
// `interrupt`, `reconnect`, `reset`, and `interaction` are all mediated,
// not just the routes that create sessions.
const located = requestedId === null ? undefined : sessions.get(requestedId);
const entry =
located !== undefined &&
capabilityMatches(presentedCapability(request, located.surface), located.capabilityHash)
? located
: undefined;
if (entry === undefined) {
send(response, 404, { error: "unknown_session" });
return;
@@ -463,12 +637,17 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
await entry.session.reconnect();
entry.connection = { state: "connected", attempt: 0 };
} else if (route === "reset") {
// Reset rotates the capability as well as the session: the cookie the
// caller arrived with must stop working, here and on the replacement.
const capability = mintCapability();
const capabilityHash = sha256(capability);
if (entry.surface === "cleanroom") {
// A clean-room reset is a new tenant, not a rewound one: the seed is
// blank either way, so restoring it would hand back the same mock
// identities the board just saw.
await retire(service, entry.session.id, "clean-room reset");
const replacement = await createCleanRoomSession(runner, service);
const replacement = await createCleanRoomSession(runner, service, capabilityHash);
response.setHeader("set-cookie", capabilityCookie(request, "cleanroom", capability));
send(response, 200, cleanRoomPayload(runner, replacement));
return;
}
@@ -478,9 +657,11 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
...entry,
session: next,
turns: 0,
capabilityHash,
connection: { state: "connected", attempt: 0 },
};
sessions.set(next.id, resetEntry);
response.setHeader("set-cookie", capabilityCookie(request, "issue", capability));
send(response, 200, payload(runner, resetEntry));
return;
} else if (route === "interaction") {
@@ -14,6 +14,7 @@
import { createServer } from "node:http";
import { once } from "node:events";
import { createPhase7CookieJar } from "./phase7-cookie-jar.mjs";
import { createPhase7IssueThreadMiddleware } from "./phase7-issue-thread-server.mjs";
const { readPhase7TurnStream, PHASE7_TURN_STREAM_ACCEPT } = await import(
@@ -21,8 +22,8 @@ const { readPhase7TurnStream, PHASE7_TURN_STREAM_ACCEPT } = await import(
);
/** Runs one turn over the NDJSON stream and returns its settled payload. */
async function runTurn(origin, body) {
const response = await fetch(`${origin}/api/phase7/ui/message`, {
async function runTurn(jar, body) {
const response = await jar.fetch("/api/phase7/ui/message", {
method: "POST",
headers: { "content-type": "application/json", accept: PHASE7_TURN_STREAM_ACCEPT },
body: JSON.stringify(body),
@@ -68,10 +69,13 @@ async function main() {
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
// One jar for the whole run: the routes are bound to a per-browser capability
// (track 7U), so the smoke has to behave like one browser.
const jar = createPhase7CookieJar(origin);
const assertions = {};
let sessionId = null;
try {
const created = await (await fetch(`${origin}/api/phase7/ui/session`)).json();
const created = await (await jar.fetch("/api/phase7/ui/session")).json();
sessionId = created.sessionId;
assertions.sessionCreated = typeof sessionId === "string" && sessionId.length > 0;
assertions.liveIdentity =
@@ -81,7 +85,7 @@ async function main() {
assertions.mockIdentifier = created.view.issue.identifier.startsWith("MCK-");
assertions.toolsProjected = created.view.evidence.tools.length >= 0;
const first = await runTurn(origin, {
const first = await runTurn(jar, {
sessionId,
message:
"Call get_task_context, then call report_progress with a one-sentence status. Do not ask me anything.",
@@ -99,7 +103,7 @@ async function main() {
(row) => row.disposition === "control_plane_owned",
);
const second = await runTurn(origin, {
const second = await runTurn(jar, {
sessionId,
message: "Summarise the mock task in one line.",
});
@@ -107,6 +111,11 @@ async function main() {
assertions.composerReady = second.view.composer.state === "ready";
assertions.noCredentialInView = credentialLeaks(second.view).length === 0;
// A browser without this run's capability must not reach this session.
const stranger = createPhase7CookieJar(origin);
const foreign = await stranger.fetch(`/api/phase7/ui/session?sessionId=${sessionId}`);
await foreign.text();
assertions.crossSessionDenied = foreign.status === 404;
const failures = Object.entries(assertions).filter(([, ok]) => ok !== true);
if (asJson) {
+8
View File
@@ -15,6 +15,7 @@ export * from "./drivers/codex/codex-app-server-driver.js";
export * from "./mock-core/mock-control-plane-adapter.js";
export * from "./mock-core/phase7-control-plane-types.js";
export * from "./mock-core/phase7-mock-control-plane-adapter.js";
export * from "./standalone/phase6-demo.js";
export * from "./mock-core/phase4-codex-runner.js";
export * from "./mock-core/phase4b-demo-server.js";
export * from "./mock-core/phase4b-demo-manifests.js";
@@ -29,11 +30,18 @@ export * from "./reducer/session-reducer.js";
export * from "./tracer/phase0-runner.js";
export * from "./tracer/phase1-replay.js";
export * from "./tracer/phase2-live.js";
export * from "./generated/phase7-capability-contract.js";
export * from "./tools/index.js";
export * as acceptedPhase7SemanticTools from "./semantic-tools/index.js";
export * from "./phase7/clean-room.js";
export * from "./phase7/live-session.js";
export * from "./phase7/runnerd-codex-transport.js";
export * from "./phase7/turn-stream.js";
export * from "./phase7/evidence-redaction.js";
export { projectPhase7IssueThread } from "./issue-thread/live-projection.js";
export {
toPhase7PublicThreadView,
type Phase7PublicIssueThreadView,
type Phase7PublicThreadViewOptions,
} from "./issue-thread/public-view.js";
export * as phase7IssueThread from "./issue-thread/index.js";
@@ -1,3 +1,4 @@
export * from "./types.js";
export * from "./fixtures.js";
export * from "./live-projection.js";
export * from "./public-view.js";
@@ -13,6 +13,10 @@ import type {
Phase7FixtureState,
Phase7JsonValue,
} from "../mock-core/phase7-control-plane-types.js";
import {
phase7EvidenceDetail,
redactPhase7ToolResult,
} from "../phase7/evidence-redaction.js";
import type {
Phase7LiveEvidenceEntry,
Phase7LiveSessionSnapshot,
@@ -330,7 +334,7 @@ function toolActivityItem(pair: CallPair): Phase7ThreadItem {
operationId: pair.operationId,
summary: toolSummary(pair.operationId, pair.result),
input: pair.input,
result: pair.result,
result: redactPhase7ToolResult(pair.result),
evidenceRef: { section: "calls", recordId: pair.callId },
};
}
@@ -441,7 +445,9 @@ function derivedRecordItems(
at: pair.at,
operationId,
status: task?.status ?? "in_review",
body: readString(readRecord(pair.input).summary, readString(readRecord(pair.input).reason)),
// The one input value the contract renders, carried under its own key by
// the evidence redactor rather than read out of raw arguments.
body: readString(readRecord(pair.input).dispositionSummary),
blockerOwner: null,
evidenceRef: { section: "calls", recordId: pair.callId },
});
@@ -566,17 +572,21 @@ function evidenceModel(
tools: turnIds.map((turnId) => ({ id: `tools-${turnId}`, turnId, rows })),
calls: calls.map((pair) => {
const payload = readRecord(pair.result ?? undefined);
const fields = readRecord(pair.input).fields;
const fieldCount = Array.isArray(fields) ? fields.length : 0;
return {
id: pair.callId,
turnId: pair.turnId ?? "turn-0",
operationId: pair.operationId,
version: 1,
providerRequest: `tools/call ${pair.operationId} ${JSON.stringify(pair.input)}`,
// The arguments themselves are never republished — the redacted field
// list already says what the call carried (track 7U).
providerRequest: `tools/call ${pair.operationId} · ${fieldCount} field${fieldCount === 1 ? "" : "s"}`,
dispatchedCommand:
payload.ok === false ? "rejected before dispatch" : `control_plane.dispatch(${pair.operationId})`,
outcome: payload.ok === false ? ("denied" as const) : ("ok" as const),
result: pair.result ?? null,
redactions: [],
result: pair.result === null ? null : redactPhase7ToolResult(pair.result),
redactions: pair.result === null ? [] : ["tool_result_detail"],
threadAnchorId: `item-call-${pair.callId}`,
};
}),
@@ -611,12 +621,14 @@ function evidenceModel(
stateRevision: decision.stateRevision,
threadAnchorId: null,
}))],
// A stringified record used to be the detail line, which republished every
// retained field. The detail is now composed from the redacted record.
runner: snapshot.evidence.map((entry, index) => ({
id: entry.id,
turnId: entry.turnId ?? "turn-0",
kind: entry.kind,
ordinal: index + 1,
detail: JSON.stringify(entry.data),
detail: phase7EvidenceDetail(entry.kind, entry.data),
})),
state: calls
.filter((pair) => pair.afterRevision !== null && pair.afterRevision !== pair.beforeRevision)
@@ -0,0 +1,322 @@
import { describe, expect, it } from "vitest";
import {
phase7AllowlistedInputFields,
phase7EvidenceDetail,
redactPhase7EvidenceData,
redactPhase7ToolResult,
} from "../phase7/evidence-redaction.js";
import { toPhase7PublicThreadView } from "./public-view.js";
import { PHASE7_ISSUE_THREAD_VIEW_SCHEMA, type Phase7IssueThreadSnapshot } from "./types.js";
/**
* Track 7U, finding 1, at the unit level.
*
* The end-to-end sweep in `clean-room-server.test.ts` proves nothing forbidden
* reaches the wire. These tests pin the two components that make that true, so
* a regression names the layer that broke rather than only the symptom.
*/
const CANARY = "CANARY-9f31c7ab";
/** Provider-shaped identifiers: opaque, long, and not ours to publish. */
const PROVIDER_TURN = "turn_01HZX8Q2M4KDR7VJ";
const PROVIDER_CALL = "call_01HZX8Q2M4KDR7VJ";
const PROVIDER_THREAD = "thr_01HZX8Q2M4KDR7VJWN";
function projectedView(): Phase7IssueThreadSnapshot {
return {
schema: PHASE7_ISSUE_THREAD_VIEW_SCHEMA,
sessionId: "session-1",
mode: "live",
identity: {
agentLabel: "Real Codex",
runnerLabel: "Real runnerd",
runnerAttached: true,
controlPlaneLabel: "Mock Paperclip",
controlPlaneTooltip: "All issue records are mock.",
replaySource: null,
},
issue: {
identifier: "MCK-31",
title: "Mock task",
status: "in_progress",
priority: "high",
assignee: "Mock Engineer",
runState: "run-1 · idle",
scenarioId: "clean-room",
fixtureProfile: "clean-room",
},
turns: [
{
id: PROVIDER_TURN,
ordinal: 1,
mode: "live",
toolCallCount: 1,
at: "2026-08-10T00:00:00.000Z",
stoppedByUser: false,
items: [
{
kind: "tool_activity",
id: `item-call-${PROVIDER_CALL}`,
at: "2026-08-10T00:00:01.000Z",
status: "ok",
operationId: "report_progress",
summary: "state revision 2",
input: { fields: ["body"], leaked: CANARY } as never,
result: { ok: true, stateRevision: 2, detail: CANARY } as never,
evidenceRef: { section: "calls", recordId: PROVIDER_CALL },
},
],
},
],
composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
evidence: {
tools: [],
calls: [
{
id: PROVIDER_CALL,
turnId: PROVIDER_TURN,
operationId: "report_progress",
version: 1,
providerRequest: "tools/call report_progress · 1 field",
dispatchedCommand: "control_plane.dispatch(report_progress)",
outcome: "ok",
result: { ok: true, stateRevision: 2, detail: CANARY } as never,
redactions: ["tool_result_detail"],
threadAnchorId: `item-call-${PROVIDER_CALL}`,
},
],
authorization: [],
control_plane: [],
runner: [
{
id: "evidence-000001",
turnId: PROVIDER_TURN,
kind: "provider_event",
ordinal: 1,
detail: `provider event · assistant_delta (${PROVIDER_THREAD})`,
},
],
state: [],
traceability: [],
parity: [],
},
connection: { state: "connected", attempt: 0 },
replay: null,
renderedAt: "2026-08-10T00:00:02.000Z",
};
}
describe("Phase 7 public issue-thread DTO", () => {
it("copies by allowlist, so a field added to the projection cannot ship", () => {
const view = projectedView() as Phase7IssueThreadSnapshot & { leakedTop?: string };
view.leakedTop = CANARY;
(view.issue as unknown as Record<string, string>).leakedIssue = CANARY;
(view.turns[0]!.items[0] as unknown as Record<string, string>).leakedItem = CANARY;
const encoded = JSON.stringify(toPhase7PublicThreadView(view));
expect(encoded).not.toContain(CANARY);
expect(encoded).not.toContain("leakedTop");
expect(encoded).not.toContain("leakedIssue");
expect(encoded).not.toContain("leakedItem");
});
it("narrows a tool payload to the summary the UI renders", () => {
const published = toPhase7PublicThreadView(projectedView());
const item = published.turns[0]!.items[0]!;
expect(item.kind).toBe("tool_activity");
if (item.kind !== "tool_activity") throw new Error("expected a tool activity item");
expect(item.input).toEqual({ fields: ["body"] });
expect(item.result).toEqual({ ok: true, stateRevision: 2, result: { entityRefs: [] } });
expect(JSON.stringify(published.evidence.calls[0]!.result)).not.toContain(CANARY);
});
it("replaces provider identifiers with in-view aliases everywhere they appear", () => {
const published = toPhase7PublicThreadView(projectedView());
const encoded = JSON.stringify(published);
expect(published.turns[0]!.id).toBe("turn-1");
expect(published.evidence.calls[0]!.id).toBe("call-1");
expect(published.evidence.calls[0]!.turnId).toBe("turn-1");
expect(published.evidence.calls[0]!.threadAnchorId).toBe("item-call-call-1");
expect(published.turns[0]!.items[0]!.id).toBe("item-call-call-1");
// The anchor and the record it points at still agree, which is the whole
// reason aliases are used instead of dropping the ids.
expect(published.turns[0]!.items[0]!.kind === "tool_activity"
? published.turns[0]!.items[0]!.evidenceRef.recordId
: null).toBe("call-1");
expect(encoded).not.toContain(PROVIDER_TURN);
expect(encoded).not.toContain(PROVIDER_CALL);
});
it("scrubs withheld provider identity even out of free-text detail", () => {
const published = toPhase7PublicThreadView(projectedView(), {
withheldValues: [PROVIDER_THREAD, ""],
});
expect(JSON.stringify(published)).not.toContain(PROVIDER_THREAD);
expect(published.evidence.runner[0]!.detail).toContain("[withheld]");
});
});
describe("Phase 7 evidence redaction", () => {
it("reduces a provider notification to a coarse category", () => {
const redacted = redactPhase7EvidenceData("provider_event", {
method: "item/agentMessage/delta",
params: {
threadId: PROVIDER_THREAD,
delta: CANARY,
model: "codex-internal-preview",
usage: { inputTokens: 4211 },
},
});
expect(redacted).toEqual({ event: "assistant_delta" });
expect(JSON.stringify(redacted)).not.toContain(CANARY);
expect(phase7EvidenceDetail("provider_event", redacted)).toBe("provider event · assistant_delta");
});
it("collapses an unknown provider method rather than echoing it", () => {
expect(redactPhase7EvidenceData("provider_event", { method: `x/${CANARY}` })).toEqual({
event: "other",
});
});
it("withholds provider diagnostics and provider thread identity", () => {
expect(redactPhase7EvidenceData("diagnostic", { message: `PAPERCLIP_API_KEY=${CANARY}` })).toEqual({
diagnostic: "withheld",
});
expect(
redactPhase7EvidenceData("session", {
action: "started",
sessionId: "session-1",
providerThreadId: PROVIDER_THREAD,
providerSessionId: `session_${CANARY}`,
mode: "live_codex",
}),
).toEqual({ action: "started", sessionId: "session-1", mode: "live_codex" });
});
it("drops process identifiers while keeping the transport transition", () => {
expect(
redactPhase7EvidenceData("process", {
action: "transport_closed",
reason: "reconnect",
runnerExited: true,
runnerPid: 7100,
codexPid: 7200,
}),
).toEqual({ action: "transport_closed", reason: "reconnect", runnerExited: true });
});
it("keeps only the tool-call fields the catalog declares", () => {
const redacted = redactPhase7EvidenceData("tool_call", {
callId: "call-1",
operationId: "report_progress",
beforeRevision: 1,
input: { body: CANARY, idempotencyKey: "k", providerSecret: CANARY },
});
expect(redacted.input).toEqual({ fields: ["body", "idempotencyKey", "1 undeclared"] });
expect(JSON.stringify(redacted)).not.toContain(CANARY);
expect(JSON.stringify(redacted)).not.toContain("providerSecret");
});
it("carries the disposition summary the contract renders, and nothing else", () => {
const redacted = redactPhase7EvidenceData("tool_call", {
callId: "call-2",
operationId: "finish_task",
beforeRevision: 4,
input: { summary: "Spike wired to the mock control plane.", secret: CANARY },
});
expect(redacted.input).toEqual({
fields: ["summary", "1 undeclared"],
dispositionSummary: "Spike wired to the mock control plane.",
});
expect(JSON.stringify(redacted)).not.toContain(CANARY);
});
it("reduces a tool result to the outcome, revisions, and mock entity refs", () => {
const redacted = redactPhase7ToolResult({
ok: true,
operationId: "get_task_context",
callId: "call-3",
stateRevision: 6,
// A read operation returns whole mock records; none of it is public.
result: {
commandId: "cmd-1",
disposition: "applied",
stateRevision: 6,
entityRefs: ["comment:comment-1"],
detail: CANARY,
task: { title: CANARY, description: CANARY },
},
detail: CANARY,
});
expect(redacted).toEqual({
ok: true,
stateRevision: 6,
result: {
commandId: "cmd-1",
disposition: "applied",
stateRevision: 6,
entityRefs: ["comment:comment-1"],
},
});
expect(JSON.stringify(redacted)).not.toContain(CANARY);
});
it("keeps our own denial copy, which the denial card renders verbatim", () => {
const redacted = redactPhase7ToolResult({
ok: false,
denial: {
schema: "paperclip.semantic-denial.v1",
code: "required_claim_missing",
message: "decide_approval requires the ap:decide claim.",
retryable: false,
},
stateRevision: 3,
providerEcho: CANARY,
});
expect(redacted.denial).toEqual({
code: "required_claim_missing",
message: "decide_approval requires the ap:decide claim.",
});
expect(JSON.stringify(redacted)).not.toContain(CANARY);
});
it("reports undeclared arguments as a count rather than by name", () => {
expect(phase7AllowlistedInputFields("report_progress", { body: "x", oops: 1, alsoOops: 2 })).toEqual([
"body",
"2 undeclared",
]);
expect(phase7AllowlistedInputFields("not_a_tool", { anything: 1 })).toEqual(["1 undeclared"]);
});
it("returns nothing for an unlisted evidence kind", () => {
expect(redactPhase7EvidenceData("cleanup", { reason: "shutdown", authorityCleared: true })).toEqual({
reason: "shutdown",
authorityCleared: true,
});
expect(
redactPhase7EvidenceData("interaction", {
interactionId: "interaction-1",
interactionKind: "questions",
outcome: "answered",
stateRevision: 5,
result: { answers: { path: CANARY } },
}),
).toEqual({
interactionId: "interaction-1",
interactionKind: "questions",
outcome: "answered",
stateRevision: 5,
});
});
});
@@ -0,0 +1,587 @@
/**
* The public issue-thread DTO (track 7U).
*
* `projectPhase7IssueThread` produces the view contract; this module produces
* the *published* form of it. Every field is copied by name, so a field added
* to the projection — or to a record it passes through — cannot reach a browser
* until it is listed here. That is the property the previous serialization
* lacked: it handed the projection straight to `JSON.stringify`, so each new
* evidence field became public the moment it was recorded.
*
* Two further narrowings happen here:
*
* - Provider-authored identifiers (thread, turn, item/call ids) are replaced by
* stable in-view aliases. Anchors, evidence refs, and thread items keep
* working because the same alias is used everywhere the id appeared, and the
* aliases are derived from view order, so successive interim frames of one
* turn agree with each other and with the settled payload.
* - Anything the caller declares withheld — provider thread/session identity —
* is scrubbed from the encoded DTO as a final pass, so a value that slipped
* into a free-text field still does not ship.
*
* This is the only shape the browser is given, for interim frames, terminal
* payloads, reconnect/replay payloads, and error paths alike.
*/
import type {
Phase7EvidenceAuthorizationRecord,
Phase7EvidenceCallRecord,
Phase7EvidenceControlPlaneRecord,
Phase7EvidenceModel,
Phase7EvidenceParityRecord,
Phase7EvidenceRef,
Phase7EvidenceRunnerRecord,
Phase7EvidenceStateDiffRecord,
Phase7EvidenceToolsRecord,
Phase7EvidenceTraceabilityRecord,
Phase7IssueThreadSnapshot,
Phase7ThreadInteractionCard,
Phase7ThreadInteractionPayload,
Phase7ThreadItem,
Phase7ThreadLink,
Phase7ThreadTurn,
} from "./types.js";
import { PHASE7_ISSUE_THREAD_VIEW_SCHEMA } from "./types.js";
import type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js";
/**
* Structurally the view contract: the browser renders one component tree for
* the deterministic fixtures and for live sessions, and a second shape would
* only invite the two to drift. The guarantee lives in the builder below, not
* in a second set of field names.
*/
export type Phase7PublicIssueThreadView = Phase7IssueThreadSnapshot;
export interface Phase7PublicThreadViewOptions {
/**
* Values that must not appear anywhere in the published DTO — provider thread
* and session identity. Short values are ignored: scrubbing a two-character
* token out of prose would corrupt more than it protects.
*/
readonly withheldValues?: readonly string[];
}
/** Below this a value is too short to scrub without damaging unrelated text. */
const MIN_SCRUBBABLE_LENGTH = 8;
const WITHHELD = "[withheld]";
const MAX_DETAIL_CHARS = 240;
const MAX_BODY_CHARS = 20_000;
/** Tool payloads are summaries by the time they arrive; this is the fence. */
const TOOL_INPUT_KEYS = ["fields", "dispositionSummary"] as const;
const TOOL_RESULT_KEYS = ["ok", "stateRevision", "denial", "result"] as const;
const TOOL_DENIAL_KEYS = ["code", "message"] as const;
const TOOL_RESULT_INNER_KEYS = [
"commandId",
"commandKind",
"disposition",
"revision",
"stateRevision",
"entityRefs",
] as const;
function clamp(value: string, limit = MAX_DETAIL_CHARS): string {
return value.length <= limit ? value : `${value.slice(0, limit)}…`;
}
function asRecord(value: Phase7JsonValue | null): Record<string, Phase7JsonValue> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
}
function pick(
source: Record<string, Phase7JsonValue>,
keys: readonly string[],
): Record<string, Phase7JsonValue> {
const result: Record<string, Phase7JsonValue> = {};
for (const key of keys) if (key in source) result[key] = source[key]!;
return result;
}
/** Maps every provider-authored identifier in the view to an in-view alias. */
class Aliases {
readonly #map = new Map<string, string>();
readonly #ordered: string[];
constructor(view: Phase7IssueThreadSnapshot) {
view.turns.forEach((turn, index) => this.#add(turn.id, `turn-${index + 1}`));
view.evidence.calls.forEach((call, index) => this.#add(call.id, `call-${index + 1}`));
// Longest first, so one raw id that contains another cannot be half-replaced.
this.#ordered = [...this.#map.keys()].sort((left, right) => right.length - left.length);
}
#add(raw: string, alias: string): void {
if (raw.length === 0 || this.#map.has(raw)) return;
this.#map.set(raw, alias);
}
/** Rewrites an id-shaped string, including composite forms like `call:<id>`. */
text(value: string): string {
let result = value;
for (const raw of this.#ordered) {
if (!result.includes(raw)) continue;
result = result.split(raw).join(this.#map.get(raw)!);
}
return result;
}
turn(value: string): string {
return this.#map.get(value) ?? this.text(value);
}
}
function publicRef(ref: Phase7EvidenceRef, aliases: Aliases): Phase7EvidenceRef {
return { section: ref.section, recordId: aliases.text(ref.recordId) };
}
function publicLink(link: Phase7ThreadLink | null): Phase7ThreadLink | null {
return link === null ? null : { label: clamp(link.label), href: clamp(link.href) };
}
function publicToolInput(value: Phase7JsonValue): Phase7JsonValue {
const source = pick(asRecord(value), TOOL_INPUT_KEYS);
const fields = Array.isArray(source.fields)
? source.fields.filter((entry): entry is string => typeof entry === "string")
: [];
const summary = source.dispositionSummary;
return {
fields,
...(typeof summary === "string" ? { dispositionSummary: clamp(summary, 500) } : {}),
};
}
function publicToolResult(value: Phase7JsonValue | null): Phase7JsonValue | null {
if (value === null) return null;
const source = pick(asRecord(value), TOOL_RESULT_KEYS);
const inner = pick(asRecord(source.result ?? null), TOOL_RESULT_INNER_KEYS);
const entityRefs = Array.isArray(inner.entityRefs)
? inner.entityRefs.filter((entry): entry is string => typeof entry === "string")
: [];
const denial = source.denial === undefined ? undefined : pick(asRecord(source.denial), TOOL_DENIAL_KEYS);
return {
ok: source.ok === true,
stateRevision: typeof source.stateRevision === "number" ? source.stateRevision : 0,
result: { ...inner, entityRefs },
...(denial === undefined ? {} : { denial }),
};
}
function publicInteractionPayload(
payload: Phase7ThreadInteractionPayload,
): Phase7ThreadInteractionPayload {
switch (payload.kind) {
case "questions":
return {
kind: "questions",
submitLabel: clamp(payload.submitLabel),
questions: payload.questions.map((question) => ({
id: question.id,
prompt: clamp(question.prompt, MAX_BODY_CHARS),
control: question.control,
...(question.options === undefined
? {}
: { options: question.options.map((option) => clamp(option)) }),
...(question.required === undefined ? {} : { required: question.required }),
})),
};
case "confirmation":
return {
kind: "confirmation",
targetSummary: clamp(payload.targetSummary, MAX_BODY_CHARS),
acceptLabel: clamp(payload.acceptLabel),
rejectLabel: clamp(payload.rejectLabel),
requireRejectReason: payload.requireRejectReason,
};
case "checkbox":
return {
kind: "checkbox",
options: payload.options.map((option) => ({
id: option.id,
label: clamp(option.label),
...(option.defaultSelected === undefined
? {}
: { defaultSelected: option.defaultSelected }),
})),
minSelected: payload.minSelected,
maxSelected: payload.maxSelected,
acceptLabel: clamp(payload.acceptLabel),
rejectLabel: clamp(payload.rejectLabel),
};
case "suggest_tasks":
return {
kind: "suggest_tasks",
acceptLabel: clamp(payload.acceptLabel),
tasks: payload.tasks.map((task) => ({
id: task.id,
title: clamp(task.title),
description: clamp(task.description, MAX_BODY_CHARS),
})),
};
case "item_verdicts":
return {
kind: "item_verdicts",
submitLabel: clamp(payload.submitLabel),
items: payload.items.map((item) => ({
id: item.id,
title: clamp(item.title),
...(item.requireReason === undefined ? {} : { requireReason: item.requireReason }),
...(item.lockedVerdict === undefined ? {} : { lockedVerdict: item.lockedVerdict }),
})),
};
}
}
function publicInteractionCard(
card: Phase7ThreadInteractionCard,
aliases: Aliases,
): Phase7ThreadInteractionCard {
return {
interactionId: card.interactionId,
interactionKind: card.interactionKind,
title: clamp(card.title),
prompt: clamp(card.prompt, MAX_BODY_CHARS),
payload: publicInteractionPayload(card.payload),
state: card.state,
target: publicLink(card.target),
stateLabel: clamp(card.stateLabel),
resolvedSummary: card.resolvedSummary.map((entry) => clamp(entry)),
reason: card.reason === null ? null : clamp(card.reason, MAX_BODY_CHARS),
supersededBy: publicLink(card.supersededBy),
evidenceRef: publicRef(card.evidenceRef, aliases),
};
}
function publicItem(item: Phase7ThreadItem, aliases: Aliases): Phase7ThreadItem {
const id = aliases.text(item.id);
switch (item.kind) {
case "user_message":
return { kind: "user_message", id, at: item.at, author: item.author, body: item.body };
case "agent_message":
return {
kind: "agent_message",
id,
at: item.at,
author: item.author,
body: item.body,
streaming: item.streaming,
};
case "durable_comment":
return {
kind: "durable_comment",
id,
at: item.at,
author: item.author,
body: clamp(item.body, MAX_BODY_CHARS),
operationId: item.operationId,
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "tool_activity":
return {
kind: "tool_activity",
id,
at: item.at,
status: item.status,
operationId: item.operationId,
summary: clamp(item.summary),
input: publicToolInput(item.input),
result: publicToolResult(item.result),
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "interaction":
return {
kind: "interaction",
id,
at: item.at,
...publicInteractionCard(item, aliases),
};
case "document":
return {
kind: "document",
id,
at: item.at,
documentKey: item.documentKey,
title: clamp(item.title),
author: item.author,
revisionFrom: item.revisionFrom,
revisionTo: item.revisionTo,
staleBehind: item.staleBehind,
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "deliverable":
return {
kind: "deliverable",
id,
at: item.at,
filename: clamp(item.filename),
deliverableKind: item.deliverableKind,
byteSize: item.byteSize,
registeredBy: item.registeredBy,
contentRef: clamp(item.contentRef),
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "dependency":
return {
kind: "dependency",
id,
at: item.at,
createdTasks: item.createdTasks.map((task) => ({
identifier: task.identifier,
title: clamp(task.title),
})),
blockerEdges: item.blockerEdges.map((edge) => clamp(edge)),
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "disposition":
return {
kind: "disposition",
id,
at: item.at,
operationId: item.operationId,
status: item.status,
body: clamp(item.body, MAX_BODY_CHARS),
blockerOwner: item.blockerOwner,
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "denial":
return {
kind: "denial",
id,
at: item.at,
operationId: item.operationId,
reason: clamp(item.reason, MAX_BODY_CHARS),
evidenceRef: publicRef(item.evidenceRef, aliases),
};
case "system_notice":
return {
kind: "system_notice",
id,
at: item.at,
glyph: item.glyph,
text: clamp(item.text, MAX_BODY_CHARS),
evidenceRef: publicRef(item.evidenceRef, aliases),
};
}
}
function publicTurn(turn: Phase7ThreadTurn, aliases: Aliases): Phase7ThreadTurn {
return {
id: aliases.turn(turn.id),
ordinal: turn.ordinal,
mode: turn.mode,
toolCallCount: turn.toolCallCount,
at: turn.at,
stoppedByUser: turn.stoppedByUser,
items: turn.items.map((item) => publicItem(item, aliases)),
};
}
function publicTools(record: Phase7EvidenceToolsRecord, aliases: Aliases): Phase7EvidenceToolsRecord {
return {
id: aliases.text(record.id),
turnId: aliases.turn(record.turnId),
rows: record.rows.map((row) => ({
operationId: row.operationId,
disposition: row.disposition,
grant: row.grant,
description: clamp(row.description),
})),
};
}
function publicCall(record: Phase7EvidenceCallRecord, aliases: Aliases): Phase7EvidenceCallRecord {
return {
id: aliases.text(record.id),
turnId: aliases.turn(record.turnId),
operationId: record.operationId,
version: record.version,
providerRequest: clamp(record.providerRequest),
dispatchedCommand: clamp(record.dispatchedCommand),
outcome: record.outcome,
result: publicToolResult(record.result),
redactions: record.redactions.map((entry) => clamp(entry)),
threadAnchorId: aliases.text(record.threadAnchorId),
};
}
function publicAuthorization(
record: Phase7EvidenceAuthorizationRecord,
aliases: Aliases,
): Phase7EvidenceAuthorizationRecord {
return {
id: aliases.text(record.id),
turnId: aliases.turn(record.turnId),
operationId: record.operationId,
phase: record.phase,
allowed: record.allowed,
code: record.code,
reason: clamp(record.reason, MAX_BODY_CHARS),
claimsConsidered: record.claimsConsidered.map((claim) => clamp(claim)),
redactions: record.redactions.map((entry) => clamp(entry)),
stateChangeRef: record.stateChangeRef === null ? null : aliases.text(record.stateChangeRef),
threadAnchorId: aliases.text(record.threadAnchorId),
};
}
function publicControlPlane(
record: Phase7EvidenceControlPlaneRecord,
aliases: Aliases,
): Phase7EvidenceControlPlaneRecord {
return {
id: aliases.text(record.id),
turnId: aliases.turn(record.turnId),
category: record.category,
outcome: clamp(record.outcome),
reason: clamp(record.reason, MAX_BODY_CHARS),
stateRevision: record.stateRevision,
threadAnchorId: record.threadAnchorId === null ? null : aliases.text(record.threadAnchorId),
};
}
function publicRunner(
record: Phase7EvidenceRunnerRecord,
aliases: Aliases,
): Phase7EvidenceRunnerRecord {
return {
id: record.id,
turnId: aliases.turn(record.turnId),
kind: record.kind,
ordinal: record.ordinal,
detail: clamp(aliases.text(record.detail)),
};
}
function publicStateDiff(
record: Phase7EvidenceStateDiffRecord,
aliases: Aliases,
): Phase7EvidenceStateDiffRecord {
return {
id: aliases.text(record.id),
turnId: aliases.turn(record.turnId),
fromRevision: record.fromRevision,
toRevision: record.toRevision,
rows: record.rows.map((row) => ({
entityClass: row.entityClass,
entityRef: clamp(row.entityRef),
before: clamp(row.before),
after: clamp(row.after),
})),
};
}
function publicTraceability(
record: Phase7EvidenceTraceabilityRecord,
aliases: Aliases,
): Phase7EvidenceTraceabilityRecord {
return {
id: record.id,
turnId: aliases.turn(record.turnId),
capabilityId: record.capabilityId,
sourceAnchor: clamp(record.sourceAnchor),
caseId: record.caseId,
group: record.group,
browserEvidenceRecipe: clamp(record.browserEvidenceRecipe, MAX_BODY_CHARS),
expectedSemanticOperations: [...record.expectedSemanticOperations],
forbiddenOperations: [...record.forbiddenOperations],
requiredCapabilityGrants: [...record.requiredCapabilityGrants],
};
}
function publicParity(
record: Phase7EvidenceParityRecord,
aliases: Aliases,
): Phase7EvidenceParityRecord {
return {
id: record.id,
turnId: aliases.turn(record.turnId),
assertion: clamp(record.assertion, MAX_BODY_CHARS),
verdict: record.verdict,
note: record.note === null ? null : clamp(record.note, MAX_BODY_CHARS),
};
}
function publicEvidence(evidence: Phase7EvidenceModel, aliases: Aliases): Phase7EvidenceModel {
return {
tools: evidence.tools.map((record) => publicTools(record, aliases)),
calls: evidence.calls.map((record) => publicCall(record, aliases)),
authorization: evidence.authorization.map((record) => publicAuthorization(record, aliases)),
control_plane: evidence.control_plane.map((record) => publicControlPlane(record, aliases)),
runner: evidence.runner.map((record) => publicRunner(record, aliases)),
state: evidence.state.map((record) => publicStateDiff(record, aliases)),
traceability: evidence.traceability.map((record) => publicTraceability(record, aliases)),
parity: evidence.parity.map((record) => publicParity(record, aliases)),
};
}
/**
* Final pass over the encoded DTO. The field-by-field copy above is the real
* control; this exists so a value that reached a free-text field by some route
* nobody anticipated still cannot ship, and so the property is testable by
* scanning bytes rather than by reasoning about call graphs.
*/
function scrubWithheld(view: Phase7PublicIssueThreadView, values: readonly string[]): Phase7PublicIssueThreadView {
const scrubbable = [...new Set(values.filter((value) => value.length >= MIN_SCRUBBABLE_LENGTH))]
.sort((left, right) => right.length - left.length);
if (scrubbable.length === 0) return view;
let encoded = JSON.stringify(view);
let replaced = false;
for (const value of scrubbable) {
if (!encoded.includes(value)) continue;
encoded = encoded.split(value).join(WITHHELD);
replaced = true;
}
// A turn streams hundreds of frames, and the common case is that the
// field-by-field copy already left nothing to scrub. Re-parsing then would buy
// an identical object for the price of a full round-trip per frame.
if (!replaced) return view;
return JSON.parse(encoded) as Phase7PublicIssueThreadView;
}
/**
* Builds the one shape the browser is allowed to see.
*
* Callers must publish this, not the projection: `payload`, every interim
* frame, the settled frame, and every reconnect or replay response go through
* here so a single omission cannot open a disclosure path that the other
* responses closed.
*/
export function toPhase7PublicThreadView(
view: Phase7IssueThreadSnapshot,
options: Phase7PublicThreadViewOptions = {},
): Phase7PublicIssueThreadView {
const aliases = new Aliases(view);
const published: Phase7PublicIssueThreadView = {
schema: PHASE7_ISSUE_THREAD_VIEW_SCHEMA,
sessionId: view.sessionId,
mode: view.mode,
identity: {
agentLabel: view.identity.agentLabel,
runnerLabel: view.identity.runnerLabel,
runnerAttached: view.identity.runnerAttached,
controlPlaneLabel: view.identity.controlPlaneLabel,
controlPlaneTooltip: clamp(view.identity.controlPlaneTooltip),
replaySource: view.identity.replaySource,
},
issue: {
identifier: view.issue.identifier,
title: clamp(view.issue.title),
status: view.issue.status,
priority: view.issue.priority,
assignee: view.issue.assignee,
runState: clamp(view.issue.runState),
scenarioId: view.issue.scenarioId,
fixtureProfile: view.issue.fixtureProfile,
},
turns: view.turns.map((turn) => publicTurn(turn, aliases)),
composer: {
state: view.composer.state,
helper: view.composer.helper === null ? null : clamp(view.composer.helper),
reason: view.composer.reason === null ? null : clamp(view.composer.reason),
pendingInteractionId: view.composer.pendingInteractionId,
},
evidence: publicEvidence(view.evidence, aliases),
connection: { state: view.connection.state, attempt: view.connection.attempt },
replay:
view.replay === null ? null : { ordinal: view.replay.ordinal, total: view.replay.total },
renderedAt: view.renderedAt,
};
return scrubWithheld(published, options.withheldValues ?? []);
}
@@ -36,8 +36,23 @@ interface FakeProviderState {
transports: FakeCleanRoomTransport[];
/** Releases one step of a gated streaming turn (`stream` messages). */
gate: Gate;
/** Lets the canary turn raise a provider diagnostic the way runnerd does. */
diagnostic: ((message: string) => void) | null;
}
/**
* Values a provider or tool could realistically carry that must never reach a
* browser. Each one is planted somewhere the rejected build republished it.
*/
const CANARIES = {
prompt: "CANARY-PROMPT-8f2a41d0",
credential: "CANARY-CREDENTIAL-sk-live-4b19c7ee",
environment: "CANARY-ENV-VALUE-3d77aa10",
diagnostic: "CANARY-DIAGNOSTIC-6c0be913",
providerEvent: "CANARY-PROVIDER-EVENT-91a4f2bd",
toolResult: "CANARY-TOOL-RESULT-c58d7e04",
} as const;
/** Single-slot signal, so a streamed turn advances only when the test says so. */
class Gate {
#waiters: Array<() => void> = [];
@@ -92,6 +107,7 @@ class AsyncNotifications implements AsyncIterable<CodexRpcNotification> {
class FakeCleanRoomTransport implements CodexAppServerTransport {
readonly queue = new AsyncNotifications();
readonly exposedTools: string[] = [];
#callCounter = 0;
#handler: CodexServerRequestHandler = async () => ({});
#closed = false;
@@ -185,6 +201,10 @@ class FakeCleanRoomTransport implements CodexAppServerTransport {
});
return;
}
if (message.includes("canary")) {
await this.#runCanaryTurn(turnId, message);
return;
}
// The resumed turn carries the typed interaction result as its input, so it
// is matched before the keyword branches it would otherwise trip.
if (message.includes("semantic-interaction-result")) {
@@ -225,6 +245,59 @@ class FakeCleanRoomTransport implements CodexAppServerTransport {
});
}
/**
* One turn that carries every forbidden record class: provider protocol
* traffic that echoes the prompt and quotes model/token metadata, a
* diagnostic that quotes an environment value, and a tool call with an
* undeclared argument. The rejected build republished all of it.
*/
async #runCanaryTurn(turnId: string, message: string): Promise<void> {
this.queue.push({
method: "item/started",
params: {
threadId: this.state.threadId,
turnId,
sessionId: this.state.providerSessionId,
item: {
id: `item_${CANARIES.providerEvent}`,
type: "reasoning",
text: `Working on: ${message}`,
},
model: "codex-internal-preview",
usage: { inputTokens: 4211, outputTokens: 77, cachedTokens: 1024 },
requestHeaders: { authorization: `Bearer ${CANARIES.credential}` },
rawPrompt: `${CANARIES.prompt} — verbatim provider prompt echo`,
},
});
this.state.diagnostic?.(
`codex app-server: PAPERCLIP_API_KEY=${CANARIES.environment} ${CANARIES.diagnostic}`,
);
const reply = await this.#call(turnId, "report_progress", {
idempotencyKey: `canary-${turnId}`,
body: "Recorded a mock status for the canary turn.",
});
// A second call carrying an argument no catalog schema declares. The mock
// control plane refuses it, and the refusal is itself recorded — which is
// how the rejected build republished the argument name and value.
await this.#call(turnId, "report_progress", {
idempotencyKey: `canary-undeclared-${turnId}`,
body: "Second status.",
internalProviderDetail: `${CANARIES.toolResult} ${CANARIES.credential}`,
});
this.queue.push({
method: "item/completed",
params: {
threadId: this.state.threadId,
turnId,
item: { id: `message-${turnId}`, type: "agentMessage", text: reply },
},
});
this.queue.push({
method: "turn/completed",
params: { threadId: this.state.threadId, turn: { id: turnId, status: "completed" } },
});
}
async #call(turnId: string, tool: string, args: Record<string, unknown>): Promise<string> {
const request: CodexRpcServerRequest = {
id: `request-${turnId}`,
@@ -232,7 +305,7 @@ class FakeCleanRoomTransport implements CodexAppServerTransport {
params: {
threadId: this.state.threadId,
turnId,
callId: `call-${turnId}`,
callId: `call-${turnId}-${++this.#callCounter}`,
tool,
arguments: args,
},
@@ -255,6 +328,7 @@ function fakeTransportFactory(state: FakeProviderState): Phase7LiveTransportFact
childEnvironmentKeys: ["CODEX_HOME", "HOME", "PATH"],
diagnostics: [],
};
state.diagnostic = (message) => options.onDiagnostic?.(message);
const transport = new FakeCleanRoomTransport(state, () => {
evidence.runnerExited = true;
options.onEvidence?.(evidence);
@@ -336,6 +410,60 @@ class FrameReader {
}
}
/**
* Minimal cookie jar.
*
* Every session-scoped route is bound to a per-browser capability cookie, so a
* test that shares one `fetch` shares one browser. Two jars are two browsers,
* which is exactly what the cross-session denial criteria need.
*/
class Jar {
#cookies = new Map<string, string>();
headers(): Record<string, string> {
if (this.#cookies.size === 0) return {};
return {
cookie: [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; "),
};
}
capture(response: Response): void {
for (const raw of response.headers.getSetCookie()) {
const pair = raw.split(";", 1)[0] ?? "";
const separator = pair.indexOf("=");
if (separator < 1) continue;
this.#cookies.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
}
}
value(name: string): string | undefined {
return this.#cookies.get(name);
}
}
/** Sends one request through a jar, capturing whatever capability it is given. */
async function send(
jar: Jar,
path: string,
init: RequestInit & { json?: unknown } = {},
): Promise<Response> {
const { json, headers, ...rest } = init;
const response = await fetch(`${origin}${path}`, {
...rest,
...(json === undefined
? {}
: { method: rest.method ?? "POST", body: JSON.stringify(json) }),
headers: {
...(json === undefined ? {} : { "content-type": "application/json" }),
...(headers as Record<string, string> | undefined),
...jar.headers(),
},
});
jar.capture(response);
return response;
}
let jar: Jar;
let server: Server;
let origin: string;
let middleware: Awaited<ReturnType<typeof createMiddleware>>;
@@ -372,19 +500,10 @@ async function call(
path: string,
body?: unknown,
frames?: Phase7IssueThreadSnapshot[],
browser: Jar = jar,
): Promise<CleanRoomPayload> {
const url = `${origin}${path}`;
requestedUrls.push(url);
const response = await fetch(
url,
body === undefined
? {}
: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
requestedUrls.push(`${origin}${path}`);
const response = await send(browser, path, body === undefined ? {} : { json: body });
if (response.headers.get("content-type")?.includes("x-ndjson") === true) {
return await readTurn(response, frames);
}
@@ -393,29 +512,22 @@ async function call(
return payload;
}
async function status(path: string, body?: unknown): Promise<number> {
const response = await fetch(
`${origin}${path}`,
body === undefined
? {}
: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
async function status(path: string, body?: unknown, browser: Jar = jar): Promise<number> {
const response = await send(browser, path, body === undefined ? {} : { json: body });
await response.text();
return response.status;
}
beforeEach(async () => {
requestedUrls.length = 0;
jar = new Jar();
providerState = {
threadId: "codex-thread-clean-room",
providerSessionId: "codex-provider-clean-room",
nextTurn: 0,
transports: [],
gate: new Gate(),
diagnostic: null,
};
middleware = await createMiddleware(providerState);
server = createServer((request, response) => {
@@ -632,10 +744,8 @@ describe("Phase 7 clean-room chat server", () => {
for (let turn = 0; turn < opened.limits.maxTurns; turn += 1) {
await call("/api/phase7/ui/message", { sessionId: opened.sessionId, message: `turn ${turn}` });
}
const response = await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "one more" }),
const response = await send(jar, "/api/phase7/ui/message", {
json: { sessionId: opened.sessionId, message: "one more" },
});
const body = (await response.json()) as { error: string; message: string };
expect(response.status).toBe(429);
@@ -645,10 +755,9 @@ describe("Phase 7 clean-room chat server", () => {
it("streams the turn as frames while the POST is still open", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const response = await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/x-ndjson" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "stream your answer" }),
const response = await send(jar, "/api/phase7/ui/message", {
headers: { accept: "application/x-ndjson" },
json: { sessionId: opened.sessionId, message: "stream your answer" },
});
expect(response.status).toBe(200);
@@ -708,10 +817,9 @@ describe("Phase 7 clean-room chat server", () => {
it("interrupts the provider turn when the client abandons the stream", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const controller = new AbortController();
const response = await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/x-ndjson" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "stream your answer" }),
const response = await send(jar, "/api/phase7/ui/message", {
headers: { accept: "application/x-ndjson" },
json: { sessionId: opened.sessionId, message: "stream your answer" },
signal: controller.signal,
});
const reader = new FrameReader(response);
@@ -754,4 +862,217 @@ describe("Phase 7 clean-room chat server", () => {
);
expect(scenarioAgain.view.turns).toEqual([]);
});
/**
* Track 7U, finding 1. The rejected build projected `provider_event` records
* and unredacted tool results into `view.evidence`, so one ordinary turn
* handed the browser provider protocol traffic, the echoed prompt, provider
* thread/turn/item ids, model and token metadata, and complete tool payloads.
* Every canary below was observable in that build's NDJSON response.
*/
it("keeps provider traffic, diagnostics, and tool payloads out of every streamed surface", async () => {
const logs: string[] = [];
const stdout = process.stdout.write.bind(process.stdout);
const stderr = process.stderr.write.bind(process.stderr);
process.stdout.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
logs.push(String(chunk));
return stdout(chunk as string, ...(rest as []));
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
logs.push(String(chunk));
return stderr(chunk as string, ...(rest as []));
}) as typeof process.stderr.write;
let opened: CleanRoomPayload;
let settled: CleanRoomPayload;
let reconnected: CleanRoomPayload;
let replayed: CleanRoomPayload;
const frames: Phase7IssueThreadSnapshot[] = [];
try {
opened = await call("/api/phase7/ui/cleanroom/session");
settled = await call(
"/api/phase7/ui/message",
{ sessionId: opened.sessionId, message: `canary turn: ${CANARIES.prompt}` },
frames,
);
reconnected = await call("/api/phase7/ui/reconnect", { sessionId: opened.sessionId });
replayed = await call(`/api/phase7/ui/cleanroom/session?sessionId=${opened.sessionId}`);
} finally {
process.stdout.write = stdout;
process.stderr.write = stderr;
}
const capability = jar.value("paperclip_phase7_chat");
expect(capability).toMatch(/^[A-Za-z0-9_-]{40,}$/);
// The turn really did run: without a settled reply the sweep below would
// pass on an empty transcript.
expect(frames.length).toBeGreaterThan(0);
expect(items(settled.view).some((item) => item.kind === "durable_comment")).toBe(true);
// The prompt is legitimately echoed once per turn as the user's own card;
// it must appear nowhere else, so user bodies are removed before scanning
// rather than exempted wholesale.
const withoutUserMessages = (view: Phase7IssueThreadSnapshot): string =>
JSON.stringify({
...view,
turns: view.turns.map((turn) => ({
...turn,
items: turn.items.filter((item) => item.kind !== "user_message"),
})),
});
const surfaces: Array<[string, string]> = [
...frames.map((view, index) => [`frame ${index}`, withoutUserMessages(view)] as [string, string]),
["settled payload", withoutUserMessages(settled.view)],
["reconnect payload", withoutUserMessages(reconnected.view)],
["replay payload", withoutUserMessages(replayed.view)],
["persisted evidence", JSON.stringify(replayed.view.evidence)],
["server log", logs.join("")],
];
for (const [label, encoded] of surfaces) {
for (const [name, canary] of Object.entries(CANARIES)) {
expect(`${label}:${name}:${encoded.includes(canary)}`).toBe(`${label}:${name}:false`);
}
// Provider thread identity and the browser's own capability are equally
// off-limits, and the undeclared argument name must not be echoed either.
expect(encoded).not.toContain(providerState.threadId);
expect(encoded).not.toContain(providerState.providerSessionId);
expect(encoded).not.toContain(capability);
expect(encoded).not.toContain("internalProviderDetail");
expect(encoded).not.toContain("inputTokens");
}
// The evidence panel still says what happened — the fix is a summary, not a
// blank section.
const runner = replayed.view.evidence.runner;
expect(runner.some((record) => record.kind === "provider_event")).toBe(true);
expect(runner.filter((record) => record.kind === "provider_event").every((record) =>
/^provider event · [a-z_]+$/.test(record.detail),
)).toBe(true);
expect(runner.some((record) => record.detail.includes("diagnostic · withheld"))).toBe(true);
expect(replayed.view.evidence.calls.every((record) =>
record.providerRequest.startsWith("tools/call ") && !record.providerRequest.includes("{"),
)).toBe(true);
});
/**
* Track 7U, finding 2. The rejected build authorized any route on possession
* of a session id, because the only capability was minted once per gateway
* process and shared by every browser.
*/
it("denies every session-scoped route to a second browser and after rotation", async () => {
const alice = new Jar();
const mallory = new Jar();
const aliceRoom = await call("/api/phase7/ui/cleanroom/session", undefined, undefined, alice);
const malloryRoom = await call("/api/phase7/ui/cleanroom/session", undefined, undefined, mallory);
const aliceIssue = await call("/api/phase7/ui/session?scenario=hb-baseline", undefined, undefined, alice);
expect(aliceRoom.sessionId).not.toBe(malloryRoom.sessionId);
expect(alice.value("paperclip_phase7_chat")).not.toBe(mallory.value("paperclip_phase7_chat"));
// Reciprocal read denial: neither browser can name the other's session.
for (const [reader, target] of [
[mallory, aliceRoom.sessionId],
[alice, malloryRoom.sessionId],
] as Array<[Jar, string]>) {
expect(
await status(`/api/phase7/ui/cleanroom/session?sessionId=${target}`, undefined, reader),
).toBe(404);
}
// Every mutation route, not only the ones that create sessions.
const mutations = ["message", "interrupt", "reconnect", "reset", "interaction"];
for (const route of mutations) {
expect(
await status(`/api/phase7/ui/${route}`, { sessionId: aliceRoom.sessionId, message: "hi" }, mallory),
).toBe(404);
expect(
await status(`/api/phase7/ui/${route}`, { sessionId: aliceIssue.sessionId, message: "hi" }, mallory),
).toBe(404);
}
// A cookie-less caller is refused too: the capability is required, not
// merely preferred when present.
expect(
await status("/api/phase7/ui/message", { sessionId: aliceRoom.sessionId, message: "hi" }, new Jar()),
).toBe(404);
// Alice still owns her own sessions throughout.
expect(
await status(`/api/phase7/ui/cleanroom/session?sessionId=${aliceRoom.sessionId}`, undefined, alice),
).toBe(200);
expect(await status(`/api/phase7/ui/session?sessionId=${aliceIssue.sessionId}`, undefined, alice)).toBe(200);
});
it("rotates the capability on reset and new chat and revokes the old one", async () => {
const alice = new Jar();
const opened = await call("/api/phase7/ui/cleanroom/session", undefined, undefined, alice);
const first = alice.value("paperclip_phase7_chat");
const stale = new Jar();
stale.capture(new Response(null, {
headers: { "set-cookie": `paperclip_phase7_chat=${first}` },
}));
const afterReset = await call("/api/phase7/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
const second = alice.value("paperclip_phase7_chat");
expect(second).not.toBe(first);
expect(afterReset.sessionId).not.toBe(opened.sessionId);
// The old cookie is dead against the replacement session as well as the
// retired one, so a stolen cookie does not survive a reset.
expect(await status(`/api/phase7/ui/cleanroom/session?sessionId=${afterReset.sessionId}`, undefined, stale)).toBe(404);
expect(await status("/api/phase7/ui/message", { sessionId: afterReset.sessionId, message: "hi" }, stale)).toBe(404);
const afterNewChat = await call("/api/phase7/ui/cleanroom/session", { sessionId: afterReset.sessionId }, undefined, alice);
const third = alice.value("paperclip_phase7_chat");
expect(third).not.toBe(second);
const previous = new Jar();
previous.capture(new Response(null, {
headers: { "set-cookie": `paperclip_phase7_chat=${second}` },
}));
expect(await status(`/api/phase7/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, previous)).toBe(404);
expect(await status("/api/phase7/ui/reset", { sessionId: afterNewChat.sessionId }, previous)).toBe(404);
expect(await status(`/api/phase7/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, alice)).toBe(200);
});
it("lets two tabs of one browser hold their own sessions without revoking each other", async () => {
// One jar is one browser. Reloading a page whose stored id is gone must not
// rotate the capability, or two tabs would revoke each other on every load.
const browser = new Jar();
const tabA = await call("/api/phase7/ui/cleanroom/session", undefined, undefined, browser);
const capability = browser.value("paperclip_phase7_chat");
const tabB = await call("/api/phase7/ui/cleanroom/session", undefined, undefined, browser);
expect(tabB.sessionId).not.toBe(tabA.sessionId);
expect(browser.value("paperclip_phase7_chat")).toBe(capability);
for (const sessionId of [tabA.sessionId, tabB.sessionId]) {
expect(
await status(`/api/phase7/ui/cleanroom/session?sessionId=${sessionId}`, undefined, browser),
).toBe(200);
}
// A different browser still reaches neither of them.
const stranger = new Jar();
for (const sessionId of [tabA.sessionId, tabB.sessionId]) {
expect(
await status(`/api/phase7/ui/cleanroom/session?sessionId=${sessionId}`, undefined, stranger),
).toBe(404);
}
});
it("rotates the issue-surface capability on reset", async () => {
const alice = new Jar();
const opened = await call("/api/phase7/ui/session?scenario=hb-baseline", undefined, undefined, alice);
const first = alice.value("paperclip_phase7_issue");
const stale = new Jar();
stale.capture(new Response(null, {
headers: { "set-cookie": `paperclip_phase7_issue=${first}` },
}));
const reset = await call("/api/phase7/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
expect(alice.value("paperclip_phase7_issue")).not.toBe(first);
expect(await status(`/api/phase7/ui/session?sessionId=${reset.sessionId}`, undefined, stale)).toBe(404);
expect(await status(`/api/phase7/ui/session?sessionId=${reset.sessionId}`, undefined, alice)).toBe(200);
});
});
@@ -0,0 +1,278 @@
/**
* Redaction for Phase 7 live evidence (track 7U).
*
* The live session used to persist provider notifications, tool arguments, and
* tool results verbatim, and the issue-thread projection then rendered those
* records into the browser. That made every admitted browser a reader of
* provider protocol traffic, prompts, thread/turn/item identifiers, model and
* token metadata, and complete tool results — record classes the Phase 7
* security boundary forbids on the public surface.
*
* The fix is applied at the point evidence is *recorded*, not at the point it
* is rendered: `Phase7LiveSession` writes only what this module allows, so a
* new reader, a new frame type, a replay payload, or a future serialization
* cannot reintroduce a value that was never retained. Everything downstream —
* the projection, the public DTO, the NDJSON frames — narrows further, never
* widens.
*
* What survives is purpose-built: the identifiers the projection needs to pair
* a call with its result, the revisions it needs to show what changed, the
* mock entity refs it needs to derive thread items, and our own denial copy.
* Provider-authored payloads do not survive at all, with one deliberate
* exception: the disposition summary a `finish_task`/`block_task`/
* `request_review` call carries, because that text *is* the disposition card
* the UX contract requires. It is carried under its own key so it reads as an
* exposed field rather than as a passthrough of raw arguments.
*/
import type { Phase7JsonValue } from "../mock-core/phase7-control-plane-types.js";
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "../semantic-tools/catalog.js";
export type Phase7EvidenceKind =
| "session"
| "provider_event"
| "tool_exposure"
| "tool_call"
| "tool_result"
| "interaction"
| "process"
| "diagnostic"
| "cleanup";
/** Disposition copy is rendered, so it is bounded rather than unbounded. */
const MAX_DISPOSITION_CHARS = 500;
const MAX_FIELD_NAMES = 32;
const MAX_ENTITY_REFS = 64;
/**
* Coarse categories for provider notifications. The protocol method name is
* itself provider surface, so it is mapped to a fixed vocabulary and anything
* unrecognised collapses to `other` instead of being echoed.
*/
const PROVIDER_EVENT_CATEGORIES: Readonly<Record<string, string>> = Object.freeze({
"turn/started": "turn_started",
"turn/completed": "turn_completed",
"turn/failed": "turn_failed",
"item/started": "item_started",
"item/completed": "item_completed",
"item/agentMessage/delta": "assistant_delta",
"item/reasoning/delta": "reasoning_delta",
});
/** Operations whose input summary is the rendered disposition body (§9). */
const DISPOSITION_OPERATIONS: ReadonlySet<string> = new Set([
"finish_task",
"block_task",
"request_review",
]);
/** Result fields the projection reads. Everything else is provider/mock payload. */
const RESULT_FIELDS = ["commandId", "commandKind", "disposition", "revision", "stateRevision"] as const;
const SESSION_FIELDS = ["action", "sessionId", "mode", "runner", "controlPlane", "reason"] as const;
const PROCESS_FIELDS = ["action", "reason"] as const;
const CLEANUP_FIELDS = ["reason", "authorityCleared", "mockStopped", "processExited"] as const;
const CATALOG_OPERATION_IDS: ReadonlySet<string> = new Set(
PHASE7_SEMANTIC_TOOL_CATALOG.map((descriptor) => descriptor.operationId),
);
const CATALOG_INPUT_FIELDS: ReadonlyMap<string, ReadonlySet<string>> = new Map(
PHASE7_SEMANTIC_TOOL_CATALOG.map((descriptor) => {
const properties = descriptor.inputSchema.properties ?? {};
return [descriptor.operationId, new Set(Object.keys(properties))] as const;
}),
);
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function clamp(value: string, limit: number): string {
return value.length <= limit ? value : `${value.slice(0, limit)}…`;
}
function copyFields(
data: Record<string, unknown>,
names: readonly string[],
): Record<string, Phase7JsonValue> {
const result: Record<string, Phase7JsonValue> = {};
for (const name of names) {
const value = data[name];
if (typeof value === "string") result[name] = clamp(value, MAX_DISPOSITION_CHARS);
else if (typeof value === "number" || typeof value === "boolean") result[name] = value;
}
return result;
}
/**
* Field *names* an operation declares, intersected with what the call actually
* carried. A name the catalog does not declare is reported as its own count
* rather than echoed, so a model-invented argument name never reaches a reader.
*/
export function phase7AllowlistedInputFields(operationId: string, input: unknown): string[] {
const declared = CATALOG_INPUT_FIELDS.get(operationId) ?? new Set<string>();
const present = Object.keys(asRecord(input));
const known = present.filter((name) => declared.has(name)).sort();
const unknownCount = present.length - known.length;
const fields = unknownCount > 0 ? [...known, `${unknownCount} undeclared`] : known;
return fields.slice(0, MAX_FIELD_NAMES);
}
function redactToolInput(operationId: string, input: unknown): Record<string, Phase7JsonValue> {
const summary: Record<string, Phase7JsonValue> = {
fields: phase7AllowlistedInputFields(operationId, input),
};
if (!DISPOSITION_OPERATIONS.has(operationId)) return summary;
const record = asRecord(input);
const body = asString(record.summary) || asString(record.reason);
if (body.length > 0) summary.dispositionSummary = clamp(body, MAX_DISPOSITION_CHARS);
return summary;
}
/**
* A tool result reduced to the shape the thread and evidence panel read: the
* outcome, our own denial copy, the revisions, and the mock entity refs the
* projection resolves into cards. Read-operation payloads — mock task bodies,
* comment text, search hits — are dropped wholesale; the UI needs the fact of
* the call, not its contents.
*/
export function redactPhase7ToolResult(result: unknown): Record<string, Phase7JsonValue> {
const record = asRecord(result);
const inner = asRecord(record.result);
const denial = asRecord(record.denial);
const entityRefs = Array.isArray(inner.entityRefs)
? inner.entityRefs
.filter((ref): ref is string => typeof ref === "string")
.slice(0, MAX_ENTITY_REFS)
: [];
const redacted: Record<string, Phase7JsonValue> = {
ok: record.ok === true,
stateRevision: typeof record.stateRevision === "number" ? record.stateRevision : 0,
result: { ...copyFields(inner, RESULT_FIELDS), entityRefs },
};
if (record.ok === false) {
redacted.denial = {
code: clamp(asString(denial.code), 120),
message: clamp(asString(denial.message), MAX_DISPOSITION_CHARS),
};
}
return redacted;
}
/**
* The single gate every evidence record passes through before it is retained.
*
* Unlisted kinds and unlisted fields produce `{}` rather than a passthrough, so
* adding a record class is an explicit decision here instead of an accidental
* disclosure at the far end of the stream.
*/
export function redactPhase7EvidenceData(
kind: Phase7EvidenceKind,
data: Record<string, unknown>,
): Record<string, Phase7JsonValue> {
switch (kind) {
case "provider_event":
return { event: PROVIDER_EVENT_CATEGORIES[asString(data.method)] ?? "other" };
case "diagnostic":
// A provider diagnostic is operator surface, not board surface. Only the
// fact that one was recorded survives.
return { diagnostic: "withheld" };
case "session":
// `providerThreadId` and `providerSessionId` are deliberately absent:
// provider thread identity is not a public record.
return copyFields(data, SESSION_FIELDS);
case "process":
// Process ids are host detail; only the transport transition survives.
return { ...copyFields(data, PROCESS_FIELDS), runnerExited: data.runnerExited === true };
case "cleanup":
return copyFields(data, CLEANUP_FIELDS);
case "tool_exposure":
return {
operationIds: (Array.isArray(data.operationIds) ? data.operationIds : [])
.filter((id): id is string => typeof id === "string" && CATALOG_OPERATION_IDS.has(id))
.sort(),
scenarioId: clamp(asString(data.scenarioId), 120),
};
case "tool_call": {
const operationId = asString(data.operationId);
return {
callId: asString(data.callId),
operationId,
beforeRevision: typeof data.beforeRevision === "number" ? data.beforeRevision : 0,
input: redactToolInput(operationId, data.input),
};
}
case "tool_result": {
const beforeRevision = typeof data.beforeRevision === "number" ? data.beforeRevision : 0;
return {
callId: asString(data.callId),
operationId: asString(data.operationId),
beforeRevision,
afterRevision: typeof data.afterRevision === "number" ? data.afterRevision : beforeRevision,
result: redactPhase7ToolResult(data.result),
};
}
case "interaction":
// The typed answer already reaches the UI through the mock interaction
// record it resolved; a second raw copy in evidence buys nothing.
return {
interactionId: asString(data.interactionId),
interactionKind: asString(data.interactionKind),
outcome: asString(data.outcome),
stateRevision: typeof data.stateRevision === "number" ? data.stateRevision : 0,
};
default:
return {};
}
}
/**
* The one-line `Runner & events` detail. Built from the redacted record rather
* than by stringifying it, so the evidence panel shows a sentence a reader can
* use instead of a JSON dump that happens to be safe today.
*/
export function phase7EvidenceDetail(
kind: Phase7EvidenceKind,
data: Record<string, Phase7JsonValue>,
): string {
const value = (name: string): string => {
const entry = data[name];
return typeof entry === "string" || typeof entry === "number" ? String(entry) : "";
};
switch (kind) {
case "provider_event":
return `provider event · ${value("event") || "other"}`;
case "diagnostic":
return "provider diagnostic · withheld from the public view";
case "session":
return `session · ${value("action") || "recorded"}`;
case "process":
return `process · ${value("action") || "recorded"}`;
case "cleanup":
return `cleanup · ${data.authorityCleared === true ? "authority cleared" : "recorded"}`;
case "tool_exposure": {
const ids = Array.isArray(data.operationIds) ? data.operationIds.length : 0;
return `tool exposure · ${ids} operation${ids === 1 ? "" : "s"}`;
}
case "tool_call": {
const fields = asRecord(data.input).fields;
const count = Array.isArray(fields) ? fields.length : 0;
return `tool call · ${value("operationId")} · ${count} field${count === 1 ? "" : "s"}`;
}
case "tool_result": {
const result = asRecord(data.result);
return `tool result · ${value("operationId")} · ${result.ok === true ? "ok" : "denied"} · revision ${value("afterRevision")}`;
}
case "interaction":
return `interaction · ${value("interactionKind")} · ${value("outcome")}`;
default:
return kind;
}
}
@@ -23,6 +23,10 @@ import type {
Phase7SemanticToolDefinition,
Phase7SemanticToolResult,
} from "../semantic-tools/types.js";
import {
redactPhase7EvidenceData,
type Phase7EvidenceKind,
} from "./evidence-redaction.js";
import {
createPhase7RunnerdCodexTransport,
type Phase7RunnerdCodexTransport,
@@ -58,18 +62,14 @@ export interface Phase7LiveTranscriptEntry {
export interface Phase7LiveEvidenceEntry {
id: string;
kind:
| "session"
| "provider_event"
| "tool_exposure"
| "tool_call"
| "tool_result"
| "interaction"
| "process"
| "diagnostic"
| "cleanup";
kind: Phase7EvidenceKind;
at: string;
turnId: string | null;
/**
* Redacted at append time by `redactPhase7EvidenceData`. Nothing here is a
* verbatim provider payload, tool argument, or tool result, so no reader,
* frame, replay payload, or log can reintroduce one (track 7U).
*/
data: Record<string, Phase7JsonValue>;
}
@@ -1028,6 +1028,11 @@ export class Phase7LiveSession {
return id;
}
/**
* Records one evidence entry. Redaction happens here rather than at render
* time so the retained record is already the public one: a raw provider
* payload never enters the snapshot, the store, a stream frame, or a log.
*/
#appendEvidence(
kind: Phase7LiveEvidenceEntry["kind"],
turnId: string | null,
@@ -1039,7 +1044,7 @@ export class Phase7LiveSession {
kind,
at: this.#now().toISOString(),
turnId,
data: jsonValue(data) as Record<string, Phase7JsonValue>,
data: redactPhase7EvidenceData(kind, jsonValue(data) as Record<string, unknown>),
});
if (this.#evidence.length > 2_000) this.#evidence.splice(0, this.#evidence.length - 2_000);
}
@@ -17,8 +17,10 @@ import {
import {
createPhase7TurnStreamDecoder,
encodePhase7TurnStreamFrame,
PHASE7_TURN_STREAM_MAX_FRAME_BYTES,
PHASE7_TURN_STREAM_SCHEMA,
Phase7TurnStreamError,
phase7Utf8ByteLength,
readPhase7TurnStream,
type Phase7TurnStreamFrame,
} from "./turn-stream.js";
@@ -247,6 +249,30 @@ describe("Phase 7 turn stream framing", () => {
expect(() => truncated.flush()).toThrow(/before a terminal frame/);
});
/**
* The buffered-frame limit is named in bytes. It used to be enforced with
* `String#length`, so a frame of three-byte characters could reach about
* three times the stated cap before the decoder objected (track 7U).
*/
it("enforces the frame limit in encoded bytes, not code units", () => {
// Well under the cap by code units, well over it by encoded bytes.
const multiByte = "€".repeat(Math.ceil((PHASE7_TURN_STREAM_MAX_FRAME_BYTES / 3) * 1.2));
expect(multiByte.length).toBeLessThan(PHASE7_TURN_STREAM_MAX_FRAME_BYTES);
expect(phase7Utf8ByteLength(multiByte)).toBeGreaterThan(PHASE7_TURN_STREAM_MAX_FRAME_BYTES);
expect(() => createPhase7TurnStreamDecoder().push(multiByte)).toThrow(/frame limit/);
});
it("counts encoded bytes the way TextEncoder does, including surrogate pairs", () => {
for (const sample of ["", "ascii", "é", "€", "𝄞", "a€𝄞z", "\u{1F600}\u{1F601}"]) {
expect(phase7Utf8ByteLength(sample)).toBe(new TextEncoder().encode(sample).length);
}
// A lone surrogate is what a chunk boundary can leave in the buffer; it
// must be counted, not crash the count.
expect(phase7Utf8ByteLength("\uD83D")).toBe(3);
});
it("streams three gated deltas into growing client states that match the final text", async () => {
const gate = new Gate();
const { session, close } = await openGatedSession(gate);
@@ -38,6 +38,33 @@ export const PHASE7_TURN_STREAM_ACCEPT = "application/x-ndjson" as const;
*/
export const PHASE7_TURN_STREAM_MAX_FRAME_BYTES = 4 * 1024 * 1024;
/**
* Encoded UTF-8 length of a decoded string.
*
* The buffered-frame limit is named in bytes, so it has to be enforced in
* bytes: `String#length` counts UTF-16 code units, which lets a frame of
* multi-byte text run to roughly three times the stated cap before tripping it.
* Counted rather than encoded, because the check runs on every chunk and the
* point of the cap is to avoid allocating for an oversized frame.
*/
export function phase7Utf8ByteLength(value: string): number {
let bytes = 0;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
// Surrogate pair: one code point, four bytes, two code units consumed.
const low = value.charCodeAt(index + 1);
if (low >= 0xdc00 && low <= 0xdfff) {
bytes += 4;
index += 1;
} else bytes += 3;
} else bytes += 3;
}
return bytes;
}
/**
* Why the server emitted an interim frame. Carried for observability only: the
* client renders `view` the same way whatever produced it.
@@ -169,7 +196,7 @@ export function createPhase7TurnStreamDecoder<View, Payload>(): Phase7TurnStream
return {
push(chunk) {
buffer += chunk;
if (buffer.length > PHASE7_TURN_STREAM_MAX_FRAME_BYTES) {
if (phase7Utf8ByteLength(buffer) > PHASE7_TURN_STREAM_MAX_FRAME_BYTES) {
throw new Phase7TurnStreamError(
"stream_frame_too_large",
"A turn frame exceeded the stream frame limit.",