From b0f8b1e91e68f962e15f6c3ce513a34881e33271 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 10 Aug 2026 22:40:32 +0000 Subject: [PATCH] fix(runner): redact the public Phase 7 stream and bind sessions to a capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../devtools/issue-thread/src/live-client.ts | 14 +- .../docs/phase-07-issue-thread-ui.md | 55 ++ ...-disclosure-and-capability-verification.md | 190 ++++++ .../knowledge/evidence/index.md | 1 + packages/paperclip-runner/package.json | 2 +- .../scripts/phase7-clean-room-smoke.mjs | 20 +- .../scripts/phase7-cookie-jar.mjs | 41 ++ .../scripts/phase7-issue-thread-server.mjs | 229 ++++++- .../scripts/phase7-issue-thread-smoke.mjs | 19 +- packages/paperclip-runner/src/index.ts | 8 + .../src/issue-thread/index.ts | 1 + .../src/issue-thread/live-projection.ts | 24 +- .../src/issue-thread/public-view.test.ts | 322 ++++++++++ .../src/issue-thread/public-view.ts | 587 ++++++++++++++++++ .../src/phase7/clean-room-server.test.ts | 393 ++++++++++-- .../src/phase7/evidence-redaction.ts | 278 +++++++++ .../src/phase7/live-session.ts | 27 +- .../src/phase7/turn-stream.test.ts | 26 + .../src/phase7/turn-stream.ts | 29 +- 19 files changed, 2176 insertions(+), 90 deletions(-) create mode 100644 packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07u-disclosure-and-capability-verification.md create mode 100644 packages/paperclip-runner/scripts/phase7-cookie-jar.mjs create mode 100644 packages/paperclip-runner/src/issue-thread/public-view.test.ts create mode 100644 packages/paperclip-runner/src/issue-thread/public-view.ts create mode 100644 packages/paperclip-runner/src/phase7/evidence-redaction.ts diff --git a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts index 52a537316a8..f8296b003d9 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts +++ b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts @@ -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 { 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 { 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 { 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; }, diff --git a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md index 9753ed6d906..0f4f7886878 100644 --- a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md +++ b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md @@ -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`, diff --git a/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07u-disclosure-and-capability-verification.md b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07u-disclosure-and-capability-verification.md new file mode 100644 index 00000000000..17dbf9542f6 --- /dev/null +++ b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07u-disclosure-and-capability-verification.md @@ -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. diff --git a/packages/paperclip-runner/knowledge/evidence/index.md b/packages/paperclip-runner/knowledge/evidence/index.md index acd8e21ebd0..e367ef0ee84 100644 --- a/packages/paperclip-runner/knowledge/evidence/index.md +++ b/packages/paperclip-runner/knowledge/evidence/index.md @@ -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. diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index f5035b8f709..eb7ec3418b3 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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", diff --git a/packages/paperclip-runner/scripts/phase7-clean-room-smoke.mjs b/packages/paperclip-runner/scripts/phase7-clean-room-smoke.mjs index 43a5204a872..ac716625ae5 100644 --- a/packages/paperclip-runner/scripts/phase7-clean-room-smoke.mjs +++ b/packages/paperclip-runner/scripts/phase7-clean-room-smoke.mjs @@ -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", diff --git a/packages/paperclip-runner/scripts/phase7-cookie-jar.mjs b/packages/paperclip-runner/scripts/phase7-cookie-jar.mjs new file mode 100644 index 00000000000..84d6945633b --- /dev/null +++ b/packages/paperclip-runner/scripts/phase7-cookie-jar.mjs @@ -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); + }, + }; +} diff --git a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs index 9f8a1b6babe..c48b1446dd3 100644 --- a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs +++ b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs @@ -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") { diff --git a/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs b/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs index 0e88c5b6cf8..ec099af28b3 100644 --- a/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs +++ b/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs @@ -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) { diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index e459a25be8c..c720ca9befc 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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"; diff --git a/packages/paperclip-runner/src/issue-thread/index.ts b/packages/paperclip-runner/src/issue-thread/index.ts index bf460f0bc4e..774ecea9cf3 100644 --- a/packages/paperclip-runner/src/issue-thread/index.ts +++ b/packages/paperclip-runner/src/issue-thread/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./fixtures.js"; export * from "./live-projection.js"; +export * from "./public-view.js"; diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index 4aa4e42b099..4a57bb5615a 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -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) diff --git a/packages/paperclip-runner/src/issue-thread/public-view.test.ts b/packages/paperclip-runner/src/issue-thread/public-view.test.ts new file mode 100644 index 00000000000..00e5d1aa4a2 --- /dev/null +++ b/packages/paperclip-runner/src/issue-thread/public-view.test.ts @@ -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).leakedIssue = CANARY; + (view.turns[0]!.items[0] as unknown as Record).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, + }); + }); +}); diff --git a/packages/paperclip-runner/src/issue-thread/public-view.ts b/packages/paperclip-runner/src/issue-thread/public-view.ts new file mode 100644 index 00000000000..c5ec88b6c00 --- /dev/null +++ b/packages/paperclip-runner/src/issue-thread/public-view.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} + +function pick( + source: Record, + keys: readonly string[], +): Record { + const result: Record = {}; + 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(); + 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:`. */ + 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 ?? []); +} diff --git a/packages/paperclip-runner/src/phase7/clean-room-server.test.ts b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts index 8d8673c23f5..e4301021554 100644 --- a/packages/paperclip-runner/src/phase7/clean-room-server.test.ts +++ b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts @@ -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 { 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 { + 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): Promise { 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(); + + headers(): Record { + 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 { + 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 | undefined), + ...jar.headers(), + }, + }); + jar.capture(response); + return response; +} + +let jar: Jar; let server: Server; let origin: string; let middleware: Awaited>; @@ -372,19 +500,10 @@ async function call( path: string, body?: unknown, frames?: Phase7IssueThreadSnapshot[], + browser: Jar = jar, ): Promise { - 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 { - 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 { + 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); + }); }); diff --git a/packages/paperclip-runner/src/phase7/evidence-redaction.ts b/packages/paperclip-runner/src/phase7/evidence-redaction.ts new file mode 100644 index 00000000000..d26ca70724a --- /dev/null +++ b/packages/paperclip-runner/src/phase7/evidence-redaction.ts @@ -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> = 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 = 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 = new Set( + PHASE7_SEMANTIC_TOOL_CATALOG.map((descriptor) => descriptor.operationId), +); + +const CATALOG_INPUT_FIELDS: ReadonlyMap> = 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 { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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, + names: readonly string[], +): Record { + const result: Record = {}; + 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(); + 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 { + const summary: Record = { + 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 { + 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 = { + 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, +): Record { + 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 { + 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; + } +} diff --git a/packages/paperclip-runner/src/phase7/live-session.ts b/packages/paperclip-runner/src/phase7/live-session.ts index 60b3f161b1f..a8c4efb09e4 100644 --- a/packages/paperclip-runner/src/phase7/live-session.ts +++ b/packages/paperclip-runner/src/phase7/live-session.ts @@ -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; } @@ -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, + data: redactPhase7EvidenceData(kind, jsonValue(data) as Record), }); if (this.#evidence.length > 2_000) this.#evidence.splice(0, this.#evidence.length - 2_000); } diff --git a/packages/paperclip-runner/src/phase7/turn-stream.test.ts b/packages/paperclip-runner/src/phase7/turn-stream.test.ts index 8485a1042bd..f813005dcb1 100644 --- a/packages/paperclip-runner/src/phase7/turn-stream.test.ts +++ b/packages/paperclip-runner/src/phase7/turn-stream.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/phase7/turn-stream.ts b/packages/paperclip-runner/src/phase7/turn-stream.ts index 6f11937fa28..a88a8bb6978 100644 --- a/packages/paperclip-runner/src/phase7/turn-stream.ts +++ b/packages/paperclip-runner/src/phase7/turn-stream.ts @@ -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(): 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.",