Files
boxmaker/docs/specs/2026-09-18-m3a-decision-path.md
T
kyleandClaude Fable 5.1 3492734434 Fix seven M3a spec review findings in policy and the audit chain
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>
2026-09-18 22:13:28 -07:00

40 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.

  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
  1. 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".
  2. A host in hosts is not a valid host pattern (section 3, "Hosts").
  3. 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:

{"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:

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 fsynced 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 fsyncs 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:

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
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, 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).