On a single-user machine the ledger is a SQLite file owned by the account that runs
tdd, which is the account the agent runs as. The agent cannot reach it by editing its
worktree, but it can reach it by looking: sqlite3 ~/.local/share/tdd-cli/*.sqlite3
opens it, and nothing in the file says afterwards that anyone did. tdd doctor reports
this as mode: single and says so in its ledger isolation check.
Split mode moves the ledger, and the process that observes the tests, to a second account:
- A runner account owns the ledger, in a directory only it can open (mode 700).
- The agent's
tdddoes nothing itself exceptdocsandinit. For every other verb it asks the runner, throughsudo. - The runner runs every suite, gate, artifact hook and git command as the agent, in the agent's worktree, and reads the results from outside. The agent's code never runs as the account that owns the ledger.
The command surface is unchanged. No verb accepts a phase, a cycle number or an identity on either side of the boundary.
This is one machine's arrangement. Each machine has its own runner and its own ledger.
It stops the agent's account from reading or writing the ledger, and from running code as the account that can.
It does not stop:
- A forged test report. The suite runs as the agent, so a test can write whatever report it likes. The runner parses reports as data and never executes them, but it cannot know a passing report is honest. This is the same on a single-user machine.
- A client-side downgrade. An agent can point its own
tddat a config of its own (TDD_RUNNER_CONFIG), or install a second copy of tdd-cli, and record a run in a ledger it owns. Nothing is forged by that: the run is simply absent from the runner's ledger, which is the only one to trust. Read runs from the runner's ledger.
You need root for this. Examples use tdd-runner for the runner account and agent1
for the agent's.
Create a system account with a home directory and no login shell you hand out.
On Linux: useradd --system --create-home --shell /usr/sbin/nologin tdd-runner.
On macOS: sysadminctl -addUser tdd-runner -home /var/tdd-runner (then hide it if you
wish).
A runner whose code the agent can edit is the agent. Install for the runner as root:
python3 -m venv /opt/tdd-cli
/opt/tdd-cli/bin/pip install tdd-cli
It must be readable and executable by the agent's account: the runner runs small
helpers from this install as the agent (python -m tddcli.agentfs,
python -m tddcli.identity). It must not be writable by it.
The agent's own tdd can be this same binary or a separate install; both read the same
config and become a client.
Two lines, in a file under /etc/sudoers.d/ (edit with visudo -f):
agent1 ALL=(tdd-runner) NOPASSWD: /opt/tdd-cli/bin/tdd
tdd-runner ALL=(agent1) NOPASSWD:SETENV: ALL
The first lets the agent ask the runner, and only through that one binary. The second lets the runner act as the agent. Add one pair per agent account.
Four things to get right:
SETENV:is required on the second line. The runner passes the agent's own environment to the agent's commands withsudo -E; without the tag sudo answers "sorry, you are not allowed to preserve the environment" andtdd doctorfails itsruns as the agentcheck.NOPASSWDis required on both.tddalways passes-n, so a rule that would prompt fails at once instead of hanging an unattended agent.- Leave
env_reseton (it is sudo's default). It is what strips the agent'sTDD_LEDGER_HOME,TDD_RUNNER_CONFIGandPYTHONPATHon the way to the runner. - Nothing to configure for
HOMEor the working directory. The client passes-H, because macOS sudo would otherwise leaveHOMEpointing at the agent's and the runner's ledger home would follow it. sudo keeps the caller's working directory, which is how the runner knows which worktree it was asked about.
/etc/tdd-cli/runner.toml, owned by root, mode 644:
[runner]
user = "tdd-runner"
command = "/opt/tdd-cli/bin/tdd" # the path the first sudoers line names
# sudo = "/usr/bin/sudo" # the default
# ledger_home = "/var/lib/tdd-cli" # default: ~tdd-runner/.local/share/tdd-cli
[executor] # optional, see "Executor identity"
agent1 = "claude-fable-5-1"
A process whose account is user is the runner. Every other process on the machine is
a client. With no file, the machine is single-user and nothing changes.
The file is trusted only if it is owned by root or by the account reading it, and is
not group- or world-writable. Anything else is refused. That is what stops an agent
handing the runner a config of its own: even if a sudoers rule let TDD_RUNNER_CONFIG
through, the file it named would belong to the agent, and the runner refuses it.
The runner reads the worktree as itself: tdd.toml, the test files it globs for, JUnit
XML under build/. The agent's worktree, and the directories above it, must be readable
and traversable by the runner account (chmod -R go+rX, or a shared group). On macOS
that rules out ~/Desktop, ~/Documents and ~/Downloads, which are mode 700.
The runner never writes there as itself. Commits, the friction log, sensitivity-check restores and temporary probe worktrees are all made as the agent.
If a file is unreadable the runner says which, with reason: "worktree_unreadable".
Run these as the agent's account, in a worktree:
tdd doctorreportsmode: "split",healthy: true, and these three checksok:runs as the agent:id -u, asked through sudo, answered with the agent's uid.ledger out of the agent's reach:test -r <ledger>, as the agent, was refused.install not writable by the agent:test -w <install>, as the agent, was refused.
cat ~tdd-runner/.local/share/tdd-cli/*.sqlite3(or yourledger_home) is denied.- Start a run and close a cycle.
git log -1 --format='%an %cn'shows the agent as author and committer, andls -lon the committed files shows the agent as owner. sudo -u tdd-runner /opt/tdd-cli/bin/tdd status, run by hand from the agent's account, works; the same command naming any other binary is denied.
The project's own test suite cannot do this for you. It runs without root, so it uses a stand-in for sudo that does everything except change uid. The three doctor checks are the only place the uid change itself is verified, and they run against your real sudo.
tdd run start records which model did the work. Everything it reads on a single-user
machine (the session id, the transcript, TDD_EXECUTOR_MODEL) is the agent's to write,
so in split mode the runner trusts none of it:
- If the calling account is in
[executor], the run records that model withexecutor_source: "operator". One account per agent slot makes this exact. - Otherwise the ordinary resolution runs on the agent's side and the run records what
it found with
executor_source: "claimed". Leaveclaimedruns out of any comparison between models that has to be defended.
--executor, the human label, is a claim like any other in split mode.
A ledger written before the split was reachable by the agent the whole time. The runner can keep it, marked as such. From a root shell (so that sudo reports uid 0), or logged in as the runner with no sudo at all:
sudo -u tdd-runner /opt/tdd-cli/bin/tdd runner import /home/agent1/.local/share/tdd-cli/<repo>.sqlite3
The runner must be able to read that file. It copies the ledger into the
runner's home under the same name, migrates it to the current schema, and records
pre_split_import with the source path, the time, and last_run_id. tdd metrics
reports that marker, so anyone comparing runs can see which predate the split.
It refuses when:
- the machine is not split, or the caller is not the runner (
not_split); - an agent's account is the caller (
operator_only): an agent that could import could hand the runner a history it wrote itself; - the runner already holds a ledger of that name (
ledger_exists). There is no merge. The runner's copy may hold runs recorded out of the agent's reach, and a second import would trade them for ones that were not.
Afterwards, remove the agent's copy so that nothing reads it by mistake.
- Worker leases move with the runner: they live under the runner's
~/.cache/tdd-cli, so one budget covers every agent account on the machine. tdd fleet,tdd metricsandtdd log renderare forwarded like everything else and read the runner's ledger.- A runner invoked directly, with no sudo in between, acts for nobody: every verb
except
runner importandledgeris refused withreason: "no_agent". It never falls back to running suites as itself.
Everything above is one machine. Once agents run in disposable VMs or containers, the
runner has to be in the guest, because that is where the suites run. Its ledger must not
be: it would be deleted with the guest, two guests' files could never be merged, and an
agent with root in the guest (a docker group membership is enough) could rewrite it
whatever the file modes say.
So the runner in the guest keeps no ledger. It reads and writes the host's through a unix socket, served by a ledger service on the host. The service owns the SQLite files and is their only writer. The runner still runs every suite, gate and git command as the agent, in the agent's worktree, exactly as above.
host guest (one per run)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ tdd ledger serve │ │ agent ─sudo─▶ runner │
│ admin.sock (operator) │ │ ledger_socket │
│ vm-1.sock ───forwarded──────────────────▶│ /run/tdd-cli/ledger.sock │
│ <home>/<repo>.sqlite3 │ │ (no ledger file here) │
└──────────────────────────────┘ └──────────────────────────────┘
A run's source is the socket it arrived on. The operator names it when they create the socket, and the protocol has no field in which a guest could name it instead.
/etc/tdd-cli/ledger.toml (or TDD_LEDGER_SERVICE_CONFIG), owned by root or the
service account and not group- or world-writable, the same trust rule as runner.toml:
[service]
admin_socket = "/run/tdd-cli/admin.sock"
# home = "/var/lib/tdd-cli" # default: the service account's ~/.local/share/tdd-cli
Run tdd ledger serve under a dedicated account that owns home. It runs until SIGTERM
and exits 0. A systemd unit:
[Unit]
Description=tdd-cli ledger service
[Service]
User=tdd-ledger
Group=tdd-guests
RuntimeDirectory=tdd-cli
ExecStart=/opt/tdd-cli/bin/tdd ledger serve
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
Host ledgers are <home>/<slug>.sqlite3, one per repository, where the slug is the
guest's repository path with / replaced by -, as on a single machine. Every guest
that checks a repository out at the same path shares that file, kept apart by source.
The operator adds a source for each guest, from the host:
tdd ledger add-source vm-1 --socket /run/tdd-cli/vm-1.sock --executor claude-fable-5-1
tdd ledger bind vm-1 --executor claude-opus-5-5 # the model for its next runs
tdd ledger sources # every source, socket and binding
tdd ledger remove-source vm-1 # closes the socket; its runs stay
Sources are kept in <home>/sources.json, so a restarted service listens on all of them
again. The admin socket is mode 600, the service account's alone. A guest's socket is
mode 660: give its group to whatever forwards it into the guest, and nothing else.
tdd ledger verbs are always answered on the machine you run them on, never forwarded to
a runner. With no service config they fail with reason: "no_service".
The forwarding itself is the operator's, and tdd-cli does not set it up:
- ssh:
ssh -R /run/tdd-cli/ledger.sock:/run/tdd-cli/vm-1.sock guest, withStreamLocalBindUnlink yesin the guest'ssshd_configso a reconnect can replace a stale socket, andStreamLocalBindMask 0117so the forwarded socket is not world-open. - containers: bind-mount the source's socket,
-v /run/tdd-cli/vm-1.sock:/run/tdd-cli/ledger.sock. Mount the one socket, never the host's ledger directory. - vsock: forward the source's socket to a vsock port with a relay such as
socat, and relay it back to a unix socket in the guest.
The guest's runner.toml names the forwarded socket instead of a ledger home:
[runner]
user = "tdd-runner"
command = "/opt/tdd-cli/bin/tdd"
ledger_socket = "/run/tdd-cli/ledger.sock" # instead of ledger_home
Naming both ledger_socket and ledger_home is refused. In the guest, tdd doctor's
ledger reachable check pings the service and names the source it reached:
socket /run/tdd-cli/ledger.sock (source vm-1).
It fails closed. If the socket is missing or the service hangs up, every verb that
needs the ledger fails with reason: "ledger_unreachable". Nothing in the guest ever
falls back to a ledger of its own.
The service enforces this for every request, so a modified client cannot get around it:
- A guest reads and writes only its own source's runs. A run, cycle or plan contract
of another source is refused with
refusal: "foreign_run", and so is one that does not exist, so a guest cannot probe for other sources' ids. Claims and the baseline cache are per source: one guest can neither block another'srun startat the same path nor hand it a forged cached baseline. - A closed run stays closed. Writes to a
completeorabandonedrun are refused withrefusal: "run_closed".tdd noteis the one exception, so a closing narrative can still be added. Ablockedrun is not closed:resume --unblockstill works. - A guest reaches only named domain methods. The wire carries a method name and its
arguments, never SQL. Methods meant for the host alone (
set_meta, the generic row helpers) are refused withrefusal: "method_not_allowed", and a repository path that is not absolute is refused withrefusal: "bad_repo".
Refusals reach the guest's envelope as reason: "ledger_refused" with the refusal code.
What a compromised guest can still do is record runs under its own source, and lie in them, which is no worse than a forged test report on a single machine.
Executor identity is set on the host. A guest's [executor] table is guest root's to
edit, so it is a claim. When the operator binds an executor to a source, the service
records that model with executor_source: "operator" on every run the source starts or
abandons, whatever the guest sent. With no binding, a guest that says operator is
recorded as claimed.
On the host, the operator reads every source together through the admin socket:
tdd ledger metrics [--repo <slug>] # `tdd metrics` over every source, per repository
tdd ledger fleet # active runs of every source, and the host's suites
tdd ledger log --repo <slug> --run <id> [--out FILE]
Every run in them carries its source. Inside a guest, tdd metrics, tdd fleet and
tdd log render still work, and see only that guest's source.
Worker leases are the host's. A socket-mode runner takes each suite's worker lease from the service, which splits the host's cores across every guest's running suites. A lease ends when its connection closes, which is also what a dead VM does, and one older than the stale limit is not counted. Named leases stay in the guest: they guard resources the guest's suites run against.
tdd runner import is refused on a socket-mode runner (reason: "ledger_remote"): it has
no ledger to import into. Bring an existing ledger in on the host instead, as one source:
tdd ledger import /home/agent1/.local/share/tdd-cli/<repo>.sqlite3 --source legacy
It copies the file into the service's home under the same name, migrates it, retags every
run and plan contract it recorded to the named source, drops its claims, and marks it as
pre_split_import, which tdd ledger metrics reports. It refuses a file the host already
holds (reason: "ledger_exists"). There is no merge.
Ledgers from before schema v13 gain a source on their first open: every existing run,
contract, claim and cached baseline becomes local, the source of single-user mode and of
a split runner on one machine.