M3a spec: fold in area E's findings; runbook and egress to match

Every request is recorded, unreadable state is recorded as secret, a
refusal that cannot be recorded is an error, and the other cases the
brokerd reference settled. The audit-unavailable and
broker-state-damaged entries name the new messages; egress lists the
development calls to straylight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:40:02 -07:00
co-authored by Claude Opus 5
parent 43f5b8abc6
commit 69f0a0a218
4 changed files with 38 additions and 9 deletions
+1
View File
@@ -6,6 +6,7 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit
| Date | Decision | Reason | | Date | Decision | Reason |
|---|---|---| |---|---|---|
| 2026-09-18 | M3a plan checks, `brokerd`. Every tool request gets a `Decision` record, including those denied `grants_invalid` or `state_unreadable`; forbidden kinds get none. An unreadable session state is recorded as `secret`, untrusted. A refusal that cannot be recorded is `error internal`, not `ok`. The re-decision's outcome is the matched grant's mode. A pending frame that cannot be sent is handled like a lost connection. Expiry lives in `admin`. A request frame without a read timeout is accepted for M3a. | Found while writing the reference for tasks 10 to 15: the spec left each case open, and `bxctl refuse` would have reported success for a refusal that was not on disk. |
| 2026-09-18 | M3a plan checks, audit. `DecisionRecord::Allowed {}` and `Ask {}` are empty struct variants. The resumed verifier accepts a break naming an older file inside a failed region too. When the previous file's last line does not parse, an ordinary start verifies the whole log. A `Recovery` that describes no line is a failure. `abandoned` and `unfinished` both name the decision's `seq`. The report carries what a `Recovery` or `AcceptedBreak` must hold. `--accept-break` with nothing to accept writes nothing. | serde ignores `deny_unknown_fields` on unit variants of an internally tagged enum, so `{"outcome":"allowed","x":1}` decoded; found by `strict.rs` in two areas, and the struct-variant fix was kept over a hand-written `try_from` as the smaller one that also refuses `"reason":null`. As specified, a correctly accepted break could stop every later start while `--accept-break` said "nothing to accept". | | 2026-09-18 | M3a plan checks, audit. `DecisionRecord::Allowed {}` and `Ask {}` are empty struct variants. The resumed verifier accepts a break naming an older file inside a failed region too. When the previous file's last line does not parse, an ordinary start verifies the whole log. A `Recovery` that describes no line is a failure. `abandoned` and `unfinished` both name the decision's `seq`. The report carries what a `Recovery` or `AcceptedBreak` must hold. `--accept-break` with nothing to accept writes nothing. | serde ignores `deny_unknown_fields` on unit variants of an internally tagged enum, so `{"outcome":"allowed","x":1}` decoded; found by `strict.rs` in two areas, and the struct-variant fix was kept over a hand-written `try_from` as the smaller one that also refuses `"reason":null`. As specified, a correctly accepted break could stop every later start while `--accept-break` said "nothing to accept". |
| 2026-09-18 | M3a plan checks, policy and `loopd`. A host name's last label starts with a letter. For `write_file` a grant path equal to the argument does not count toward the match. `redecide` returns `Result<Decision, Denial>`, and `Denial` carries the `deny` grant and its hash. `BrokerPort`'s waits are deadlines, not per-read timeouts; the envelope id is `request.call.0`. `approval_pending.tool` and `tool_denied.name` are the target tool, not `call_tool`. `echo` stays in the test registry. | `127.0.0.1` and `127.1` fitted the host grammar, so "no IP literals" was false. A per-read timeout let a trickling peer hold a turn for ever. `call_tool` tells the owner nothing. | | 2026-09-18 | M3a plan checks, policy and `loopd`. A host name's last label starts with a letter. For `write_file` a grant path equal to the argument does not count toward the match. `redecide` returns `Result<Decision, Denial>`, and `Denial` carries the `deny` grant and its hash. `BrokerPort`'s waits are deadlines, not per-read timeouts; the envelope id is `request.call.0`. `approval_pending.tool` and `tool_denied.name` are the target tool, not `call_tool`. `echo` stays in the test registry. | `127.0.0.1` and `127.1` fitted the host grammar, so "no IP literals" was false. A per-read timeout let a trickling peer hold a turn for ever. `call_tool` tells the owner nothing. |
| 2026-09-18 | M3a plan checks, `bxctl` and the runbook. A runbook anchor must be written out in the source, and the check fails when it finds no pointer. `--say` shows the approval block and does not ask; `--json` prints only the event and never contacts `brokerd`. Everything the model wrote is escaped, tool names and the final answer included. The approval list shows arguments in `serde_json`'s compact form. | A computed anchor could not be checked. "Print the event only" had no meaning without `--json`. The spec's example contradicted section 6. | | 2026-09-18 | M3a plan checks, `bxctl` and the runbook. A runbook anchor must be written out in the source, and the check fails when it finds no pointer. `--say` shows the approval block and does not ask; `--json` prints only the event and never contacts `brokerd`. Everything the model wrote is escaped, tool names and the final answer included. The approval list shows arguments in `serde_json`'s compact form. | A computed anchor could not be checked. "Print the event only" had no meaning without `--json`. The spec's example contradicted section 6. |
+1
View File
@@ -6,4 +6,5 @@ allowed.
| When | From | To | What | | When | From | To | What |
|---|---|---|---| |---|---|---|---|
| Development | `cargo` | crates.io | Downloading the crates listed in `docs/dependencies.md` | | Development | `cargo` | crates.io | Downloading the crates listed in `docs/dependencies.md` |
| Development | `make verify-device`, `tools/check-m3a-device.sh` | straylight's `llama-server` | Checks against the real model through a private `inferproxy`; the script also reads `/slots` with `curl` first |
| Development | `make audit` | github.com/rustsec/advisory-db | The RustSec advisory database, fetched by `cargo deny check advisories` | | Development | `make audit` | github.com/rustsec/advisory-db | The RustSec advisory database, fetched by `cargo deny check advisories` |
+10 -2
View File
@@ -52,7 +52,14 @@ grants again at the next call.
## audit-unavailable ## audit-unavailable
**What you see.** `brokerd` prints an error writing to `$BOXMAKER_HOME/audit/`, then this entry. **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". Tool calls are denied, and the model says "the audit log cannot be written". Every later call
prints `brokerd: an earlier audit write failed; every call is denied until brokerd is restarted`.
A call that ran but whose result could not be recorded reaches the model as "the result could not
be recorded", without its content; `bxctl refuse` says "the refusal could not be recorded".
A variant: `brokerd: a thread panicked while holding the ledger`. That is a bug in `brokerd`, not
a disk problem; the fix below is the same, and the panic message above it in `brokerd`'s output is
worth keeping for a report.
**Why.** A call runs only after its decision is on disk. If the record cannot be written, nothing **Why.** A call runs only after its decision is on disk. If the record cannot be written, nothing
runs. runs.
@@ -161,7 +168,8 @@ does not help and is not needed.
**What you see.** `brokerd` prints an error reading or writing **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 `$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 denied ("this session's broker state is damaged"), or one call failed with "the result could not
be recorded". be recorded". Such a call has no `Result` record, so `bxctl audit verify` lists it under "running
or unfinished"; that is expected, not a second fault.
**Why.** The file holds the session's taint and untrusted flag. If `brokerd` cannot read it, it **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 does not know how sensitive the session's data is, so it denies. If it cannot write it after a
+26 -7
View File
@@ -61,7 +61,9 @@ Each is one file under 500 lines with one purpose. Only `serve` starts threads.
### Threads and locks ### Threads and locks
`serve` starts one thread per connection and one expiry thread. They share two things, each behind `serve` starts one thread per connection and one expiry thread. Expiry answers an entry the way
`approve` does, so it lives in `admin` (`expire_due`), called by that thread; `approvals` stays a
plain table. They share two things, each behind
its own `Mutex`, and no thread ever holds both at once: its own `Mutex`, and no thread ever holds both at once:
- **The ledger**: the audit writer and every session state file. A thread holds it for each of - **The ledger**: the audit writer and every session state file. A thread holds it for each of
@@ -289,7 +291,7 @@ pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp
invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then
`decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the `decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the
arguments are not valid (`invalid_arguments`); matching. The first two need no `Decision`-like arguments are not valid (`invalid_arguments`); matching. The first two need no `Decision`-like
guard: anyone may deny. guard: anyone may deny. They are still recorded (section 5, "Write order").
## 4. Session state ## 4. Session state
@@ -413,7 +415,15 @@ For every tool request:
result could not be recorded", and the content is not sent: content that raised the taint must 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. 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. Denials are answered after step 1 (or 2); nothing else is written for them. Every tool request
gets a `Decision` record, including one denied with `grants_invalid` or `state_unreadable` before
`decide` runs. A message of a forbidden kind is not a tool request and gets none.
When the session's state cannot be read, records carry `taint: secret` and `untrusted: true`:
`brokerd` does not know how sensitive the session is.
In step 3, a failed state write (or a state that cannot be read) leaves no `Result` record, so the
log shows the call as unfinished; the `broker-state-damaged` runbook entry says so.
### Verification ### Verification
@@ -580,11 +590,16 @@ One connection per request, as on `loop.sock`:
either runs the call or sends the denial. either runs the call or sends the denial.
- **Approve.** The admin thread decides again (`redecide`) with the grants and session state as - **Approve.** The admin thread 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 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 allowed it outright); the grant matched now is the one used and recorded. The outcome is `ask`
if the grant matched now is an `ask` grant, `allowed` if it is `auto`. (`redecide` does not
return the mode; the ledger looks the grant up in the set it passed. M3b may have `redecide`
return it.) Any denial, from a
`deny` grant or from no grant still matching, denies the call with that reason. The `Approval` `deny` grant or from no grant still matching, denies the call with that reason. The `Approval`
record carries `approved` and the re-decision. If that record cannot be written, the verdict and record carries `approved` and the re-decision. If that record cannot be written, the verdict and
the `approve_result` are both `denied` with `audit_unavailable`. the `approve_result` are both `denied` with `audit_unavailable`.
- **Refuse.** Denied with `approval_refused`. - **Refuse.** Denied with `approval_refused`. If the refusal's `Approval` record cannot be
written, the call is denied with `audit_unavailable` and the answer to `bxctl` is `error`
`internal`, "the refusal could not be recorded", with a pointer to `audit-unavailable`.
- **Expiry.** A thread checks the table every second. An expired approval is denied with - **Expiry.** A thread checks the table every second. An expired approval is denied with
`approval_expired`. `approval_expired`.
- **Lost connection.** While it waits, the connection's thread wakes every second - **Lost connection.** While it waits, the connection's thread wakes every second
@@ -594,7 +609,8 @@ One connection per request, as on `loop.sock`:
it is nightly-only; a zero read timeout is an error.) If `loopd` has gone, the thread tries to it is nightly-only; a zero read timeout is an error.) If `loopd` has gone, the thread tries to
take its own entry out of the table. If it gets it, nothing is written, and take its own entry out of the table. If it gets it, nothing is written, and
`bxctl audit verify` reports the decision as abandoned. If the entry is already gone, someone `bxctl audit verify` reports the decision as abandoned. If the entry is already gone, someone
is answering it: the thread waits for the verdict. is answering it: the thread waits for the verdict. The same holds if the pending frame cannot be
sent.
- **One more check before running.** On a verdict to run, the thread checks its socket once more. - **One more check before running.** On a verdict to run, the thread checks its socket once more.
If `loopd` has gone, the call does not run, and a `Result` record with status `failed` (message If `loopd` has gone, the call does not run, and a `Result` record with status `failed` (message
"the requester went away") closes the call in the log. `loopd` can still go away between this "the requester went away") closes the call in the log. `loopd` can still go away between this
@@ -891,7 +907,7 @@ implementation, the oracle, or a compiling skeleton) is in section 15.
comes from `BOXMAKER_BROKERD`; the test is `#[ignore]`d without it, and `make gate` builds the comes from `BOXMAKER_BROKERD`; the test is `#[ignore]`d without it, and `make gate` builds the
workspace and then runs it with the variable set) on a temporary home, and runs `loopd`'s turn workspace and then runs it with the variable set) on a temporary home, and runs `loopd`'s turn
loop against the fake llama server with a `BrokerPort` on that `brokerd`'s socket. The scripted loop against the fake llama server with a `BrokerPort` on that `brokerd`'s socket. The scripted
model calls `call_tool` for `read_file` with no grant; its next request contains "Denied: no model calls `read_file` with no grant; its next request contains "Denied: no
grant allows this call."; the audit directory, read with `proto::ChainVerifier`, verifies and grant allows this call."; the audit directory, read with `proto::ChainVerifier`, verifies and
holds one record, a `Decision` with `no_grant`. It cannot be one process: that would make `loopd` depend on holds one record, a `Decision` with `no_grant`. It cannot be one process: that would make `loopd` depend on
`brokerd`, even as a dev-dependency, and no crate may depend on another role's crate `brokerd`, even as a dev-dependency, and no crate may depend on another role's crate
@@ -934,6 +950,9 @@ accepts only allowlisted host names refuses other hosts and IP literals.
starts at `private`. Taint therefore contains the model, not a compromised `loopd`; what starts at `private`. Taint therefore contains the model, not a compromised `loopd`; what
contains that is the grants themselves (`ask` on anything that leaves the host). Accepted for contains that is the grants themselves (`ask` on anything that leaves the host). Accepted for
v0. A host-wide taint floor would close it and is not proposed here. v0. A host-wide taint floor would close it and is not proposed here.
- `brokerd` sets no read timeout on a request frame, so a client that connects and sends nothing
holds a thread until it goes. Only `loopd` can reach `broker.sock` and only the owner
`admin.sock`. Accepted for M3a; M3b should add a timeout.
- Deleting `<home>/broker/sessions/<id>.json` puts a session back to `private` and nothing - Deleting `<home>/broker/sessions/<id>.json` puts a session back to `private` and nothing
notices; the audit log, by contrast, shows tampering. Only the owner's user can do it, and the notices; the audit log, by contrast, shows tampering. Only the owner's user can do it, and the
`Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user. `Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user.