
Tamper-evident audit trails for AI agents: hash-chained Runtime Records, dependency-free, verifiable by anyone.
Tamper-evident audit trails for AI agents — hash-chained Runtime Records, rendered as a Runtime Report your customers can check themselves.
Every action your instrumentation captures (tool calls, model calls, data access, approvals) becomes one Runtime Record in an append-only, hash-chained log; the Runtime Report is that chain rendered as an HTML page that re-computes the hash chain in the reader's browser and renders what it displays from the records it verified (for a check that does not rely on the page's own code, run halo verify on the JSONL). Any party holding a checkpoint of the chain can verify that the records up to that checkpoint have not been altered since it was taken, without trusting whoever produced them — that checkpoint is the load-bearing piece: the chain alone is tamper-evident against everyone except the party operating the recorder (LIMITS.md §1). When a customer's security team asks "what did your agent do with our data?", you hand them a link instead of a paragraph. Security reviews already ask AI questions next to the SOC 2 checklist — and increasingly those questions come from ISO 42001, the EU AI Act's record-keeping articles, and customers' own questionnaires. Today a written assurance still passes. The bet behind this project is that it won't for long.
Featured in Help Net Security (August 2026).
The record format is open and free to implement. This package is the reference implementation: recorder, verifier, witness client, and report server.
Using halo-record, or thinking about it? Tell me who you are and what for → Who's using halo-record?
You are being asked to put a recorder inside your agent. You should not take that on faith:
pip install halo-record installs exactly one package.halo serve --verify-email (sends a one-time code and the report's subject name, by SMTP through the server you configure in $HALO_SMTP_HOST, to the address a report viewer enters). All are off unless you invoke them; no other record contents leave your infrastructure.summaries=False) keeps no summary at all. Redaction is best-effort (regex over common secret and PII formats plus an entropy catch-all): treat it as defense-in-depth, not a guarantee. Outcome fields you supply beyond summary are scanned and masked with the same patterns, but not cut to 200 characters (LIMITS §13).What each layer proves — the load-bearing distinction in this project (LIMITS.md §1): a chain you hold yourself proves records were not edited, relative to a head someone already holds; only checkpoints held outside the operator prove none were removed; and no hash proves every action was captured.
| Claim | Self-held chain | + External checkpoints | + Trusted capture |
|---|---|---|---|
| Detect edits to an established artifact | ✔ | ✔ | ✔ |
| Detect rewriting of committed history | — | ✔ | ✔ |
| Detect missing/late checkpoints | — | ✔ (agreed cadence) | ✔ |
| Prove every action was recorded | — | — | depends on capture boundary |
See one before you install: a sample Runtime Report — fictional data, real chain, and its script re-computes the embedded records' hash chain in your browser when you open it.
No agent required. With uv, nothing to install:
uvx --from halo-record halo demo --serve
or the classic way:
pip install halo-record
halo demo --serve
Either one scaffolds a fictional support-agent vendor with two customers, witnesses the chains (with a local witness file standing in for one outside the operator — see LIMITS.md §1), serves their gated Runtime Reports, and opens the operator console in your browser. Then try the tamper test: delete a line from one of the .jsonl files and reload. The report catches it.
One line at the boundary:
from halo_record import trace
agent = trace(run_my_agent, profile="my-agent", log="audit.jsonl") # wraps your entrypoint; records the run boundary to ./audit.jsonl — add record_call() or a framework adapter at each tool boundary to capture individual calls
A from halo import ... convenience shim also ships — but the halo name on PyPI belongs to an unrelated terminal-spinner package, and if that package is installed it wins the import. halo_record is unambiguous, so the examples use it.
Without log=, records go to ~/.halo/my-agent.jsonl (one chain per agent). The wrapper seals the run boundary; the evidence lives in the per-call records. Capture those with a framework adapter (matrix below) — or explicitly, which also shows how delegation links:
from halo_record import Recorder, record_call
rec = Recorder("audit.jsonl")
with record_call(rec, "crm.lookup", {"account": "acct-9"}) as call: # one sealed record per tool call
call.result = crm.lookup("acct-9")
with record_call(rec, "payments.refund", {"amount": 120},
parent_id=rec.last_record_id()) as call: # child links to the action that spawned it
call.result = payments.refund(120)
Then render the report:
halo report audit.jsonl -o report.html # one chain -> HTML that re-verifies in the browser
halo serve ./records --port 8721 # all tenants, gated per customer
Upgrading a directory first served by 0.2.45? halo serve re-reads each earlier grant at startup. A grant on a file whose records carry no subject id (a plain Recorder file such as agent.jsonl) is quarantined and printed at every startup with the halo grant command that re-issues it — see LIMITS.md §17, "Serving a directory first served by 0.2.45".
The quickstart ends when you are looking at your own agent's Runtime Report in a browser. If you got a JSONL file and no report, something is wrong: open an issue.