Skip to content

Request shapes: record real traffic's structure without its text, and replay it - #152

Merged
askalf merged 3 commits into
mainfrom
shape-replay
Oct 15, 2026
Merged

askalf merged 3 commits into
mainfrom
shape-replay

Conversation

@askalf

@askalf askalf commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Real traffic, replayed without keeping any of it. Cordon records the shape of each request that names its subjects, and a replay fills every recorded shape with invented people and runs it through redaction and the egress check.

Recording: SHAPE_LOG (off unless set)

  • src/shapes.ts. skeleton() keeps the provider's own keys, array lengths (cut at 12 with a count), string size classes (exact to 16, then powers of two), line counts, a JSON string's own shape (and whether it was pretty-printed), and the API's enum values from fixed lists (an unlisted value is recorded by size only). Tool names (tool definitions, tool calls, chat function) become a stable 8-hex digest. It never keeps:

    • text;
    • a caller-defined key (written *0, *1, …) or anything under one;
    • a chat participant name;
    • a number;
    • a model name;
    • any value a caller chose, such as an unlisted enum value or a tool name.

    Markers start with $, which no provider key does.

  • ShapeLog. Appends each distinct shape once (sha256 of provider, dialect and shape), up to SHAPE_LOG_MAX (default 2000). It reads the seen set back after a restart and never throws.

  • Proxy. It records only for subject requests, fire-and-forget after the egress check, so it is never on the request path.

Replay: npm run replay:shapes [file]

  • src/replay.ts. instantiate() fills a shape for one person, in one of two ways: with their identifiers written into message and tool text (tool-call arguments and JSON text included), or with only ordinary words, their name's plain words among them in lowercase. Caller-defined fields outside tool data, request metadata and media payloads get neutral words: cordon reads them at egress but does not rewrite them, and a declared identifier there is refused by design.
  • replayShapes(). Runs every shape × 7 hard invented people × both fills. A leak, meaning an identifier left after redaction, fails, and so does a refusal of ordinary text.
  • src/tools/replay-shapes.ts. The CLI prints failures as shape digest, person, check and egressAt path, never text, and exits 1 on any failure. It is in the image, so a box can replay its live shapes in place.

Tests

  • _test_shapes.mjs.
    • An identifier-shaped lowercase value in an enum field, or in a tool name, is never kept.
    • No text, caller key or participant name in a shape, across Messages, Chat Completions and Responses bodies full of identifiers.
    • Listed enums, tool-name digests, JSON shape and size classes are kept.
    • Recording dedups, caps and survives a restart.
    • Realistic shapes replay clean.
  • _test_replay_recorded.mjs. The main test instance runs with SHAPE_LOG. After the proxy suites, the CLI replays the shapes the battery's own traffic recorded: 45 shapes, 630 runs, 0 failed.
  • _test_subject_proxy.mjs. The live instance's shape log holds a line per distinct shape and no subject value.
  • Flaky test fix. clean() in _test_subject.mjs dropped nothing before looking, so a placeholder's random hex tag could hold the digits checked for and fail about 1 run in 20,000. It now drops placeholders first.
  • npm test. All green, 11 suites.

… replay it

