M3a spec: fold in the defects the plan's checks found in areas A to D

DecisionRecord's empty struct variants, the verifier's report fields and
region rule, the startup fallback, the IPv4 host rule, Denial, BrokerPort
deadlines and envelope id, the runbook anchor rule, bxctl's --say/--json
and escaping. Recorded in docs/decisions.md; the array-form question is
left open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:09:32 -07:00
co-authored by Claude Opus 5
parent b39dac3f71
commit 43f5b8abc6
2 changed files with 109 additions and 40 deletions
+4
View File
@@ -6,6 +6,9 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit
| Date | Decision | Reason | | Date | Decision | Reason |
|---|---|---| |---|---|---|
| 2026-09-18 | M3a plan checks, audit. `DecisionRecord::Allowed {}` and `Ask {}` are empty struct variants. The resumed verifier accepts a break naming an older file inside a failed region too. When the previous file's last line does not parse, an ordinary start verifies the whole log. A `Recovery` that describes no line is a failure. `abandoned` and `unfinished` both name the decision's `seq`. The report carries what a `Recovery` or `AcceptedBreak` must hold. `--accept-break` with nothing to accept writes nothing. | serde ignores `deny_unknown_fields` on unit variants of an internally tagged enum, so `{"outcome":"allowed","x":1}` decoded; found by `strict.rs` in two areas, and the struct-variant fix was kept over a hand-written `try_from` as the smaller one that also refuses `"reason":null`. As specified, a correctly accepted break could stop every later start while `--accept-break` said "nothing to accept". |
| 2026-09-18 | M3a plan checks, policy and `loopd`. A host name's last label starts with a letter. For `write_file` a grant path equal to the argument does not count toward the match. `redecide` returns `Result<Decision, Denial>`, and `Denial` carries the `deny` grant and its hash. `BrokerPort`'s waits are deadlines, not per-read timeouts; the envelope id is `request.call.0`. `approval_pending.tool` and `tool_denied.name` are the target tool, not `call_tool`. `echo` stays in the test registry. | `127.0.0.1` and `127.1` fitted the host grammar, so "no IP literals" was false. A per-read timeout let a trickling peer hold a turn for ever. `call_tool` tells the owner nothing. |
| 2026-09-18 | M3a plan checks, `bxctl` and the runbook. A runbook anchor must be written out in the source, and the check fails when it finds no pointer. `--say` shows the approval block and does not ask; `--json` prints only the event and never contacts `brokerd`. Everything the model wrote is escaped, tool names and the final answer included. The approval list shows arguments in `serde_json`'s compact form. | A computed anchor could not be checked. "Print the event only" had no meaning without `--json`. The spec's example contradicted section 6. |
| 2026-09-18 | From M3a, a full reference implementation is written only where it still pays: the audit writer and `ChainVerifier` (which also generates the tampering fixtures) and the approval handoff. `policy` is checked by the naive oracle inside its property test. Everything else gets a skeleton: the task's real signatures with `todo!()` bodies, checked to compile against the given tests. Each plan's README records, per task, which check it had and what defect in the spec, task or tests that check exposed; a category that exposes none across a milestone drops a level (reference, skeleton, nothing). Given tests stay for policy, audit and approvals whatever the record says. | In M1 to M2b the reference checked the tasks, not the implementer: it proved tests could pass and signatures compiled (T16), shared the author's blind spots (T5), and caught none of the implementer's defects, which review and probing found. An unsatisfiable test costs one stop-and-report. The tokens are better spent on review. Where a wrong implementation is an authority bypass, the code's author must not also write its acceptance tests. | | 2026-09-18 | From M3a, a full reference implementation is written only where it still pays: the audit writer and `ChainVerifier` (which also generates the tampering fixtures) and the approval handoff. `policy` is checked by the naive oracle inside its property test. Everything else gets a skeleton: the task's real signatures with `todo!()` bodies, checked to compile against the given tests. Each plan's README records, per task, which check it had and what defect in the spec, task or tests that check exposed; a category that exposes none across a milestone drops a level (reference, skeleton, nothing). Given tests stay for policy, audit and approvals whatever the record says. | In M1 to M2b the reference checked the tasks, not the implementer: it proved tests could pass and signatures compiled (T16), shared the author's blind spots (T5), and caught none of the implementer's defects, which review and probing found. An unsatisfiable test costs one stop-and-report. The tokens are better spent on review. Where a wrong implementation is an authority bypass, the code's author must not also write its acceptance tests. |
| 2026-09-18 | Spec review of M3a, approvals. The approval block in `bxctl chat` is fetched from `brokerd` by id, never taken from `loopd`'s event. It shows the parsed arguments serialised again, with control, invisible and direction-changing characters as `\uXXXX`. The owner types the approval id, not `y`. `bxctl chat` escapes model text the same way. A `tool_denied` turn event carries the reason to the owner. | A compromised `loopd` must not choose what the owner approves; `\u002fetc` and `/etc` must look alike; U+202E can make a path read as another; a line already waiting in stdin must never approve; `chat` wrote model text raw, so a conceal sequence could hide the block. | | 2026-09-18 | Spec review of M3a, approvals. The approval block in `bxctl chat` is fetched from `brokerd` by id, never taken from `loopd`'s event. It shows the parsed arguments serialised again, with control, invisible and direction-changing characters as `\uXXXX`. The owner types the approval id, not `y`. `bxctl chat` escapes model text the same way. A `tool_denied` turn event carries the reason to the owner. | A compromised `loopd` must not choose what the owner approves; `\u002fetc` and `/etc` must look alike; U+202E can make a path read as another; a line already waiting in stdin must never approve; `chat` wrote model text raw, so a conceal sequence could hide the block. |
| 2026-09-18 | Spec review of M3a, `brokerd` internals. One lock (the ledger) covers the audit writer and the session state files; the pending table has its own, and whoever removes an entry answers it. After any failed audit write, `brokerd` denies with `audit_unavailable` until restarted. `policy` is pure, and `decide` never returns a `Decision` for an `ask` grant: only `redecide` turns an `Ask` into one. A `RunError`'s text is fixed text, never tool output, and a failure does not raise taint. `BrokerPort` waits `[broker] timeout_ms` for a first frame, and until `expires` plus that after a pending one. | Without the lock two results could lower a session's taint. A partial line followed by more records is a broken chain. `loopd` logs failures as `public`, so tool output in one would be unlabelled. | | 2026-09-18 | Spec review of M3a, `brokerd` internals. One lock (the ledger) covers the audit writer and the session state files; the pending table has its own, and whoever removes an entry answers it. After any failed audit write, `brokerd` denies with `audit_unavailable` until restarted. `policy` is pure, and `decide` never returns a `Decision` for an `ask` grant: only `redecide` turns an `Ask` into one. A `RunError`'s text is fixed text, never tool output, and a failure does not raise taint. `BrokerPort` waits `[broker] timeout_ms` for a first frame, and until `expires` plus that after a pending one. | Without the lock two results could lower a session's taint. A partial line followed by more records is a broken chain. `loopd` logs failures as `public`, so tool output in one would be unlabelled. |
@@ -43,6 +46,7 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit
| Decision | Needed by | | Decision | Needed by |
|---|---| |---|---|
| Thinking cap size. It starts at 4,096 tokens so that the right size can be found from data. From M2b every completion logs its reasoning-token count and whether the cap fired. Revisit once 200 completions are logged, or sooner if the owner sees answers cut short: raise the cap if more than 5% of completions hit it. Each hit also costs a re-read of that turn. | after M2b has run for a while | | Thinking cap size. It starts at 4,096 tokens so that the right size can be found from data. From M2b every completion logs its reasoning-token count and whether the cap fired. Revisit once 200 completions are logged, or sooner if the owner sees answers cut short: raise the cap if more than 5% of completions hit it. Each hit also costs a re-read of that turn. | after M2b has run for a while |
| Whether `proto`'s structs should refuse the JSON array form. serde's derived `Deserialize` accepts `[…]` in field order, so `"body":[]` decodes as `Empty`; true of every `proto` struct since M1. Not an authority problem (the values are validated the same way); closing it needs hand-written `Deserialize` for every struct. Found by the M3a plan checks. | before M7 |
| Repository licence. Crates are `publish = false` until it is chosen. | before publishing | | Repository licence. Crates are `publish = false` until it is chosen. | before publishing |
| Audit integrity beyond a self-contained hash chain (for example anchoring the chain head outside `brokerd`). Deferred by the owner on 2026-09-17 to a later revision. | after v0 | | Audit integrity beyond a self-contained hash chain (for example anchoring the chain head outside `brokerd`). Deferred by the owner on 2026-09-17 to a later revision. | after v0 |
| How an approval whose arguments do not fit one Mattermost post is shown. Settled on 2026-09-18: what is being approved is never truncated, in any channel, because the unseen tail is where injected text would put its payload; `bxctl` prints arguments in full. Leading option for Mattermost: a thread of numbered chunks split on character boundaries of the escaped text, where only a reply or reaction on the last post counts and that post repeats the id, the chunk count and the hash of the whole; above some number of chunks, the post points to `bxctl approvals` and approval is local only. The post size limit and rate limits must be checked against the Mattermost API reference first. | M4 | | How an approval whose arguments do not fit one Mattermost post is shown. Settled on 2026-09-18: what is being approved is never truncated, in any channel, because the unseen tail is where injected text would put its payload; `bxctl` prints arguments in full. Leading option for Mattermost: a thread of numbered chunks split on character boundaries of the escaped text, where only a reply or reaction on the last post counts and that post repeats the id, the chunk count and the hash of the whole; above some number of chunks, the post points to `bxctl approvals` and approval is local only. The post size limit and rate limits must be checked against the Mattermost API reference first. | M4 |
+105 -40
View File
@@ -1,7 +1,8 @@
# M3a design: the decision path # M3a design: the decision path
Status: draft for owner review, 2026-09-18; revised the same day after a design review (the Status: draft for owner review, 2026-09-18; revised the same day after a design review (the
"Spec review of M3a" rows in `docs/decisions.md`). M3 is split in two (`docs/decisions.md`). M3a is "Spec review of M3a" rows in `docs/decisions.md`), and again after the plan's checks (the "M3a plan
checks" rows there). 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, 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 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 real tool port. Tools themselves do not run in M3a: the runner is a trait whose only
@@ -98,13 +99,16 @@ admin = "/var/lib/boxmaker/run/owner-broker/admin.sock"
ttl_ms = 900000 # 15 min ttl_ms = 900000 # 15 min
``` ```
A socket path that is absent or empty means the default under `home`, as in `loopd`.
## 3. Grants ## 3. Grants
### Files ### Files
One grant per file, `grants/<id>.toml`. The id is the file stem: 1 to 64 characters of 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-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. a bad stem is an invalid grant. A grants directory that is missing or cannot be read makes the set
invalid; an empty directory is a valid empty set. The fields are `proto::Grant` as defined in M1.
```toml ```toml
# grants/notes-read.toml # grants/notes-read.toml
@@ -228,12 +232,16 @@ component. Paths are compared as they are, never normalised, and matching is by
`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the `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. 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. When several of a grant's paths contain the argument, the longest one is the matched path. For
`write_file`, a grant path equal to the argument does not count: with `paths = ["/s", "/s/out"]`,
a write to `/s/out` is covered through `/s`, and `/s` is the matched path.
### Hosts ### Hosts
A host name: 1 to 253 bytes, lowercase, at least two labels separated by `.`, each label 1 to 63 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 bytes of `[a-z0-9-]` not starting or ending with `-`, and the last label starts with a letter,
which excludes every spelling of an IPv4 address (`127.0.0.1`, `127.1`, `10.0.0.0x1`). A host
pattern in a grant is a host name, or
`*.` followed by a host name. `*.` followed by a host name.
| Grant host | URL host | Result | | Grant host | URL host | Result |
@@ -256,16 +264,18 @@ the property test drive it.
```rust ```rust
pub struct SessionState { pub taint: DataClass, pub untrusted: bool } pub struct SessionState { pub taint: DataClass, pub untrusted: bool }
pub enum Outcome { pub struct Denial {
Allowed(Decision), pub reason: DenyReason,
Ask(Ask), pub grant: Option<String>, // the `deny` grant, for `denied_by_grant` only
Denied { reason: DenyReason, grant: Option<String> }, pub grant_sha256: Option<Hash32>,
} }
pub enum Outcome { Allowed(Decision), Ask(Ask), Denied(Denial) }
pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp) pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp)
-> Outcome; -> Outcome;
pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp) pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp)
-> Result<Decision, DenyReason>; -> Result<Decision, Denial>;
``` ```
- `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail` - `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail`
@@ -274,7 +284,7 @@ pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp
into a `Decision`, so "this call was approved" is a fact about the types, not about the order into a `Decision`, so "this call was approved" is a fact about the types, not about the order
of statements in `broker`. of statements in `broker`.
- `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else - `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else
gives the reason. gives the `Denial`, whose grant and file hash the `Approval` record needs.
- `broker` checks in this order, and the first that applies is the answer: the grant set is - `broker` checks in this order, and the first that applies is the answer: the grant set is
invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then
`decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the `decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the
@@ -295,7 +305,8 @@ pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp
Neither ever goes down. The read, the comparison and the write happen under the ledger lock Neither ever goes down. The read, the comparison and the write happen under the ledger lock
(section 2). A `failed` call changes nothing: its message is `brokerd`'s own text (section 7). (section 2). A `failed` call changes nothing: its message is `brokerd`'s own text (section 7).
- Writes are atomic: write `<id>.json.tmp`, `fsync`, rename over `<id>.json`, `fsync` the directory. - 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 - A file that exists but cannot be read or parsed, or that says `public` (a session is never below
`private`, so `brokerd` did not write it), is an error: every call for that session is
denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`. denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`.
- The brief's State list names this file (P14, applied 2026-09-18). - The brief's State list names this file (P14, applied 2026-09-18).
- `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy - `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy
@@ -342,9 +353,13 @@ pub enum AuditEvent {
} }
#[serde(tag = "outcome", rename_all = "snake_case")] #[serde(tag = "outcome", rename_all = "snake_case")]
pub enum DecisionRecord { Allowed, Ask, Denied { reason: DenyReason } } pub enum DecisionRecord { Allowed {}, Ask {}, Denied { reason: DenyReason } }
``` ```
`Allowed {}` and `Ask {}` are empty struct variants, not unit variants: serde does not apply
`deny_unknown_fields` to a unit variant of an internally tagged enum, so with `Allowed` the text
`{"outcome":"allowed","x":1}` would decode. The JSON is the same either way.
`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape `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. (with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event.
@@ -411,20 +426,35 @@ impl ChainVerifier {
pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point 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 file(&mut self, name: &str); // starts the next file
pub fn line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line pub fn line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line
pub fn feed(&mut self, name: &str, content: &[u8]); // file, then each of its lines
pub fn finish(self) -> ChainReport; pub fn finish(self) -> ChainReport;
} }
pub struct ChainReport { pub struct ChainReport {
pub records: u64, pub head: Option<Hash32>, pub next_seq: u64, pub records: u64, pub head: Option<Hash32>, pub next_seq: u64,
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what pub failure: Option<ChainFailure>, // the first one since the last accepted break
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>, pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them 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 pub unfinished: Vec<u64>, // seq of decisions allowed, at once or by approval,
// outcome is allowed, with no Result after them // with no Result after them
pub clock_warnings: Vec<Location>, // time went backwards pub clock_warnings: Vec<Location>, // time went backwards
pub torn_tail: Option<Location>, // the last line of the last file needs recovery pub torn_tail: Option<TornTail>, // the last line of the last file needs recovery
}
pub struct ChainFailure { // file, line (1-based), what; and what an
pub file: String, pub line: u64, pub what: String, // AcceptedBreak appended now must carry
pub last_good: Hash32, pub break_prev: Hash32, pub break_seq: u64,
pub tail_torn: bool, // the last line fed has no newline
}
pub struct TornTail { // and what its Recovery record must carry
pub at: Location, pub has_newline: bool, pub bytes: u64, pub sha256: Hash32,
pub recovery_prev: Hash32, pub recovery_seq: u64,
} }
``` ```
The report carries what the writer must put in a `Recovery` or `AcceptedBreak` record, so that no
consumer computes `seq` or `prev` a second time. Both lists name the *decision's* `seq`, for an
approved call too: `Approval.decision` and `Result.decision` both refer to it, and it is the
approval id the owner saw.
A line fails if it does not parse as an `AuditRecord`, its `seq` is not the next one, or its 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 `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: does not chain from the previous file's last line fails at its line 1. Two cases are not failures:
@@ -440,6 +470,9 @@ does not chain from the previous file's last line fails at its line 1. Two cases
record exactly before its newline, leaving complete JSON. A recovered line is not a record and 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`. nothing may refer to its `seq`, which the `Recovery` record reuses. Reported in `recoveries`.
A `Recovery` record that describes no line (its hash and length match nothing before it) is a
failure at the `Recovery` record.
The verifier therefore holds each line back until it has seen the next one, and judges it then (or The verifier therefore holds each line back until it has seen the next one, and judges it then (or
at `finish`). at `finish`).
@@ -462,15 +495,21 @@ 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` 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 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. continues from it; the full verification judges the rest. This holds inside a failed region too:
if the latest file has damage before such a break record, the resumed verifier is already in a
region when it meets the break, and the break still clears it. Otherwise a correctly accepted
break would stop every later start while `--accept-break` said "nothing to accept".
### Startup ### Startup
1. Take the lock. If it is held: print "brokerd is already running" 1. Take the lock. If it is held: print "brokerd is already running"
and `see docs/runbook.md#brokerd-already-running`, exit 1. 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 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 it (if any). If that line does not parse as a record, there is no point to resume from, and the
re-read; `bxctl audit verify` does that. With `--accept-break`: verify every file, exactly as whole log is verified instead. (Treating it as the failure would deadlock: after a break at
that very line is accepted, every later start would fail on it again.) Otherwise the earlier
files are not re-read; `bxctl audit verify` does that. Damage in an older file is therefore not
seen by an ordinary start, by design. 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 `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. 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, 3. If the report has a failure: without `--accept-break`, write nothing, print the failure's file,
@@ -481,7 +520,8 @@ continues from it; the full verification judges the rest.
4. Otherwise, if the report has a torn tail: write `\n` after the torn bytes if it is missing, 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 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`. torn final line" and `see docs/runbook.md#audit-recovered`.
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2. 5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2. It writes nothing,
not even the recovery of a torn tail; the next start without the flag does that.
An empty latest file (created, then a crash before its first record) is not a failure and needs 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. no recovery: the next record is its line 1 and chains from the file before.
@@ -582,7 +622,8 @@ pub struct GrantProblem { pub file: String, pub line: Option<u64>, pub problem:
An unknown or already answered `approval` is `error` `no_such_approval`. An unknown or already answered `approval` is `error` `no_such_approval`.
`PendingApproval.arguments` is not the string `loopd` sent. It is the parsed arguments serialised `PendingApproval.arguments` is not the string `loopd` sent. It is the parsed arguments serialised
again by `serde_json` from the typed value in `args`, fields in the order of section 3's table. again by `serde_json` from the typed value in `args`, fields in the order of section 3's table,
compact, with an absent `cwd` left out (`"cwd": null` in the request means absent).
The owner approves what policy matched: in the raw string `"\u002fetc"` and `"/etc"` look The owner approves what policy matched: in the raw string `"\u002fetc"` and `"/etc"` look
different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps
the raw string. the raw string.
@@ -633,17 +674,27 @@ processes, output size) are M3b's.
- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback: - **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback:
`fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`. `fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`.
The read timeout is `[broker] timeout_ms` (default 120,000) until the first frame. After a `BrokerPort` waits for the first frame until `[broker] timeout_ms` (default 120,000) after the
pending frame it runs until the frame's `expires` plus `timeout_ms`, which leaves a call call began, and after a pending frame until the frame's `expires` plus `timeout_ms`, which
approved at the last moment the same time to run as any other. M3b must keep its tool time leaves a call approved at the last moment the same time to run as any other. These are
limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the answer deadlines, not per-read socket timeouts: a peer that trickles bytes must not hold a turn for
is `Failed { "the tool broker is unavailable" }`, and `loopd` prints ever. (Unlike the inference path's liveness rule, this is a total limit.) M3b must keep its tool
time limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the
answer is `Failed { "the tool broker is unavailable" }`, and `loopd` prints
`see docs/runbook.md#broker-unavailable`. The turn goes on. `see docs/runbook.md#broker-unavailable`. The turn goes on.
- The envelope `id` of a tool request is `request.call.0`, and every answer must carry it. Also
failures of the port, each with its own fixed text and no runbook pointer unless it says so: a
request too large for one frame ("the request is too large for the tool broker"); a pending
frame as the final answer ("the tool broker gave no final answer"); an `expires` so far off
that the deadline cannot be computed is a broker that is unavailable. `timeout_ms = 0` is not
refused at load; every call then fails closed as unavailable.
- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and - **`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 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 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 schemas from section 3. `echo` stays in `FakeTools` and in the test registry `Registry::m2b()`,
new sessions only. which recorded conversations and tests find it through; `loopd serve` uses `Registry::m3a()`.
The `tools` array is the same for both (`clock`, `find_tool`, `call_tool`), so this changes only
what `find_tool` finds.
- **Denials become tool results** the model can explain, written by `loopd` (`class` public, - **Denials become tool results** the model can explain, written by `loopd` (`class` public,
`untrusted` false): `untrusted` false):
@@ -664,7 +715,9 @@ processes, output size) are M3b's.
frame arrives. It is a notice that something is waiting, not a description of it: `loopd` does frame arrives. It is a notice that something is waiting, not a description of it: `loopd` does
not know the grant or the taint, and what the owner is shown must not come from `loopd` not know the grant or the taint, and what the owner is shown must not come from `loopd`
(section 9). `tool_denied { name, reason }` is sent before the `tool_result` event of a denied (section 9). `tool_denied { name, reason }` is sent before the `tool_result` event of a denied
call, so the owner sees the reason itself and not only the model's account of it. call, so the owner sees the reason itself and not only the model's account of it. In both
events the tool is the one `brokerd` decides on (`request.tool`), not `call_tool`;
`tool_call_started` and `tool_result` keep the name the model called.
- **Config**: `[broker] socket` and `[broker] timeout_ms`. If `socket` is absent, `loopd serve` - **Config**: `[broker] socket` and `[broker] timeout_ms`. If `socket` is absent, `loopd serve`
uses no port and every tool call except the core ones fails with "no tool broker is uses no port and every tool call except the core ones fails with "no tool broker is
configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`. configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`.
@@ -678,7 +731,7 @@ processes, output size) are M3b's.
``` ```
41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private 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"} shell {"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}
``` ```
- `bxctl approve <id>` prints `approved 41: runs` (exit status 0) or - `bxctl approve <id>` prints `approved 41: runs` (exit status 0) or
@@ -689,8 +742,10 @@ processes, output size) are M3b's.
status 1 if there are problems. status 1 if there are problems.
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints - `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 `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 abandoned", calls "running or unfinished", clock warnings and a torn final line, one per line
`<file>:<line>: <what>` and exit status 1. The two double names are because `bxctl` reads the (the exact lines are fixed in task 19; a torn tail is also what a `brokerd` mid-write looks
like, so it is not a failure); or the failure as `<file>:<line>: <what>` and the runbook pointer,
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 files without asking `brokerd`: an approval still waiting and a call still running look the same
on disk as ones a crash cut off. on disk as ones a crash cut off.
- `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the - `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the
@@ -701,7 +756,12 @@ processes, output size) are M3b's.
on stderr and reads one line from stdin; exactly the id sends `approve`, anything else sends on stderr and reads one line from stdin; exactly the id sends `approve`, anything else sends
`refuse`. The id, not `y`, because lines typed while the turn ran are still waiting in stdin, `refuse`. The id, not `y`, because lines typed while the turn ran are still waiting in stdin,
and `bxctl` cannot discard them without a terminal library: a stray line must never approve. and `bxctl` cannot discard them without a terminal library: a stray line must never approve.
`--say` and `--json` print the event only. `bxctl chat` gains `--admin-socket`. `--say` shows the block and does not ask; the owner answers with `bxctl approve` from another
terminal. `--json` prints the event as JSON and nothing else, and never contacts `brokerd`. The
chat lines and the approval's answer are read from one buffered reader on stdin. `bxctl chat`
gains `--admin-socket`. The exact texts of the block, the spans and the one-line failures
(`brokerd` unreachable, an approval expired before the answer, end of input at the question,
which refuses) are fixed in tasks 18 and 20.
- A `tool_denied` event prints `[denied <name>: <reason>]`, and for `grants_invalid`, - A `tool_denied` event prints `[denied <name>: <reason>]`, and for `grants_invalid`,
`audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`, `audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`,
`audit-unavailable`, `broker-state-damaged`) on the next line. `audit-unavailable`, `broker-state-damaged`) on the next line.
@@ -714,7 +774,8 @@ processes, output size) are M3b's.
spelling of the same character, so the printed text is still exactly the arguments and cannot spelling of the same character, so the printed text is still exactly the arguments and cannot
be forged: a real backslash in a value is already `\\` there. be forged: a real backslash in a value is already `\\` there.
- **Model text is printed the same way** from M3a: `bxctl chat` applies the same escaping to - **Model text is printed the same way** from M3a: `bxctl chat` applies the same escaping to
reasoning and content, except that newline and tab pass through, and writes `ESC[0m` before an everything the model wrote (reasoning, content, tool names, and the answer on stdout), except
that newline and tab pass through, and writes `ESC[0m` before an
approval block. Until now it wrote model text to the terminal as it came, and a colour or approval block. Until now it wrote model text to the terminal as it came, and a colour or
conceal sequence in it would have carried into the block that follows. conceal sequence in it would have carried into the block that follows.
- The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`. - The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`.
@@ -742,8 +803,12 @@ heading whose text is its anchor (`## grants-invalid`), with five parts: what yo
system refuses, how to confirm, how to fix, and how to check the fix. 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, 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 `scripts/check-runbook.sh`, collects every `docs/runbook.md#<anchor>` in the `*.rs` files under
`docs/runbook.md` lacks the heading `## <anchor>`. It has a self-test like the other gate scripts. `crates/` (tests included, `target/` excluded) and fails if `docs/runbook.md` lacks the heading
`## <anchor>`. An anchor must be written out in the source: a pointer whose anchor the script
cannot read (built with `format!`, or a placeholder in a comment) is an error. It also fails if it
finds no pointer at all, since that means the search is broken. It has a self-test like the other
gate scripts.
## 12. Testing ## 12. Testing
@@ -769,12 +834,12 @@ implementation, the oracle, or a compiling skeleton) is in section 15.
- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the - **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 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 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 one before; a middle line cut short. Each must fail at the right file and line in `ChainVerifier`, and in
`ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`, `brokerd`'s startup when the damaged file is the latest (an ordinary start reads only that one). Not failures: a torn tail (startup writes a `Recovery`,
and the next verify reports it); an accepted break (reported by every later verify). Failures: 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 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` `Recovery` whose hash or length does not match the line before it, or that describes no line; a
describes followed by a torn `Recovery`. 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 - **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 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 "does not parse" rule gets wrong); an unparseable last line that has its newline (recovered