Repository navigation
Request shapes: record real traffic's structure without its text, and replay it - #152
Conversation
… 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.
| // 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; } |
| // 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; } |
| 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); |
| 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
left a comment
There was a problem hiding this comment.
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>.
| 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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
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, chatfunction) become a stable 8-hex digest. It never keeps:*0,*1, …) or anything under one;name;Markers start with
$, which no provider key does.ShapeLog. Appends each distinct shape once (sha256 of provider, dialect and shape), up toSHAPE_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 andegressAtpath, 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._test_replay_recorded.mjs. The main test instance runs withSHAPE_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.clean()in_test_subject.mjsdropped 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.