Architecture
nd7 is small on purpose: one library, two binaries, no daemon (no long-running background process), no async runtime, and one file format that everything else is arranged around. This page describes the parts, what runs as what process, how an event gets from Claude Code to disk and from disk to a vault, what each part has to trust, and why two of the more visible choices — two binaries, and hooks before kernel observation — were made. It follows ARCHITECTURE.md.
nd7 run applies one Seatbelt floor to Claude Code and everything it starts, nd7-exec is the floor's only exit, Claude Code's hooks feed nd7 record, and nd7 ship pushes encrypted batches of the log to a vault.
Components#
Everything shared lives in one library crate, nd7_core, and the two binaries on top of it hold very little code of their own.
hook: the typed model of every Claude Code hook payload, the nd7 event, and the conversion from one to the other. Pure, meaning it reads and writes nothing outside itself: no files, no network.hook_prefix: thePreToolUsereply that routes a Bash command throughnd7-exec. Pure.policy: what a session may do, rendered as the two Seatbelt profiles that enforce it. Pure.session: the directory onend7 runowns, wherend7-execfinds the rules it applies.sandbox: Seatbelt: apply a profile between fork and exec, or to the calling process. macOS only.session_log: the append-only per-session log: lock, head, chain, verify. The only module that knows where events live on disk.vault: the wire format and cryptography the machine and the vault server share. Pure.ship:nd7 enrollandnd7 ship: the session directory, the vault's own files, and the network.
And two binaries:
nd7— the command line.recordis what hooks call;verifychecks a chain;enrollandshiptalk to a vault;runstarts a sandboxed session;hook-prefixis the hookruninstalls;allowanddenychange a running session's rules.nd7-exec— the one program the sandbox allows to start outside its own rules. It applies the session's current rules to itself and becomes the shell for one command. It never writes anything.
Process model#
Nothing in nd7 is long-lived. There is no daemon, no socket, no async runtime, and no configuration to parse beyond environment variables.
nd7 recordis a fresh process per hook event. It reads standard input, appends one line, exits 0. It does each step in order and waits for it to finish, with no concurrency anywhere.nd7 hook-prefixis a fresh process per Bash tool call. It reads the payload, prints one JSON reply, exits 0.nd7-execis a fresh process per Bash command, and it does not outlive that command: it replaces itself with/bin/zshthroughexec. So no extra process sits in the middle, to be left holding an orphaned command when Claude Code kills it, and the shell's exit status is the command's own.nd7 runis the only one that stays: it lives as long as the program it started, owns the session directory, and removes it on exit.nd7 shipruns once and exits by default.--everymakes it loop, but the recommended shape is a system timer running the run-once form.
ADR-0003, one of the project's architecture decision records, holds the measurements behind this. On the reference laptop, starting any process at all costs about 3.6–3.9 ms; wrapping it in sh -c adds about 6 ms; opening, appending and closing the log costs about 1 ms. A client that did nothing but start up and connect to a socket cost about 8 ms, as much as the whole nd7 binary. A daemon could remove at most that 1 ms, while adding problems of its own: keeping the process alive, surviving crashes without losing entries, and version skew, where the daemon in memory is older than the binary on disk. The last of those would break the rebuild-and-go development workflow. So: no daemon.
Asynchronous hooks were considered and rejected for a different reason. Claude Code does not wait for them to finish, so an entry could be appended after a later event's hook had already run, which would put the log out of order.
Data flow: recording#
Claude Code ──hook JSON on stdin──▶ nd7 record ──▶ log writer ──▶ events.ndjson
(intent) seq, ts, prev, hash
In order:
Invocation::nowtakes the timestamp, hostname and parent process id before it reads standard input, sotsmarks when the hook fired.- The recorder parses the payload into the typed model. All 33 documented events have a type of their own, and the payload is kept whole as well.
Event::newproduces one event. Six kinds get a body of their own; anything else, including event names this build has never seen, becomes ahookevent carrying the raw payload.SessionLog::appendtakes the session's exclusive advisory lock, which is a lock every nd7 process agrees to respect. It then readsheadand the log's last line and checks them against each other. Finally it assignsseqandprev, seals the entry with its own hash, appends it with one write, and rewritesheadthrough a temporary file and a rename.- Exit 0, with nothing on standard output.
Two rules make this safe to do from a process that lives for a few milliseconds. The lock means two hooks running at once cannot collide. The order of writes, entry first and head second, means a crash leaves head at most one entry behind, which the next append detects and repairs from the end of the log. The log itself is never rewritten.
Data flow: sandboxing#
nd7 run ──floor profile──▶ claude ──Bash call──▶ PreToolUse hook (nd7 hook-prefix)
│ rewrites the command
▼
nd7-exec ──reads policy.sb, applies it──▶ /bin/zsh -c …
nd7 run builds the rules from three directories: the project directory resolved to its full real path, the temporary directory, and the home directory as the system's account records give it. It writes those rules into ~/.nd7/sessions/<pid>/ as record.json and policy.sb, and applies the rendered floor (the baseline set of rules, which nothing inside can widen) to claude and to everything claude starts. The only way out of the floor is running one exact path, nd7-exec, which is marked so that the program it starts is no longer inside the sandbox.
nd7-exec then finds its session by walking up the chain of parent processes, which the caller cannot fake because the kernel answers those questions. It reads that session's policy.sb, applies it to itself, and becomes the shell. nd7 allow edits the same two files in a way that leaves no half-written state, so the next command picks up the change with no restart and no signal.
Failure is closed rather than open: if the hook stops being applied, the command simply runs without the prefix, under the floor, which is never wider. How the macOS sandbox works has the rules and the reasoning.
Data flow: shipping#
nd7 ship is a second reader of the log, and the only part of nd7 that uses the network. It reads the entries after the last one the vault acknowledged, then runs the same chain verification the local verifier runs over exactly those bytes. It splits those bytes into batches of at most 8 MiB, compresses and encrypts each batch with a key held for that one chain, and pushes it. That chain key is itself encrypted to the admin keys the machine pinned when it enrolled. The vault sees encrypted batches, hashes and timestamps, never the entries themselves.
vault is pure — no sockets, no files, no clock — so the server can build on the same module, and the two cannot end up disagreeing about the format on the wire. ship is the part that holds the files and the network. Nothing in the hook path depends on either, and nd7 record never opens a socket. See Ship sessions to a vault you run.
Session layout on disk#
$XDG_STATE_HOME/nd7/ (default ~/.local/state/nd7)
├── sessions/
│ └── <session_id>/
│ ├── events.ndjson append-only event log, one entry per line
│ ├── head "<seq> <hash>\n", chain head for fast append
│ ├── lock empty, the advisory-lock target
│ ├── shipped "<seq> <hash>\n", last entry the vault acked
│ └── chain.key 0600, present while the chain is open
└── vault/ only on an enrolled machine, all 0600
├── machine.key Ed25519 seed, signs every request
├── machine.id vault-assigned id
├── server base URL
└── recipients pinned admin public keys, signed
Per-session files rather than one big log, because the session is what you read, ship and delete as a unit. Keeping it that way makes it obvious what a retention rule or an upload covers, and it means two sessions running at once never wait on each other. The files go in the state directory of the XDG Base Directory specification rather than the data or cache one. What they hold is machine-local state that should survive a reboot, and it is neither data a person wrote nor something safe to throw away.
The sandbox's own records are separate, at ~/.nd7/sessions/<pid>/, and are short-lived.
Trust boundaries#
Recording has almost nothing to defend: it runs as you, reads what Claude Code pipes to it, and writes a file you own. Its only defensive rules are about not interfering — always exit 0, never print to standard output, never tell Claude Code to block or change anything — and about the log's own integrity. Each entry describes itself; absolute paths are stored exactly as they arrived rather than being tidied up; and nothing assumes the log will be read on the machine that wrote it.
Sandboxing has a real boundary. It trusts the kernel; the nd7 and nd7-exec binaries and the directory holding them, which nd7 run checks at session start to be writable by nobody but you; the session directory under ~/.nd7, which the profile makes unreadable and unwritable from inside the sandbox; and the walk up the chain of parent processes, which the kernel answers. It does not trust the model, the command text (which it quotes and never interprets), or the caller's arguments, working directory and environment. That last point is why nd7-exec takes the home directory from the system's account records rather than from $HOME.
Shipping trusts the machine to record honestly at the moment it records. The attacker it defends against is someone who rewrites the logs afterwards, or who steals the vault's database. Hence encryption on the machine, to keys the vault does not hold, and a fingerprint pinned at enrolment so that a dishonest vault cannot hand over a key of its own instead.
Why two binaries#
Two reasons, and they are different reasons.
The library split is ordinary. Every future producer of entries — a Codex adapter, the kernel collector — and every reader needs the same writer and the same event model. One Cargo binary cannot import another, so the shared code lives in src/lib.rs, and adding a tool is one more [[bin]] entry.
The nd7-exec split is a security property. The floor allows exactly one exact path to start outside the sandbox. That path has to be a program that does one narrow thing, takes nothing from the caller beyond the command text, and cannot be argued into applying different rules. Making it a subcommand of nd7 would mean letting the whole of that command line out of the sandbox. Keeping it separate also gives the check at session start something to check: nd7 run verifies that this one file, and the directory holding it, are writable by nobody but you.
Why hooks first#
ADR-0001. What the kernel observes is the stronger evidence, but collecting it needs special entitlements or root, a long-running daemon, separate code per operating system, and a way of working out which process did what. Capturing intent through hooks needs a settings entry and a binary that reads standard input. Starting with hooks meant a readable session within days, on every platform Claude Code runs on, and it forced the decisions about schema and storage early, against real data rather than against a guess.
The cost is that every claim has to be qualified — the log says what the agent said, not what happened — which is why source names the kind of evidence in every entry, and why the verifier prints its caveat. Kernel-observed effects will land in the same log with source: effect:* and no change of format, because the fields needed to match them up with intent entries (ts, cwd, argv, paths, hook_ppid, tool_use_id, host) are stored from the first entry onwards.
Three things the design deliberately avoids for the same reason: field names specific to one agent in the envelope, which is the set of fields every entry shares; assuming that only one process ever appends to a session, which is why there is a lock and a head file; and assuming the log is read on the machine that wrote it.