Prove which human directed an AI agent's action — tamper-evident flight recorder for MCP and OpenAI function-calling.
Project description
Crumb
Prove which human directed an AI agent's action, across MCP and OpenAI function-calling, in a tamper-evident record an auditor can verify without trusting you.
Every agent action drops a crumb. The trail leads back to the human who directed it.
Live demo: crumb.alexlaguardia.dev. Hit Operator rollback and watch the signed chain stay green while the external anchor catches the forgery.
The problem
AI agents run under service accounts and shared API keys. So when an agent reads a patient record, exports customer data, or moves money, your audit log says the agent did it, not which human told it to.
That gap is about to be illegal. The EU AI Act (Article 12, in force Aug 2 2026) requires high-risk systems to log "the identification of the natural persons involved." A service-account log can't answer that.
And it isn't a model problem you can prompt your way out of. A tool call from a model is just {"name": ..., "arguments": ...}, with no field for who. Worse, anything the model emits can be prompt-injected, so identity must never come from the model. It has to be stamped by the runtime, outside the agent's reasoning.
Crumb is the runtime that stamps it.
What it is (and isn't)
Crumb is a flight recorder for agent actions: read-only, after-the-fact, provable. It records who-did-what and proves the record wasn't altered.
It is not a control plane. It doesn't block, doesn't author policy, doesn't draw an identity graph. Those are the funded lane (Cerbos, Capsule, Astrix). Crumb stays in its own: forensics and accountability, and points at them for enforcement.
How it works
human (OIDC session)
│ sub=alice, captured once, never from the model
▼
┌─────────┐ mint RFC 8693 token (sub + act, scoped) ┌──────────────────┐
│ GATEWAY │ ──────────────────────────────────────────▶ │ tool / MCP server │
└─────────┘ the enforced chokepoint └──────────────────┘
│ writes a signed crumb
▼
hash-chained, Ed25519-signed ledger
│ Merkle root, hourly
▼
Sigstore Rekor (public transparency log the operator doesn't control)
Four moves:
- Capture the human once, at an OIDC session, before the agent runs. Identity never comes from the model.
- Bind every tool call to an RFC 8693-shaped delegation token carrying the human (
sub) and the agent acting for them (act), scoped to one resource. The tool serves data only against a valid token. - Record a hash-chained, Ed25519-signed crumb per call. Edit, delete, or reorder any entry and a different check breaks at the exact row.
- Anchor the Merkle root of the whole log to Sigstore's public Rekor log. Per-entry signatures stop a forger without the key. Anchoring stops the operator, who could otherwise re-sign a rewritten history.
Cross-vendor by design. An OpenAI function-call and an MCP tools/call are two different wires with the same gap: neither carries the human. Both normalize to one ToolCall, flow through the same gateway, and land in one canonical crumb schema, distinguished only by a transport field. Attribution is a property of the runtime, not the wire, so it survives a change of wire.
Install
pip install crumb-attest
The distribution is crumb-attest; it imports and runs as crumb (the name crumb
was already taken on PyPI). Verify the install:
crumb --help
Or run it without installing anything:
uvx --from crumb-attest crumb --help
From a clone, for development:
pip install -e ".[test]"
pytest
Quickstart
python -m crumb.demo # the gap, then the gateway closing it
python -m crumb.cross_vendor_demo # same action over OpenAI + a REAL MCP HTTP hop
python -m crumb.hijack_demo # a poisoned tool hijacks the agent; the crumb pins it on the agent, not the human
python -m crumb.tamper_demo # write crumbs, verify, edit one, watch it break
python -m crumb.anchor_demo # the operator rollback the anchor catches
python -m crumb.actor_binding_demo # operator forges the HUMAN, the token proves the truth
python -m crumb.cross_issuer_demo # a delegation chain across TWO issuers, provenance stapled
python -m crumb.jwks_federation_demo # verifier fetches issuer keys live over HTTP, follows rotation
python -m crumb.idp_demo # a REAL RFC 8693 token exchange + JWKS verify
python -m crumb.verify # re-verify the ledger on its own
uvicorn crumb.web:app --port 8730 # the timeline view + live tamper/rollback
uvicorn crumb.mcp_http:app --port 8731 # records-mcp as a standalone MCP server
uvicorn crumb.idp:app --port 8732 # the IdP: RFC 8693 token-exchange + JWKS
records-mcp is a real Resource Server — drive it with any MCP client, or curl:
curl -s localhost:8731/mcp -H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"read_record","arguments":{"record_id":42}}}'
# no token → HTTP 401 + WWW-Authenticate, like any OAuth 2.1 Resource Server
What each one shows:
- demo: a human authenticates, an agent reads a "regulated" record, and the crumb ties the action back to them.
- cross_vendor_demo: the same read driven once as an OpenAI function-call and once as a real MCP
tools/callover HTTP torecords-mcp. Both trace to the same human in one schema. Then, over that same wire, the same call with a service-account token (the human is gone) versus a delegation token (the human survives). That delta is the product. - hijack_demo: the attack Crumb is built for. A poisoned tool description drives the agent into two calls the human never authorized — a same-verb read of a patient she never named, and an export to an attacker. alice scoped her session to
read_record(42); the read of record 43 and the export both landunauthorized, directive null, pinned on the agent. The read of 43 is the sharp one: same tool name, different resource, so a verb-only check clears it — the argument scope is what catches it, and it flags even though the call succeeds against a real record. The ledger still verifies; the point is who each call is pinned on. - tamper_demo: a careless edit. Verification catches it at the exact row.
- anchor_demo: the one to watch. A key-holding operator rewrites a crumb and re-signs the entire chain, so per-entry
verifypasses the forgery. The Rekor anchor catches it, because the rewritten root is not the one already public. - actor_binding_demo: anchor_demo catches a rewritten record; this catches a rewritten human. The operator edits
actor_identityand re-signs, so integrity passes over their own log. But the crumb carries the delegation token, andcrumb verify --federationre-walks it to the root issuer: the token still provesalice, the record claimsmallory, and the human the operator can't re-sign is the one that wins. The recorded human stops being their word. - cross_issuer_demo: the human logs in at one IdP (A) and the tool is governed by another (B). Vanilla token exchange would have B mint a fresh token and drop A's signature, leaving only B's word for who the human was. Crumb staples instead: B's token carries A's exact token and its hash, so a verifier re-checks A's segment against A's key and never takes B's word for the upstream. Same human, provable across the boundary.
- jwks_federation_demo: the trust set made trustless. Two IdPs come up on real ports serving
/.well-known/openid-configuration+/jwks, and the verifier pins nothing — it names the issuers, discovers their JWKS, and fetches their keys live. Then A rotates its signing key: a verifier that had pinned A's PEM would break, but this one sees an unknownkid, refetches A's JWKS once, and keeps verifying. Keys always come from the issuer's own endpoint, never the log-holder. - idp_demo: the bind made real. The gateway no longer signs its own authority — it runs a genuine RFC 8693 token exchange against an identity provider (
crumb/idp.py), which returns an RS256 token, and the resource verifies it against the provider's published JWKS. No shared secret. SetCRUMB_IDP_URLand the same path points at Okta/Keycloak/Zitadel; leave it unset and the gateway falls back to a local dev mint, so every other demo runs with zero infra.
Honest scope
- Attribution is only as good as the gateway's interposition. Bypass the proxy and attribution is void. The demo enforces the chokepoint; the tool refuses calls without a valid token.
- Reconciliation is only as tight as the directive's scope. A directive can be verb-level (authorize
read_record, any record) or scoped to arguments (read_recordfor record 42 only). Verb-level is a deliberate choice, not an oversight — it authorizes any call to that tool, so it can't distinguish a same-verb hijack. Scope the directive to the resource and it can. The authorizer picks the granularity; Crumb records against whatever they chose and never silently widens it. - OpenAI function-calling has zero native identity. The binding is a runtime convention, not a protocol guarantee. Crumb secures a runtime, not a wire format.
- MCP attribution is permitted by the spec but rarely implemented.
records-mcpreads the human off the token over a real HTTP wire; most deployments send a shared service-account token instead and the human never makes it across. Crumb stamps the record, but it can't force a non-compliant upstream to act on the human identity. - The MCP transport here is plain JSON-RPC over HTTP with bearer auth. Streamable-HTTP/SSE framing (session ids, event streams) is the documented upgrade and changes the envelope, not the attribution.
- The token is RFC 8693-shaped and minted with a dev key. Production swaps that for a real token exchange against an IdP (Keycloak/Zitadel/Auth0). It changes the issuer, not the demo.
Built on
OAuth 2.1 + PKCE, OIDC, RFC 8693 token exchange, RFC 8707 resource indicators, the MCP authorization spec, RFC 6962 Merkle trees, and Sigstore Rekor. Ed25519 + SHA-256 via cryptography, JWTs via PyJWT. All real, all standard.
Full design in SPEC.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file crumb_attest-0.1.1.tar.gz.
File metadata
- Download URL: crumb_attest-0.1.1.tar.gz
- Upload date:
- Size: 464.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cb76a76a0e868388333333ced1dbc4cda1f4a08bfcabca58db3fdd79d57387c7
|
|
| MD5 |
1dcdce49ae9ce934bba231bc556ae058
|
|
| BLAKE2b-256 |
1449f98981d0d65e1523288233f143e8fb5274dcf5b32e1d704a3a45004e4725
|
File details
Details for the file crumb_attest-0.1.1-py3-none-any.whl.
File metadata
- Download URL: crumb_attest-0.1.1-py3-none-any.whl
- Upload date:
- Size: 452.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
549e6b15e9a75a7609db833d449a4d285d2a6299a7288558c3c7542ab87b8008
|
|
| MD5 |
bd690ba308a0c5d98ca16e2cfc4ff84c
|
|
| BLAKE2b-256 |
ad49c748d7bdf24579715a95bfdfb9a1d266ae17c22ae02b99dbdcf40b51101a
|