diff --git a/src/host/foldMetering.ts b/src/host/foldMetering.ts index 8ea6b28..a2eef97 100644 --- a/src/host/foldMetering.ts +++ b/src/host/foldMetering.ts @@ -116,6 +116,11 @@ export function addDecode(timing: TimingTotals, kind: DecodeKind, ms: number, bl /** A new name beyond the cap evicts the smallest tally, so the state stays bounded even over a hostile log of unique names. */ export function bumpToolTotals(timing: TimingTotals, name: string, ms: number): void { + // `__proto__` is the one key that assigns the prototype instead of a row: the tally would be lost, the cap would + // never see it, and `foldWire`'s `for...in` would then copy the inherited `calls`/`ms` as `{}` — failing the strict + // wire schema, which throws inside the registry's uncontained drive and stalls this unit's push feed for good. The + // client boundary skips the same name. + if (name === '__proto__') return // hasOwn, not an index check: a missing key IS possible at runtime (a name outside the persisted tally). if (!Object.hasOwn(timing.tools, name)) { if (Object.keys(timing.tools).length >= TOOL_TIMING_CAP) { diff --git a/tests/host/fold-timing.spec.ts b/tests/host/fold-timing.spec.ts index fbba9ee..c937208 100644 --- a/tests/host/fold-timing.spec.ts +++ b/tests/host/fold-timing.spec.ts @@ -395,6 +395,25 @@ describe('timing — served wire view', () => { assert.equal(view.timing, undefined) }) + test('a hostile __proto__ tool name books no row and keeps the served view schema-valid', () => { + const def = timelineDef({}) + // One hostile tool only: a later tool-run event re-clones the tally through a spread, which would launder + // the damage and hide it from this probe. + const drive = driveTimeline([ + toolCall(5, { callId: 'c1', name: '__proto__' }), + toolResult(6, { callId: 'c1', content: text('ok') }), + ]) + const tools = drive.state.timing?.tools ?? {} + assert.equal(Object.getPrototypeOf(tools), Object.prototype, 'the tally keeps a plain prototype') + assert.deepEqual(Object.keys(tools), [], 'the reserved name books no row') + assert.deepEqual(drive.view.timing?.tools, {}, 'nothing inherited rides the wire') + // The consequence this guards: assigning that name sets the prototype instead of a row, so foldWire's + // `for...in` copies the inherited `calls`/`ms` as `{}` — which the strict tool-timing schema rejects, and the + // registry's drive has no error boundary, so the throw would stall this unit's push feed for good. + const wire = def as unknown as { wire: { viewSchema: { parse(v: unknown): unknown } } } + assert.doesNotThrow(() => wire.wire.viewSchema.parse(drive.view)) + }) + test('the persisted-state schema accepts the timing shape (stamped slot included)', () => { const def = timelineDef({}) const drive = driveTimeline([...step(1, 0, 1_000, { tokenMs: 300 }),