diff --git a/src/hooks/after-tool-call.test.ts b/src/hooks/after-tool-call.test.ts index 93f7781..dc0f25e 100644 --- a/src/hooks/after-tool-call.test.ts +++ b/src/hooks/after-tool-call.test.ts @@ -390,6 +390,37 @@ describe('after_tool_call evidence extraction', () => { expect(() => JSON.stringify(body)).not.toThrow() }) + it('drops an entry whose evidence_data serializes away (function/symbol), not only ones that throw', async () => { + const { client, audit } = makeClient() + const registry = new CorrelationRegistry() + bind(registry, 'eval-e11', { session: 'oc:s1', toolName: 'send_email', toolCallId: 'tc-11' }) + const hook = createAfterToolCallHook({ + client, + registry, + evidence: { + send_email: [ + { key: 'recipient', path: ['to'] }, + { key: 'cb', path: ['callback'] }, + ], + }, + }) + + // `callback` resolves to a function. JSON.stringify silently OMITS function/symbol properties + // (it does not throw), so the snapshot would succeed with `evidence_data` gone — a malformed + // entry missing its required field. It must be dropped, not sent. + await hook( + event({ + toolName: 'send_email', + toolCallId: 'tc-11', + result: { to: 'a@b.com', callback: Math.max }, + }), + ctx({ toolName: 'send_email', sessionId: 's1', toolCallId: 'tc-11' }), + ) + + const body = audit.mock.calls[0]?.[0] + expect(body?.evidence).toEqual([{ evidence_key: 'recipient', evidence_data: 'a@b.com' }]) + }) + // The sideband rejects bodies over 1 MiB (docs/adapter-api.md), so an oversized optional field // must not 413 the mandatory audit for a call that ran. Drop result first, then trim evidence. const overOneMiB = 'x'.repeat(1_100_000) diff --git a/src/hooks/after-tool-call.ts b/src/hooks/after-tool-call.ts index eaa8089..9440fea 100644 --- a/src/hooks/after-tool-call.ts +++ b/src/hooks/after-tool-call.ts @@ -172,11 +172,15 @@ export function createAfterToolCallHook(deps: AfterToolCallDeps) { const result = event.result !== undefined ? safeJsonSnapshot(event.result) : undefined // Snapshot evidence per entry so one unserializable extracted value drops only its own entry, - // not the valid grounding facts beside it. + // not the valid grounding facts beside it. A snapshot can *succeed* yet silently drop + // `evidence_data` when the extracted value is a function/symbol (JSON.stringify omits those + // from an object rather than throwing), leaving a malformed entry with no required field — so + // also drop any entry whose `evidence_data` did not survive the round-trip. const safeEvidence: AuditEvidenceEntry[] = [] for (const entry of evidence) { const snapshot = safeJsonSnapshot(entry) - if (snapshot.ok) safeEvidence.push(snapshot.value) + if (snapshot.ok && snapshot.value.evidence_data !== undefined) + safeEvidence.push(snapshot.value) } const base: AuditRequest = {