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:
@@ -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<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. |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+10
-2
@@ -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/<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".
|
||||
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
|
||||
|
||||
@@ -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 `<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
|
||||
`Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user.
|
||||
|
||||
Reference in New Issue
Block a user