How the audit log works
nd7's audit log is one file per session, and that file is append-only: lines are only ever added at the end, and nothing already written is changed. A hook is a command you register in your settings, which Claude Code runs at a fixed point, such as before a tool call. Claude Code calls nd7 record for each hook event, and nd7 record appends exactly one line. Each line carries a fingerprint of its own bytes and of the line before it, so no line can be changed or removed without the chain of fingerprints saying where. This page covers what gets recorded and where it lands, how nd7 stops hooks that run at the same moment from tripping over each other, what nd7 verify checks, and the one thing the log honestly does not prove.
Hooks in, entries out#
Claude Code runs a configured command for each hook event and pipes one JSON object to its standard input. nd7 record does five things, in order, and nothing else:
- Take the invocation facts — wall-clock time at nanosecond resolution, the hostname, and the parent process id — before reading standard input, so the timestamp marks when the hook fired rather than when parsing ended.
- Read standard input to the end and parse it into a typed model, which means nd7 has a named shape for the event rather than a bag of loose JSON. All 33 documented Claude Code hook events are typed, and the payload is also kept whole.
- Turn it into one nd7 event.
- Append that event to the session's log.
- Exit 0 with nothing on standard output.
Two rules hold throughout. nd7 record always exits 0, and it never prints to standard output. Claude Code treats JSON on standard output, and an exit code of 2, as instructions that change what the agent does next, and the recorder must never issue one of those. If anything fails — malformed JSON, an unwritable directory — it writes a single line to standard error and still exits 0. Losing one event is better than blocking the agent.
The six kinds with their own shape, and everything else#
Six hook events get an entry shape of their own, with the interesting fields pulled out and named:
SessionStart: entry kindsession_start. The body holds why the session started:startup,resume,clear,compact,fork.UserPromptSubmit: entry kindprompt. The body holds the prompt text, verbatim.PreToolUse: entry kindtool_call. The body holds tool name, tool use id, the full tool input, plusargvandpathscopied out of it into fields of their own.PostToolUse/PostToolUseFailure: entry kindtool_result. The body holds tool name, tool use id, the response,oktrue or false, duration.Stop: entry kindturn_end. The body holds the final assistant message of the turn.SessionEnd: entry kindsession_end. The body holds why the session ended.
Every other hook event is kept whole under kind: hook, with the full payload in body.raw. That is deliberate: when an upgrade of Claude Code adds or renames an event, nd7 still stores it in full instead of quietly dropping it. The log format has every field.
Where logs live#
$XDG_STATE_HOME/nd7/sessions/<session-id>/
with ~/.local/state/nd7 as the default when XDG_STATE_HOME is not set. That variable comes from the XDG Base Directory specification, the usual convention for where a program keeps machine-local state. There is one directory per session, named after Claude Code's own session_id used verbatim; nd7 rejects an id containing a path separator or ... The directory holds:
events.ndjson: the log: one JSON object per line, append-onlyhead:"<seq> <hash>\n", the chain's last position, so an append need not scan the loglock: empty; the file every appending process locks, so that only one appends at a time (see below)shipped:"<seq> <hash>\n", the last entry a vault acknowledged (only once enrolled)chain.key: the encryption key for this chain, mode 0600, present only while the chain is open
There is no reader command yet. nd7 sessions and nd7 show were planned and then dropped. The things that read the log are programs — later phases working out sandbox rules from it, and the vault's viewer — rather than a person watching a timeline in a terminal. Until something else exists, ordinary tools work:
ls ~/.local/state/nd7/sessions/
tail -f ~/.local/state/nd7/sessions/<session-id>/events.ndjson \
| jq -c '{seq, kind, ev: .body.hook_event_name, tool: .body.tool_name}'
The first event for an unknown session id creates the directory. There is no separate "open the session" step, because hooks can arrive in any order after a crash, and SessionStart may fire with resume or compact on a session that already has a log.
The per-session lock#
Claude Code can run several tool calls at once, so several hook processes can try to append at the same moment. Without coordination, two of them could claim the same sequence number, or link onto the same previous entry and lose one of the two lines.
Every append takes an exclusive advisory lock on the session's lock file, which is a lock the operating system offers and every nd7 process agrees to respect (flock underneath). It holds that lock for the whole read-then-write cycle: reading head, appending the entry, rewriting head. nd7 ship takes the same lock while it reads the log and rewrites shipped. nd7 verify takes a shared lock, which several readers may hold at once but no writer can, so that no append can land between reading the log and reading head.
This is tested rather than asserted: a 16-thread unit test and a 40-process integration test both fail when the lock is removed. The benchmark run fired 50 recorder processes at one 10,000-entry session. All 50 exited 0, the entry count moved by exactly 50, and nd7 verify was clean afterwards.
The chain and the head companion file#
Every entry ends with hash, the BLAKE3 fingerprint of its own bytes, and carries prev, the fingerprint of the entry before it. That is what makes the log a chain: each entry names the one behind it. The first entry's prev is the fingerprint of the session id, so a chain cannot be moved from one session to another and passed off as that session's.
Each entry names the one behind it by fingerprint, so changing any line breaks every arrow after it; head is only a cache of where the chain has got to.
Reading the whole log to find where the chain has got to would make appends cost more as the log grows. Instead head holds the last entry's sequence number and hash, and an append reads that one small file plus the log's last line, which it finds by reading backwards from the end. The measured result is that recording costs the same however large the log is (O(1) in log size): over a fifty-fold growth in log length the median time moved by 22 microseconds, which is inside the noise. See Benchmarks.
head is only a cache of what the log already says, and nd7 never trusts it on its own. Every append reads both head and the log's last line and checks them against each other. Two disagreements are repaired, because in both the log itself is intact and only the cache is behind:
headis missing although the log has entries;headnames the entry before the last one, and the last entry'sprevis exactly that hash. This is an append that died between writing its line and rewritinghead.
Every other disagreement is refused: head ahead of the log, further behind it than one entry, or naming the last entry with a different hash. In those cases nd7 appends nothing, leaves both files untouched, and reports an error naming the position. Appending onto a chain that already disagrees with itself would look exactly like tampering to anyone reading it later, so the writer will not do it.
Surviving a crash follows from the order of the writes. The entry is appended with a single write to a file opened in append mode; only then is head rewritten, by writing head.tmp and renaming it over head. A crash in between leaves head one entry behind, which the next append detects and repairs. The log itself is never rewritten.
nd7 verify#
nd7 verify <session-id>
It walks the chain from the first entry. For each line it checks, in order, four things: that the line is a sealed entry, meaning it ends with a hash member the writer put there; that this hash equals BLAKE3 of the line's own bytes; that its sequence number matches its position in the file; and that its prev matches the previous entry's hash — the session-id fingerprint at position 0. Then it compares the log's last entry with head. It stops at the first failure, names the entry, and exits 1.
Each entry is checked against nothing but its own bytes and the previous entry's stored hash. That is what would let nd7 later spread the walk across several cores without changing a single check.
On success it prints the count, the first 16 characters of the head hash, and always this:
verified: 1487 frames, head 8f2c1a09b3d7e455
note: proves the log is unchanged since its last frame was written by this machine; anyone with write access could rewrite the whole chain.
An empty or missing log verifies with zero entries. The log format lists every way verification can fail.
What the chain proves, and what "intent, not proof" means#
Two caveats, both stated plainly in the repository.
The chain is self-checking, not third-party evidence. A clean verify proves the file has not been modified since it was written — if you trust head. Someone who can write the directory can rewrite the entire chain, head included, and the rewritten log will verify. Closing that gap is what shipping to a vault is for: a copy on a machine the agent cannot reach makes a later local rewrite show up, because the rewritten chain no longer matches the copy. The format reserves a field, sig, for a signature, and nd7 never writes it today.
The log records intent, not effect. Every entry comes from Claude Code's own hook stream: what the agent said it was about to do, and what it reported back afterwards. It is not proof of what actually ran on the machine. That is why every entry carries a source field naming the kind of evidence it is — intent:claude-code today — so that no reader can mistake one kind for the other. Recording what the kernel itself observed, written into the same log with source: effect:*, is a planned phase; see Roadmap.
The one place Phase 1, the version described here, comes close to evidence of effects is bashEditDiff. When a Bash command modifies files, Claude Code's own response carries a full unified diff of each one, and nd7 stores that diff exactly as given. It is the single biggest reason entries can reach tens of kilobytes, and the reason the decision went to storing content in full rather than cutting it short.