docs/book/src/security/tool-receipts.md
Tool receipts are cryptographic proofs that a successful tool result came from the runtime. When receipts are enabled, successful tool executions receive an HMAC-SHA256 digest over the call and its result. The digest is appended to the tool-result text and passed back to the model as part of the conversation.
The practical outcome: the model cannot convincingly claim to have run a tool it didn't run, and it cannot fabricate a tool result. Those claims produce missing or invalid receipts.
An LLM is a string generator. By default, nothing prevents it from narrating a tool call it never made ("I ran git log and the latest commit is…"), or inventing a result for a tool call ("The weather API says 72°F", when the call timed out). For an agent with autonomy, this is more than a correctness issue: it's a deniability issue.
Tool receipts close that gap with the cheapest possible construct: a symmetric MAC with an ephemeral in-memory key.
What "ephemeral" means here. Channel-server paths keep one HMAC key in memory for the lifetime of the channel runtime context. Direct agent turn paths create a fresh receipt scope for the turn. In both cases the key stays in memory, is never sent to the model, and is not a durable cross-session verifier.
Based on: Basu, A. (2026). "Tool Receipts, Not Zero-Knowledge Proofs: Practical Hallucination Detection for AI Agents." arXiv:2603.10060.
ChannelRuntimeContext; direct turn paths create a fresh scope for the turn. The key is never written to disk, never sent to the model, and never logged.receipt = HMAC-SHA256(key, tool_name || args || result || timestamp)
[receipt: zc-receipt-<timestamp>-<base64url-digest>]
The model sees every receipt in its conversation history. It can echo them in text it produces to the user. But it cannot produce a new valid receipt: the HMAC requires the session key, which the model doesn't have.
zc-receipt-1774608496-gzpEBuUIRYX1vd4fQl4oYkqhq4-GnoJDStmlYzvQiWA
^ epoch seconds ^ base64url(HMAC-SHA256 digest)
The zc-receipt- prefix exists so the leak detector doesn't redact them (receipts are safe to surface; they contain no secret material).
| Scenario | Without receipts | With receipts |
|---|---|---|
| Model claims it ran a tool, didn't | Undetectable | No receipt: fabrication visible |
| Model fabricates a result for a real call | Undetectable | HMAC mismatches on verification |
| Model denies a successful call it made | Unverifiable | Receipt in conversation proves a signed result was emitted |
| Model fabricates a plausible receipt string | Plausible | HMAC verification fails |
background: true) do not surface receipts in the user-visible block, since the per-turn collector is rendered before those spawns finish. Receipts inside synchronous delegate sub-agents are captured.RUST_LOG=zeroclaw_runtime::agent=debug zeroclaw daemon
Produces:
DEBUG Tool receipt generated tool=shell receipt=zc-receipt-1774604899-fVRG...
If [agent.tool_receipts] show_in_response = true, the reply includes a trailing block:
Here's the weather in Istanbul: 16°C, sunny.
---
Tool receipts:
weather: zc-receipt-1774608496-gzpEBuUIRYX1vd4fQl4oYkqhq4-GnoJDStmlYzvQiWA
Because the model sees receipts in its context, it may echo them when describing tool results. The leak detector is configured to pass zc-receipt-* tokens through unmodified so this echoing works. If both the runtime and the model include a receipts block, the user sees two: strip one via channel-specific formatting rules.
hmac + sha2 from the Rust ecosystem.| Feature | Status |
|---|---|
| HMAC generation for successful tool results | Shipped |
| Receipt appended to successful tool result | Shipped |
| Debug log of receipts | Shipped |
show_in_response | Shipped |
| System-prompt instruction to echo receipts | Shipped |
| Persistent audit database of receipts | Planned |
| Cross-scope receipt verification | Not planned (see ephemeral-key design) |