diff --git a/CLAUDE.md b/CLAUDE.md index 5905fc6..9c64bf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Boxmaker is a sovereign personal agent harness written in Rust. Work proceeds one milestone at a time (M0 to M7, table in `docs/milestones.md`). M0 (measurements), M1 (workspace, `proto`, gate, `Decision`), M2a (the inference path: `inferproxy`, `loopd`'s HTTP, SSE and llama client, the -startup self-test) and M2b (sessions, the turn loop, `loopd serve`, `bxctl chat`) are done. M3 -(`brokerd`) is next to design. Check `docs/implementer-log.md` for +startup self-test) and M2b (sessions, the turn loop, `loopd serve`, `bxctl chat`) are done. M3 is split: +M3a (`brokerd`'s decision path) is specified in `docs/specs/2026-09-18-m3a-decision-path.md`; +M3b (the container runner and tools) follows it. `docs/runbook.md` has an entry for every +fail-closed state; any new one needs an entry and a pointer in its message. Check `docs/implementer-log.md` for what is in flight. - `docs/design.md` is the binding design brief. If it looks wrong or conflicts with a measurement, diff --git a/docs/decisions.md b/docs/decisions.md index ee5ef86..f08b454 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -6,6 +6,15 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit | Date | Decision | Reason | |---|---|---| +| 2026-09-18 | M3 is split. M3a is the decision path: grants and matching, session taint, the hash-chained audit log, approvals through `bxctl`, the `broker.sock` and `admin.sock` protocol, and `loopd`'s `BrokerPort`; tools do not run (the runner is a trait with a refusing implementation). M3b is the Podman runner, the four tools and the egress proxy. Review happens after each. | The security logic is reviewed before real tools run on it, as with M2a and M2b. Spec `docs/specs/2026-09-18-m3a-decision-path.md`. | +| 2026-09-18 | Grant loading fails closed: one invalid grant file denies every call (`grants_invalid`) until it is fixed. M3 grants may not set `secret` or `patterns`; each tool has fixed rules for which constraints it takes. Among matching grants the most restrictive mode wins (deny, ask, auto). An approval re-decides against the current grants and taint, and runs only if the outcome is still ask or auto. | A skipped, mistyped `deny` grant would silently become an allow. Pattern-matching shell commands is a false comfort; the container is the boundary. | +| 2026-09-18 | Every fail-closed state has an entry in `docs/runbook.md` with what you see, why, how to confirm, how to fix and how to check; its message ends with `see docs/runbook.md#`, and a gate script checks that every referenced entry exists. | Owner's requirement: a refusing system must come with clear, actionable remediation. | +| 2026-09-18 | `brokerd` listens on two sockets: `broker.sock` (tool requests only, for `loopd`) and `admin.sock` (approvals and grant checks, for `bxctl`). Each refuses the other's messages with `forbidden`. Replaces the single `broker.sock` of the pre-M1 spec. | Anything that could reach the approving socket could approve its own calls. Until M7 all roles run as the owner's user, so the split is enforced in code; M7 enforces it with mounts. | +| 2026-09-18 | The audit log records events (`decision`, `approval`, `result`, `recovery`, `accepted_break`) in one chain. A decision is on disk before anything runs; results are recorded by hash and size, not content. A broken chain stops `brokerd` until the owner runs it once with `--accept-break`, which records the break; nothing is ever repaired or deleted. Chain verification is a pure function in `proto` shared by `brokerd` and `bxctl audit verify`. Approval ids are the `seq` of the decision record. | A secret read must not be copied into the audit log. Verification must work when `brokerd` refuses to start. | +| 2026-09-18 | Secrets move to M4. In M3 a grant that sets `secret` is invalid. | None of M3's tools needs a secret; the first is the Mattermost bot token. | +| 2026-09-18 | Until M7, `brokerd` and its containers run as the owner's user through rootless Podman; a container escape is the owner's user. Containers are hardened (`--network=none` unless granted, `--read-only`, `--cap-drop=all`, `no-new-privileges`, process and memory limits). | Separate users per role are M7's work. Measured on straylight: rootless Podman 5.8.6 with crun, 40 to 80 ms per container. | +| 2026-09-18 | Tool containers run from one OCI image built by Nix (`dockerTools`) holding static `toolkit`, `busybox`, `curl` and the CA bundle, loaded with `podman load` and named by digest. No registry pull at call time. The owner's Gitea registry is the route when there is more than one host. | Pinned contents with no egress. Nix already builds the three binaries statically. | +| 2026-09-18 | `http_fetch` runs `curl` in a container with no network, through a per-call SOCKS5 proxy in its own container on a mounted Unix socket. The proxy is ours (in `toolkit`), accepts host names only, checks each against the grant's hosts, and copies bytes; TLS stays end to end in the tool container. `clock` moves into `loopd`. | No TLS stack of our own. Redirects are checked at the proxy. Measured on straylight 2026-09-18: allowed host 200 in 0.26 s; other host and IP literal refused. | | 2026-09-18 | M2b: the baseline (system prompt, core tool schemas, `memory/core.md`) is snapshotted per epoch into `sessions//.baseline.json`; resume uses the snapshot, so edits apply only to later sessions. The agent is called Boxmaker; the first `system.md` is drafted by the design model and edited by the owner. A `Usage` log record follows each `Assistant` record. `bxctl chat` shows reasoning dimmed by default. The core tool set is `clock`, `find_tool`, `call_tool`; `echo` is the first discoverable tool. | Owner's choices during the M2b design review; spec `docs/specs/2026-09-18-m2b-agent-loop.md`. Dimmed thinking shows progress and helps spot stalls. | | 2026-09-17 | A compromised `loopd` can degrade the shared `llama-server` for other clients (large prompts, unpinned requests). Accepted for v0 and written into the threat model. `inferproxy` stays a byte forwarder with a connection cap and an accept-rate limit; it does not enforce slot or model policy. If isolation is wanted later, do it on the server side. | The harm is availability only, the owner would notice, and a policy proxy would put a parser for untrusted input into the component meant to have none. | | 2026-09-17 | M2a limits: 10 min wait for a busy slot, 3 min for a model load, 30 s liveness after the first byte, thinking cap 4,096 tokens with `max_tokens` 8,192 as backstop. All are config values. The slot gate is held per request, never across a tool call. | Owner's choices during the M2a design review; measurements (j) to (n) in `docs/inference-contract.md`. | @@ -31,7 +40,7 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit | Thinking cap size. It starts at 4,096 tokens so that the right size can be found from data. From M2b every completion logs its reasoning-token count and whether the cap fired. Revisit once 200 completions are logged, or sooner if the owner sees answers cut short: raise the cap if more than 5% of completions hit it. Each hit also costs a re-read of that turn. | after M2b has run for a while | | Repository licence. Crates are `publish = false` until it is chosen. | before publishing | | Audit integrity beyond a self-contained hash chain (for example anchoring the chain head outside `brokerd`). Deferred by the owner on 2026-09-17 to a later revision. | after v0 | -| Secret store backend, and where the v0 file's key lives. straylight has no secrets manager today. | M3 | +| Secret store backend, and where the v0 file's key lives. straylight has no secrets manager today. Moved from M3 on 2026-09-18. | M4 | | Whether cloud-led sessions are ever allowed, and for which data classes. | M6 | | Where the M5b embedding model runs. The brief says a separate `llama-server` instance, but under the shared router it would be a third model against `--models-max 2` and could unload Ornith or Laguna. Owner's note, 2026-09-17: more compute is likely by then (possibly one to three DGX Spark machines), so do not design around today's memory limit. | M5b | @@ -62,4 +71,6 @@ Later changes to the brief: ## Proposed changes to the design brief -None pending. +| # | Change | Reason | +|---|---|---| +| P13 | Authority contract 4: add "Tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time." | The brief does not say where tool images come from; a pull at call time would be unlisted egress. Decided 2026-09-18 above. | diff --git a/docs/milestones.md b/docs/milestones.md index d454204..5688bc4 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -9,7 +9,8 @@ One session per milestone. The design brief is `docs/design.md`. | M0 | Measurement spike | The cache and tool-call assumptions hold on straylight | Numbers in `docs/inference-contract.md` | | M1 | Workspace, `proto`, gate | The type-level authority model compiles | `make gate` | | M2 | `loopd` core + `bxctl chat` | Append-only loop, baseline ≤ 3k tokens, self-test, runaway control, with one fake in-process tool | Prefix-extension property test; turn-2 cache hit on straylight | -| M3 | `brokerd` | Grants, deny by default, hash-chained audit, rootless container runner, four tools (read file, write file, sandboxed shell, HTTP fetch with host allowlist), `ask` approvals through `bxctl` | Tests for deny paths; a container with no network grant cannot reach the network | +| M3a | `brokerd` decisions | Grants, deny by default, taint, hash-chained audit, `ask` approvals through `bxctl`, two sockets, `loopd`'s broker port; tools do not run yet | Tests for every deny path; audit tampering suite; a denial reaches Ornith on straylight as a tool result | +| M3b | `brokerd` runner | Rootless container runner, four tools (read file, write file, sandboxed shell, HTTP fetch with host allowlist), egress proxy | A container with no network grant cannot reach the network; fetch refused for hosts outside the grant | | M4 | `gatewayd` + Mattermost | Bot connection, user-ID allowlist, threads as sessions, async delivery, approvals by reply or reaction, no listening port | End-to-end direct message on the real server; a non-allowlisted user gets no response | | M5 | Scheduler + memory | Heartbeat and cron on their own slot in isolated sessions; core block, `recall` and `remember` with provenance, FTS5 index, flush-then-compact at idle | Scheduled run leaves the main slot's cache intact; a fact from epoch 1 is recalled in epoch 2; a note written after reading untrusted content is marked on recall | | M5b | Embedding index (when justified) | Local embedding server, hybrid retrieval behind the `Retriever` trait, rebuild on model change | A paraphrased query finds a note that lexical search misses; no memory text leaves the host | diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..a74a439 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,299 @@ +# Runbook + +One entry for every state in which Boxmaker refuses to work or holds something back, and for +events worth understanding when you see them. Every message for such a state ends with +`see docs/runbook.md#`; a gate check makes sure the entry exists. + +Each entry says what you see, why the system refuses, how to confirm, how to fix, and how to check +the fix. Commands assume: + +- `$BOXMAKER_HOME` is the harness home: `/var/lib/boxmaker` when deployed, `.state` in the + repository during development. +- `$GRANTS` is `[paths] grants` from `brokerd.toml`: `/etc/boxmaker/grants` when deployed. +- Until M7 there are no service units: the daemons run in a terminal, and "stop" means Ctrl-C or + `pkill -x `. + +Entries for M3a states are written ahead of the code and describe it as specified in +`docs/specs/2026-09-18-m3a-decision-path.md`. + +## grants-invalid + +**What you see.** `brokerd` prints one or more lines naming a grant file and a problem, then this +entry. Every tool call is denied, and the model tells you "the grant files have an error". + +**Why.** One grant file is invalid, so `brokerd` denies everything until it is fixed. It cannot +skip the bad file: if the bad file was a `deny` grant, skipping it would allow what it was written +to forbid. + +**Confirm.** + +```sh +bxctl grants check +``` + +It prints each problem as `:: `. Common ones: a misspelled field (unknown +fields are errors); `secret` set (not supported until M4); `patterns` set (not supported); `hosts` +on a file tool or `paths` on `http_fetch`; a relative path or one with `..`, `.` or `//`; a host +with uppercase letters, a port, or an IP address; a file name with characters outside `[a-z0-9-]`. + +**Fix.** Edit the file, or move it out of `$GRANTS` while you work on it: + +```sh +mkdir -p ~/grants-parked && mv "$GRANTS/.toml" ~/grants-parked/ +``` + +Do not park a `deny` grant unless you are sure nothing else would then allow what it forbids. + +**Check.** `bxctl grants check` prints `grants: ok`. No restart is needed: `brokerd` reads the +grants again at the next call. + +## audit-unavailable + +**What you see.** `brokerd` prints an error writing to `$BOXMAKER_HOME/audit/`, then this entry. +Tool calls are denied, and the model says "the audit log cannot be written". + +**Why.** A call runs only after its decision is on disk. If the record cannot be written, nothing +runs. + +**Confirm.** + +```sh +df -h "$BOXMAKER_HOME" +ls -ld "$BOXMAKER_HOME/audit"; ls -l "$BOXMAKER_HOME/audit" | tail -3 +``` + +Look for a full disk, a directory or file not owned by the user `brokerd` runs as, or a read-only +file system. + +**Fix.** Free space or correct ownership (`chown` to the `brokerd` user; files are mode 0600, the +directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain. + +**Check.** Ask for any tool call. It is decided normally, and `bxctl audit verify` prints +`audit: ok`. If `brokerd` keeps failing after the cause is gone, stop and start it. + +## audit-chain-broken + +**What you see.** `brokerd` refuses to start and prints `:: `, then this entry. +Or `bxctl audit verify` prints the same and exits 1. + +**Why.** A record does not parse, its `seq` is not the next number, its `prev` is not the hash of +the line before, or a file does not continue from the one before it. Something changed the log +after it was written: an edit, a deleted or reordered line, a file restored from a backup, a +partial copy. `brokerd` will not add records after a history it cannot vouch for. + +**Confirm.** + +```sh +bxctl audit verify +sed -n ',p' "$BOXMAKER_HOME/audit/" +``` + +Work out what happened before going on: a restore, a manual edit, a disk problem, or none you know +of. If none you know of, treat it as a possible intrusion and keep the files as they are. + +**Fix.** Keep a copy first, then accept the break explicitly: + +```sh +cp -a "$BOXMAKER_HOME/audit" ~/audit-copy-$(date +%F) +brokerd serve --config --accept-break +``` + +`brokerd` appends an `accepted_break` record naming the file and line, and continues from there. +Nothing is repaired or deleted. The break stays visible in every later verification. + +**Check.** `bxctl audit verify` prints `audit: ok` and lists the accepted break with its file and +line. A second failure later in the log needs its own accepted break. + +## audit-recovered + +**What you see.** `brokerd` prints "audit: recovered a torn final line" at start, then this entry. +It starts normally. + +**Why.** The last record was being written when `brokerd` or the machine stopped, so the file ended +without a newline. That is expected after a crash, not an error. `brokerd` kept the partial line, +ended it, and wrote a `recovery` record holding its length and hash, chained from the last complete +record. The partial record was never acted on: records are written before the action they allow. + +**Confirm.** `bxctl audit verify` lists the recovery with its file and line. + +**Fix.** None needed. If recoveries happen often, find out why `brokerd` is being killed. + +**Check.** `bxctl audit verify` prints `audit: ok`. + +## brokerd-already-running + +**What you see.** `brokerd` exits at start with "brokerd is already running" and this entry. + +**Why.** Only one `brokerd` may write the audit log. It holds a lock on +`$BOXMAKER_HOME/audit/.lock` for as long as it runs, and the lock is released when the process +ends, however it ends. + +**Confirm.** + +```sh +pgrep -a brokerd +``` + +**Fix.** Use the running one, or stop it (`pkill -x brokerd`) and start again. Deleting `.lock` +does not help and is not needed. + +**Check.** `pgrep -a brokerd` shows one process. + +## broker-state-damaged + +**What you see.** `brokerd` prints an error reading or writing +`$BOXMAKER_HOME/broker/sessions/.json`, then this entry. Either every call for that session is +denied ("this session's broker state is damaged"), or one call failed with "the result could not +be recorded". + +**Why.** The file holds the session's taint and untrusted flag. If `brokerd` cannot read it, it +does not know how sensitive the session's data is, so it denies. If it cannot write it after a +tool ran, it withholds the result: content that raised the taint must not reach the model unless +the raised taint is on disk. + +**Confirm.** + +```sh +ls -l "$BOXMAKER_HOME/broker/sessions/.json"* +cat "$BOXMAKER_HOME/broker/sessions/.json" +df -h "$BOXMAKER_HOME" +``` + +A good file is one line such as `{"taint":"private","untrusted":false}`. + +**Fix.** For a write failure, fix space or ownership as in +[audit-unavailable](#audit-unavailable). For a damaged file, do not guess low: set it from the +session's results. The session's audit `result` records show the highest `taint_after`, and any +result from an untrusted grant means `"untrusted":true`. If unsure, write +`{"taint":"secret","untrusted":true}`, which is always safe. The session only loses access to +grants with a lower `max_taint`. A `.tmp` file beside it is a write that did not finish and can +be removed. + +**Check.** The next call for the session is decided normally. + +## socket-forbidden + +**What you see.** `brokerd` prints that a message of some kind arrived on the wrong socket, then +this entry. + +**Why.** `broker.sock` accepts only tool requests from `loopd`; `admin.sock` accepts only admin +requests from `bxctl`. Nothing in the harness sends a wrong kind, so this is a bug or a component +doing what it should not, such as something trying to approve through the tool socket. + +**Confirm.** Note the time, kind and socket. Check what was running: `pgrep -a 'loopd|bxctl'`. +Look at the session logs active at that time. + +**Fix.** If a harness version mismatch explains it (a new `bxctl` against an old `brokerd`), +rebuild and restart both. Otherwise keep the logs, stop `loopd`, and investigate before running it +again. + +**Check.** No further messages of this kind. + +## broker-unavailable + +**What you see.** `loopd` prints that it cannot reach `broker.sock`, then this entry. Tool calls +fail with "the tool broker is unavailable", the model says so, and the conversation goes on. + +**Why.** `brokerd` is not running, was restarted during a call, or `loopd`'s `[broker] socket` +points somewhere else. Pending approvals are lost when `brokerd` restarts. `bxctl audit verify` +lists them as abandoned. + +**Confirm.** + +```sh +pgrep -a brokerd +ls -l "$BOXMAKER_HOME/run/loop-broker/" +``` + +Compare the socket path with `[broker] socket` in `loopd`'s config and `[sockets] broker` in +`brokerd.toml`. + +**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart: it connects per +call. + +**Check.** Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of +failing. + +## loopd-selftest-failed + +**What you see.** `loopd` prints `selftest: FAILED: ` and this entry, and exits 1. + +**Why.** `loopd` will not start against a server that differs from the one its config describes, +that does not parse tool calls, or whose cache does not work. + +**Confirm and fix, by reason.** + +- `chat template sha256: expected …, got …`, `context per slot: …` or `slot count: …`. The server + changed: a model update, or new flags in `~/src/nixos/hw/straylight/default.nix`. Check what + changed on straylight. If the change is intended, update `[expect]` in `loopd`'s config: + + ```sh + curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq -j .chat_template | sha256sum + curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq '.default_generation_settings.n_ctx, .total_slots' + ``` + + A template change also invalidates the M0 measurements. Re-run the checks in + `docs/inference-contract.md` before trusting the cache behaviour. +- `the tool call did not come back parsed: …`. The template or the server's tool parser changed. + Treat it like a template change. Do not start `loopd` until tool calls parse again. +- `turn 2 did not reuse turn 1's cache: …`. Another client may have taken the slot between the two + requests. Check `curl -s 'http://straylight:11434/slots?model=ornith-1.5-35b-a3b'` and run + `loopd selftest --config ` again. If it fails twice with the slot otherwise idle, the cache + is not working: check the server flags for `cache_prompt` and the KV settings. +- A connection, timeout or HTTP error. Check that `inferproxy` is running and that its upstream + answers: `curl -s http://straylight:11434/health`. A model load can take tens of seconds, so + retry once. + +**Check.** `loopd selftest --config ` prints `selftest: ok`. + +## session-log-damaged + +**What you see.** A turn on an existing session fails with `:: ` naming +`sessions//.jsonl`, and this entry. Other sessions work. + +**Why.** `loopd` replays the log exactly to rebuild the conversation, so a line it cannot read +would change what the model sees. It refuses rather than guess. `last line does not end in a +newline` means a write was cut off: `loopd` writes a record only after the request it describes +has finished, so a cut-off line was never used. + +**Confirm.** + +```sh +f="$BOXMAKER_HOME/sessions//0.jsonl" +wc -l "$f"; tail -c 300 "$f" +``` + +**Fix.** Keep a copy first. Then either start a new session (always safe), or, for a cut-off last +line only, drop it: + +```sh +cp -a "$f" "$f.damaged-$(date +%s)" +head -n "$f.damaged-"* > "$f" # keep the complete lines before the damaged one +``` + +Anything else (a damaged line in the middle, an unknown record type) means the file was changed +by something other than `loopd`. Start a new session and keep the old log for inspection. + +**Check.** The next turn on the session succeeds. The first request re-reads the conversation if +the cache was lost, which `loopd` logs as a cache loss. + +## core-memory-unreadable + +**What you see.** Starting a new session fails with an error naming `memory/core.md`, and this +entry. Existing sessions still work: they use the baseline saved when they started. + +**Why.** `memory/core.md` exists but cannot be read. A missing file is fine, but starting without +memory you wrote, and without telling you, is not. + +**Confirm.** + +```sh +ls -l "$BOXMAKER_HOME/memory/core.md"; head -c 100 "$BOXMAKER_HOME/memory/core.md" +``` + +Look for wrong ownership or mode, or a file that is not UTF-8 text. + +**Fix.** Make it readable by the user `loopd` runs as (`chmod 0600` and `chown` to that user), or +save it again as UTF-8. + +**Check.** A new session starts, and its `0.baseline.json` contains the text. diff --git a/docs/specs/2026-09-18-m3a-decision-path.md b/docs/specs/2026-09-18-m3a-decision-path.md new file mode 100644 index 0000000..a4302b9 --- /dev/null +++ b/docs/specs/2026-09-18-m3a-decision-path.md @@ -0,0 +1,611 @@ +# M3a design: the decision path + +Status: draft for owner review, 2026-09-18. M3 is split in two (`docs/decisions.md`). M3a is +everything that decides whether a tool call may run and records it: grant files and matching, +session taint, the audit log, approvals through `bxctl`, the two `brokerd` sockets, and `loopd`'s +real tool port. Tools themselves do not run in M3a: the runner is a trait whose only +implementation refuses. M3b adds the Podman runner, the four tools and the egress proxy. Where +this document and `docs/design.md` disagree, the brief wins. The threat model and data classes are +in `docs/specs/2026-09-17-pre-m1-design.md`; section 6 of this document replaces its socket table. + +## 1. What M3a proves + +| Claim | Checked by | +|---|---| +| No grant, an expired grant, too much taint, or arguments outside the grant each end in a denial with the right reason | Policy table tests; property test against an oracle | +| One invalid grant file denies every call | Loading tests | +| Nothing runs without a decision record on disk first | Audit-before-action tests with a failing writer | +| Any edit, deletion, reordering or gap in the audit log is found, with its file and line | Tampering suite, through `bxctl audit verify` and `brokerd`'s startup | +| `loopd` cannot approve: the tool socket refuses every admin message | Socket tests | +| An approval only lets through a call that is still `ask` when approved | Approval tests | +| A denial reaches the model as a plain tool result and the turn goes on | End-to-end test in one process; on straylight with Ornith | +| Every fail-closed state names a runbook entry that exists | Gate script | + +Out of scope: running tools, containers, the egress proxy, tool timeouts (all M3b); secrets +(moved to M4); approvals over Mattermost (M4); job-scoped grants (M5). + +## 2. Components + +``` +loopd ── run/loop-broker/broker.sock ──┐ + brokerd ── Runtime (M3a: refuses; M3b: podman) +bxctl ── run/owner-broker/admin.sock ──┘ │ + ├── grants/*.toml (read only) + ├── /broker/sessions/.json + └── /audit/YYYY-MM-DD.jsonl +``` + +### `brokerd` modules + +Each is one file under 500 lines with one purpose. Only `serve` starts threads. + +| Module | Purpose | +|---|---| +| `config` | `brokerd.toml` into a typed `Config`. Unknown keys are errors. | +| `grants` | Reads `grants/*.toml` into a `GrantSet`, applying the loading rules in section 3. | +| `args` | Parses each tool's arguments into typed values; checks paths and URLs for form. | +| `policy` | `decide` and `redecide`. The only place a `Decision` is built. Already exists from M1. | +| `state` | Each session's taint and untrusted flag, read and written as files. | +| `audit` | The audit writer: lock, chain, sync, rollover, startup check, recovery, accepted breaks. | +| `approvals` | The table of pending approvals and their expiry. In memory only. | +| `runner` | `Runtime`, `RunSpec`, and `run(decision, runtime)`. The only place a `RunSpec` is built. | +| `broker` | Handles one `broker.sock` connection: one tool request from decision to answer. | +| `admin` | Handles one `admin.sock` connection: one admin request. | +| `serve` | Startup, the two listeners, one thread per connection. | + +`brokerd serve --config [--accept-break]`. `brokerd` gains `serde`, `serde_json` and +`toml`, all already vetted. + +### Configuration + +```toml +# brokerd.toml +[paths] +home = "/var/lib/boxmaker" # default: BOXMAKER_HOME, then /var/lib/boxmaker +grants = "/etc/boxmaker/grants" + +[sockets] +broker = "/var/lib/boxmaker/run/loop-broker/broker.sock" +admin = "/var/lib/boxmaker/run/owner-broker/admin.sock" + +[approvals] +ttl_ms = 900000 # 15 min +``` + +## 3. Grants + +### Files + +One grant per file, `grants/.toml`. The id is the file stem: 1 to 64 characters of +`[a-z0-9-]`. Other files in the directory (not ending in `.toml`) are ignored; a `.toml` file with +a bad stem is an invalid grant. The fields are `proto::Grant` as defined in M1. + +```toml +# grants/notes-read.toml +tool = "read_file" +mode = "auto" +max_taint = "secret" +result_class = "private" +untrusted = false +expires = "2027-01-01T00:00:00.000Z" + +[constraints] +paths = ["/home/kyle/notes"] +``` + +### Loading + +`brokerd` reads every grant file at the start of every decision. There is no cache and no reload +signal: a change applies to the next call. + +A grant file is invalid if any of these holds. The error names the file and, where the parser +gives one, the line and field. + +1. It cannot be read, is not UTF-8, or is not valid TOML for `proto::Grant` (unknown fields + included). +2. Its stem is not a valid id. +3. `tool` is not one of `read_file`, `write_file`, `shell`, `http_fetch`. +4. `secret` is set: "secrets are not supported until M4". +5. `constraints.patterns` is not empty: "patterns are not supported". +6. A constraint is present that the tool does not take, or a required one is missing: + +| Tool | `paths` | `hosts` | +|---|---|---| +| `read_file` | required | must be empty | +| `write_file` | required | must be empty | +| `shell` | optional | must be empty | +| `http_fetch` | must be empty | required | + +7. A path in `paths` is not a valid absolute path (section 3, "Paths"). +8. A host in `hosts` is not a valid host pattern (section 3, "Hosts"). + +**If any file is invalid, the whole set is invalid**, and every call is denied with +`grants_invalid` until it is fixed. `brokerd` prints each problem, once per distinct set of +problems, followed by `see docs/runbook.md#grants-invalid`. The reason: a mistyped `deny` grant +that was skipped would silently turn into an allow wherever another grant matches. + +### Matching + +For a request to tool `T`, with the session's current taint `S`: + +1. Candidates are the grants whose `tool` is `T`. +2. A candidate is ruled out by the first of these that holds: it has expired (`now >= expires`); + `S > max_taint`; its constraints do not cover the arguments. +3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Ties go to + the lowest grant id in byte order. +4. If none is left, the reason is `grant_expired` if some candidate was ruled out only by expiry; + otherwise `taint_too_high` if some candidate was ruled out only by taint; otherwise `no_grant`. + +"Only by expiry" means the grant would have matched had it not expired: taint and constraints both +pass. Likewise for taint. + +The outcome is one of: allowed by grant `g` (`auto`); ask, under grant `g`; denied by grant `g` +(`denied_by_grant`); denied with no grant (`no_grant`, `grant_expired`, `taint_too_high`). + +### What each tool takes, and what its constraints cover + +Arguments are the model's JSON object, parsed strictly (unknown fields rejected). Arguments that +do not parse or fail the form checks below are denied with `invalid_arguments` before matching. + +| Tool | Arguments | Covered when | +|---|---|---| +| `read_file` | `{ "path": "" }` | `path` is inside one of the grant's `paths` | +| `write_file` | `{ "path": "", "content": "" }` | `path` is inside one of the grant's `paths` and is not the grant path itself | +| `shell` | `{ "command": "", "cwd": "" }`, `cwd` optional | the grant has no `paths` and `cwd` is absent, or `cwd` is inside one of the grant's `paths` | +| `http_fetch` | `{ "url": "https://…" }` | the URL's host matches one of the grant's `hosts` | + +`command` and `content` are not inspected. The container is the boundary for what a command can do. + +### Paths + +A valid path, in a grant or an argument: starts with `/`; is at most 4,096 bytes; contains no NUL; +has no empty component (no `//`, no trailing `/` except the root itself), no `.` and no `..` +component. Paths are compared as they are, never normalised, and matching is by whole components: + +| Grant path | Requested | Result | +|---|---|---| +| `/home/kyle/notes` | `/home/kyle/notes/a.md` | inside | +| `/home/kyle/notes` | `/home/kyle/notes` | inside (the directory itself) | +| `/home/kyle/notes` | `/home/kyle/notes2/a.md` | not inside | +| `/home/kyle/notes` | `/home/kyle/notes/../.ssh/id` | `invalid_arguments` (`..`) | +| `/home/kyle/notes` | `notes/a.md` | `invalid_arguments` (relative) | +| `/home/kyle/notes` | `/home/kyle//notes/./a.md` | `invalid_arguments` (`//`, `.`) | +| `/` | `/etc/passwd` | inside (a grant of `/` covers everything; allowed, not advised) | + +`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the +same path, so a symlink pointing outside it points at nothing inside the container. + +When several of a grant's paths contain the argument, the longest one is the matched path. + +### Hosts + +A host name: 1 to 253 bytes, lowercase, at least two labels separated by `.`, each label 1 to 63 +bytes of `[a-z0-9-]` not starting or ending with `-`. A host pattern in a grant is a host name, or +`*.` followed by a host name. + +| Grant host | URL host | Result | +|---|---|---| +| `example.com` | `example.com` | matches | +| `example.com` | `www.example.com` | no match | +| `*.example.com` | `www.example.com`, `a.b.example.com` | matches | +| `*.example.com` | `example.com` | no match (list both to allow both) | + +A valid `url`: at most 2,048 bytes; starts with `https://`; the host is a host name as above (so no +IP literals, no uppercase, no `[`); no userinfo (`@` before the path); no port, or port `443`; +then optionally `/` and a path, query or fragment of printable ASCII other than space. Anything +else is `invalid_arguments`. + +## 4. Session state + +`/broker/sessions/.json`, written only by `brokerd`: + +```json +{"taint":"private","untrusted":false} +``` + +- A session with no file is at `private`, trusted. The file is created at its first result. +- After a tool result, taint becomes the higher of its current value and the grant's + `result_class`; a grant with `untrusted = true` sets the flag. Neither ever goes down. +- Writes are atomic: write `.json.tmp`, `fsync`, rename over `.json`, `fsync` the directory. +- A file that exists but cannot be read or parsed is an error: every call for that session is + denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`. +- `loopd` has no access to `/broker/`. The `class` and `untrusted` values it logs are a copy + for its own use and never an input to policy. + +## 5. The audit log + +### Records + +Replaces the M1 `AuditRecord` (nothing has written one yet). Every record has a header and an +event: + +```rust +pub struct AuditRecord { pub seq: u64, pub time: Timestamp, pub prev: Hash32, pub event: AuditEvent } + +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuditEvent { + Decision { + session: SessionId, call: CallId, tool: String, + arguments: String, // in full, as received + outcome: DecisionRecord, + grant: Option, grant_sha256: Option, // the matched grant and its file's hash + taint: DataClass, untrusted: bool, // the session's state when decided + }, + Approval { + session: SessionId, call: CallId, decision: u64, // seq of the Decision record + answer: ApprovalAnswer, // approved | refused | expired + by: Option, // "bxctl"; from M4 a Mattermost user id + post: Option, // from M4, the Mattermost post id + reason: Option, // the owner's text, for refusals + outcome: DecisionRecord, // the re-decision (section 6); denied if refused or expired + }, + Result { + session: SessionId, call: CallId, decision: u64, + status: ResultStatus, // result | failed + class: DataClass, untrusted: bool, truncated: bool, + bytes: u64, sha256: Hash32, // of the content (or of the failure message) + taint_after: DataClass, + }, + Recovery { torn_bytes: u64, torn_sha256: Hash32 }, + AcceptedBreak { file: String, line: u64, last_good: Hash32 }, +} + +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum DecisionRecord { Allowed, Ask, Denied { reason: DenyReason } } +``` + +`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape +(with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event. + +Results are recorded by hash and size, never content: a `read_file` of a secret must not copy the +secret into the audit log. Arguments are recorded in full; they are at most one frame (1 MiB). + +### The chain + +- A record is one line of JSON written by serde, then `\n`. +- `hash(line)` is `proto::sha256` of the line's bytes without the `\n`. +- `prev` is the hash of the previous line; the first record ever written has `prev` all zeros and + `seq` 0. +- `seq` goes up by one per record with no gaps, across files. +- The file is `audit/YYYY-MM-DD.jsonl` by the UTC date of the record's `time`. The first record of + a new day starts a new file and chains from the last line of the previous file. Files are created + with mode 0600; the directory is `fsync`ed after a file is created. +- `brokerd` is the only writer. It holds an exclusive `flock` on `audit/.lock` for its whole life + and `fsync`s after every record. + +### Write order + +For every tool request: + +1. Decide (section 3). Write the `Decision` record and sync it. **If this write fails, nothing + runs**: the answer is `denied` with `audit_unavailable`, and `brokerd` prints + `see docs/runbook.md#audit-unavailable`. +2. If `ask`: wait (section 6). Write the `Approval` record. If it fails, the call does not run and + the answer is `denied` with `audit_unavailable`. +3. If allowed: run. Update the session state file. Write the `Result` record. Then answer `loopd`. + If the state write or the `Result` write fails, the answer is `failed` with the message "the + result could not be recorded", and the content is not sent: content that raised the taint must + never reach the model unless the raised taint is on disk. + +Denials are answered after step 1 (or 2); nothing else is written for them. + +### Verification + +`proto::audit::ChainVerifier` is a pure state machine, shared by `brokerd` and `bxctl`: + +```rust +pub struct ChainVerifier { /* … */ } +impl ChainVerifier { + pub fn new() -> Self; // expects seq 0, prev zero + pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point + pub fn file(&mut self, name: &str); // starts the next file + pub fn line(&mut self, bytes: &[u8], last_in_file: bool, has_newline: bool); + pub fn finish(self) -> ChainReport; +} +pub struct ChainReport { + pub records: u64, pub head: Option, pub next_seq: u64, + pub failure: Option, // the first one: file, line (1-based), what + pub recoveries: Vec, pub accepted_breaks: Vec, + pub abandoned: Vec, // seq of Ask decisions with no Approval after them + pub clock_warnings: Vec, // time went backwards + pub torn_tail: Option, // last line of the last file has no newline +} +``` + +A line fails if it does not parse as an `AuditRecord`, its `seq` is not the next one, or its +`prev` is not the hash of the line before. Files are fed in name order; a file whose first record +does not chain from the previous file's last line fails at its line 1. Two cases are not failures: + +- **Torn tail.** The last line of the last file has no newline. It is reported in `torn_tail`. +- **Recovered line.** A line that does not parse, immediately followed by a `Recovery` record whose + `torn_bytes` and `torn_sha256` describe exactly that line, and whose `prev` is the hash of the + line before the torn one and whose `seq` follows that line's. Reported in `recoveries`. + +An `AcceptedBreak` record is accepted only if its `file` and `line` name the first failure found +since the last accepted break (or the start). The verifier then clears that failure, reports the +break, and continues with the break record as the new head. Lines between the failure and the +break record are not checked. + +### Startup + +1. Take the lock. If it is held: print "brokerd is already running" + and `see docs/runbook.md#brokerd-already-running`, exit 1. +2. Verify the latest file, resumed from the last line of the file before it (if any). The earlier + files are not re-read; `bxctl audit verify` does that. +3. If the report has a torn tail: write `\n` after the torn bytes, then a `Recovery` record chained + from the line before the torn one. Print "audit: recovered a torn final line" and + `see docs/runbook.md#audit-recovered`. +4. If the report has a failure: without `--accept-break`, print the failure's file, line and what, + and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`, append an + `AcceptedBreak` record naming it, with `last_good` the hash of the last line that verified, + `prev` the hash of the file's last line, and `seq` one more than the highest `seq` among lines + that parse (or than the failure line's predecessor if none after it parse). Print what was + accepted. +5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2. + +Nothing is truncated, rewritten or deleted, ever. + +## 6. Sockets and messages + +### Two sockets + +| Socket | Directory | Client | Accepts | +|---|---|---|---| +| `broker.sock` | `run/loop-broker/` | `loopd` | `tool_request` | +| `admin.sock` | `run/owner-broker/` | `bxctl` | `approvals`, `approve`, `refuse`, `check_grants` | + +`brokerd` creates each directory with mode 0700 if it is missing, removes a stale socket file, +binds, and sets the socket to 0600. Any other message kind on a socket is answered with `error` +`forbidden`, and the connection is closed. `brokerd` prints the kind and the socket, with +`see docs/runbook.md#socket-forbidden`: nothing in the harness sends a wrong kind, so it means a +bug or a component doing what it should not. + +In M3 all roles run as the owner's user, so the directory permissions do not yet keep `loopd` out of +`admin.sock`; `loopd` simply has no code that sends admin messages. M7 mounts only +`run/loop-broker/` into `loopd`'s container. This gap is recorded in section 11. + +### Tool requests, on `broker.sock` + +One connection per request, as on `loop.sock`: + +``` +→ tool_request { session, call, tool, arguments } +← tool_response { status: pending_approval, approval, expires } final: false (ask only) +← tool_response { status: result | failed | denied, … } final: true +``` + +`approval` becomes a `u64`: the `seq` of the call's `Decision` record. + +### Approvals + +- An `ask` call is added to the pending table with `expires` = now + `ttl_ms`, or the grant's + `expires` if that is earlier. `brokerd` sends the pending frame and waits. +- **Approve.** `brokerd` decides again (`redecide`) with the grants and session state as they are + now. An `ask` outcome lets the call run, and so does `auto` (the owner has since allowed it + outright); the grant matched now is the one used and recorded. Any denial, from a `deny` grant + or from no grant still matching, denies the call with that reason. The `Approval` record carries + `approved` and the re-decision. +- **Refuse.** Denied with `approval_refused`. +- **Expiry.** A thread checks the table every second. An expired approval is denied with + `approval_expired`. +- **Lost connection.** If `loopd`'s connection closes while pending, the entry is removed and + nothing is written. `bxctl audit verify` reports the decision as abandoned. +- **Restart.** The table is in memory only. After a restart there are no pending approvals. + +### Admin messages, on `admin.sock` + +| Request | Answer (final) | +|---|---| +| `approvals {}` | `approval_list { items: [PendingApproval] }` | +| `approve { approval }` | `approve_result { outcome: DecisionRecord }` — the re-decision | +| `refuse { approval, reason }` | `ok {}` | +| `check_grants {}` | `grants_report { problems: [GrantProblem] }`, empty when valid | + +```rust +pub struct PendingApproval { + pub approval: u64, pub session: SessionId, pub call: CallId, pub tool: String, + pub arguments: String, pub grant: String, pub taint: DataClass, + pub created: Timestamp, pub expires: Timestamp, +} +pub struct GrantProblem { pub file: String, pub line: Option, pub problem: String } +``` + +An unknown or already answered `approval` is `error` `no_such_approval`. + +## 7. The runner seam + +```rust +pub trait Runtime: Send + Sync { + fn run(&self, spec: &RunSpec) -> Result; +} +pub struct RunSpec { // fields private; built only in runner.rs + tool: ToolName, // ReadFile | WriteFile | Shell | HttpFetch + arguments: ToolArgs, // the typed arguments from `args` + mounts: Vec, // { path, writable } + egress: Option>, // the grant's host patterns; None = no network +} +pub struct RunOutput { pub content: String, pub truncated: bool } +pub enum RunError { Failed(String), Unavailable(String) } +pub fn run(decision: Decision, runtime: &dyn Runtime) -> ToolResponse; +``` + +`RunSpec` has public getters and no public constructor; a `compile_fail` doctest proves it, like +`Decision`'s. `Decision` gains the parsed arguments and the matched grant (id, `result_class`, +`untrusted`, the matched path or hosts), all set by `policy`. + +What `run` puts in the spec, from the decision: + +| Tool | `mounts` | `egress` | +|---|---|---| +| `read_file` | the matched grant path, read-only | none | +| `write_file` | the matched grant path, writable | none | +| `shell` | every path of the grant, writable | none | +| `http_fetch` | none | the grant's `hosts` | + +M3a's production runtime is `Refusing`: every call is `RunError::Unavailable("the runner arrives +in M3b")`, which becomes `failed` for `loopd`. Tests use a recording fake. Limits (timeout, memory, +processes, output size) are M3b's. + +## 8. `loopd` changes + +- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback: + `fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`. + The read timeout while waiting is the pending frame's `expires` plus 30 s. If the socket cannot + be reached or closes early, the answer is `Failed { "the tool broker is unavailable" }`, and + `loopd` prints `see docs/runbook.md#broker-unavailable`. The turn goes on. +- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and + needs no broker. The core tools stay `clock`, `find_tool`, `call_tool`. The registry's + discoverable tools become `read_file`, `write_file`, `shell`, `http_fetch`, with the argument + schemas from section 3. `echo` stays in `FakeTools` for tests only. This changes the baseline of + new sessions only. +- **Denials become tool results** the model can explain, written by `loopd` (`class` public, + `untrusted` false): + +| Reason | Content | +|---|---| +| `no_grant` | Denied: no grant allows this call. | +| `grant_expired` | Denied: the grant for this call has expired. | +| `taint_too_high` | Denied: this session has seen data too sensitive for this call. | +| `denied_by_grant` | Denied: a grant forbids this call. | +| `approval_refused` | Denied: the owner refused this call. | +| `approval_expired` | Denied: the approval request expired without an answer. | +| `invalid_arguments` | Denied: the arguments are not valid for this tool. | +| `grants_invalid` | Denied: the grant files have an error; the owner has been told. | +| `audit_unavailable` | Denied: the audit log cannot be written; the owner has been told. | +| `state_unreadable` | Denied: this session's broker state is damaged; the owner has been told. | + +- **A new turn event**, `approval_pending { approval, tool, expires }`, sent when the pending frame + arrives. +- **Config**: `[broker] socket`. If absent, `loopd serve` uses no port and every tool call except + the core ones fails with "no tool broker is configured". +- **Runbook pointers** on the existing fail-closed messages: the failed self-test + (`loopd-selftest-failed`), a damaged session log (`session-log-damaged`), an unreadable + `memory/core.md` (`core-memory-unreadable`). + +## 9. `bxctl` changes + +- `bxctl approvals [--admin-socket ]` lists pending approvals: + +``` +41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private + shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"} +``` + +- `bxctl approve ` prints `approved 41: runs` or `approved 41: denied ()`. + `bxctl refuse [--reason ]` prints `refused 41`. +- `bxctl grants check` prints each problem as `:: `, or `grants: ok`; exit + status 1 if there are problems. +- `bxctl audit verify [--home ]` reads `/audit/` itself (no daemon) and prints + `audit: ok, records, head `, then any recoveries, accepted breaks, abandoned approvals + and clock warnings, one per line; or the failure as `:: ` and exit status 1. +- `bxctl chat`: an `approval_pending` event prints the same two-line block as `approvals`. In the + interactive mode it then asks `approve 41? [y/N] ` on stderr and reads one line from stdin; `y` + sends `approve`, anything else sends `refuse`. `--say` and `--json` print the event only. +- **Arguments are printed as data.** Every character below U+0020, U+007F, and U+0080 to U+009F is + printed as `\u00XX`; so is U+001B wherever it appears. Nothing the model wrote can move the + cursor, change colours or hide text. +- The admin socket defaults to `/run/owner-broker/admin.sock`. + +## 10. `proto` changes + +| Change | Detail | +|---|---| +| `AuditRecord`, `AuditEvent`, `DecisionRecord`, `ApprovalAnswer`, `ResultStatus` | Section 5; the M1 audit fixtures are replaced | +| `audit::ChainVerifier`, `ChainReport`, `ChainFailure`, `Location` | Section 5 | +| `ToolResponse::PendingApproval.approval` | `String` becomes `u64` | +| `DenyReason` | gains `grants_invalid`, `audit_unavailable`, `invalid_arguments`, `state_unreadable` | +| `ErrorCode` | gains `forbidden`, `no_such_approval` | +| `Message` | gains `approvals`, `approval_list`, `approve`, `approve_result`, `refuse`, `ok`, `check_grants`, `grants_report` | +| `PendingApproval`, `GrantProblem` | Section 6 | +| `TurnEvent` | gains `approval_pending { approval, tool, expires }` | + +All of them reject unknown fields. Every new message kind gets a byte-exact fixture. + +## 11. The runbook + +`docs/runbook.md` has one entry for every state in which the harness refuses to work or withholds +something, and for events the owner should understand when they see them. Each entry is a level-2 +heading whose text is its anchor (`## grants-invalid`), with five parts: what you see, why the +system refuses, how to confirm, how to fix, and how to check the fix. + +Every message for such a state ends with `see docs/runbook.md#`. A gate script, +`scripts/check-runbook.sh`, collects every `docs/runbook.md#` in `crates/` and fails if +`docs/runbook.md` lacks the heading `## `. It has a self-test like the other gate scripts. + +## 12. Testing + +Tests and fixtures are given to the implementer and checked against a reference implementation +first, as in M2. + +- **Policy tables.** Every row of every table in section 3 is a case, plus: mode precedence among + three matching grants; the reason order when one candidate is expired and another has too much + taint; expiry exactly at `expires`; ties by grant id. +- **Loading.** One case per rule in "Loading", each with the problem text it must contain; one + invalid file among valid ones denies a call a valid file would allow. +- **Property test.** A seeded xorshift generator (no crate) makes grant sets, session taints and + requests. A separate, deliberately naive oracle in the test file says what should happen; every + case must agree. Over sequences of calls: taint never goes down; every `Result` record follows a + `Decision` for the same call; the fake runtime sees a call only after `allowed` or an approval. + The seed is printed on failure. +- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the + lock refusing a second writer. The tampering suite, each a fixture directory: a changed byte in a + middle line; a deleted line; two lines swapped; a `seq` gap; a file that does not chain from the + one before; a middle line cut short. Each must fail at the right file and line in both + `ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`, + and the next verify reports it); an accepted break (reported by every later verify); an + `AcceptedBreak` naming the wrong line (a failure). +- **Audit before action.** A writer that fails on demand: the answer is `audit_unavailable` and + the fake runtime's count is 0. A state writer that fails: `failed`, and the content is absent. +- **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`. + Socket mode 0600, directory mode 0700. +- **Approvals.** Approve; refuse; expire with `ttl_ms` 100; approve after the grant file is + removed (denied, `no_grant`); approve after the taint rose past `max_taint` (denied, + `taint_too_high`); an unknown id; a dropped `loopd` connection while pending (removed, nothing + written, reported as abandoned). +- **Runner seam.** The recording fake shows the mounts and egress of section 7 for each tool, and no + egress for anything but `http_fetch`. `compile_fail` for `RunSpec`. +- **`loopd`.** `BrokerPort` against a fake `brokerd`: result, denial text, pending event, broker + gone. The turn loop with `approval_pending`. +- **`bxctl`.** `approvals` output with an argument full of escape sequences; `approve`, `refuse`, + `grants check` and `audit verify` against fakes and fixture directories. +- **End to end, in one process.** The fake llama server, `loopd`'s turn loop, and the `brokerd` + library with the recording runtime. The scripted model calls `call_tool` for `read_file` with no + grant; its next request contains "Denied: no grant allows this call."; the audit log holds one + `Decision` record with `no_grant`. +- **The runbook script**, with its self-test. + +**On straylight** (a scripted check, not part of `verify-device`): `brokerd serve` and `loopd serve` +with a test home holding one `ask` grant for `read_file`. A `bxctl chat` asks Ornith to read a file +in that directory; the approval block appears; approving it gives the M3a runner's failure, which +the model reports. The audit log verifies, with `Decision`, `Approval` and `Result` records. + +## 13. What M3b needs from this + +M3b implements `Runtime` with Podman and adds nothing to policy. Its tests: golden `podman` +argument lists per `RunSpec`; on device, a container with no egress reaches neither the tailnet, +the host nor the internet; `http_fetch` reaches an allowed host and is refused for another and for +a redirect to another; `write_file` cannot write outside its mount; a symlink to `~/.ssh` in a +granted directory reads nothing; memory, process and time limits hold; no container with +Boxmaker's label remains after a call. Measurements behind these choices, 2026-09-18 on straylight: +rootless Podman 5.8.6 with crun starts a `--network=none` container in 40 to 80 ms; such a +container reaches neither `100.100.100.100` nor the host; static `curl` in it fetches HTTPS through +a SOCKS5 proxy on a mounted Unix socket (`--proxy socks5h://localhost/`), and a proxy that +accepts only allowlisted host names refuses other hosts and IP literals. + +## 14. Threat model notes + +- M3 runs every role as the owner's user. A compromised `loopd` could connect to `admin.sock` and + approve its own calls, and a container escape is the owner's user. Both close in M7 (separate + users and containers; `admin.sock` is never mounted into `loopd`). Accepted for M3, because the + main adversary is injected text, and `loopd` has no code path that sends an admin message. +- The pending-approval table is in memory. A `brokerd` crash loses pending approvals; the calls + fail and are reported as abandoned. Nothing runs without an approval record. +- Results are audited by hash. Proving what a result was needs the content from elsewhere (the + session log holds it, capped). + +## 15. How the work is handed over + +As in M2: small closed tasks with given tests, driven by `tools/run-plan.sh`, reviewed once after +the last M3a task. The reference implementation lives in a git worktree at `~/src/boxmaker-ref` +on an unpushed branch, not in `/tmp`. No task mixes policy with plumbing. Every task that adds a +fail-closed path lists its exits and its runbook anchor (tips T14 and T16).