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>
32 KiB
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
# 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.
# 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.
- It cannot be read, is not UTF-8, or is not valid TOML for
proto::Grant(unknown fields included). - Its stem is not a valid id.
toolis not one ofread_file,write_file,shell,http_fetch.secretis set: "secrets are not supported until M4".constraints.patternsis not empty: "patterns are not supported".- 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 |
- A path in
pathsis not a valid absolute path (section 3, "Paths"). - A host in
hostsis 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:
- Candidates are the grants whose
toolisT. - 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. - Among those left, the most restrictive mode wins:
deny, thenask, thenauto. Ties go to the lowest grant id in byte order. - If none is left, the reason is
grant_expiredif some candidate was ruled out only by expiry; otherwisetaint_too_highif some candidate was ruled out only by taint; otherwiseno_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:
{"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 withuntrusted = truesets the flag. Neither ever goes down. - Writes are atomic: write
<id>.json.tmp,fsync, rename over<id>.json,fsyncthe directory. - A file that exists but cannot be read or parsed is an error: every call for that session is
denied with
state_unreadableandbrokerdprintssee docs/runbook.md#broker-state-damaged. loopdhas no access to<home>/broker/. Theclassanduntrustedvalues 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:
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)isproto::sha256of the line's bytes without the\n.previs the hash of the previous line; the first record ever written hasprevall zeros andseq0.seqgoes up by one per record with no gaps, across files.- The file is
audit/YYYY-MM-DD.jsonlby the UTC date of the record'stime. 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 isfsynced after a file is created. brokerdis the only writer. It holds an exclusiveflockonaudit/.lockfor its whole life andfsyncs after every record.
Write order
For every tool request:
- Decide (section 3). Write the
Decisionrecord and sync it. If this write fails, nothing runs: the answer isdeniedwithaudit_unavailable, andbrokerdprintssee docs/runbook.md#audit-unavailable. - If
ask: wait (section 6). Write theApprovalrecord. If it fails, the call does not run and the answer isdeniedwithaudit_unavailable. - If allowed: run. Update the session state file. Write the
Resultrecord. Then answerloopd. If the state write or theResultwrite fails, the answer isfailedwith 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:
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
Recoveryrecord whosetorn_bytesandtorn_sha256describe exactly that line, and whoseprevis the hash of the line before the torn one and whoseseqfollows that line's. Reported inrecoveries.
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
- Take the lock. If it is held: print "brokerd is already running"
and
see docs/runbook.md#brokerd-already-running, exit 1. - 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 verifydoes that. - If the report has a torn tail: write
\nafter the torn bytes, then aRecoveryrecord chained from the line before the torn one. Print "audit: recovered a torn final line" andsee docs/runbook.md#audit-recovered. - If the report has a failure: without
--accept-break, print the failure's file, line and what, andsee docs/runbook.md#audit-chain-broken, and exit 1. With--accept-break, append anAcceptedBreakrecord naming it, withlast_goodthe hash of the last line that verified,prevthe hash of the file's last line, andseqone more than the highestseqamong lines that parse (or than the failure line's predecessor if none after it parse). Print what was accepted. --accept-breakwith 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
askcall is added to the pending table withexpires= now +ttl_ms, or the grant'sexpiresif that is earlier.brokerdsends the pending frame and waits. - Approve.
brokerddecides again (redecide) with the grants and session state as they are now. Anaskoutcome lets the call run, and so doesauto(the owner has since allowed it outright); the grant matched now is the one used and recorded. Any denial, from adenygrant or from no grant still matching, denies the call with that reason. TheApprovalrecord carriesapprovedand 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 verifyreports 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 |
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
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
BrokerPortimplementsToolPortoverbroker.sock.ToolPort::callgains a callback:fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse. The read timeout while waiting is the pending frame'sexpiresplus 30 s. If the socket cannot be reached or closes early, the answer isFailed { "the tool broker is unavailable" }, andloopdprintssee docs/runbook.md#broker-unavailable. The turn goes on.clockmoves intoloopd, besidefind_toolandcall_tool: the time is not authority and needs no broker. The core tools stayclock,find_tool,call_tool. The registry's discoverable tools becomeread_file,write_file,shell,http_fetch, with the argument schemas from section 3.echostays inFakeToolsfor tests only. This changes the baseline of new sessions only.- Denials become tool results the model can explain, written by
loopd(classpublic,untrustedfalse):
| 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 serveuses 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 unreadablememory/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>printsapproved 41: runsorapproved 41: denied (<reason>).bxctl refuse <id> [--reason <text>]printsrefused 41.bxctl grants checkprints each problem as<file>:<line>: <problem>, orgrants: ok; exit status 1 if there are problems.bxctl audit verify [--home <path>]reads<home>/audit/itself (no daemon) and printsaudit: 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: anapproval_pendingevent prints the same two-line block asapprovals. In the interactive mode it then asksapprove 41? [y/N]on stderr and reads one line from stdin;ysendsapprove, anything else sendsrefuse.--sayand--jsonprint 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
Resultrecord follows aDecisionfor the same call; the fake runtime sees a call only afterallowedor an approval. The seed is printed on failure. - Audit. The writer: chain across a day boundary,
seqacross 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; aseqgap; 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 bothChainVerifierandbrokerd's startup. Not failures: a torn tail (startup writes aRecovery, and the next verify reports it); an accepted break (reported by every later verify); anAcceptedBreaknaming the wrong line (a failure). - Audit before action. A writer that fails on demand: the answer is
audit_unavailableand the fake runtime's count is 0. A state writer that fails:failed, and the content is absent. - Sockets. Each admin kind on
broker.sockandtool_requestonadmin.sockisforbidden. Socket mode 0600, directory mode 0700. - Approvals. Approve; refuse; expire with
ttl_ms100; approve after the grant file is removed (denied,no_grant); approve after the taint rose pastmax_taint(denied,taint_too_high); an unknown id; a droppedloopdconnection 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_failforRunSpec. loopd.BrokerPortagainst a fakebrokerd: result, denial text, pending event, broker gone. The turn loop withapproval_pending.bxctl.approvalsoutput with an argument full of escape sequences;approve,refuse,grants checkandaudit verifyagainst fakes and fixture directories.- End to end, in one process. The fake llama server,
loopd's turn loop, and thebrokerdlibrary with the recording runtime. The scripted model callscall_toolforread_filewith no grant; its next request contains "Denied: no grant allows this call."; the audit log holds oneDecisionrecord withno_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
loopdcould connect toadmin.sockand approve its own calls, and a container escape is the owner's user. Both close in M7 (separate users and containers;admin.sockis never mounted intoloopd). Accepted for M3, because the main adversary is injected text, andloopdhas no code path that sends an admin message. - The pending-approval table is in memory. A
brokerdcrash 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).