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
+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
`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.