Skip to main content

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

attestroot evidence pipeline

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

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)

Source distribution for attestroot 0.2.0
File Size Uploaded
attestroot-0.2.0.tar.gz 214.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for attestroot 0.2.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page