SHAPE_LOG records the shape of each subject request (keys, size classes, line counts, enum values, tool names; never text or caller-defined keys), once per distinct shape. npm run replay:shapes fills every shape with invented people and fails on a leak or a refusal of ordinary text. The battery replays the shapes its own traffic recorded.
@github-actions github-actions Bot added proxy The fail-closed request path tests Test suite and CI size/L 200-799 hand-written lines labels Oct 15, 2026
Comment thread src/shapes.ts Fixed
Comment thread src/shapes.ts Outdated
// An enum field's value is the API's own vocabulary ("content", "image/png"), kept when it is
// a short lowercase token; anything else there is treated as text.
if (protocol && ENUM_FIELDS.has(k) && typeof v === "string" && (ENUM_VALUES.has(v) || ENUM_TOKEN.test(v))) { out[key] = v; continue; }
if (protocol && k === "name" && typeof v === "string" && (tool || isToolCall(node)) && TOOL_NAME.test(v)) { out[key] = { $tool: v }; continue; }
Comment thread src/shapes.ts
// a short lowercase token; anything else there is treated as text.
if (protocol && ENUM_FIELDS.has(k) && typeof v === "string" && (ENUM_VALUES.has(v) || ENUM_TOKEN.test(v))) { out[key] = v; continue; }
if (protocol && k === "name" && typeof v === "string" && (tool || isToolCall(node)) && TOOL_NAME.test(v)) { out[key] = { $tool: v }; continue; }
if (protocol && k === "model" && typeof v === "string") { out[key] = { $model: 1 }; continue; }
Comment thread src/shapes.ts
if (protocol && k === "model" && typeof v === "string") { out[key] = { $model: 1 }; continue; }
const below = !protocol || CALLER_CONTAINERS.has(k) || (k === "input" && isToolCall(node));
const toolHere = protocol && (k === "tools" || k === "function" || k === "tool_choice");
out[key] = skeleton(v, depth + 1, below, toolHere);
Comment thread src/shapes.ts
if (seen.has(h)) return;
seen.add(h);
const rec: ShapeRecord = { ts: Date.now(), h, provider, dialect, shape };
await appendFile(this.path, JSON.stringify(rec) + "\n");

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The shape recorder and replay path are wired into subject requests, but the recorder can retain caller text in fields treated as arbitrary enum tokens.

1. Blocking: src/shapes.ts:61

if (protocol && ENUM_FIELDS.has(k) && typeof v === "string" && (ENUM_VALUES.has(v) || ENUM_TOKEN.test(v))) { out[key] = v; continue; }

A subject request can put an arbitrary lowercase string in a protocol enum field and that string is written verbatim to the shape log. For example, a request containing a content part with {"type":"marisolquinterovance","text":"..."} reaches this branch because type is an enum field and the value matches ENUM_TOKEN; the log then contains marisolquinterovance, contrary to the guarantee that request text is never retained. The same issue applies to arbitrary values in role, status, and the other listed fields.

Suggested fix:

Retain only values explicitly listed in `ENUM_VALUES` (or a per-field allowlist of documented values). Represent any unrecognized value as a string-size marker rather than persisting it, and add a test using an identifier-shaped lowercase value in an enum field.

rule:none

…mes as digests

An unlisted value in an enum field is recorded by size, and a tool name becomes a stable 8-hex digest, so a caller cannot put text into the shape log through either. The replay fills unkept enum fields with neutral words and rebuilds tool names as tool_<digest>.
Comment thread src/shapes.ts
const key = protocol ? k : `*${anon++}`;
// An enum field's value is kept only when it is a listed value of the API's own vocabulary;
// any other value there (a caller can write anything) is recorded as text, by size only.
if (protocol && ENUM_FIELDS.has(k) && typeof v === "string" && (ENUM_VALUES.has(v) || SHAPE_ENUMS.has(v))) { out[key] = v; continue; }

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The shape recorder and replay path are wired into subject requests and cover the intended redaction checks, but replay does not honor recorded array counts above its local expansion limit.

1. Blocking: src/replay.ts:85

    for (let k = 0; k < Math.min(more, 20); k++) out.push(instantiate(last, p, mode, pick, data));

A recorded array with more than 32 items cannot be replayed at its recorded length. For example, skeleton() records a 33-element messages array as 12 samples plus $more: 21, but this loop appends only 20 items, so the instantiated request has 32 messages. Larger arrays are also reduced to 32 items. This drops the recorded structure and can omit traffic paths that the replay is meant to exercise.

Suggested fix:

Instantiate all `$more` entries, or explicitly impose and record a replay truncation policy. Add a test for an array with at least 33 items that verifies the instantiated array length matches the recorded count.

rule:none

A shape records 12 samples plus a count of the rest; the replay now rebuilds the full count, so a long history is replayed at its real length. Tests for 13, 33 and 140 items.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve. The change records bounded, anonymized request structures only after subject egress processing and replays them through the existing redaction and egress paths. The shape and integration tests cover redaction of retained structure, log deduplication and restart behavior, and replay of recorded traffic including long arrays.

@askalf
askalf merged commit f6ce3a0 into main Oct 15, 2026
13 of 14 checks passed
@askalf
askalf deleted the shape-replay branch October 15, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proxy The fail-closed request path size/L 200-799 hand-written lines tests Test suite and CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants