attestroot
Local-first, tamper-evident evidence for AI agent activity.
Captures what an AI agent did, hash-chains it, seals it into an RFC 6962 Merkle commitment, and produces a bundle anyone can verify — on a clean machine, with no dependencies, without our software and without contacting us.
pip install attestroot
Status: pre-release. M0–M3 are functionally complete and pass their exit
tests, and the countersignature service is live, so D3 is reachable. No
independent party has reviewed any of it yet — that is the next gate, and
docs/REVIEW_PACK.md is written to make breaking it as
cheap as possible.
How it works
New to this? docs/EXPLAINER.md explains the whole thing in
plain terms and defines every term used here — D0–D4, ground truth, split view,
the lot.
Eight capture points, a local hash-chained ledger, one RFC 6962 seal, and a single network call that carries a root and some counters and nothing else.
Colour is the honest part. Claude Code and Codex both keep a record of their own
— a transcript and a rollout file respectively, written whether or not
attestroot exists — so capture can be diffed against it and "nothing is missing"
becomes checkable. Those two can reach D4. The other six declare
has_ground_truth: false and are capped at D1 mechanically, from their own
capability manifest rather than from documentation.
Gemini is the instructive one. It has no hooks at all, so capture is an import of its OpenTelemetry outfile — and its own session log holds user messages only, never tool calls, so there is nothing to diff against. D1 is Gemini's limit, not ours.
What it claims, and what it does not
It attests that a record existed at a given time and has not been altered since.
It does not prove the record is a truthful account of what the agent did. A party controlling its own capture could record something false, and it would seal and verify perfectly. Integrity and existence-in-time is a real, checkable property — it is simply not the same property as truth.
That distinction is enforced in code, not just in wording: every adapter declares its own blind spots, and evidence lacking independent ground truth is capped at a lower grade with the reason recorded.
Current state
| Component | Status |
|---|---|
| RFC 6962 Merkle tree | ✅ verified against the spec, incl. a path-length guard most implementations omit |
| Ed25519 (RFC 8032) | ✅ pure-Python, all official vectors pass; uses cryptography when present |
| Hash-chained ledger | ✅ 0.47 ms/append, names the exact sequence number where a chain breaks |
| Capture hook | ✅ fail-open, exits 0 on every hostile input tested; 44 ms/event, 99% of it interpreter startup |
| Adapter contract | ✅ capability manifest drives grade ceilings mechanically |
| Claude Code adapter | ✅ validated against a real 442-tool-call session |
| Codex adapter | ✅ hooks + rollout-file ground truth; D4-capable, like Claude Code |
| Cursor adapter | ✅ session, prompt, compaction and subagent events; D1 — Cursor exposes no per-tool hook |
| Numbat import | ✅ seals Perplexity Numbat's record streams; splits multi-session files; D1 |
| Gemini CLI adapter | ✅ telemetry import, validated against real exporter output; records human accept/reject; D1 — no hooks, no independent record |
| MCP stdio proxy | ✅ byte-transparent, <1 ms/frame, fail-open; declares its D1 ceiling |
| MCP over HTTP/SSE | ✅ streams without buffering; headers never recorded |
| OTel GenAI import | ✅ detects sampling, dropped spans and truncation as gaps |
| Config rewrite helper | ✅ dry run by default, backed up, reversible |
| Seal / bundle / verify | ✅ end-to-end, 12/12 tamper attacks detected |
| Conformance suite | ✅ 23 cases across 8 adapters, all passing |
| Grader (D0–D4) | ✅ deterministic, fixture bundles match the hand-computed rubric |
| Standalone verifier | ✅ one file, no dependencies, output identical to the CLI |
| Rekor anchoring | ✅ weekly Cloudflare cron; heads only, never bundles |
| Anchor verifier | ✅ both dialects (Ed25519ph, ECDSA P-256), stdlib-only, checked against NIST CAVP |
| Countersignature client | ✅ D2→D3/D4 end to end |
| Countersignature service | ✅ live at api.attestroot.com; two implementations, one wire contract |
| Transparency log | ✅ RFC 6962 with signed heads; rewrite and split-view detection verified against a lying service |
| Independent review | ⬜ not yet done — see docs/REVIEW_PACK.md |
364 tests on Python 3.9–3.13. scripts/ci_local.sh runs every CI job
locally, including the version matrix and both service implementations.
Exit test
The M0 gate was "seal a real 50-turn session; verify in a clean container."
session real Claude Code transcript — 1661 lines, 442 tool calls, 1 compaction
captured 442 events @ 0.25 ms of work each (44 ms wall, see EXPLAINER)
bundle 2103 leaves, 447 members, 7.7 MB
gaps 0 (every ground-truth tool_use id matched a captured one)
clean room 13/13 checks pass — no dependencies, no signing key, no local state
tampering 12/12 attacks detected
scripts/clean_room_verify.sh runs it. Where Docker is unavailable it builds a
hermetic room with python3 -I -S, which is stricter on the dependency question
than a container: site-packages is unreachable, so cryptography cannot be
imported even when installed, forcing the pure-Python signature path.
Try it
attestroot init # or PYTHONPATH=src python3 -m attestroot.cli init
attestroot status
attestroot seal <session-id>
attestroot verify <bundle> --offline-bundle
attestroot grade <bundle>
attestroot countersign <bundle> --show-request # see exactly what would be sent
attestroot report <bundle> -o report.html # a document for a human
attestroot conformance
Zero runtime dependencies. cryptography is an optional extra that only makes
signing faster — verification never needs it.
Handing evidence to a person
attestroot report session.arbundle -o report.html
A self-contained document: the grade and what it means, every check with its reason, the gaps, the adapter's declared blind spots, and whether anyone other than you signed it. No external stylesheet, script or font, so opening it tells nobody you opened it.
It derives nothing — verify and grade already computed all of it. Three
things it does carry deliberately: the SHA-256 of the exact bundle it
describes, so a clean report cannot be paired with a different file; the command
to re-derive the same result independently; and the list of things the
evidence does not establish, which is not a disclaimer footer but the same
weight as the findings.
Verifying without installing anything
dist/attestroot_verify.py is a single file with no dependencies. Email it
alongside the bundle:
python3 attestroot_verify.py evidence.arbundle
It produces byte-identical output to attestroot verify, and prints its own
SHA-256 at build time so a recipient can check the copy they were sent against
the one published here.
This exists because "clone the repo and set PYTHONPATH" is already too much friction for someone who was emailed an evidence file and has thirty minutes. Evidence nobody checks is decoration.
The one network call
attestroot countersign is the only thing in the product that talks to a
network, and it sends the Merkle root, a manifest hash, and counts. Nothing
else. Roughly 425 bytes.
attestroot countersign <bundle> --show-request
That prints the complete payload and sends nothing, so you can check the claim
rather than take it. There is also --dry-run, which builds and validates the
request without transmitting.
The request is assembled from an allowlist, never by filtering a copy of the manifest — a denylist forgets the field somebody adds next year. A second check re-scans the serialised bytes before they leave and refuses to send if anything resembling a path, identifier or content appears.
A consequence worth stating: because the service is only ever shown a digest, it cannot sign the manifest — it signs the digest it was handed. Its countersignature means "I was shown this value at this time", which is exactly the claim being sold, and it is the strongest claim a party who never saw your data is able to make.
Two implementations exist and are held to one wire contract
(tests/test_service_contract.py, run against both in CI): the Cloudflare
Worker in service/ and the stdlib reference in reference_service/.
The Worker is live at https://api.attestroot.com, which is the client's
default endpoint, so attestroot countersign works out of the box and D3 is
reachable. Nothing obliges you to use it: point --endpoint at your own copy of
either implementation if you would rather not have a vendor in the loop. The
signature is only as independent as the party holding the key, and for some
buyers their own security team is a better answer than us.
Every receipt is published in an RFC 6962 transparency log with a signed head:
attestroot log --bundle session-countersigned.arbundle
This checks the head's signature, recomputes its root from the published entries, proves your receipt is in the tree, and — the part that matters — re-derives every head this machine has seen before from the log as it stands today. A service that rewrote its history would still serve a self-consistent head; only a remembered one catches it. Heads are journalled locally and never pruned, because an old head is the only thing that can catch a late rewrite.
Heads are also published to LOG_HEADS.jsonl outside the service's own API, and
the audit cross-checks against them by default. That is the first defence
against a split view — a service showing one client a private log and
everyone else a different one, which a locally remembered head cannot catch
because the private log is self-consistent from the client's first audit.
It raises the cost rather than eliminating the attack, since we publish the
witness file too, and it is inert until the repository is public. Both are said
plainly in docs/REVIEW_PACK.md §6.1, along with what a
receipt still does not prove: we run both the sealing tool and the service, so
"independent of the sealer" is true and "independent of the vendor" is not.
Recording any MCP client
attestroot mcp install
Finds the config files for Claude Desktop, Claude Code, Cursor and Windsurf, and
shows what it would change. It writes nothing without --apply — a tool that
edits your configuration as a side effect of being run is one you stop trusting
with your configuration. When you do apply it, the original is backed up first,
--uninstall puts everything back, and running it twice is a no-op rather than a
wrapper around a wrapper. HTTP and SSE servers are reported as skipped, because a
stdio middleman cannot sit in front of one.
Or do a single server by hand:
attestroot mcp wrap -- npx -y @modelcontextprotocol/server-filesystem /tmp
A stdio middleman between an MCP client and server. One integration covers every MCP-speaking client — Claude Desktop, Cursor, Windsurf, agent SDKs — because they all speak the same newline-delimited JSON-RPC.
It forwards bytes before recording them, so recording cannot add latency to the session, and frames are relayed byte-identical rather than reserialised. Measured overhead is well under a millisecond per frame. If the ledger is unwritable the traffic still flows and a gap record is written — evidence collection must never take down the thing it observes.
What it honestly cannot claim. The MCP proxy declares
has_ground_truth: false, which caps it at D1. It sees everything routed
through it, but nothing lets it prove that nothing bypassed it — a second
server configured without the wrapper leaves no trace. It also declares
view: partial, because native tool calls never touch MCP.
The Claude Code adapter can claim ground truth because the transcript is written independently of our hooks. A proxy has no second source, and saying otherwise would be the overclaim this product exists to detect.
Remote MCP servers
Servers reached over HTTP are called, not launched, so there is no command to wrap. Run a local recorder instead and point the client at it:
attestroot mcp serve --upstream https://mcp.example.com/mcp --port 9900
Frames are the same JSON-RPC, so classification is shared with the stdio proxy rather than reimplemented. Streaming responses are relayed chunk by chunk and flushed immediately — a proxy that buffered an event stream would turn a live response into one long pause, with the client blocked on a token that had already arrived.
Headers are never recorded. They carry bearer tokens, and an evidence bundle is built to be handed to auditors and opposing counsel. The ledger is hash-chained precisely so its contents cannot be quietly altered later, which means a credential written into it could not be removed without destroying the evidence around it. Headers are forwarded upstream and dropped on the recording path, by allowlist rather than denylist — a denylist eventually misses whatever header a vendor invents next, and the cost of missing one is a live token inside a document designed to be shared.
Two further limits are in the capability manifest: TLS to the upstream is terminated and re-originated here, so run it on the same host as the client; and a resumed SSE stream may replay events, which are recorded as they arrive rather than deduplicated.
Importing existing OpenTelemetry traces
attestroot import otel trace.json
Covers the frameworks that never touch MCP — LangChain, LlamaIndex, Semantic Kernel, Bedrock, the vendor SDK instrumentations — because all of them emit spans under the GenAI semantic conventions. If you already run OpenTelemetry you already have the record; what you lack is a reason for anyone else to believe it was not edited afterwards.
The claim is deliberately narrow. Both live_capture and has_ground_truth are
false, so an imported session cannot exceed D1. This reads a file exported
by a pipeline nobody here observed: sealing proves what the file said and that it
has not changed since, and nothing whatsoever about the period before the import.
It cannot prove a trace is complete — so it proves what it can. Three defects are detected mechanically and recorded as gaps, not buried in a log:
| Detected | Why it matters |
|---|---|
| Dangling parents | A span names a parent that is not in the file, so spans that existed are absent — sampled, dropped by an exporter, or removed. |
| Sampling | A tracer at 10% produces a record that is 10% of reality and looks complete. Read from the W3C tracestate threshold where present. |
| Span-limit truncation | Attributes and events discarded before export. The spans are here; their contents are not. |
Detecting incompleteness while being unable to demonstrate completeness is the correct shape for this problem. Reporting only the half that can be proven would make the record look better than it is.
One consequence worth knowing: a single span's bytes never existed
independently, so per-span payloads are re-serialised and marked
payload_derivation: "reserialized". The whole source file is committed to
first, by SHA-256, in the first entry of the chain — so anyone holding the
original can prove it is the one that was imported.
Adapters
An adapter is a translator from a source's native events to canonical W Events.
It declares what it can see in a capability manifest, and the grader acts on
that declaration: an adapter reporting has_ground_truth: false caps the grade
at D1 with the reason recorded, because completeness cannot be evaluated without
an independent record to diff against.
Adding one means implementing the contract in adapters/base.py and submitting
conformance fixtures with the change. An adapter is certified only when its
suite passes — including a check that its declared capabilities match what its
output can actually support. Claiming ground truth while producing none fails.
Design constraints
These are load-bearing, not preferences:
- Fail-open capture. The hook never blocks or slows the agent. Every failure path writes a gap record and exits 0. A bug in evidence collection must not stop someone's work.
- No network in the free path. Ever. Not for telemetry, not for time.
- Store raw. Payloads are persisted verbatim and interpreted at read time. Tool schemas change; today's normalisation is tomorrow's lost evidence.
- Zero dependencies for verification. An auditor must be able to verify on a machine where they cannot install anything.
- Gaps are a feature. Missing captures are detected by diffing against the source's own record, never from self-reported gap records — otherwise hiding a gap is as easy as not writing one.
Built on
incident-ledger — the append-only
hash-chained evidence log this extends to agent activity.
pip install incident-ledger
Published standard
- Implementing Agentic-AI Accountability — five evidence principles crosswalked to the IMDA MGF, EU AI Act, NIST AI RMF and ISO/IEC 42001, with the D0–D4 evidentiary maturity lens · CC BY 4.0
- incident-ledger · MIT
Tamper-evident logging, deterministic replay and evidence-maturity grading are cited prior art, not claims of this work. Hash chains and Merkle trees are 1979 and 1991 public cryptography.
Not supported
Windows. Paths, file locking and fsync semantics differ enough that half-support would be worse than none. WSL works.
MIT · attestroot.com · AVATROX TECHNOLOGY PRIVATE LIMITED
Metadata
Release files for attestroot 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| attestroot-0.2.0.tar.gz | 214.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| attestroot-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 402.3 kB
Release files / attestroot-0.2.0.tar.gz
| Download URL | attestroot-0.2.0.tar.gz |
|---|---|
| Size | 214.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
665ba4c2d3b5eb505b0f22feb1a71c26b5113b31c40701f0e3acef988824e5f5
|
|
BLAKE2b-256 checksum How to use checksums |
2dc1b5c19f29ef949dff47a76683c7fbf70533b2c308a842bd5961cd8c2b3418
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / attestroot-0.2.0-py3-none-any.whl
| Download URL | attestroot-0.2.0-py3-none-any.whl |
|---|---|
| Size | 187.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4c2c47ed3ca1a46eb9791b61c7733714bf0848708c2eba31a05314905e4aca17
|
|
BLAKE2b-256 checksum How to use checksums |
7eaabd9fb1932d830ed794cbfe79b9ce1638dc8248a1602004ec9efb46478e1c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|