# halo-record 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](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md)). 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](https://www.helpnetsecurity.com/2026/08/31/halo-record-open-source-ai-agent-audit-trail/)** (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?](https://github.com/bkuan001/halo-record/discussions/7) ## Check it yourself You are being asked to put a recorder inside your agent. You should not take that on faith: - **Zero runtime dependencies.** Standard library only. `pip install halo-record` installs exactly one package. - **No network calls**, except four opt-in ones — anchoring to a witness (sends the subject id, a record count, and two chain fingerprints — the head and the chain root), reading a witness's checkpoints back (sends the subject id), the RFC 3161 timestamp (sends only a checkpoint's state hash to a Timestamp Authority), and `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. - **Raw tool arguments are hashed, with a redacted summary alongside.** Arguments are stored as a canonical hash plus a summary: the argument text with known secret and PII patterns masked, capped at 200 characters. A short input that matches no pattern appears in the summary in full; hash-only mode (`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). - **Small enough to audit.** ~8,700 lines of Python, including the report page's embedded JavaScript and CSS (code lines, not counting blanks, comments, and docstrings). Read all of it in a day. - **Apache-2.0.** - **The paperwork is first-class.** [LIMITS.md](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md) (what the chain can't prove), [PRIVACY.md](https://github.com/bkuan001/halo-record/blob/main/PRIVACY.md) (what records contain and what leaves your machine), [RETENTION.md](https://github.com/bkuan001/halo-record/blob/main/RETENTION.md) (operating under a retention policy), and [REVIEWERS.md](https://github.com/bkuan001/halo-record/blob/main/REVIEWERS.md) — the four-command independent check plus a citation format for review findings. What each layer proves — the load-bearing distinction in this project ([LIMITS.md §1](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md)): 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](https://bkuan001.github.io/halo-record/sample-report.html) — fictional data, real chain, and its script re-computes the embedded records' hash chain in your browser when you open it. ## 60-second demo No agent required. With [uv](https://docs.astral.sh/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](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md)), 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. ## Record your own agent One line at the boundary: ```python 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: ```python 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"](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md#17-one-chain-file-per-subject). 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. ### The verification block If a guardrail or policy layer checked the action, its verdict can ride on the record — an optional block recording what the gate decided, sealed into the hash chain like every other field: ```python from halo_record import build build("tool_call", "security", tool="payments.refund", verification={"status": "allowed", "verifier": "gate/1.2", "policy_ref": "sha256:1f3a...", "checked_at": "2026-08-01T12:00:00Z"}) ``` One naming note: the package exports a `record` *function* (the decorator), which shadows the `halo_record.record` *module* on the package object. Import from the module path directly when you want its internals — `from halo_record.record import build` — rather than `import halo_record.record as record`. which seals into the record as: ```json "verification": {"status": "allowed", "verifier": "gate/1.2", "policy_ref": "sha256:1f3a...", "checked_at": "2026-08-01T12:00:00Z"} ``` `record_call(...)` accepts the same `verification=` keyword. `status` is required within the block; `verifier`, `policy_ref`, and `checked_at` are optional. What each status means: | Status | What the gate reports | Did the action execute? | |---|---|---| | `allowed` | it permitted the action | yes — the action proceeded | | `blocked` | it denied the action | determined by the integration, not by this field — a record may still carry an outcome, and a block does not by itself prove non-execution | | `modified` | it altered the action before execution — `action.input` describes the action **as executed**, post-modification | yes, in the altered form | | `unverified` | it ran (or was consulted) but made no determination — distinct from an absent block, which means no verification claim was made at all | yes — the action proceeded without a verdict | The block is supplied by the operator's integration code and records what it reports the gate said — the same trust posture as `principal` (see [LIMITS](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md#11-verification-status-is-the-gates-report-not-halos-finding)). Sealing proves the status was not edited after the fact; it does not prove the check occurred, that the verdict was correct, or that a blocked action did not execute. This is not independent verification. For `policy_ref` to be usable as evidence, use a content hash of the ruleset and retain the ruleset artifact — an unresolvable label makes the field decorative. ## Connect to what you already run | Captured at the boundary | Ingested from existing telemetry | |---|---| | Native recorder (`from halo_record import trace`) | OpenTelemetry GenAI spans | | MCP interceptor | LiteLLM callbacks | | LangChain / LangGraph callback | Langfuse export | | OpenAI Agents SDK hooks | Any gateway / reverse-proxy log | | Claude Agent SDK hook | Claude Code and Codex CLI `PostToolUse` hooks (fire after the tool ran) | Framework adapters and ingestion paths stamp each record with a `source` tag, so the report discloses how each piece of evidence was collected. Captured and ingested records live in the same chain. For LangChain / LangGraph, it is a callback handler: ```python from halo_record import Recorder from halo_record.integrations.langchain import HaloCallbackHandler recorder = Recorder("audit.jsonl") result = my_chain.invoke(inputs, config={"callbacks": [HaloCallbackHandler(recorder)]}) # every tool call becomes a record ``` For MCP, one call wraps the client session — and then *any* MCP-using agent emits records for every tool call, regardless of which framework drives it: ```python from mcp import Client # mcp 2.x from halo_record import Recorder from halo_record.integrations.mcp import instrument_client_session async def run(server): # an MCPServer, a server URL, or stdio parameters async with Client(server) as client: instrument_client_session(client, Recorder("audit.jsonl"), server="stripe") await client.call_tool("charge", {"customer": "cus_123", "amount": 500}) # recorded ``` Every `call_tool()` on the wrapped client is recorded, and a tool that returns an error result is sealed as `error` (the adapter reads both `isError` and the mcp 2.x `is_error`). On mcp 1.x, pass the initialized `ClientSession` instead of `Client`; the wrapper is the same. For gateway or proxy logs (Cloudflare AI Gateway, Portkey, nginx in front of the model), map a log row into the chain — explicitly tagged as ingested, not boundary-captured: ```python from halo_record.integrations.gateway import record_log record_log(Recorder("audit.jsonl"), {"tool": "gen_ai:gpt-4o", "model": "gpt-4o", "status": 200, "subject": "acme-corp"}) ``` Anything that emits OpenTelemetry GenAI spans (CrewAI, LlamaIndex, and most agent frameworks with OTel instrumentation) lands in the chain through the OTel adapter, and the [TypeScript package](https://github.com/bkuan001/halo-record-ts) ships native adapters for the Vercel AI SDK and the JS agent ecosystem. Missing an adapter for your stack? Open an issue. Most adapters are about a hundred lines. ## Record your coding agent (Claude Code or Codex) Claude Code fires a `PostToolUse` hook after every tool call. Point it at `halo hook` and each action — file writes, shell commands, MCP connector calls — becomes a record in a local chain. No code changes; one settings entry: ```json { "hooks": { "PostToolUse": [ {"matcher": "*", "hooks": [{"type": "command", "command": "halo hook"}]} ] } } ``` Add that to `~/.claude/settings.json` and records land in `~/.halo/audit.jsonl` (override with `$HALO_LOG`). Pure-orchestration tools that touch no data, network, or external state are skipped — the chain records trust-boundary actions, not thinking. Set `HALO_HASH_ONLY=1` to record content hashes without summaries. Set `HALO_AGENT_VERSION` (and optionally `HALO_AGENT_MODEL`) to bind every record to the agent build that produced it — when an auditor asks about the version that was running in a given window, the export answers by column instead of by recollection. Codex CLI ships the same lifecycle hooks with the same event shape (hooks are on by default). Add this to `~/.codex/hooks.json` and Codex's shell commands, `apply_patch` edits, and MCP calls land in the same chain: ```json { "hooks": { "PostToolUse": [ {"matcher": ".*", "hooks": [{"type": "command", "command": "halo hook"}]} ] } } ``` The hook tells the two apart from the event itself (Codex adds `turn_id` and `model`) and labels each record `claude-code` or `codex`; set `HALO_HOOK_AGENT` to force one. Both are the ingested tier: a `PostToolUse` hook fires after the tool ran, so the record is built from what the harness reports. If you need the report to answer "under what rules did this run happen?", set `HALO_AUTHORITY_FILE` to a JSON snapshot of the effective authority for the session. Keep it privacy-safe: hashes and refs, not raw prompts, private policy text, secrets, or full tool schemas — known secret and personal-data formats are masked at seal time, but hashes and refs pass through untouched and free-form text is not detected (see LIMITS §6). Reuse a `snapshot_id` only while the underlying authority is unchanged; consecutive records with the same id and unchanged content are compacted — a reused id over changed content is stored in full, with a notice. ```json { "snapshot_id": "auth_2026_07_08T1100Z", "captured_at": "2026-07-08T11:00:00Z", "scope": "session", "workspace": {"path_hash": "sha256:...", "git_commit": "abc1234"}, "refs": [ {"kind": "project_rules", "id": "CLAUDE.md", "hash": "sha256:...", "loaded": true, "truncated": false}, {"kind": "mcp_tool_registry", "id": "filesystem", "hash": "sha256:..."} ], "omissions": [{"kind": "private_policy", "reason": "customer_secret", "hash": "sha256:..."}], "stale_if": ["project_rules_hash_changed", "mcp_tool_registry_hash_changed"] } ``` ```sh HALO_AUTHORITY_FILE=./authority.json halo hook ``` The snapshot is sealed into the same hash chain as the action records. A good default is one session-level snapshot at start, plus a new snapshot when rules, Skills, hooks, MCP tool registries, or compaction policy change. To keep long sessions lean, consecutive records with the same `authority.snapshot_id` are compacted after the first full snapshot: later records keep only `{"snapshot_id": "...", "same_as_previous": true}`. The pointer stays hash-chained, but the bulky refs/omissions/stale-if block is not repeated on every action. (Compaction is per recorder process: hook-style capture spawning one process per tool call re-stores the full body whenever the previous full snapshot isn't the tail record, so short-lived processes trade chain size for the reuse guard.) SDK users attach the same block directly — `build(..., authority={...})` or `record_call(..., authority={...})`; hash-only capture is the same surface (`summaries=False` on any of them): ```python from halo_record import Recorder, record_call rec = Recorder("audit.jsonl") with record_call(rec, "crm.lookup", {"account": "acct-9"}, authority={"snapshot_id": "auth_1", "rules_hash": "sha256:..."}, summaries=False) as call: # hash-only: no summaries, no excerpts call.result = crm.lookup("acct-9") ``` Then, the usual: ``` halo verify ~/.halo/audit.jsonl halo report ~/.halo/audit.jsonl -o report.html ``` Any agent runtime that exposes a post-action hook can feed the same command — the hook reads one event as JSON on stdin and appends one record. One chain, one writer at a time. A chain is a linked list: two writers that read the same head and both append will fork it (two records claiming the same predecessor), and verification will name the affected records. `Recorder` serializes its own appends with a sidecar lock (POSIX `flock` here; a lock directory in the TypeScript package), and `halo hook` appends through `Recorder`, so the hook setup above is covered. Anything that writes the chain file directly — a hand-rolled hook, parallel workers, a log shipper — must hold an equivalent exclusive lock across the read-head-then-append sequence, or write to per-process chains. [LIMITS.md section 9](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md#9-single-writer-chains) covers this in full, including the cross-language boundary. ## When recording fails Records are written after the action runs. Halo does not block an action when its record cannot be written; writing the record before the action is not offered. - **Default: the action's result passes through.** If a record cannot be written (disk full, permissions, a lock timeout), `trace()`, `record_call`, `@record`, the framework adapters (OpenAI Agents, LangChain, LiteLLM, OpenTelemetry), the MCP wrapper and `halo hook` return the action's own result unchanged, or re-raise the action's own exception if it failed. A warning on stderr names the missing record: `halo-record: FAILED to record '' (); the action ran, but its record is NOT in the evidence log. ...` (with no usable stderr, it goes to the `halo_record` logger). The LangChain handler and the OTel exporter also count losses (`lost_records`). Nothing in the chain can show a record that was never written, so the remaining records still verify. A reviewer watching the witness checkpoint cadence sees a chain that has stalled; a single missing record between later ones shows only in the stderr line. - **Strict mode raises as well.** Pass `strict_recording=True` (to `trace`, `record_call`, `@record`, `instrument_client_session`, or an adapter's constructor), or set `HALO_STRICT_RECORDING=1`, and the same stderr line is written, then the recording error is raised. It reaches the caller from `trace`, `record_call`, `@record`, the MCP wrapper, the OpenAI Agents hooks and the LangChain handler (which sets LangChain's `raise_error`). It cannot reach the caller from the OTel exporter or the LiteLLM logger: those frameworks catch and log what an exporter or callback raises, so there the stderr line is the signal in both modes (LIMITS.md section 18). The action has already run when the error is raised, so a caller that treats it as "the action failed" and retries will repeat the action's side effects (a refund issued twice). Use strict mode only where a missing record is worse than that risk. - `record_call` and `@record` with no recorder (none passed, none bound by `trace`) raise `RuntimeError` before the wrapped code runs. - `@record` and `trace()` accept `async def` functions. The wrapper is itself `async def`: it awaits the function, then records what it returned or raised (a cancellation is recorded as an error). `trace()` keeps its recorder bound while the body is awaited, so a `record_call` or `@record` tool inside an async agent lands on the traced chain, and concurrent agents each keep their own. A plain function that returns an awaitable is recorded when it returns. An async generator function is recorded when it is called, with the generator object as its result; what it yields is not recorded. ## Integrity vs. completeness (read this part) Be precise about what each layer proves — because they are different claims, and the differences are the point: A self-held chain proves **integrity relative to an established head**: given a chain head someone already holds, any edit, reordering, or deletion in the records behind it becomes detectable. By itself — before anyone outside the operator has seen a head — a chain proves internal consistency, not history: an operator could drop a record and re-seal, and the new file would verify. The chain becomes **historically committed** the moment its head leaves the operator's control. That is the witness: a party outside the operator holding periodic checkpoints of the chain — the subject id, a record count, and two chain fingerprints (the head and the chain root); the exact payload, and nothing else. Checkpoints make rewriting committed history detectable, and a missed checkpoint is itself a visible event: ``` halo anchor audit.jsonl witness.jsonl # anchor a checkpoint to a local witness halo anchor audit.jsonl witness.jsonl --check # completeness verdict against it ``` For *time* specifically, an external RFC 3161 timestamp replaces the checkpoint's self-asserted clock with a proof from a Timestamp Authority outside the operator's control — "this chain reached this head no later than T", verifiable by a third party with no hosted infrastructure. The default TSA is the free freetsa.org (fine for evaluation); for production, point `--tsa` at a TSA outside the operator's control (e.g. DigiCert / Sectigo, or one a relying party designates): ``` halo anchor audit.jsonl witness.jsonl --timestamp # attach a TSA time proof to the checkpoint halo anchor audit.jsonl witness.jsonl --check # reads the token's claimed time ``` `--check` confirms the token binds this chain state and reads its attested time, but it does **not** validate the TSA's signature — that is deliberately left to a standard tool so a reviewer trusts no code of ours. To verify the time independently (this is what you hand a security reviewer): ``` # tsa.token_b64 lives in the witness log; decode the latest one to a standard .tsr file python3 -c 'import json,base64; cps=[json.loads(l) for l in open("witness.jsonl") if l.strip()]; t=[c["tsa"] for c in cps if c.get("tsa")][-1]; open("token.tsr","wb").write(base64.b64decode(t["token_b64"])); print(t["digest"])' curl -s -o tsa-ca.pem https://freetsa.org/files/cacert.pem # CA for the default TSA (a commercial TSA publishes its own) openssl ts -verify -digest -in token.tsr -CAfile tsa-ca.pem # → "Verification: OK" ``` One more boundary, stated plainly: neither the chain nor the witness proves that every real-world action passed through the recorder. That is **capture completeness** — a property of where the recorder sits in the stack (native instrumentation, hooks, gateway ingestion), not of any hash. Records carry a `source` tag for exactly this reason. The claims table under “Check it yourself” at the top of this page is the summary of these three layers. Anyone can run a witness. A witness you run yourself commits history to *you*; committing it to *your customer* requires a witness they have reason to trust. The protocol is open either way. A hosted, recognized witness is how this project will sustain itself. Early access: brian@briankuan.com. ## Personal data in the chain The chain is append-only: anything sealed into a record stays there, because removing it would break verification for everything after it. Tool arguments are already handled — stored as a hash plus a redacted summary capped at 200 characters (hash-only mode keeps no summary; in id fields it masks a token in a known secret format as in summaries and replaces any other secret-shaped token with `sha256:` plus 16 hex digits — all but `subject.id`, which routes the record to its tenant's chain). Note the limit in that sentence: *redacted*, not removed. [LIMITS.md](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md) section 6 is explicit that a name or a postal address has no reliable pattern, so neither is detected and neither is masked. And `subject` is not the only field that carries text you supply — `principal`, `approver`, `session_id`, `agent`, `authority`, `data` and the summaries all do. The pattern that works: put a stable pseudonymous id in the chain and hold the mapping to any individual in a system you can delete from. An erasure request is then satisfied by deleting the mapping. Keep `subject` pointing at the tenant organization, not a person: ```python from halo_record import build build("tool_call", "privacy", subject={"id": "acme", "name": "Acme Corp"}) ``` No setting enforces this — it is a discipline in how you call the recorder. It makes erasure tractable; it is not anonymization, and there is no built-in retention or pruning yet. [LIMITS.md section 13](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md#13-personal-data-and-erasure) has the full field list, explains why the stored input fingerprint can confirm a guessable value even after the mapping is gone, and ends with questions a reviewer should ask. Record a model call (the buyer's first question: "which model saw my data?"): ```python from halo_record import Recorder, record_model_call rec = Recorder("audit.jsonl") record_model_call(rec, provider="anthropic", model="claude-sonnet-4-6", zdr=True, purpose="draft support reply", subject="acme") # tool=model.generate, scope=model:anthropic ``` The model is also sealed as `agent.model` and the retention terms as `data.zdr`, so the export's model column and policy rules can read them, and both survive `summaries=False`. Pass `messages=` and the record's input carries how many there were — a count, never their content. ## Where this sits in a compliance stack halo-record is an evidence layer, not a certification. It produces the artifact that assessment frameworks keep asking for in different words. One scope note that governs every bullet below: these are integrity claims about the record; completeness against the operator requires an external witness holding checkpoints ([LIMITS.md §1](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md)). - **Security questionnaires and SOC 2 reviews:** answer the AI sections with a verifiable Runtime Report instead of screenshots and prose. - **AIUC-1:** produces the tamper-evident logging evidence (E015.4) and execution-chain records with authorization events (E015.2 — with a stated gap: reasoning traces are not captured) that the standard's Accountability control E015 names. E015 itself is mandatory; E015.2 and E015.4 are its supplemental tier: not required to pass, the one a vendor adopts when a customer or regulator asks for it. Once the chain is anchored to a witness the relying party has reason to trust — a witness the operator runs itself does not provide this — that's a continuously witnessed chain rather than one reconstructed at audit time (what entered the chain is still bounded by the capture surface). A control-by-control evidence mapping, including what's deliberately out of scope, is in [`AIUC.md`](https://github.com/bkuan001/halo-record/blob/main/AIUC.md). - **OWASP Top 10 for Agentic Applications 2026:** eight of the ten threats map to deterministic policy rules over the record, two are marked out of scope with reasons, and the pack ships in the package, runnable by name (`halo policy chain.jsonl owasp-starter`). An approximate community mapping, not an official OWASP artifact. See [`OWASP.md`](https://github.com/bkuan001/halo-record/blob/main/OWASP.md). - **AARM (CSA):** produces the tamper-evident action receipt AARM specifies — R5, and the sealing half of R6 (identity is sealed into the hash, not cryptographically authenticated). halo-record is the receipt layer; pair it with an enforcement gateway for a full AARM system. See [`AARM.md`](https://github.com/bkuan001/halo-record/blob/main/AARM.md). - **Agentic Trust Controls:** the runtime records behind the ATC's evidence controls — tamper-evident action logging (RBM-03) and the record half of authority attestation (AID-05; the enforcement half belongs to the gate) in one chained record. See [`ATC.md`](https://github.com/bkuan001/halo-record/blob/main/ATC.md). - **CSA AI Controls Matrix (AICM) / STAR for AI:** the LOG-domain evidence — audit records generated, sealed against undetected modification, input and output events logged — mapped control-by-control in [`AICM.md`](https://github.com/bkuan001/halo-record/blob/main/AICM.md). CSA's own v1.1 crosswalk ties that domain to AIUC-1 E015. - **MITRE ATLAS:** the agent-telemetry mitigation (AML.M0024) implemented with an integrity property ATLAS itself doesn't ask for — the log is verifiable by someone outside the operator. See [`ATLAS.md`](https://github.com/bkuan001/halo-record/blob/main/ATLAS.md). - **EU AI Act / ISO 42001 / NIST AI RMF:** the record-keeping and logging obligations these frameworks describe are the same artifact class — mapped conservatively in [EU-AI-ACT.md](https://github.com/bkuan001/halo-record/blob/main/EU-AI-ACT.md), [ISO42001.md](https://github.com/bkuan001/halo-record/blob/main/ISO42001.md), and [NIST-AI-RMF.md](https://github.com/bkuan001/halo-record/blob/main/NIST-AI-RMF.md). None of this certifies anything by itself. It gives your assessor something verifiable to look at. The boundaries — what halo-record deliberately does not do, and what to say when a reviewer asks — are documented in [`LIMITS.md`](https://github.com/bkuan001/halo-record/blob/main/LIMITS.md). ### Getting the evidence into your GRC platform Most GRC platforms (Vanta, Drata, and similar) accept uploaded files as custom evidence against a control. halo-record's export is built to drop into that flow: ```bash halo export audit.jsonl --from 2026-06-01 --to 2026-06-30 -o evidence.csv # scope the export to the actions a control covers halo export audit.jsonl --from 2026-06-01 --to 2026-06-30 --tool email.send --tool db.query -o evidence.csv # check completeness against a witness, under the customer id you expect halo export audit.jsonl --from 2026-06-01 --to 2026-06-30 --witness-url https:// --expect-subject acme -o evidence.csv ``` This writes two files for the audit window: the CSV (one row per recorded action, grouped left to right as *when → what happened → who → under what authority → what was flagged → provenance → how to verify*, including a redacted plain-language summary of the call and its result, the agent build and model that produced each, the identity it ran on behalf of, the record it was caused by, its authorization decision and scope, and any personal-data categories or ingested threat flags) and a manifest (`evidence.csv.manifest.json`) that ties the CSV to its source — the chain's head hash links it to the verifiable log it came from, and `csv_sha256` is the exported file's own hash, so a CSV edited after export no longer matches its manifest. Narrow the population with `--tool` when a control only covers certain actions; the manifest records the filter, so a scoped export discloses that it is a subset rather than reading as the whole population. Upload both against your logging or monitoring control; attach the Runtime Report HTML when a reviewer wants to verify the chain themselves. The export refuses to run on a chain that fails verification. With `--witness` / `--witness-url` it also checks completeness: a chain that conflicts with the witness (INCOMPLETE) or carries a subject other than `--expect-subject` is refused, an unreachable witness is refused, and the manifest records the subject ids, the witness, and the completeness status (`NOT_CHECKED` when no witness was named). Record timestamps are the recording host's clock; the manifest says so (`chain.ts_source`). A native push integration — evidence landing in your platform automatically — is on the roadmap. The file path above works today with any platform that accepts uploaded evidence. ## CLI ``` halo verify validate schema + hash chain (exit 1 broken, 3 empty chain; CI-friendly) halo report render a chain as an HTML Runtime Report that re-verifies its hash chain in the browser (`halo verify` also checks the record format) (--from/--to: a date-windowed report covering only the review period; -w/--witness or --witness-url: completeness checked at generation; exit 1 INCOMPLETE or refused — chain (or window) does not verify, unexpected subject, or unreachable witness — 2 local witness file missing or damaged, 3 unwitnessed or an empty chain (a --from/--to window holding no records exits 0); --expect-subject pins the customer id) halo policy corroborate a chain against a declarative policy pack (per-rule pass / violation / evidence-gap; exit 1 violated, 2 refused — invalid policy or unexpected subject — or non-attestable — the chain does not verify; the rules are printed under a NON-ATTESTABLE header when the records can be read — 3 nothing in scope; --from/--to/--expect-subject; bundled packs run by name; --help shows the format) halo serve serve per-tenant reports over HTTP, access-scoped per customer halo grant designate a report recipient (email or domain) halo viewers list who has unlocked a gated report halo anchor witness a chain head, or --check completeness (exit 1 incomplete, 2 local witness file missing or damaged, 3 unwitnessed; --remote --check needs no vendor key) halo witness-serve run a witness over HTTP: vendors anchor chain heads, viewers fetch checkpoints halo demo scaffold the full vendor demo (record -> witness -> gated report) halo export date-bounded evidence export: CSV + manifest tied to the chain head (--witness/--witness-url/--expect-subject: completeness in the manifest; exit 1 refused — chain fails verification, INCOMPLETE, unexpected subject, or unreachable witness — 2 unreadable chain, local witness file missing or damaged, or output directory missing — 3 empty chain or unwitnessed; a --from/--to window holding no records exits 0) halo sample emit a valid example log halo hash canonical sha256 of a JSON value halo hook Claude Code PostToolUse hook ``` ## Integrity model To compute a record's hash: take the record excluding `integrity.hash`, with `integrity.prev_hash` set to the previous record's hash; canonicalize with RFC 8785 (JSON Canonicalization Scheme); SHA-256 the bytes. The first record's `prev_hash` is 64 zeros. Verification recomputes every hash and checks every link. No secret required; that is the point. Think you can tamper with a chain without the verifier noticing? [Attempts and results live here](https://github.com/bkuan001/halo-record/discussions/2). Full field reference: [`halo-record.schema.json`](src/halo_record/halo-record.schema.json). ## TypeScript The same recorder ships for Node: [`halo-record-ts`](https://github.com/bkuan001/halo-record-ts). Same chain format, same witness protocol. Records written in either language verify with either verifier. ## Community Examples [trail-halo-poc](https://github.com/AmeyParle/trail-halo-poc) — community proof of concept binding a Halo record's principal authority to TRAIL credentials: a reciprocal org–agent binding and org-signed scope grants recorded into a Halo chain, with an adversarial verification suite. ## Contributing Issues, discussions, and pull requests welcome — see [CONTRIBUTING.md](https://github.com/bkuan001/halo-record/blob/main/CONTRIBUTING.md) for the ground rules (short version: tests required, small PRs, schema changes get discussed first). ## License Apache-2.0