Why in-process audit logs fail under agent compromise, and how to fix them with out-of-process, request-bound signatures.
Several open-source tools promise tamper-evident audit trails for AI agents. They record every tool call in a hash chain and sign entries with an HMAC to satisfy compliance requirements.
The core mechanism has a gap: the key that signs the log lives in the same process as the agent being logged.
The threat model
Security teams need audit logs because prompt injection can redirect LLM agents. The basic threat model assumes the agent process itself is compromised.
Most current implementations import an audit library directly into the agent runtime. That library holds an HMAC key or computes unsalted hashes. On every tool call, it appends a record to a local file.
When an attacker gains control of the agent process, they also control the signing key. They can do a bunch of things such as,
- Mint valid records attributed to any user.
- Modify history and recompute the entire hash chain.
- Drop log entries completely so actions go unrecorded.
Because the attacker holds the key, the modified chain still verifies.
A reliable audit system must guarantee that a compromised agent cannot impersonate another principal, alter history, or prevent its actions from being logged.
Attribution does not prevent an agent from running harmful commands if it already has permission to run them. It only guarantees an accurate record after the fact.
Meeting this guarantee requires three controls:
- The signing key must live outside the agent process to prevent forgery.
- The signature must bind to the specific request (tool name, argument hash, nonce, and timestamp) so signatures cannot be replayed on different calls.
- The log record must leave the host over a channel the agent cannot reach before the agent gets its signature. Otherwise, the agent can drop the log.
In-process logging tools do not meet these requirements. I wrote mcp-identity-shim to implement them for Model Context Protocol (MCP) tool calls.
Architecture
The agent process holds no key material.
It computes public values: a SHA-256 hash of canonicalised arguments, a UUID nonce, and an RFC 3339 timestamp. It sends these values to a sidecar daemon running under a separate UID over a Unix domain socket with peer-credential checks.
The sidecar sends the log record to an off-host, append-only sink before returning the signature. Because of this sequence, the agent cannot receive a valid signature while dropping the audit record.
Each call binds two identities:
Machine identity: A SPIFFE JWT-SVID signed by the SPIRE server. The workload does not hold the SPIRE signing key, so machine identity cannot be forged.
User delegation: An OAuth 2.0 Token Exchange (RFC 8693) act_token. In this token, sub is the human user and act.sub is the agent's SPIFFE ID. This records that Agent X acted for Alice, including nested sub-agent delegations.
Why a second signature is necessary
The SPIFFE Workload API does not support custom claims in a JWT-SVID. It only issues sub and aud. Request-specific data (args_sha256, nonce, ts) cannot be embedded directly in the SVID.
Binding each request requires a second signature over the full payload. SPIRE delivers the X.509-SVID private key to the workload, so the sidecar runs as the SPIFFE workload instead of the agent. The sidecar holds the X.509 key and signs payloads; the agent remains keyless. The process separation provides the security boundary.
The signed assertion travels in the MCP request's _meta field under a reverse-DNS key (dev.mcpshim/identity). A server-side verifier can recompute the argument hash and check the signature. Because downstream MCP servers might strip _meta, the off-host sink remains the source of truth.
"_meta": {
"dev.mcpshim/identity": {
"v": "1",
"jwt_svid": "<SPIRE-signed: proves WHICH workload>",
"act_token": "<RFC 8693: agent acting-for Alice>",
"req": { "tool": "enterprise_db_query", "args_sha256": "…", "nonce": "…", "ts": "…" },
"sig": "<sidecar signature over everything above>"
}
}
Verified failure modes: The test suite in the repository verifies these properties directly
Tampering: Modifying arguments after signing breaks verification because args_sha256 does not match.
Replay attacks: Attaching an existing signature to a different tool call fails because the signature covers the tool name, argument hash, nonce, and timestamp. Timestamps outside the freshness window fail verification.
Suppression: Killing the agent process immediately after a tool call does not prevent logging. The sidecar ships the record before returning the signature.
Credential leakage: The LLM context never contains keys, SVIDs, or tokens. Credentials attach out-of-process after generation, so prompt injection cannot extract them.
Scope and limitations:
- This shim provides attribution without policy enforcement. It does not inspect arguments or block malicious requests from an authorised agent. If an agent with database access receives a malicious prompt, it can still run destructive queries; the shim only guarantees that the query is permanently logged and attributed.
- Short token lifetimes limit the exposure window, but do not replace active revocation.
Current status:
The core implementation is functional: Ed25519 signatures, a Unix domain socket sidecar, an append-only hash chain, and roughly 300 tests against stub SPIFFE and STS backends. Live SPIRE integration via py-spiffe, an RFC 8693 client, verifier-side SVID validation, and replay tracking are still in progress.
IETF working groups (including AIMS, WIMSE, and Transaction Tokens) and the MCP community are standardising SPIFFE and token exchange for agents. Out-of-process signing provides the request binding needed to keep those audit logs reliable when an agent is compromised.
The repository is available at mcp-identity-shim.

Top comments (1)
The out-of-process boundary fixes a failure mode that hash chains inside the agent cannot: a compromised process should not be able to mint a valid-looking history. I’d make one invariant explicit in the verifier tests, though: “accepted” must mean the sink has durably acknowledged the record, not merely that the sidecar returned a signature. Otherwise a sidecar crash or network partition can still create a signed request with no durable audit event.
I’d also treat the signed assertion as attribution, not authorization, as you note. The policy decision should bind the same canonical tool name and argument hash, then independently check freshness, delegation scope, and replay state. A useful chaos case is: sign, receive the MCP response, kill the sink connection, and retry with the same nonce. The expected result is one durable record and a rejected duplicate, while the agent gets an explicit delivery failure rather than a misleading success.