Specify M3a (the decision path) and write the runbook
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>
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user