Split M3 into M3a and M3b, record the M3 design decisions, propose P13 (tool image built by Nix, named by digest), and add docs/runbook.md with an entry for every fail-closed state, including loopd's existing ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
612 lines
32 KiB
Markdown
612 lines
32 KiB
Markdown
# M3a design: the decision path
|
|
|
|
Status: draft for owner review, 2026-09-18. 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 names a runbook entry that exists | Gate script |
|
|
|
|
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.
|
|
|
|
### 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").
|
|
8. A host in `hosts` is not a valid host pattern (section 3, "Hosts").
|
|
|
|
**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.
|
|
|
|
### 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`. Ties go to
|
|
the lowest grant id in byte order.
|
|
4. 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.
|
|
|
|
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` (`//`, `.`) |
|
|
| `/` | `/etc/passwd` | inside (a grant of `/` covers everything; allowed, not advised) |
|
|
|
|
`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`.
|
|
|
|
## 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 grant's
|
|
`result_class`; a grant with `untrusted = true` sets the flag. Neither ever goes down.
|
|
- 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`.
|
|
- `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
|
|
},
|
|
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.
|
|
|
|
Results are recorded by hash and size, never content: a `read_file` of a secret must not copy the
|
|
secret into the audit log. Arguments are recorded in full; they are at most one frame (1 MiB).
|
|
|
|
### The chain
|
|
|
|
- A record is one line of JSON written by serde, then `\n`.
|
|
- `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.
|
|
- `brokerd` is the only writer. It holds an exclusive `flock` on `audit/.lock` for its whole life
|
|
and `fsync`s after every record.
|
|
|
|
### 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`.
|
|
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], last_in_file: bool, has_newline: bool);
|
|
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 clock_warnings: Vec<Location>, // time went backwards
|
|
pub torn_tail: Option<Location>, // last line of the last file has no newline
|
|
}
|
|
```
|
|
|
|
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. It is reported in `torn_tail`.
|
|
- **Recovered line.** A line that does not parse, immediately followed by a `Recovery` record whose
|
|
`torn_bytes` and `torn_sha256` describe exactly that line, and whose `prev` is the hash of the
|
|
line before the torn one and whose `seq` follows that line's. Reported in `recoveries`.
|
|
|
|
An `AcceptedBreak` record is accepted only if its `file` and `line` name the first failure found
|
|
since the last accepted break (or the start). 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 not checked.
|
|
|
|
### 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. Verify the latest file, resumed from the last line of the file before it (if any). The earlier
|
|
files are not re-read; `bxctl audit verify` does that.
|
|
3. If the report has a torn tail: write `\n` after the torn bytes, 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`.
|
|
4. If the report has a failure: without `--accept-break`, print the failure's file, line and what,
|
|
and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`, append an
|
|
`AcceptedBreak` record naming it, with `last_good` the hash of the last line that verified,
|
|
`prev` the hash of the file's last line, and `seq` one more than the highest `seq` among lines
|
|
that parse (or than the failure line's predecessor if none after it parse). Print what was
|
|
accepted.
|
|
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2.
|
|
|
|
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 with mode 0700 if it is missing, removes a stale socket file,
|
|
binds, and sets the socket to 0600. 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. `brokerd` sends the pending frame and waits.
|
|
- **Approve.** `brokerd` 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.
|
|
- **Refuse.** Denied with `approval_refused`.
|
|
- **Expiry.** A thread checks the table every second. An expired approval is denied with
|
|
`approval_expired`.
|
|
- **Lost connection.** If `loopd`'s connection closes while pending, the entry is removed and
|
|
nothing is written. `bxctl audit verify` reports the decision as abandoned.
|
|
- **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`.
|
|
|
|
## 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 and the matched grant (id, `result_class`,
|
|
`untrusted`, the matched path or hosts), 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` |
|
|
|
|
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 while waiting is the pending frame's `expires` plus 30 s. If the socket cannot
|
|
be reached or closes early, 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. |
|
|
|
|
- **A new turn event**, `approval_pending { approval, tool, expires }`, sent when the pending frame
|
|
arrives.
|
|
- **Config**: `[broker] socket`. If absent, `loopd serve` uses no port and every tool call except
|
|
the core ones fails with "no tool broker is configured".
|
|
- **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` or `approved 41: denied (<reason>)`.
|
|
`bxctl refuse <id> [--reason <text>]` prints `refused 41`.
|
|
- `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, abandoned approvals
|
|
and clock warnings, one per line; or the failure as `<file>:<line>: <what>` and exit status 1.
|
|
- `bxctl chat`: an `approval_pending` event prints the same two-line block as `approvals`. In the
|
|
interactive mode it then asks `approve 41? [y/N] ` on stderr and reads one line from stdin; `y`
|
|
sends `approve`, anything else sends `refuse`. `--say` and `--json` print the event only.
|
|
- **Arguments are printed as data.** Every character below U+0020, U+007F, and U+0080 to U+009F is
|
|
printed as `\u00XX`; so is U+001B wherever it appears. Nothing the model wrote can move the
|
|
cursor, change colours or hide text.
|
|
- 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 }` |
|
|
|
|
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`; ties by grant id.
|
|
- **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. 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); an
|
|
`AcceptedBreak` naming the wrong line (a failure).
|
|
- **Audit before action.** A writer that fails on demand: the answer is `audit_unavailable` and
|
|
the fake runtime's count is 0. A state writer that fails: `failed`, and the content is absent.
|
|
- **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`.
|
|
Socket mode 0600, directory mode 0700.
|
|
- **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).
|
|
- **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. The turn loop with `approval_pending`.
|
|
- **`bxctl`.** `approvals` output with an argument full of escape sequences; `approve`, `refuse`,
|
|
`grants check` and `audit verify` against fakes and fixture directories.
|
|
- **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`): `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).
|
|
|
|
## 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).
|