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