Specify M3a (the decision path) and write the runbook
Split M3 into M3a and M3b, record the M3 design decisions, propose P13 (tool image built by Nix, named by digest), and add docs/runbook.md with an entry for every fail-closed state, including loopd's existing ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+13
-2
@@ -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#<entry>`, 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/<id>/<epoch>.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. |
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
+299
@@ -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#<entry>`; 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 <name>`.
|
||||
|
||||
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 `<file>:<line>: <problem>`. 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/<file>.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 `<file>:<line>: <what>`, 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 '<line-1>,<line+1>p' "$BOXMAKER_HOME/audit/<file>"
|
||||
```
|
||||
|
||||
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 <path> --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/<id>.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/<id>.json"*
|
||||
cat "$BOXMAKER_HOME/broker/sessions/<id>.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: <reason>` 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 <path>` 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 <path>` prints `selftest: ok`.
|
||||
|
||||
## session-log-damaged
|
||||
|
||||
**What you see.** A turn on an existing session fails with `<file>:<line>: <reason>` naming
|
||||
`sessions/<id>/<epoch>.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/<id>/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 <line-1> "$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.
|
||||
@@ -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)
|
||||
├── <home>/broker/sessions/<id>.json
|
||||
└── <home>/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 <path> [--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/<id>.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": "<abs>" }` | `path` is inside one of the grant's `paths` |
|
||||
| `write_file` | `{ "path": "<abs>", "content": "<text>" }` | `path` is inside one of the grant's `paths` and is not the grant path itself |
|
||||
| `shell` | `{ "command": "<text>", "cwd": "<abs>" }`, `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
|
||||
|
||||
`<home>/broker/sessions/<id>.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 `<id>.json.tmp`, `fsync`, rename over `<id>.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 `<home>/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<String>, grant_sha256: Option<Hash32>, // 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<String>, // "bxctl"; from M4 a Mattermost user id
|
||||
post: Option<String>, // from M4, the Mattermost post id
|
||||
reason: Option<String>, // 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<Hash32>, pub next_seq: u64,
|
||||
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what
|
||||
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
||||
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them
|
||||
pub clock_warnings: Vec<Location>, // time went backwards
|
||||
pub torn_tail: Option<Location>, // 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<u64>, 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<RunOutput, RunError>;
|
||||
}
|
||||
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<Mount>, // { path, writable }
|
||||
egress: Option<Vec<String>>, // 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 <path>]` 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 <id>` prints `approved 41: runs` or `approved 41: denied (<reason>)`.
|
||||
`bxctl refuse <id> [--reason <text>]` prints `refused 41`.
|
||||
- `bxctl grants check` prints each problem as `<file>:<line>: <problem>`, or `grants: ok`; exit
|
||||
status 1 if there are problems.
|
||||
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints
|
||||
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, abandoned approvals
|
||||
and clock warnings, one per line; or the failure as `<file>:<line>: <what>` 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 `<BOXMAKER_HOME>/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#<anchor>`. A gate script,
|
||||
`scripts/check-runbook.sh`, collects every `docs/runbook.md#<anchor>` in `crates/` and fails if
|
||||
`docs/runbook.md` lacks the heading `## <anchor>`. 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/<path>`), 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).
|
||||
Reference in New Issue
Block a user