Policy: a deny grant must apply at every taint; a result's label is combined over all matching grants and the longest matched path wins within a mode; a grant of / is invalid. Audit: a recovered line need not fail to parse; the writer never goes back to an earlier day's file; --accept-break verifies the whole log and the break record's fields are all checked, with a seq counted from lines; the Approval record carries the re-decision's grant and state; calls with no Result are reported. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
709 lines
40 KiB
Markdown
709 lines
40 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"), 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`.
|
|
|
|
## 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
|
|
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.
|
|
|
|
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`. 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 `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 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 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, 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` |
|
|
|
|
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, 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`: 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`; 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. 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).
|