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:
@@ -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