fix(host): skip the reserved prototype key in the tool-timing tally

A durable tool/call naming itself `__proto__` reached bumpToolTotals, whose
Object.hasOwn guard passes for it — the key is never an own property. The
plain assignment then set the tally's prototype instead of booking a row:
the count was lost, the 16-name cap never saw the key, and foldWire's
`for...in` copied the inherited `calls`/`ms` as `{}`, which the strict
tool-timing schema rejects. The registry parses every served view with no
error boundary, so that throw stalls the unit's push feed — the "loading"
forever failure compatibility.md designs out, and the same defect the
compat matrix already pins as `poisonedStarves`.

The client boundary has skipped this name since issue #44, with a
regression test beside it; the host fold, where the untrusted name first
lands, did not. The hostile fixture needs no second tool run to reproduce:
a later one re-clones the tally through a spread, which launders the damage.
This commit is contained in:
bowenliang123 committed 2026-10-10 02:16:50 +08:00
1 parent f50f33d58b
commit 3611d50419
2 files changed
+24

No files matched your search

+5
View File
@@ -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) {
+19
View File
@@ -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 }),