Skip to content

The audit boundary belongs outside the agent

Published 2026-05-18 · Series: Auditing AI Agents · Part 1 of 3


An agent that signs its own audit trail isn’t being audited.


The first version of Agent Receipts was deliberately small: each MCP proxy instance loaded an Ed25519 signing key, maintained its own SQLite database, and signed and stored a receipt for every tool call it intercepted — all in the same process as the agent.

The cryptography worked. The audit property didn’t. When the signing process and the audited process are the same, the auditor is holding its own signing key. It can sign what it likes, drop what it doesn’t, and nothing outside the process can disagree.

A second limitation came from running more than one MCP proxy at once — the usual case when an agent talks to several MCP servers. Each proxy kept its own chain, with no shared sequence between them. Correlating a GitHub tool call with a filesystem tool call meant application-level join logic instead of one query.


ADR-0010 split every integration into two roles.

Thin emitter — the plugin, proxy, or SDK serialises the tool call, writes it to a local Unix socket, and moves on. Signing, storage, and chaining are the daemon’s concern; the agent is never blocked waiting for the audit layer. If the daemon is running but backpressured, the emitter increments a local drop counter and flushes it on the next successful send, so the daemon can record the gap as an events_dropped receipt in the chain. If the daemon isn’t running at all, events drop without trace — that case is detectable from outside the chain, via service-manager status and the absence of fresh receipts.

agent-receipts-daemon — a separate process running as its own OS user, sole owner of the signing keys and the SQLite database. Receives events, captures peer credentials at the kernel level, canonicalises inputs (RFC 8785), hash-chains, signs (Ed25519), and persists.

agent-receipts-daemon

Agent process

fire-and-forget

fire-and-forget

mcp-proxy

agent-receipts-hook

peer credential capture

hash chain

signing key

receipts.db

The agent process can’t reach the signing keys or the database. The separation is structural, not policy.


The daemon doesn’t take the emitter’s word for who it is. The moment a connection lands on the socket, it asks the kernel directly:

  • macOS: LOCAL_PEERCRED for uid/gid, LOCAL_PEEREPID for pid, then proc_pidpath for the executable path
  • Linux: SO_PEERCRED for uid, gid, pid, then /proc/<pid>/exe for the executable path

These come straight from the kernel. The connecting process can lie about anything in its payload, but not about who it is. The daemon stamps every receipt with that identity, so an auditor can answer not just what was called but which process called it — by uid, pid, and executable path.


Because every emitter connects to the same daemon socket, all receipts land in one chain with a single monotonic sequence. An agent session that talks to three MCP servers and fires native tool calls through the Claude Code hook produces one chain — not four.

Cross-channel correlation collapses to one query: every receipt for a session carries the same session_id, regardless of which emitter captured the call.


The daemon is a system service. That’s friction the old npm install flow didn’t have. It requires installation, a signing key, and a service manager entry (launchd on macOS, systemd on Linux).

That’s the cost of an audit boundary that means something. In-process signing is simpler to install; it just isn’t the audit boundary.

Homebrew handles most of it:

Terminal window
brew install agent-receipts/tap/agent-receipts-daemon
brew services start agent-receipts-daemon

Receipts are W3C Verifiable Credentials, Ed25519-signed, hash-chained. Here’s a real one from this session, signature and hashes abbreviated:

{
"action": {
"type": "mcp.github.pull_request_read",
"parameters_hash": "sha256:2f9911eb..."
},
"credentialSubject": {
"chain": { "sequence": 2046, "chain_id": "default" }
},
"proof": {
"type": "Ed25519Signature2020",
"verificationMethod": "did:agent-receipts-daemon:local#k1",
"proofValue": "uZ7aqJ4Zfq..."
}
}

That signing key — did:agent-receipts-daemon:local#k1 — lives only in the daemon. The agent process that triggered this call has never seen it.


The daemon is the trust anchor for everything that follows: secret redaction before storage (already shipped), asymmetric payload encryption so only a forensic key holder can read stored inputs, centralised storage for cross-machine audit trails. All of it depends on having one trusted process that owns the keys and the chain.