broker/sessions/<id>.json holds each session's taint and untrusted flag. It is written only by brokerd and can be rebuilt from the audit log's result records. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
879 lines
52 KiB
Markdown
879 lines
52 KiB
Markdown
# M3a design: the decision path
|
|
|
|
Status: draft for owner review, 2026-09-18; revised the same day after a design review (the
|
|
"Spec review of M3a" rows in `docs/decisions.md`). M3 is split in two (`docs/decisions.md`). M3a is
|
|
everything that decides whether a tool call may run and records it: grant files and matching,
|
|
session taint, the audit log, approvals through `bxctl`, the two `brokerd` sockets, and `loopd`'s
|
|
real tool port. Tools themselves do not run in M3a: the runner is a trait whose only
|
|
implementation refuses. M3b adds the Podman runner, the four tools and the egress proxy. Where
|
|
this document and `docs/design.md` disagree, the brief wins. The threat model and data classes are
|
|
in `docs/specs/2026-09-17-pre-m1-design.md`; section 6 of this document replaces its socket table.
|
|
|
|
## 1. What M3a proves
|
|
|
|
| Claim | Checked by |
|
|
|---|---|
|
|
| No grant, an expired grant, too much taint, or arguments outside the grant each end in a denial with the right reason | Policy table tests; property test against an oracle |
|
|
| One invalid grant file denies every call | Loading tests |
|
|
| Nothing runs without a decision record on disk first | Audit-before-action tests with a failing writer |
|
|
| Any edit, deletion, reordering or gap in the audit log is found, with its file and line | Tampering suite, through `bxctl audit verify` and `brokerd`'s startup |
|
|
| `loopd` cannot approve: the tool socket refuses every admin message | Socket tests |
|
|
| An approval only lets through a call that is still `ask` when approved | Approval tests |
|
|
| A denial reaches the model as a plain tool result and the turn goes on | End-to-end test in one process; on straylight with Ornith |
|
|
| Every fail-closed state's message names a runbook entry, and every entry named exists | A test per state for the pointer; the gate script for the entry |
|
|
|
|
Out of scope: running tools, containers, the egress proxy, tool timeouts (all M3b); secrets
|
|
(moved to M4); approvals over Mattermost (M4); job-scoped grants (M5).
|
|
|
|
## 2. Components
|
|
|
|
```
|
|
loopd ── run/loop-broker/broker.sock ──┐
|
|
brokerd ── Runtime (M3a: refuses; M3b: podman)
|
|
bxctl ── run/owner-broker/admin.sock ──┘ │
|
|
├── grants/*.toml (read only)
|
|
├── <home>/broker/sessions/<id>.json
|
|
└── <home>/audit/YYYY-MM-DD.jsonl
|
|
```
|
|
|
|
### `brokerd` modules
|
|
|
|
Each is one file under 500 lines with one purpose. Only `serve` starts threads.
|
|
|
|
| Module | Purpose |
|
|
|---|---|
|
|
| `config` | `brokerd.toml` into a typed `Config`. Unknown keys are errors. |
|
|
| `grants` | Reads `grants/*.toml` into a `GrantSet`, applying the loading rules in section 3. |
|
|
| `args` | Parses each tool's arguments into typed values; checks paths and URLs for form. |
|
|
| `policy` | `decide` and `redecide`. The only place a `Decision` is built. Already exists from M1. |
|
|
| `state` | Each session's taint and untrusted flag, read and written as files. |
|
|
| `audit` | The audit writer: lock, chain, sync, rollover, startup check, recovery, accepted breaks. |
|
|
| `approvals` | The table of pending approvals and their expiry. In memory only. |
|
|
| `runner` | `Runtime`, `RunSpec`, and `run(decision, runtime)`. The only place a `RunSpec` is built. |
|
|
| `broker` | Handles one `broker.sock` connection: one tool request from decision to answer. |
|
|
| `admin` | Handles one `admin.sock` connection: one admin request. |
|
|
| `serve` | Startup, the two listeners, one thread per connection. |
|
|
|
|
`brokerd serve --config <path> [--accept-break]`. `brokerd` gains `serde`, `serde_json` and
|
|
`toml`, all already vetted.
|
|
|
|
### Threads and locks
|
|
|
|
`serve` starts one thread per connection and one expiry thread. 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
|
|
these steps as a whole, and never while a tool runs or an approval is awaited:
|
|
- read the session's state, decide, write the `Decision` record;
|
|
- read the state, re-decide, write the `Approval` record;
|
|
- read the state, raise it, write the state file, write the `Result` record.
|
|
|
|
Without it two results for one session could each read `private`, and the second write would
|
|
put the taint back down; and two records could take the same `seq`.
|
|
- **The pending table** (section 6). An entry is answered by whoever removes it from the table
|
|
while holding the table's lock: `approve`, `refuse`, the expiry thread, or the waiting thread
|
|
when it finds its connection closed. Everyone else finds the entry gone. This one rule settles
|
|
every race between them.
|
|
|
|
A poisoned table lock is recovered with `into_inner`, as in `loopd`. A poisoned ledger lock is
|
|
not: a thread panicked part-way through a write, so the writer's idea of the chain's head may be
|
|
wrong. Every call is then denied with `audit_unavailable` until `brokerd` is restarted, and the
|
|
startup check puts the tail right. The same holds after any failed audit write: the writer does
|
|
not write again in this process.
|
|
|
|
### Configuration
|
|
|
|
```toml
|
|
# brokerd.toml
|
|
[paths]
|
|
home = "/var/lib/boxmaker" # default: BOXMAKER_HOME, then /var/lib/boxmaker
|
|
grants = "/etc/boxmaker/grants"
|
|
|
|
[sockets]
|
|
broker = "/var/lib/boxmaker/run/loop-broker/broker.sock"
|
|
admin = "/var/lib/boxmaker/run/owner-broker/admin.sock"
|
|
|
|
[approvals]
|
|
ttl_ms = 900000 # 15 min
|
|
```
|
|
|
|
## 3. Grants
|
|
|
|
### Files
|
|
|
|
One grant per file, `grants/<id>.toml`. The id is the file stem: 1 to 64 characters of
|
|
`[a-z0-9-]`. Other files in the directory (not ending in `.toml`) are ignored; a `.toml` file with
|
|
a bad stem is an invalid grant. The fields are `proto::Grant` as defined in M1.
|
|
|
|
```toml
|
|
# grants/notes-read.toml
|
|
tool = "read_file"
|
|
mode = "auto"
|
|
max_taint = "secret"
|
|
result_class = "private"
|
|
untrusted = false
|
|
expires = "2027-01-01T00:00:00.000Z"
|
|
|
|
[constraints]
|
|
paths = ["/home/kyle/notes"]
|
|
```
|
|
|
|
### Loading
|
|
|
|
`brokerd` reads every grant file at the start of every decision. There is no cache and no reload
|
|
signal: a change applies to the next call.
|
|
|
|
A grant file is invalid if any of these holds. The error names the file and, where the parser
|
|
gives one, the line and field.
|
|
|
|
1. It cannot be read, is not UTF-8, or is not valid TOML for `proto::Grant` (unknown fields
|
|
included).
|
|
2. Its stem is not a valid id.
|
|
3. `tool` is not one of `read_file`, `write_file`, `shell`, `http_fetch`.
|
|
4. `secret` is set: "secrets are not supported until M4".
|
|
5. `constraints.patterns` is not empty: "patterns are not supported".
|
|
6. A constraint is present that the tool does not take, or a required one is missing:
|
|
|
|
| Tool | `paths` | `hosts` |
|
|
|---|---|---|
|
|
| `read_file` | required | must be empty |
|
|
| `write_file` | required | must be empty |
|
|
| `shell` | optional | must be empty |
|
|
| `http_fetch` | must be empty | required |
|
|
|
|
7. A path in `paths` is not a valid absolute path (section 3, "Paths"), or is `/`: "a grant of the
|
|
whole file system is not supported".
|
|
8. A host in `hosts` is not a valid host pattern (section 3, "Hosts").
|
|
9. `mode` is `deny` and `max_taint` is not `secret`: "a deny grant must apply at every taint".
|
|
|
|
**If any file is invalid, the whole set is invalid**, and every call is denied with
|
|
`grants_invalid` until it is fixed. `brokerd` prints each problem, once per distinct set of
|
|
problems, followed by `see docs/runbook.md#grants-invalid`. The reason: a mistyped `deny` grant
|
|
that was skipped would silently turn into an allow wherever another grant matches.
|
|
|
|
Rule 9 has the same reason. A grant stops applying when the session's taint passes its
|
|
`max_taint`, so a `deny` grant with `max_taint = "private"` would stop denying as soon as the
|
|
session read a secret, and whatever `auto` grant lay behind it would take over: the model's own
|
|
actions would have removed the owner's prohibition. Rule 7 rejects `/` because M3b mounts a grant
|
|
path at the same path inside the container, where `/` would replace the tool image itself. M3b
|
|
may reserve further prefixes for the same reason.
|
|
|
|
### Matching
|
|
|
|
For a request to tool `T`, with the session's current taint `S`:
|
|
|
|
1. Candidates are the grants whose `tool` is `T`.
|
|
2. A candidate is ruled out by the first of these that holds: it has expired (`now >= expires`);
|
|
`S > max_taint`; its constraints do not cover the arguments.
|
|
3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Within that
|
|
mode the winner is the grant with the longest matched path (section 3, "Paths"); grants with no
|
|
matched path (`http_fetch`, and `shell` without `cwd`) all tie. Remaining ties go to the lowest
|
|
grant id in byte order. The winner is the grant that is recorded and, in M3b, the one whose
|
|
paths are mounted.
|
|
4. The result's label does not come from the winner alone. `result_class` is the highest among
|
|
all the grants left after step 2, and `untrusted` is true if any of them says so.
|
|
5. If none is left, the reason is `grant_expired` if some candidate was ruled out only by expiry;
|
|
otherwise `taint_too_high` if some candidate was ruled out only by taint; otherwise `no_grant`.
|
|
|
|
"Only by expiry" means the grant would have matched had it not expired: taint and constraints both
|
|
pass. Likewise for taint.
|
|
|
|
Step 4 exists because grants overlap. With `a-home` (`paths = ["/home/kyle"]`, `result_class =
|
|
"private"`) and `b-keys` (`paths = ["/home/kyle/keys"]`, `result_class = "secret"`), both `auto`,
|
|
a read of `/home/kyle/keys/id` matches both. Whichever wins, the result is `secret`. The
|
|
longest-path rule then picks `b-keys`, so the mount is the narrower one.
|
|
|
|
Two consequences for whoever writes grants:
|
|
|
|
- An expired `deny` grant no longer denies. `expires` on a `deny` grant means "forbid this until
|
|
then".
|
|
- An `ask` grant with a lower `max_taint` than an `auto` grant over the same arguments drops out
|
|
when taint rises, and the call then runs without asking. Give the `ask` grant the higher
|
|
`max_taint`. Loading does not check this.
|
|
|
|
The outcome is one of: allowed by grant `g` (`auto`); ask, under grant `g`; denied by grant `g`
|
|
(`denied_by_grant`); denied with no grant (`no_grant`, `grant_expired`, `taint_too_high`).
|
|
|
|
### What each tool takes, and what its constraints cover
|
|
|
|
Arguments are the model's JSON object, parsed strictly (unknown fields rejected). Arguments that
|
|
do not parse or fail the form checks below are denied with `invalid_arguments` before matching.
|
|
|
|
| Tool | Arguments | Covered when |
|
|
|---|---|---|
|
|
| `read_file` | `{ "path": "<abs>" }` | `path` is inside one of the grant's `paths` |
|
|
| `write_file` | `{ "path": "<abs>", "content": "<text>" }` | `path` is inside one of the grant's `paths` and is not the grant path itself |
|
|
| `shell` | `{ "command": "<text>", "cwd": "<abs>" }`, `cwd` optional | the grant has no `paths` and `cwd` is absent, or `cwd` is inside one of the grant's `paths` |
|
|
| `http_fetch` | `{ "url": "https://…" }` | the URL's host matches one of the grant's `hosts` |
|
|
|
|
`command` and `content` are not inspected. The container is the boundary for what a command can do.
|
|
|
|
### Paths
|
|
|
|
A valid path, in a grant or an argument: starts with `/`; is at most 4,096 bytes; contains no NUL;
|
|
has no empty component (no `//`, no trailing `/` except the root itself), no `.` and no `..`
|
|
component. Paths are compared as they are, never normalised, and matching is by whole components:
|
|
|
|
| Grant path | Requested | Result |
|
|
|---|---|---|
|
|
| `/home/kyle/notes` | `/home/kyle/notes/a.md` | inside |
|
|
| `/home/kyle/notes` | `/home/kyle/notes` | inside (the directory itself) |
|
|
| `/home/kyle/notes` | `/home/kyle/notes2/a.md` | not inside |
|
|
| `/home/kyle/notes` | `/home/kyle/notes/../.ssh/id` | `invalid_arguments` (`..`) |
|
|
| `/home/kyle/notes` | `notes/a.md` | `invalid_arguments` (relative) |
|
|
| `/home/kyle/notes` | `/home/kyle//notes/./a.md` | `invalid_arguments` (`//`, `.`) |
|
|
| `/` | anything | the grant file is invalid (loading rule 7) |
|
|
|
|
`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the
|
|
same path, so a symlink pointing outside it points at nothing inside the container.
|
|
|
|
When several of a grant's paths contain the argument, the longest one is the matched path.
|
|
|
|
### Hosts
|
|
|
|
A host name: 1 to 253 bytes, lowercase, at least two labels separated by `.`, each label 1 to 63
|
|
bytes of `[a-z0-9-]` not starting or ending with `-`. A host pattern in a grant is a host name, or
|
|
`*.` followed by a host name.
|
|
|
|
| Grant host | URL host | Result |
|
|
|---|---|---|
|
|
| `example.com` | `example.com` | matches |
|
|
| `example.com` | `www.example.com` | no match |
|
|
| `*.example.com` | `www.example.com`, `a.b.example.com` | matches |
|
|
| `*.example.com` | `example.com` | no match (list both to allow both) |
|
|
|
|
A valid `url`: at most 2,048 bytes; starts with `https://`; the host is a host name as above (so no
|
|
IP literals, no uppercase, no `[`); no userinfo (`@` before the path); no port, or port `443`;
|
|
then optionally `/` and a path, query or fragment of printable ASCII other than space. Anything
|
|
else is `invalid_arguments`.
|
|
|
|
### The policy functions
|
|
|
|
`policy` does no I/O and reads no clock: everything it needs is an argument, which is what lets
|
|
the property test drive it.
|
|
|
|
```rust
|
|
pub struct SessionState { pub taint: DataClass, pub untrusted: bool }
|
|
|
|
pub enum Outcome {
|
|
Allowed(Decision),
|
|
Ask(Ask),
|
|
Denied { reason: DenyReason, grant: Option<String> },
|
|
}
|
|
|
|
pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp)
|
|
-> Outcome;
|
|
pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp)
|
|
-> Result<Decision, DenyReason>;
|
|
```
|
|
|
|
- `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail`
|
|
doctest. It holds the request, the parsed arguments and the grant that asked. **`decide` never
|
|
returns a `Decision` for an `ask` grant**, and `redecide` is the only thing that turns an `Ask`
|
|
into a `Decision`, so "this call was approved" is a fact about the types, not about the order
|
|
of statements in `broker`.
|
|
- `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else
|
|
gives the reason.
|
|
- `broker` checks in this order, and the first that applies is the answer: the grant set is
|
|
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.
|
|
|
|
## 4. Session state
|
|
|
|
`<home>/broker/sessions/<id>.json`, written only by `brokerd`:
|
|
|
|
```json
|
|
{"taint":"private","untrusted":false}
|
|
```
|
|
|
|
- A session with no file is at `private`, trusted. The file is created at its first result.
|
|
- After a tool result, taint becomes the higher of its current value and the result's
|
|
`result_class`, and an `untrusted` result sets the flag (both as combined in section 3, step 4).
|
|
Neither ever goes down. The read, the comparison and the write happen under the ledger lock
|
|
(section 2). A `failed` call changes nothing: its message is `brokerd`'s own text (section 7).
|
|
- Writes are atomic: write `<id>.json.tmp`, `fsync`, rename over `<id>.json`, `fsync` the directory.
|
|
- A file that exists but cannot be read or parsed is an error: every call for that session is
|
|
denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`.
|
|
- The brief's State list names this file (P14, applied 2026-09-18).
|
|
- `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy
|
|
for its own use and never an input to policy.
|
|
|
|
## 5. The audit log
|
|
|
|
### Records
|
|
|
|
Replaces the M1 `AuditRecord` (nothing has written one yet). Every record has a header and an
|
|
event:
|
|
|
|
```rust
|
|
pub struct AuditRecord { pub seq: u64, pub time: Timestamp, pub prev: Hash32, pub event: AuditEvent }
|
|
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum AuditEvent {
|
|
Decision {
|
|
session: SessionId, call: CallId, tool: String,
|
|
arguments: String, // in full, as received
|
|
outcome: DecisionRecord,
|
|
grant: Option<String>, grant_sha256: Option<Hash32>, // the matched grant and its file's hash
|
|
taint: DataClass, untrusted: bool, // the session's state when decided
|
|
},
|
|
Approval {
|
|
session: SessionId, call: CallId, decision: u64, // seq of the Decision record
|
|
answer: ApprovalAnswer, // approved | refused | expired
|
|
by: Option<String>, // "bxctl"; from M4 a Mattermost user id
|
|
post: Option<String>, // from M4, the Mattermost post id
|
|
reason: Option<String>, // the owner's text, for refusals
|
|
outcome: DecisionRecord, // the re-decision (section 6); denied if refused or expired
|
|
grant: Option<String>, grant_sha256: Option<Hash32>, // as matched by the re-decision;
|
|
taint: DataClass, untrusted: bool, // the session's state at the re-decision
|
|
}, // for refused and expired: no grant, the state as it is
|
|
Result {
|
|
session: SessionId, call: CallId, decision: u64,
|
|
status: ResultStatus, // result | failed
|
|
class: DataClass, untrusted: bool, truncated: bool,
|
|
bytes: u64, sha256: Hash32, // of the content (or of the failure message)
|
|
taint_after: DataClass,
|
|
},
|
|
Recovery { torn_bytes: u64, torn_sha256: Hash32 },
|
|
AcceptedBreak { file: String, line: u64, last_good: Hash32 },
|
|
}
|
|
|
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
|
pub enum DecisionRecord { Allowed, Ask, Denied { reason: DenyReason } }
|
|
```
|
|
|
|
`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape
|
|
(with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event.
|
|
|
|
`event` is a nested object: `{"seq":…,"time":…,"prev":…,"event":{"type":"decision",…}}`. It is not
|
|
flattened: `#[serde(flatten)]` does not work with `deny_unknown_fields`.
|
|
|
|
Results are recorded by hash and size, never content, so reading a secret does not by itself copy
|
|
it into the audit log. Arguments are recorded in full, exactly as received; they are at most one
|
|
frame (1 MiB). A session that has read a secret can put it in a later call's arguments (a
|
|
`write_file` content, a `shell` command), so the log is not free of secrets: a record is as
|
|
sensitive as the `taint` it carries, and the files are mode 0600 for that reason. For a `failed`
|
|
result, `class` and `untrusted` are the label the result would have had, and `taint_after` equals
|
|
the taint before.
|
|
|
|
### The chain
|
|
|
|
- A record is one line of JSON written by serde, then `\n`. The line and its `\n` go out in one
|
|
`write_all`, followed by the sync; whatever the record allows happens only after the sync
|
|
returns. A line without its newline was therefore never acted on.
|
|
- `hash(line)` is `proto::sha256` of the line's bytes without the `\n`.
|
|
- `prev` is the hash of the previous line; the first record ever written has `prev` all zeros and
|
|
`seq` 0.
|
|
- `seq` goes up by one per record with no gaps, across files.
|
|
- The file is `audit/YYYY-MM-DD.jsonl` by the UTC date of the record's `time`. The first record of
|
|
a new day starts a new file and chains from the last line of the previous file. Files are created
|
|
with mode 0600; the directory is `fsync`ed after a file is created.
|
|
- The writer never goes back. If a record's date is earlier than the latest file's (the clock was
|
|
stepped back over midnight), the record goes in the latest file. Files are verified in name
|
|
order, so a record appended to an older file would break the chain. The verifier reports the
|
|
backwards time as a clock warning.
|
|
- `Recovery` and `AcceptedBreak` records always go in the latest file, whatever their date: they
|
|
belong next to the lines they describe.
|
|
- `brokerd` is the only writer. It holds an exclusive lock on `audit/.lock` for its whole life
|
|
and `fsync`s after every record. The lock is `std::fs::File::try_lock`, stable since Rust 1.89
|
|
(checked in the std docs of 1.98.1; the workspace minimum is 1.95), so it needs neither `libc`
|
|
nor `unsafe`. The task must check its error type on docs.rs before use.
|
|
|
|
### Write order
|
|
|
|
For every tool request:
|
|
|
|
1. Decide (section 3). Write the `Decision` record and sync it. **If this write fails, nothing
|
|
runs**: the answer is `denied` with `audit_unavailable`, and `brokerd` prints
|
|
`see docs/runbook.md#audit-unavailable`. A failed write may have left part of a line, so the
|
|
writer does not write again: every later call gets the same denial until `brokerd` is
|
|
restarted, and the startup check recovers the tail.
|
|
2. If `ask`: wait (section 6). Write the `Approval` record. If it fails, the call does not run and
|
|
the answer is `denied` with `audit_unavailable`.
|
|
3. If allowed: run. Update the session state file. Write the `Result` record. Then answer `loopd`.
|
|
If the state write or the `Result` write fails, the answer is `failed` with the message "the
|
|
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.
|
|
|
|
### Verification
|
|
|
|
`proto::audit::ChainVerifier` is a pure state machine, shared by `brokerd` and `bxctl`:
|
|
|
|
```rust
|
|
pub struct ChainVerifier { /* … */ }
|
|
impl ChainVerifier {
|
|
pub fn new() -> Self; // expects seq 0, prev zero
|
|
pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point
|
|
pub fn file(&mut self, name: &str); // starts the next file
|
|
pub fn line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line
|
|
pub fn finish(self) -> ChainReport;
|
|
}
|
|
pub struct ChainReport {
|
|
pub records: u64, pub head: Option<Hash32>, pub next_seq: u64,
|
|
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what
|
|
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
|
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them
|
|
pub unfinished: Vec<u64>, // seq of allowed decisions, and of approvals whose
|
|
// outcome is allowed, with no Result after them
|
|
pub clock_warnings: Vec<Location>, // time went backwards
|
|
pub torn_tail: Option<Location>, // the last line of the last file needs recovery
|
|
}
|
|
```
|
|
|
|
A line fails if it does not parse as an `AuditRecord`, its `seq` is not the next one, or its
|
|
`prev` is not the hash of the line before. Files are fed in name order; a file whose first record
|
|
does not chain from the previous file's last line fails at its line 1. Two cases are not failures:
|
|
|
|
- **Torn tail.** The last line of the last file has no newline, or has one but does not parse (a
|
|
crash between ending a torn line and writing its `Recovery`). It is reported in `torn_tail` and
|
|
is not checked further. Treating an unparseable last line this way gives up nothing: whoever
|
|
could damage the last line could as easily remove its newline.
|
|
- **Recovered line.** A line immediately followed by a `Recovery` record whose `torn_bytes` and
|
|
`torn_sha256` describe exactly that line, whose `prev` is the hash of the line before it, and
|
|
whose `seq` follows that line's. "Immediately followed" spans files, though the writer never
|
|
produces that. **It makes no difference whether the recovered line parses**: a crash can cut a
|
|
record exactly before its newline, leaving complete JSON. A recovered line is not a record and
|
|
nothing may refer to its `seq`, which the `Recovery` record reuses. Reported in `recoveries`.
|
|
|
|
The verifier therefore holds each line back until it has seen the next one, and judges it then (or
|
|
at `finish`).
|
|
|
|
An `AcceptedBreak` record is accepted only if all of these hold:
|
|
|
|
- its `file` and `line` name the first failure found since the last accepted break (or the start);
|
|
- its `last_good` is the hash of the last line that verified before that failure (all zeros if
|
|
none did);
|
|
- its `prev` is the hash of the line immediately before the break record;
|
|
- its `seq` is the `seq` the failing line should have had, plus the number of lines from the
|
|
failure line up to the line before the break record (so a failure at line 7 with the break
|
|
record at line 10 adds 3). The count is of lines, not of what they say: a damaged
|
|
region must not choose the counter's value. After a deletion a later `seq` can therefore repeat
|
|
one inside the region; those lines are not vouched for, and a reference to a `seq` means the
|
|
latest record before it that has it.
|
|
|
|
The verifier then clears that failure, reports the break, and continues with the break record as
|
|
the new head. Lines between the failure and the break record are read only to look for the break
|
|
record and are not checked, so one break covers every failure before it.
|
|
|
|
A verifier started with `resume` has not seen the earlier files. When it meets an `AcceptedBreak`
|
|
whose `file` sorts before the first file it was given, it checks only `prev`, accepts it and
|
|
continues from it; the full verification judges the rest.
|
|
|
|
### Startup
|
|
|
|
1. Take the lock. If it is held: print "brokerd is already running"
|
|
and `see docs/runbook.md#brokerd-already-running`, exit 1.
|
|
2. Without `--accept-break`: verify the latest file, resumed from the last line of the file before
|
|
it (if any). That line must parse as a record, or it is the failure. The earlier files are not
|
|
re-read; `bxctl audit verify` does that. With `--accept-break`: verify every file, exactly as
|
|
`bxctl audit verify` does, because the break to accept must be the first failure of the whole
|
|
log, and it may lie in an older file that the short check never reads.
|
|
3. If the report has a failure: without `--accept-break`, write nothing, print the failure's file,
|
|
line and what, and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`,
|
|
end a torn final line with `\n` if there is one, then append an `AcceptedBreak` record with the
|
|
four values the verifier will check (above; lines are counted across files). Print what was
|
|
accepted.
|
|
4. Otherwise, if the report has a torn tail: write `\n` after the torn bytes if it is missing,
|
|
then a `Recovery` record chained from the line before the torn one. Print "audit: recovered a
|
|
torn final line" and `see docs/runbook.md#audit-recovered`.
|
|
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2.
|
|
|
|
An empty latest file (created, then a crash before its first record) is not a failure and needs
|
|
no recovery: the next record is its line 1 and chains from the file before.
|
|
|
|
One crash is not recovered automatically: a crash while the `Recovery` record itself is being
|
|
written leaves a line that no `Recovery` describes. That is a chain failure, and the runbook entry
|
|
says so. It takes two crashes within two consecutive one-line writes.
|
|
|
|
Nothing is truncated, rewritten or deleted, ever.
|
|
|
|
## 6. Sockets and messages
|
|
|
|
### Two sockets
|
|
|
|
| Socket | Directory | Client | Accepts |
|
|
|---|---|---|---|
|
|
| `broker.sock` | `run/loop-broker/` | `loopd` | `tool_request` |
|
|
| `admin.sock` | `run/owner-broker/` | `bxctl` | `approvals`, `approve`, `refuse`, `check_grants` |
|
|
|
|
`brokerd` creates each directory if it is missing and sets its mode to 0700 whether it made it or
|
|
found it (a failure to do so is a startup error), removes a stale socket file, binds, and sets the
|
|
socket to 0600. All of this comes after the audit lock is taken (section 5, "Startup"): the lock
|
|
is what proves the socket file is stale and not another `brokerd`'s.
|
|
|
|
Any other message kind on a socket is answered with `error` `forbidden`, and the connection is
|
|
closed. `brokerd` prints the kind and the socket, with
|
|
`see docs/runbook.md#socket-forbidden`: nothing in the harness sends a wrong kind, so it means a
|
|
bug or a component doing what it should not.
|
|
|
|
In M3 all roles run as the owner's user, so the directory permissions do not yet keep `loopd` out of
|
|
`admin.sock`; `loopd` simply has no code that sends admin messages. M7 mounts only
|
|
`run/loop-broker/` into `loopd`'s container. This gap is recorded in section 11.
|
|
|
|
### Tool requests, on `broker.sock`
|
|
|
|
One connection per request, as on `loop.sock`:
|
|
|
|
```
|
|
→ tool_request { session, call, tool, arguments }
|
|
← tool_response { status: pending_approval, approval, expires } final: false (ask only)
|
|
← tool_response { status: result | failed | denied, … } final: true
|
|
```
|
|
|
|
`approval` becomes a `u64`: the `seq` of the call's `Decision` record.
|
|
|
|
### Approvals
|
|
|
|
- An `ask` call is added to the pending table with `expires` = now + `ttl_ms`, or the grant's
|
|
`expires` if that is earlier. The entry holds the `Ask`, what `approvals` lists, and the sending
|
|
half of a `std::sync::mpsc` channel. The connection's thread sends the pending frame and waits
|
|
on the receiving half for a verdict: run this `Decision`, or deny with this reason.
|
|
- **Whoever takes the entry out of the table answers it** (section 2), and does all of the
|
|
answering: the admin thread for `approve` and `refuse`, the expiry thread for expiry. That
|
|
thread takes the ledger lock, writes the `Approval` record, sends the verdict, and only then
|
|
answers `bxctl`. The waiting thread writes nothing for an approval; it receives the verdict and
|
|
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
|
|
`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`.
|
|
- **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
|
|
(`recv_timeout`) and checks its socket: with a read timeout of 10 ms set, a `read` that returns
|
|
`Ok(0)` means `loopd` has gone; a `WouldBlock` or `TimedOut` error means it is still there; any
|
|
bytes are a protocol error and count as gone. (`UnixStream::peek` would be the natural call, but
|
|
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.
|
|
- **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
|
|
check and the run, and the call then runs with nobody waiting for it; that is accepted.
|
|
- **Restart.** The table is in memory only. After a restart there are no pending approvals.
|
|
|
|
### Admin messages, on `admin.sock`
|
|
|
|
| Request | Answer (final) |
|
|
|---|---|
|
|
| `approvals {}` | `approval_list { items: [PendingApproval] }` |
|
|
| `approve { approval }` | `approve_result { outcome: DecisionRecord }` — the re-decision |
|
|
| `refuse { approval, reason }` | `ok {}` |
|
|
| `check_grants {}` | `grants_report { problems: [GrantProblem] }`, empty when valid |
|
|
|
|
```rust
|
|
pub struct PendingApproval {
|
|
pub approval: u64, pub session: SessionId, pub call: CallId, pub tool: String,
|
|
pub arguments: String, pub grant: String, pub taint: DataClass,
|
|
pub created: Timestamp, pub expires: Timestamp,
|
|
}
|
|
pub struct GrantProblem { pub file: String, pub line: Option<u64>, pub problem: String }
|
|
```
|
|
|
|
An unknown or already answered `approval` is `error` `no_such_approval`.
|
|
|
|
`PendingApproval.arguments` is not the string `loopd` sent. It is the parsed arguments serialised
|
|
again by `serde_json` from the typed value in `args`, fields in the order of section 3's table.
|
|
The owner approves what policy matched: in the raw string `"/etc"` and `"/etc"` look
|
|
different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps
|
|
the raw string.
|
|
|
|
## 7. The runner seam
|
|
|
|
```rust
|
|
pub trait Runtime: Send + Sync {
|
|
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
|
|
}
|
|
pub struct RunSpec { // fields private; built only in runner.rs
|
|
tool: ToolName, // ReadFile | WriteFile | Shell | HttpFetch
|
|
arguments: ToolArgs, // the typed arguments from `args`
|
|
mounts: Vec<Mount>, // { path, writable }
|
|
egress: Option<Vec<String>>, // the grant's host patterns; None = no network
|
|
}
|
|
pub struct RunOutput { pub content: String, pub truncated: bool }
|
|
pub enum RunError { Failed(String), Unavailable(String) }
|
|
pub fn run(decision: Decision, runtime: &dyn Runtime) -> ToolResponse;
|
|
```
|
|
|
|
`RunSpec` has public getters and no public constructor; a `compile_fail` doctest proves it, like
|
|
`Decision`'s. `Decision` gains the parsed arguments, the winning grant (id, file hash, the matched
|
|
path or hosts) and the result's label (`result_class` and `untrusted`, combined over every matching
|
|
grant as in section 3, step 4), all set by `policy`.
|
|
|
|
What `run` puts in the spec, from the decision:
|
|
|
|
| Tool | `mounts` | `egress` |
|
|
|---|---|---|
|
|
| `read_file` | the matched grant path, read-only | none |
|
|
| `write_file` | the matched grant path, writable | none |
|
|
| `shell` | every path of the grant, writable | none |
|
|
| `http_fetch` | none | the grant's `hosts` |
|
|
|
|
**A `RunError`'s text is never tool output.** `loopd` logs a `failed` result as `public` and
|
|
trusted, and a failure does not raise taint, so the message must be one of a fixed set of
|
|
sentences written in `brokerd` or `toolkit` ("the tool timed out", "the container could not
|
|
start"). Nothing a tool printed, read from a file or received from the network goes in it. In M3b
|
|
a command that exits non-zero is a `result`, labelled like any other, with its output and exit
|
|
status as the content.
|
|
|
|
M3a's production runtime is `Refusing`: every call is `RunError::Unavailable("the runner arrives
|
|
in M3b")`, which becomes `failed` for `loopd`. Tests use a recording fake. Limits (timeout, memory,
|
|
processes, output size) are M3b's.
|
|
|
|
## 8. `loopd` changes
|
|
|
|
- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback:
|
|
`fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`.
|
|
The read timeout is `[broker] timeout_ms` (default 120,000) until the first frame. After a
|
|
pending frame it runs until the frame's `expires` plus `timeout_ms`, which leaves a call
|
|
approved at the last moment the same time to run as any other. M3b must keep its tool time
|
|
limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the answer
|
|
is `Failed { "the tool broker is unavailable" }`, and `loopd` prints
|
|
`see docs/runbook.md#broker-unavailable`. The turn goes on.
|
|
- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and
|
|
needs no broker. The core tools stay `clock`, `find_tool`, `call_tool`. The registry's
|
|
discoverable tools become `read_file`, `write_file`, `shell`, `http_fetch`, with the argument
|
|
schemas from section 3. `echo` stays in `FakeTools` for tests only. This changes the baseline of
|
|
new sessions only.
|
|
- **Denials become tool results** the model can explain, written by `loopd` (`class` public,
|
|
`untrusted` false):
|
|
|
|
| Reason | Content |
|
|
|---|---|
|
|
| `no_grant` | Denied: no grant allows this call. |
|
|
| `grant_expired` | Denied: the grant for this call has expired. |
|
|
| `taint_too_high` | Denied: this session has seen data too sensitive for this call. |
|
|
| `denied_by_grant` | Denied: a grant forbids this call. |
|
|
| `approval_refused` | Denied: the owner refused this call. |
|
|
| `approval_expired` | Denied: the approval request expired without an answer. |
|
|
| `invalid_arguments` | Denied: the arguments are not valid for this tool. |
|
|
| `grants_invalid` | Denied: the grant files have an error; the owner has been told. |
|
|
| `audit_unavailable` | Denied: the audit log cannot be written; the owner has been told. |
|
|
| `state_unreadable` | Denied: this session's broker state is damaged; the owner has been told. |
|
|
|
|
- **Two new turn events.** `approval_pending { approval, tool, expires }` is sent when the pending
|
|
frame arrives. It is a notice that something is waiting, not a description of it: `loopd` does
|
|
not know the grant or the taint, and what the owner is shown must not come from `loopd`
|
|
(section 9). `tool_denied { name, reason }` is sent before the `tool_result` event of a denied
|
|
call, so the owner sees the reason itself and not only the model's account of it.
|
|
- **Config**: `[broker] socket` and `[broker] timeout_ms`. If `socket` is absent, `loopd serve`
|
|
uses no port and every tool call except the core ones fails with "no tool broker is
|
|
configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`.
|
|
- **Runbook pointers** on the existing fail-closed messages: the failed self-test
|
|
(`loopd-selftest-failed`), a damaged session log (`session-log-damaged`), an unreadable
|
|
`memory/core.md` (`core-memory-unreadable`).
|
|
|
|
## 9. `bxctl` changes
|
|
|
|
- `bxctl approvals [--admin-socket <path>]` lists pending approvals:
|
|
|
|
```
|
|
41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private
|
|
shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"}
|
|
```
|
|
|
|
- `bxctl approve <id>` prints `approved 41: runs` (exit status 0) or
|
|
`approved 41: denied (<reason>)` (exit status 1). `bxctl refuse <id> [--reason <text>]` prints
|
|
`refused 41`. For `no_such_approval` both print `41: no such approval (already answered or
|
|
expired)` and exit 1.
|
|
- `bxctl grants check` prints each problem as `<file>:<line>: <problem>`, or `grants: ok`; exit
|
|
status 1 if there are problems.
|
|
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints
|
|
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, approvals "pending or
|
|
abandoned", calls "running or unfinished" and clock warnings, one per line; or the failure as
|
|
`<file>:<line>: <what>` and exit status 1. The two double names are because `bxctl` reads the
|
|
files without asking `brokerd`: an approval still waiting and a call still running look the same
|
|
on disk as ones a crash cut off.
|
|
- `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the
|
|
entry with that id, and prints the same two-line block as `bxctl approvals`. **Everything in the
|
|
block comes from `brokerd`, nothing from the event**: a compromised `loopd` must not choose what
|
|
the owner sees. If the id is not in the list it prints `approval 41 is no longer pending` and
|
|
does not ask. In the interactive mode it then asks `type 41 to approve, anything else refuses: `
|
|
on stderr and reads one line from stdin; exactly the id sends `approve`, anything else sends
|
|
`refuse`. The id, not `y`, because lines typed while the turn ran are still waiting in stdin,
|
|
and `bxctl` cannot discard them without a terminal library: a stray line must never approve.
|
|
`--say` and `--json` print the event only. `bxctl chat` gains `--admin-socket`.
|
|
- A `tool_denied` event prints `[denied <name>: <reason>]`, and for `grants_invalid`,
|
|
`audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`,
|
|
`audit-unavailable`, `broker-state-damaged`) on the next line.
|
|
- **Arguments are printed as data.** Each of these characters is printed as `\uXXXX`, four
|
|
lowercase hex digits: U+0000 to U+001F, U+007F to U+009F, U+200B to U+200F, U+2028 to U+202E,
|
|
U+2060 to U+2069, and U+FEFF. The first two ranges are the control characters, so nothing the
|
|
model wrote can move the cursor or change colours. The rest are the invisible and
|
|
direction-changing characters: with U+202E in a path, the path displayed would differ from the
|
|
path that was matched. The arguments are JSON, and inside a JSON string `\uXXXX` is the standard
|
|
spelling of the same character, so the printed text is still exactly the arguments and cannot
|
|
be forged: a real backslash in a value is already `\\` there.
|
|
- **Model text is printed the same way** from M3a: `bxctl chat` applies the same escaping to
|
|
reasoning and content, except that newline and tab pass through, and writes `ESC[0m` before an
|
|
approval block. Until now it wrote model text to the terminal as it came, and a colour or
|
|
conceal sequence in it would have carried into the block that follows.
|
|
- The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`.
|
|
|
|
## 10. `proto` changes
|
|
|
|
| Change | Detail |
|
|
|---|---|
|
|
| `AuditRecord`, `AuditEvent`, `DecisionRecord`, `ApprovalAnswer`, `ResultStatus` | Section 5; the M1 audit fixtures are replaced |
|
|
| `audit::ChainVerifier`, `ChainReport`, `ChainFailure`, `Location` | Section 5 |
|
|
| `ToolResponse::PendingApproval.approval` | `String` becomes `u64` |
|
|
| `DenyReason` | gains `grants_invalid`, `audit_unavailable`, `invalid_arguments`, `state_unreadable` |
|
|
| `ErrorCode` | gains `forbidden`, `no_such_approval` |
|
|
| `Message` | gains `approvals`, `approval_list`, `approve`, `approve_result`, `refuse`, `ok`, `check_grants`, `grants_report` |
|
|
| `PendingApproval`, `GrantProblem` | Section 6 |
|
|
| `TurnEvent` | gains `approval_pending { approval, tool, expires }` and `tool_denied { name, reason }` |
|
|
|
|
All of them reject unknown fields. Every new message kind gets a byte-exact fixture.
|
|
|
|
## 11. The runbook
|
|
|
|
`docs/runbook.md` has one entry for every state in which the harness refuses to work or withholds
|
|
something, and for events the owner should understand when they see them. Each entry is a level-2
|
|
heading whose text is its anchor (`## grants-invalid`), with five parts: what you see, why the
|
|
system refuses, how to confirm, how to fix, and how to check the fix.
|
|
|
|
Every message for such a state ends with `see docs/runbook.md#<anchor>`. A gate script,
|
|
`scripts/check-runbook.sh`, collects every `docs/runbook.md#<anchor>` in `crates/` and fails if
|
|
`docs/runbook.md` lacks the heading `## <anchor>`. It has a self-test like the other gate scripts.
|
|
|
|
## 12. Testing
|
|
|
|
Tests and fixtures are given to the implementer and checked against a reference implementation
|
|
first, as in M2.
|
|
|
|
- **Policy tables.** Every row of every table in section 3 is a case, plus: mode precedence among
|
|
three matching grants; the reason order when one candidate is expired and another has too much
|
|
taint; expiry exactly at `expires`; the longest matched path winning within a mode; ties by
|
|
grant id; the `a-home` and `b-keys` example labelled `secret` whichever id sorts first; an
|
|
`untrusted = true` grant among the matches setting the flag when the winner says false; an
|
|
expired `deny` no longer denying.
|
|
- **Loading.** One case per rule in "Loading", each with the problem text it must contain; one
|
|
invalid file among valid ones denies a call a valid file would allow.
|
|
- **Property test.** A seeded xorshift generator (no crate) makes grant sets, session taints and
|
|
requests. A separate, deliberately naive oracle in the test file says what should happen; every
|
|
case must agree. Two properties over single decisions, with restrictiveness ordered allowed,
|
|
ask, denied: adding a `deny` grant to a set never makes any outcome less restrictive; and a
|
|
call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint. Over
|
|
sequences of calls: taint never goes down; every `Result` record follows a
|
|
`Decision` for the same call; the fake runtime sees a call only after `allowed` or an approval.
|
|
The seed is printed on failure.
|
|
- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the
|
|
lock refusing a second writer. The tampering suite, each a fixture directory: a changed byte in a
|
|
middle line; a deleted line; two lines swapped; a `seq` gap; a file that does not chain from the
|
|
one before; a middle line cut short. Each must fail at the right file and line in both
|
|
`ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`,
|
|
and the next verify reports it); an accepted break (reported by every later verify). Failures:
|
|
an `AcceptedBreak` naming the wrong line, or with the wrong `last_good`, `prev` or `seq`; a
|
|
`Recovery` whose hash or length does not match the line before it; a line no `Recovery`
|
|
describes followed by a torn `Recovery`.
|
|
- **Audit edges**, each a fixture or a scripted writer test: a torn tail that is complete JSON
|
|
lacking only its newline (recovered, and the chain verifies afterwards: this is the case a
|
|
"does not parse" rule gets wrong); an unparseable last line that has its newline (recovered
|
|
without a second newline); a torn first line of a file; an empty latest file; a clock stepped
|
|
back over midnight (the record goes in the latest file, one clock warning, the chain verifies);
|
|
a line torn on one day and recovered on the next (the `Recovery` is in the torn line's file); a
|
|
break in an older file (plain startup succeeds, `bxctl audit verify` fails, `--accept-break`
|
|
accepts it, both verify afterwards, and so does the next plain startup through `resume`); two
|
|
failures before one break record (one break covers both); a region holding a line with `seq`
|
|
18446744073709551615 (the break's `seq` is unaffected).
|
|
- **Unfinished and abandoned.** An allowed `Decision` with no `Result` is listed in `unfinished`;
|
|
an `Ask` with no `Approval` in `abandoned`; neither is a failure.
|
|
- **Audit before action.** A writer that fails on demand: the answer is `audit_unavailable` and
|
|
the fake runtime's count is 0; the next call is denied the same way though the writer would now
|
|
succeed. A state writer that fails: `failed`, and the content is absent.
|
|
- **Concurrency.** Eight threads, two sessions, fifty calls each through the `brokerd` library
|
|
with the recording runtime, half of the grants labelled `secret`: the log verifies, no `seq`
|
|
repeats, and each session's `taint_after` never goes down from one `Result` to the next.
|
|
- **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`.
|
|
Socket mode 0600; directory mode 0700, both when `brokerd` makes it and when it finds it at 0755.
|
|
- **Approvals.** Approve; refuse; expire with `ttl_ms` 100; approve after the grant file is
|
|
removed (denied, `no_grant`); approve after the taint rose past `max_taint` (denied,
|
|
`taint_too_high`); an unknown id; a dropped `loopd` connection while pending (removed, nothing
|
|
written, reported as abandoned); `approve` and `refuse` sent together from two threads, a
|
|
hundred times (exactly one `Approval` record each time, and the other caller gets
|
|
`no_such_approval`); an approval whose `Approval` record cannot be written (both sides get
|
|
`audit_unavailable`, runtime count 0); `loopd` gone at the check before running (a `failed`
|
|
`Result`, runtime count 0). The `Approval` record names the grant matched at the re-decision
|
|
when that differs from the first.
|
|
- **Pointers.** For each fail-closed state (`grants_invalid`, `audit_unavailable`,
|
|
`state_unreadable`, a broken chain, a recovered tail, a held lock, a forbidden message, the
|
|
broker unreachable, no broker configured), a test that its message ends with
|
|
`see docs/runbook.md#<anchor>`.
|
|
- **Runner seam.** The recording fake shows the mounts and egress of section 7 for each tool, and no
|
|
egress for anything but `http_fetch`. `compile_fail` for `RunSpec`.
|
|
- **`loopd`.** `BrokerPort` against a fake `brokerd`: result, denial text, pending event, broker
|
|
gone, a broker that accepts and never answers (times out after `timeout_ms`), a final frame that
|
|
arrives after `expires` but within `timeout_ms` of it (delivered). The turn loop with
|
|
`approval_pending` and `tool_denied`.
|
|
- **`bxctl`.** `approvals` output with an argument full of escape sequences and one with U+202E,
|
|
U+200B and U+2066; `/etc` in the request shown as `/etc`; `approve`, `refuse`,
|
|
`grants check` and `audit verify` against fakes and fixture directories. `chat` against a fake
|
|
`loopd` and a fake `brokerd`: when the event says `read_file` and `brokerd`'s entry says `shell`,
|
|
the block says `shell`;
|
|
`y` refuses and the id approves; a line already waiting in stdin refuses; an id missing from the
|
|
list prints "no longer pending" and asks nothing; model content holding `ESC[8m` comes out
|
|
escaped.
|
|
- **End to end, in one process.** The fake llama server, `loopd`'s turn loop, and the `brokerd`
|
|
library with the recording runtime. The scripted model calls `call_tool` for `read_file` with no
|
|
grant; its next request contains "Denied: no grant allows this call."; the audit log holds one
|
|
`Decision` record with `no_grant`.
|
|
- **The runbook script**, with its self-test.
|
|
|
|
**On straylight** (a scripted check, not part of `verify-device`): the script first reads
|
|
`GET /slots?model=ornith-1.5-35b-a3b` and stops if slot 0 is busy, then uses slot 0 only, as
|
|
`verify-device` does. `brokerd serve` and `loopd serve`
|
|
with a test home holding one `ask` grant for `read_file`. A `bxctl chat` asks Ornith to read a file
|
|
in that directory; the approval block appears; approving it gives the M3a runner's failure, which
|
|
the model reports. The audit log verifies, with `Decision`, `Approval` and `Result` records.
|
|
|
|
## 13. What M3b needs from this
|
|
|
|
M3b implements `Runtime` with Podman and adds nothing to policy. Its tests: golden `podman`
|
|
argument lists per `RunSpec`; on device, a container with no egress reaches neither the tailnet,
|
|
the host nor the internet; `http_fetch` reaches an allowed host and is refused for another and for
|
|
a redirect to another; `write_file` cannot write outside its mount; a symlink to `~/.ssh` in a
|
|
granted directory reads nothing; memory, process and time limits hold; no container with
|
|
Boxmaker's label remains after a call. Measurements behind these choices, 2026-09-18 on straylight:
|
|
rootless Podman 5.8.6 with crun starts a `--network=none` container in 40 to 80 ms; such a
|
|
container reaches neither `100.100.100.100` nor the host; static `curl` in it fetches HTTPS through
|
|
a SOCKS5 proxy on a mounted Unix socket (`--proxy socks5h://localhost/<path>`), and a proxy that
|
|
accepts only allowlisted host names refuses other hosts and IP literals.
|
|
|
|
## 14. Threat model notes
|
|
|
|
- M3 runs every role as the owner's user. A compromised `loopd` could connect to `admin.sock` and
|
|
approve its own calls, and a container escape is the owner's user. Both close in M7 (separate
|
|
users and containers; `admin.sock` is never mounted into `loopd`). Accepted for M3, because the
|
|
main adversary is injected text, and `loopd` has no code path that sends an admin message.
|
|
- The pending-approval table is in memory. A `brokerd` crash loses pending approvals; the calls
|
|
fail and are reported as abandoned. Nothing runs without an approval record.
|
|
- Results are audited by hash. Proving what a result was needs the content from elsewhere (the
|
|
session log holds it, capped).
|
|
- Taint is kept per session id, and `loopd` chooses the id. Injected text cannot change it, but a
|
|
compromised `loopd` can read a secret under one id and send it out under a fresh one, which
|
|
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.
|
|
- 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.
|
|
- The deny reasons tell the model something about grants `loopd` cannot see: `grant_expired` and
|
|
`taint_too_high` reveal that a grant covers those arguments. Each probe is a call, so each is in
|
|
the audit log. Accepted: the reasons are worth more to the owner, through the model's
|
|
explanation, than their absence would cost an attacker.
|
|
|
|
## 15. How the work is handed over
|
|
|
|
As in M2: small closed tasks with given tests, driven by `tools/run-plan.sh`, reviewed once after
|
|
the last M3a task. The reference implementation lives in a git worktree at `~/src/boxmaker-ref`
|
|
on an unpushed branch, not in `/tmp`. No task mixes policy with plumbing. Every task that adds a
|
|
fail-closed path lists its exits and its runbook anchor (tips T14 and T16).
|