diff --git a/docs/decisions.md b/docs/decisions.md index aa1d25a..dd5acc5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -6,6 +6,8 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit | Date | Decision | Reason | |---|---|---| +| 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; `/etc` 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, policy. A `deny` grant must have `max_taint = "secret"` or the grant set is invalid. A result's `result_class` and `untrusted` are combined over every matching grant (highest class; untrusted if any says so), and within a mode the grant with the longest matched path wins before the id tie-break. A grant path of `/` is invalid. | A `deny` grant with a lower `max_taint` stopped denying once the session read a secret. With overlapping `auto` grants the id tie-break chose the label, so a secret could come back `private`. M3b mounts grant paths at the same path, where `/` would replace the tool image. | | 2026-09-18 | Spec review of M3a, audit. A line followed by a matching `Recovery` is recovered whether or not it parses; an unparseable last line is recovered like a torn one. The writer never moves to an earlier day's file, and `Recovery` and `AcceptedBreak` go in the latest file. `--accept-break` verifies the whole log; the verifier checks an `AcceptedBreak`'s `file`, `line`, `last_good`, `prev` and `seq`, and the `seq` is counted from lines, not read from them. The `Approval` record carries the re-decision's grant and session state. The report lists allowed calls with no `Result`. | As first written, a record cut exactly before its newline made `brokerd` break its own chain; so did a clock stepped back over midnight; a break in an older file could never be accepted; and a damaged line could choose the next `seq`. | | 2026-09-18 | M3 is split. M3a is the decision path: grants and matching, session taint, the hash-chained audit log, approvals through `bxctl`, the `broker.sock` and `admin.sock` protocol, and `loopd`'s `BrokerPort`; tools do not run (the runner is a trait with a refusing implementation). M3b is the Podman runner, the four tools and the egress proxy. Review happens after each. | The security logic is reviewed before real tools run on it, as with M2a and M2b. Spec `docs/specs/2026-09-18-m3a-decision-path.md`. | @@ -76,3 +78,4 @@ Later changes to the brief: | # | Change | Reason | |---|---|---| | P13 | Authority contract 4: add "Tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time." | The brief does not say where tool images come from; a pull at call time would be unlisted egress. Decided 2026-09-18 above. | +| P14 | State: add "`broker/sessions/.json` — each session's taint and untrusted flag, written only by `brokerd`". | The M3a spec keeps this state in files the brief's list does not name. It can be rebuilt from the audit log's `result` records, so the audit log stays the record of truth. | diff --git a/docs/runbook.md b/docs/runbook.md index bc20efa..301ae9b 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -69,9 +69,12 @@ file system. **Fix.** Free space or correct ownership (`chown` to the `brokerd` user; files are mode 0600, the directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain. +Then stop and start `brokerd`. It does not try again by itself: a failed write may have left part +of a line, and only the startup check deals with that. Expect +[audit-recovered](#audit-recovered) at that start. **Check.** Ask for any tool call. It is decided normally, and `bxctl audit verify` prints -`audit: ok`. If `brokerd` keeps failing after the cause is gone, stop and start it. +`audit: ok`. ## audit-chain-broken @@ -205,12 +208,14 @@ again. ## broker-unavailable -**What you see.** `loopd` prints that it cannot reach `broker.sock`, then this entry. Tool calls -fail with "the tool broker is unavailable", the model says so, and the conversation goes on. +**What you see.** `loopd` prints that it cannot reach `broker.sock`, or that `brokerd` did not +answer in time, then this entry. Tool calls fail with "the tool broker is unavailable", the model +says so, and the conversation goes on. Or, once at startup, `loopd` prints "no tool broker is +configured" and this entry, and tool calls fail with those words. -**Why.** `brokerd` is not running, was restarted during a call, or `loopd`'s `[broker] socket` -points somewhere else. Pending approvals are lost when `brokerd` restarts. `bxctl audit verify` -lists them as abandoned. +**Why.** `brokerd` is not running, was restarted during a call, did not answer within `[broker] +timeout_ms`, or `loopd`'s `[broker] socket` points somewhere else or is not set. Pending approvals +are lost when `brokerd` restarts. `bxctl audit verify` lists them as pending or abandoned. **Confirm.** @@ -222,8 +227,8 @@ ls -l "$BOXMAKER_HOME/run/loop-broker/" Compare the socket path with `[broker] socket` in `loopd`'s config and `[sockets] broker` in `brokerd.toml`. -**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart: it connects per -call. +**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart for that: it +connects per call. A changed or newly set `[broker] socket` does need a `loopd` restart. **Check.** Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of failing. diff --git a/docs/specs/2026-09-18-m3a-decision-path.md b/docs/specs/2026-09-18-m3a-decision-path.md index bd311d4..94f64ee 100644 --- a/docs/specs/2026-09-18-m3a-decision-path.md +++ b/docs/specs/2026-09-18-m3a-decision-path.md @@ -1,6 +1,7 @@ # M3a design: the decision path -Status: draft for owner review, 2026-09-18. M3 is split in two (`docs/decisions.md`). M3a is +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 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 @@ -19,7 +20,7 @@ in `docs/specs/2026-09-17-pre-m1-design.md`; section 6 of this document replaces | `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 | +| Every fail-closed state's message names a runbook entry, and every entry named exists | A test per state for the pointer; the gate script for the entry | 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). @@ -56,6 +57,30 @@ Each is one file under 500 lines with one purpose. Only `serve` starts threads. `brokerd serve --config [--accept-break]`. `brokerd` gains `serde`, `serde_json` and `toml`, all already vetted. +### Threads and locks + +`serve` starts one thread per connection and one expiry thread. They share two things, each behind +its own `Mutex`, and no thread ever holds both at once: + +- **The ledger**: the audit writer and every session state file. A thread holds it for each of + these steps as a whole, and never while a tool runs or an approval is awaited: + - read the session's state, decide, write the `Decision` record; + - read the state, re-decide, write the `Approval` record; + - read the state, raise it, write the state file, write the `Result` record. + + Without it two results for one session could each read `private`, and the second write would + put the taint back down; and two records could take the same `seq`. +- **The pending table** (section 6). An entry is answered by whoever removes it from the table + while holding the table's lock: `approve`, `refuse`, the expiry thread, or the waiting thread + when it finds its connection closed. Everyone else finds the entry gone. This one rule settles + every race between them. + +A poisoned table lock is recovered with `into_inner`, as in `loopd`. A poisoned ledger lock is +not: a thread panicked part-way through a write, so the writer's idea of the chain's head may be +wrong. Every call is then denied with `audit_unavailable` until `brokerd` is restarted, and the +startup check puts the tail right. The same holds after any failed audit write: the writer does +not write again in this process. + ### Configuration ```toml @@ -222,6 +247,39 @@ IP literals, no uppercase, no `[`); no userinfo (`@` before the path); no port, then optionally `/` and a path, query or fragment of printable ASCII other than space. Anything else is `invalid_arguments`. +### The policy functions + +`policy` does no I/O and reads no clock: everything it needs is an argument, which is what lets +the property test drive it. + +```rust +pub struct SessionState { pub taint: DataClass, pub untrusted: bool } + +pub enum Outcome { + Allowed(Decision), + Ask(Ask), + Denied { reason: DenyReason, grant: Option }, +} + +pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp) + -> Outcome; +pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp) + -> Result; +``` + +- `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail` + doctest. It holds the request, the parsed arguments and the grant that asked. **`decide` never + returns a `Decision` for an `ask` grant**, and `redecide` is the only thing that turns an `Ask` + into a `Decision`, so "this call was approved" is a fact about the types, not about the order + of statements in `broker`. +- `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else + gives the reason. +- `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 + `decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the + arguments are not valid (`invalid_arguments`); matching. The first two need no `Decision`-like + guard: anyone may deny. + ## 4. Session state `/broker/sessions/.json`, written only by `brokerd`: @@ -231,11 +289,14 @@ else is `invalid_arguments`. ``` - 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. +- After a tool result, taint becomes the higher of its current value and the result's + `result_class`, and an `untrusted` result sets the flag (both as combined in section 3, step 4). + 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). - Writes are atomic: write `.json.tmp`, `fsync`, rename over `.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`. +- The brief's State list does not name this directory yet; P14 in `docs/decisions.md` proposes it. - `loopd` has no access to `/broker/`. The `class` and `untrusted` values it logs are a copy for its own use and never an input to policy. @@ -286,8 +347,16 @@ 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). +`event` is a nested object: `{"seq":…,"time":…,"prev":…,"event":{"type":"decision",…}}`. It is not +flattened: `#[serde(flatten)]` does not work with `deny_unknown_fields`. + +Results are recorded by hash and size, never content, so reading a secret does not by itself copy +it into the audit log. Arguments are recorded in full, exactly as received; they are at most one +frame (1 MiB). A session that has read a secret can put it in a later call's arguments (a +`write_file` content, a `shell` command), so the log is not free of secrets: a record is as +sensitive as the `taint` it carries, and the files are mode 0600 for that reason. For a `failed` +result, `class` and `untrusted` are the label the result would have had, and `taint_after` equals +the taint before. ### The chain @@ -307,8 +376,10 @@ secret into the audit log. Arguments are recorded in full; they are at most one backwards time as a clock warning. - `Recovery` and `AcceptedBreak` records always go in the latest file, whatever their date: they belong next to the lines they describe. -- `brokerd` is the only writer. It holds an exclusive `flock` on `audit/.lock` for its whole life - and `fsync`s after every record. +- `brokerd` is the only writer. It holds an exclusive lock on `audit/.lock` for its whole life + and `fsync`s after every record. The lock is `std::fs::File::try_lock`, stable since Rust 1.89 + (checked in the std docs of 1.98.1; the workspace minimum is 1.95), so it needs neither `libc` + nor `unsafe`. The task must check its error type on docs.rs before use. ### Write order @@ -316,7 +387,9 @@ 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`. + `see docs/runbook.md#audit-unavailable`. A failed write may have left part of a line, so the + writer does not write again: every later call gets the same denial until `brokerd` is + restarted, and the startup check recovers the tail. 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`. @@ -336,7 +409,7 @@ 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 line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line pub fn finish(self) -> ChainReport; } pub struct ChainReport { @@ -427,9 +500,13 @@ Nothing is truncated, rewritten or deleted, ever. | `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 +`brokerd` creates each directory if it is missing and sets its mode to 0700 whether it made it or +found it (a failure to do so is a startup error), removes a stale socket file, binds, and sets the +socket to 0600. All of this comes after the audit lock is taken (section 5, "Startup"): the lock +is what proves the socket file is stale and not another `brokerd`'s. + +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. @@ -452,17 +529,35 @@ One connection per request, as on `loop.sock`: ### 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. + `expires` if that is earlier. The entry holds the `Ask`, what `approvals` lists, and the sending + half of a `std::sync::mpsc` channel. The connection's thread sends the pending frame and waits + on the receiving half for a verdict: run this `Decision`, or deny with this reason. +- **Whoever takes the entry out of the table answers it** (section 2), and does all of the + answering: the admin thread for `approve` and `refuse`, the expiry thread for expiry. That + thread takes the ledger lock, writes the `Approval` record, sends the verdict, and only then + answers `bxctl`. The waiting thread writes nothing for an approval; it receives the verdict and + either runs the call or sends the denial. +- **Approve.** The admin thread 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. If that record cannot be written, the verdict and + the `approve_result` are both `denied` with `audit_unavailable`. - **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. +- **Lost connection.** While it waits, the connection's thread wakes every second + (`recv_timeout`) and checks its socket: with a read timeout of 10 ms set, a `read` that returns + `Ok(0)` means `loopd` has gone; a `WouldBlock` or `TimedOut` error means it is still there; any + bytes are a protocol error and count as gone. (`UnixStream::peek` would be the natural call, but + it is nightly-only; a zero read timeout is an error.) If `loopd` has gone, the thread tries to + take its own entry out of the table. If it gets it, nothing is written, and + `bxctl audit verify` reports the decision as abandoned. If the entry is already gone, someone + is answering it: the thread waits for the verdict. +- **One more check before running.** On a verdict to run, the thread checks its socket once more. + If `loopd` has gone, the call does not run, and a `Result` record with status `failed` (message + "the requester went away") closes the call in the log. `loopd` can still go away between this + check and the run, and the call then runs with nobody waiting for it; that is accepted. - **Restart.** The table is in memory only. After a restart there are no pending approvals. ### Admin messages, on `admin.sock` @@ -485,6 +580,12 @@ pub struct GrantProblem { pub file: String, pub line: Option, pub problem: 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 +again by `serde_json` from the typed value in `args`, fields in the order of section 3's table. +The owner approves what policy matched: in the raw string `"/etc"` and `"/etc"` look +different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps +the raw string. + ## 7. The runner seam ```rust @@ -516,6 +617,13 @@ What `run` puts in the spec, from the decision: | `shell` | every path of the grant, writable | none | | `http_fetch` | none | the grant's `hosts` | +**A `RunError`'s text is never tool output.** `loopd` logs a `failed` result as `public` and +trusted, and a failure does not raise taint, so the message must be one of a fixed set of +sentences written in `brokerd` or `toolkit` ("the tool timed out", "the container could not +start"). Nothing a tool printed, read from a file or received from the network goes in it. In M3b +a command that exits non-zero is a `result`, labelled like any other, with its output and exit +status as the content. + 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. @@ -524,9 +632,12 @@ processes, output size) are M3b's. - **`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. + The read timeout is `[broker] timeout_ms` (default 120,000) until the first frame. After a + pending frame it runs until the frame's `expires` plus `timeout_ms`, which leaves a call + approved at the last moment the same time to run as any other. 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. - **`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 @@ -548,10 +659,14 @@ processes, output size) are M3b's. | `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". +- **Two new turn events.** `approval_pending { approval, tool, expires }` is sent when the pending + 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` + (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. +- **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 + configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`. - **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`). @@ -565,8 +680,10 @@ processes, output size) are M3b's. shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"} ``` -- `bxctl approve ` prints `approved 41: runs` or `approved 41: denied ()`. - `bxctl refuse [--reason ]` prints `refused 41`. +- `bxctl approve ` prints `approved 41: runs` (exit status 0) or + `approved 41: denied ()` (exit status 1). `bxctl refuse [--reason ]` prints + `refused 41`. For `no_such_approval` both print `41: no such approval (already answered or + expired)` and exit 1. - `bxctl grants check` prints each problem as `:: `, or `grants: ok`; exit status 1 if there are problems. - `bxctl audit verify [--home ]` reads `/audit/` itself (no daemon) and prints @@ -575,12 +692,30 @@ processes, output size) are M3b's. `:: ` 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. +- `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the + entry with that id, and prints the same two-line block as `bxctl approvals`. **Everything in the + block comes from `brokerd`, nothing from the event**: a compromised `loopd` must not choose what + the owner sees. If the id is not in the list it prints `approval 41 is no longer pending` and + does not ask. In the interactive mode it then asks `type 41 to approve, anything else refuses: ` + 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, + 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`. +- A `tool_denied` event prints `[denied : ]`, and for `grants_invalid`, + `audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`, + `audit-unavailable`, `broker-state-damaged`) on the next line. +- **Arguments are printed as data.** Each of these characters is printed as `\uXXXX`, four + lowercase hex digits: U+0000 to U+001F, U+007F to U+009F, U+200B to U+200F, U+2028 to U+202E, + U+2060 to U+2069, and U+FEFF. The first two ranges are the control characters, so nothing the + model wrote can move the cursor or change colours. The rest are the invisible and + direction-changing characters: with U+202E in a path, the path displayed would differ from the + path that was matched. The arguments are JSON, and inside a JSON string `\uXXXX` is the standard + 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. +- **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 + 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. - The admin socket defaults to `/run/owner-broker/admin.sock`. ## 10. `proto` changes @@ -594,7 +729,7 @@ processes, output size) are M3b's. | `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 }` | +| `TurnEvent` | gains `approval_pending { approval, tool, expires }` and `tool_denied { name, reason }` | All of them reject unknown fields. Every new message kind gets a byte-exact fixture. @@ -652,26 +787,49 @@ first, as in M2. - **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. + the fake runtime's count is 0; the next call is denied the same way though the writer would now + succeed. A state writer that fails: `failed`, and the content is absent. +- **Concurrency.** Eight threads, two sessions, fifty calls each through the `brokerd` library + with the recording runtime, half of the grants labelled `secret`: the log verifies, no `seq` + repeats, and each session's `taint_after` never goes down from one `Result` to the next. - **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`. - Socket mode 0600, directory mode 0700. + Socket mode 0600; directory mode 0700, both when `brokerd` makes it and when it finds it at 0755. - **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). + written, reported as abandoned); `approve` and `refuse` sent together from two threads, a + hundred times (exactly one `Approval` record each time, and the other caller gets + `no_such_approval`); an approval whose `Approval` record cannot be written (both sides get + `audit_unavailable`, runtime count 0); `loopd` gone at the check before running (a `failed` + `Result`, runtime count 0). The `Approval` record names the grant matched at the re-decision + when that differs from the first. +- **Pointers.** For each fail-closed state (`grants_invalid`, `audit_unavailable`, + `state_unreadable`, a broken chain, a recovered tail, a held lock, a forbidden message, the + broker unreachable, no broker configured), a test that its message ends with + `see docs/runbook.md#`. - **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. + gone, a broker that accepts and never answers (times out after `timeout_ms`), a final frame that + arrives after `expires` but within `timeout_ms` of it (delivered). The turn loop with + `approval_pending` and `tool_denied`. +- **`bxctl`.** `approvals` output with an argument full of escape sequences and one with U+202E, + U+200B and U+2066; `/etc` in the request shown as `/etc`; `approve`, `refuse`, + `grants check` and `audit verify` against fakes and fixture directories. `chat` against a fake + `loopd` and a fake `brokerd`: when the event says `read_file` and `brokerd`'s entry says `shell`, + the block says `shell`; + `y` refuses and the id approves; a line already waiting in stdin refuses; an id missing from the + list prints "no longer pending" and asks nothing; model content holding `ESC[8m` comes out + escaped. - **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` +**On straylight** (a scripted check, not part of `verify-device`): the script first reads +`GET /slots?model=ornith-1.5-35b-a3b` and stops if slot 0 is busy, then uses slot 0 only, as +`verify-device` does. `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. @@ -699,6 +857,18 @@ accepts only allowlisted host names refuses other hosts and IP literals. 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). +- Taint is kept per session id, and `loopd` chooses the id. Injected text cannot change it, but a + compromised `loopd` can read a secret under one id and send it out under a fresh one, which + starts at `private`. Taint therefore contains the model, not a compromised `loopd`; what + contains that is the grants themselves (`ask` on anything that leaves the host). Accepted for + v0. A host-wide taint floor would close it and is not proposed here. +- Deleting `/broker/sessions/.json` puts a session back to `private` and nothing + notices; the audit log, by contrast, shows tampering. Only the owner's user can do it, and the + `Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user. +- The deny reasons tell the model something about grants `loopd` cannot see: `grant_expired` and + `taint_too_high` reveal that a grant covers those arguments. Each probe is a call, so each is in + the audit log. Accepted: the reasons are worth more to the owner, through the model's + explanation, than their absence would cost an attacker. ## 15. How the work is handed over