From 69f0a0a21828454acdd3bff573aad5b2617f7050 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Fri, 18 Sep 2026 23:40:02 -0700 Subject: [PATCH] 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) --- docs/decisions.md | 1 + docs/egress.md | 1 + docs/runbook.md | 12 ++++++-- docs/specs/2026-09-18-m3a-decision-path.md | 33 +++++++++++++++++----- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index c172fb0..ec48727 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -6,6 +6,7 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit | 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, 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`, 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. | diff --git a/docs/egress.md b/docs/egress.md index 8ef352c..a463d31 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -6,4 +6,5 @@ allowed. | When | From | To | What | |---|---|---|---| | 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` | diff --git a/docs/runbook.md b/docs/runbook.md index 301ae9b..4bd97bb 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -52,7 +52,14 @@ 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". +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 runs. @@ -161,7 +168,8 @@ does not help and is not needed. **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". +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 does not know how sensitive the session's data is, so it denies. If it cannot write it after a diff --git a/docs/specs/2026-09-18-m3a-decision-path.md b/docs/specs/2026-09-18-m3a-decision-path.md index 9d14b38..a58fbd5 100644 --- a/docs/specs/2026-09-18-m3a-decision-path.md +++ b/docs/specs/2026-09-18-m3a-decision-path.md @@ -61,7 +61,9 @@ Each is one file under 500 lines with one purpose. Only `serve` starts threads. ### 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: - **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 `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 - guard: anyone may deny. + guard: anyone may deny. They are still recorded (section 5, "Write order"). ## 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 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 @@ -580,11 +590,16 @@ One connection per request, as on `loop.sock`: either runs the call or sends the denial. - **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 - 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` 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`. -- **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 `approval_expired`. - **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 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 - 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. 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 @@ -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 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 - 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 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 @@ -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 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. +- `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 `/broker/sessions/.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 `Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user.