Metadata-Version: 2.4
Name: attestroot
Version: 0.2.0
Summary: Local-first, tamper-evident evidence layer for AI agent activity — hash-chained capture sealed into an RFC 6962 Merkle commitment.
Author-email: Abhijeet Verma <hello@attestroot.com>
License-Expression: MIT
Project-URL: Homepage, https://attestroot.com
Project-URL: Published standard, https://doi.org/10.5281/zenodo.21387486
Project-URL: Reference implementation, https://github.com/MaqAnquor/incident-ledger
Project-URL: Live demo, https://huggingface.co/spaces/docwagon/prove-your-agent-log
Keywords: ai-audit,audit-trail,tamper-evident,hash-chain,merkle-tree,rfc6962,ed25519,ai-governance,ai-agents,compliance,evidence,claude-code,observability
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Legal Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: System :: Logging
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: fast
Requires-Dist: cryptography>=42; extra == "fast"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5; extra == "dev"
Dynamic: license-file

# 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.

```bash
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`](docs/REVIEW_PACK.md) is written to make breaking it as
cheap as possible.

---

## How it works

![attestroot evidence pipeline](docs/pipeline.svg)

New to this? [`docs/EXPLAINER.md`](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`](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

```bash
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

```bash
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:

```bash
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.

```bash
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:

```bash
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`](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

```bash
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:

```bash
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:

```bash
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

```bash
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](https://github.com/MaqAnquor/incident-ledger) — the append-only
hash-chained evidence log this extends to agent activity.
`pip install incident-ledger`

## Published standard

- [Implementing Agentic-AI Accountability](https://doi.org/10.5281/zenodo.21387486)
  — 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](https://doi.org/10.5281/zenodo.21387488) · 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](https://attestroot.com) · AVATROX TECHNOLOGY PRIVATE LIMITED
