Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/hooks/after-tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/after-tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down