diff --git a/docs/plans/M3a/01-proto-audit-types.md b/docs/plans/M3a/01-proto-audit-types.md new file mode 100644 index 0000000..80f542b --- /dev/null +++ b/docs/plans/M3a/01-proto-audit-types.md @@ -0,0 +1,118 @@ +# M3a task 01: the audit record types + +**Branch:** `m3a` (create it from `master` with `git switch -c m3a`; `git status --short` must be +empty, otherwise stop) +**Commit subject:** `Replace the audit record with chained audit events` + +## Goal + +The audit log will hold five kinds of event in one hash chain. Replace the M1 `AuditRecord` and +`DecisionRecord` in `proto` with the new shapes. Nothing has written an audit record yet, so +nothing else in the workspace changes. + +## Files + +- Copy: `crates/proto/tests/records.rs`, `crates/proto/tests/strict.rs`, + `crates/proto/tests/fixtures/records/audit.jsonl` +- Modify: `crates/proto/src/audit.rs`, `crates/proto/src/lib.rs`, `docs/implementer-log.md` + +Touch nothing else. In particular leave `wire.rs` alone: task 02 changes it. + +## Interfaces + +Replace everything in `crates/proto/src/audit.rs` below the `use` lines with these types. Keep +the fields and variants in this order: the order is the file format. + +```rust +// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] +pub enum DecisionRecord { + Allowed {}, + Ask {}, + Denied { reason: DenyReason }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalAnswer { Approved, Refused, Expired } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResultStatus { Result, Failed } + +// JSON: {"type":"decision","session":"…",…} ; the tag sits beside the fields +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum AuditEvent { + Decision { + session: SessionId, call: CallId, tool: String, arguments: String, + outcome: DecisionRecord, + grant: Option, grant_sha256: Option, + taint: DataClass, untrusted: bool, + }, + Approval { + session: SessionId, call: CallId, decision: u64, answer: ApprovalAnswer, + by: Option, post: Option, reason: Option, + outcome: DecisionRecord, + grant: Option, grant_sha256: Option, + taint: DataClass, untrusted: bool, + }, + Result { + session: SessionId, call: CallId, decision: u64, status: ResultStatus, + class: DataClass, untrusted: bool, truncated: bool, + bytes: u64, sha256: Hash32, taint_after: DataClass, + }, + Recovery { torn_bytes: u64, torn_sha256: Hash32 }, + AcceptedBreak { file: String, line: u64, last_good: Hash32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditRecord { pub seq: u64, pub time: Timestamp, pub prev: Hash32, pub event: AuditEvent } +``` + +In `lib.rs` the audit export becomes +`pub use audit::{ApprovalAnswer, AuditEvent, AuditRecord, DecisionRecord, ResultStatus};`. + +## Rules + +1. **`Allowed {}` and `Ask {}` are written with braces, exactly as shown.** They are struct + variants with no fields, not unit variants. serde does not apply `deny_unknown_fields` to a + unit variant of a tagged enum, so with `Allowed,` the text `{"outcome":"allowed","x":1}` would + decode. Both spell `{"outcome":"allowed"}` in JSON. Code that builds one writes + `DecisionRecord::Allowed {}`. +2. `event` is a nested object: `{"seq":0,"time":"…","prev":"…","event":{"type":"decision",…}}`. + Do not use `#[serde(flatten)]`: it does not work with `deny_unknown_fields`. +3. Every `Option` is written as `null` when it is `None`. Do not add `skip_serializing_if` or + `default`: a record with a field missing must not decode. +4. `deny_unknown_fields` goes on all three of `DecisionRecord`, `AuditEvent` and `AuditRecord`. + The two small enums (`ApprovalAnswer`, `ResultStatus`) are plain strings and do not take it. + +## Steps + +- [ ] **1. Copy.** `git switch -c m3a`, then + `cp docs/plans/M3a/files/crates/proto/tests/records.rs docs/plans/M3a/files/crates/proto/tests/strict.rs crates/proto/tests/` + and `cp docs/plans/M3a/files/crates/proto/tests/fixtures/records/audit.jsonl crates/proto/tests/fixtures/records/` +- [ ] **2. See the tests fail.** `cargo test -p proto --test records`. Expected: it does not + compile (`AuditEvent` is not found). +- [ ] **3. Change `audit.rs` and `lib.rs`.** Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p proto --test records --test strict`. Expected: + `3 passed` and `5 passed`. +- [ ] **5. Prove rule 1 has teeth.** Change `Allowed {}` to `Allowed` in `audit.rs` and in + nothing else, and run `cargo test -p proto --test strict`. Expected: it does not compile, or + `audit_records_reject_unknown_keys_at_every_depth` fails. Change it back, run the test again, + and say in your log row that you did this. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** + `git add crates/proto docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p proto --test records --test strict` reports 3 passed and 5 passed; `make gate` + prints `gate: ok`. + +## Stop and report if + +- Anything outside `crates/proto` stops compiling. Nothing else should use these types yet. +- A fixture line and a type disagree and the type matches this file. Do not edit the fixture. diff --git a/docs/plans/M3a/02-proto-admin-wire.md b/docs/plans/M3a/02-proto-admin-wire.md new file mode 100644 index 0000000..5d04b1e --- /dev/null +++ b/docs/plans/M3a/02-proto-admin-wire.md @@ -0,0 +1,160 @@ +# M3a task 02: the admin messages and the other wire additions + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the admin messages, approval ids as numbers, and two turn events` + +## Goal + +Everything `proto::wire` needs for M3a: four more deny reasons, two more error codes, the eight +admin messages of `admin.sock` with their body types, approval ids as `u64`, and two new turn +events. This is a format we define, so **every new struct and every new enum variant rejects +unknown fields**. + +## Files + +- Copy: `crates/proto/tests/wire.rs`, `turn_wire.rs`, `strict.rs`, `admin_wire.rs`, and the + seventeen files in `crates/proto/tests/fixtures/wire/` (sixteen new, and a changed + `tool_response_pending.json`) +- Modify: `crates/proto/src/wire.rs`, `crates/proto/src/lib.rs`, `crates/bxctl/src/chat.rs`, + `docs/implementer-log.md` + +## Check first + +Task 01 made `DecisionRecord::Allowed` and `Ask` empty struct variants (`Allowed {}`, `Ask {}`), +because serde does not apply `deny_unknown_fields` to the unit variants of an internally tagged +enum. Run `grep -n 'Allowed {},' crates/proto/src/audit.rs`. If it prints nothing, stop and +report: the test `an_outcome_rejects_unknown_and_misplaced_fields` cannot pass without it. Write +the braces wherever you name these two variants, as the tests do: `DecisionRecord::Allowed {}`. + +## Interfaces + +Keep the order of variants and fields exactly as given. The order is the wire format. + +In `wire.rs`, change the import to +`use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage};` and then: + +```rust +pub enum Message { + // … the six existing variants, unchanged, then: + Approvals(Empty), + ApprovalList(ApprovalList), + Approve(Approve), + ApproveResult(ApproveResult), + Refuse(Refuse), + Ok(Empty), + CheckGrants(Empty), + GrantsReport(GrantsReport), +} + +pub enum ErrorCode { + // … the ten existing variants, then: + Forbidden, + NoSuchApproval, +} + +pub enum ToolResponse { + PendingApproval { approval: u64, expires: Timestamp }, // was `approval: String` + // … the rest unchanged +} + +pub enum DenyReason { + // … the six existing variants, then: + GrantsInvalid, + AuditUnavailable, + InvalidArguments, + StateUnreadable, +} + +pub enum TurnEvent { + // … the ten existing variants, then: + ApprovalPending { approval: u64, tool: String, expires: Timestamp }, + ToolDenied { name: String, reason: DenyReason }, +} +``` + +New types, at the end of `wire.rs`. Every one of the eight gets +`#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]` and +`#[serde(deny_unknown_fields)]`; `Empty` also derives `Default`. + +```rust +// The admin messages on admin.sock. JSON: {"kind":"approvals","body":{}} +pub struct Empty {} + +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 ApprovalList { pub items: Vec } +pub struct Approve { pub approval: u64 } +pub struct ApproveResult { pub outcome: DecisionRecord } +pub struct Refuse { pub approval: u64, pub reason: Option } +pub struct GrantProblem { pub file: String, pub line: Option, pub problem: String } +pub struct GrantsReport { pub problems: Vec } +``` + +`Empty {}` is written with braces, not as `struct Empty;`: a unit struct would encode as `null`, +and the body must be `{}`. + +In `lib.rs` the `pub use wire::{…}` line becomes: + +```rust +pub use wire::{ + ApprovalList, Approve, ApproveResult, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, + GrantsReport, Message, PROTOCOL_VERSION, PendingApproval, Refuse, ToolRequest, ToolResponse, + Turn, TurnDone, TurnEvent, WireError, +}; +``` + +## `bxctl` must still compile + +`crates/bxctl/src/chat.rs` has two `match`es with no catch-all arm. Add arms; do not add `_ =>`. + +1. In `code_name`, after the `Inference` arm: + ```rust + ErrorCode::Forbidden => "forbidden", + ErrorCode::NoSuchApproval => "no such approval", + ``` +2. In `Printer::event`, after the `Queued | Progress` arm: + ```rust + // Printed from task 20 on; until then these events are not sent. + TurnEvent::ApprovalPending { .. } | TurnEvent::ToolDenied { .. } => {} + ``` + +`loopd` needs no change: its `match` on `ToolResponse::PendingApproval` uses `{ .. }`. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/proto/tests/*.rs crates/proto/tests/` **only for the four files + named above** (`wire.rs turn_wire.rs strict.rs admin_wire.rs`), and + `cp docs/plans/M3a/files/crates/proto/tests/fixtures/wire/*.json crates/proto/tests/fixtures/wire/` +- [ ] **2. See the tests fail.** `cargo test -p proto --test admin_wire`. Expected: it does not + compile. +- [ ] **3. Edit `wire.rs` and `lib.rs`**, then the two arms in `chat.rs`. Run `cargo fmt --all`. +- [ ] **4. Go through every new type one by one** (eight structs, and the variants added to five + enums) and check each has `deny_unknown_fields`, either its own or its enum's. The enums already + have it; the eight structs each need their own. +- [ ] **5. See the tests pass.** + `cargo test -p proto --test wire --test turn_wire --test admin_wire --test strict`. + Expected: `wire` 10 passed, `turn_wire` 5 passed, `admin_wire` 10 passed, `strict` 5 passed. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** + `git add crates/proto crates/bxctl/src/chat.rs docs/implementer-log.md && git commit` + +## Done when + +- The four test files report the counts in step 5, and `make gate` prints `gate: ok`. + +## Stop and report if + +- `Allowed {},` is missing from `audit.rs` ("Check first"). +- A fixture cannot be matched byte for byte with the field order given here. +- Any file outside `proto` other than `bxctl/src/chat.rs` stops compiling. diff --git a/docs/plans/M3a/03-proto-chain-verifier.md b/docs/plans/M3a/03-proto-chain-verifier.md new file mode 100644 index 0000000..2d5ba81 --- /dev/null +++ b/docs/plans/M3a/03-proto-chain-verifier.md @@ -0,0 +1,138 @@ +# M3a task 03: the audit chain verifier + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the audit chain verifier` + +## Goal + +`proto::ChainVerifier` checks the audit log's hash chain. It is a pure state machine: no files, +no clock. The caller feeds it lines; it returns a report. `brokerd`'s startup and +`bxctl audit verify` both use it, so they cannot disagree. + +## Files + +- Copy: `crates/proto/tests/chain.rs`, and the directory `crates/proto/tests/fixtures/audit/` + (30 small logs; their hashes are real) +- Create: `crates/proto/src/chain.rs` +- Modify: `crates/proto/src/lib.rs`, `crates/proto/src/audit.rs`, `docs/implementer-log.md` + +`lib.rs` gains `pub mod chain;` and +`pub use chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};`. +`audit.rs` gains the same `pub use crate::chain::{…};` line, so `proto::audit::ChainVerifier` +works too. + +## Interfaces + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Location { pub file: String, pub line: u64 } // line is 1-based + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChainFailure { + pub file: String, pub line: u64, pub what: String, + pub last_good: Hash32, // hash of the last line that verified before the failure + pub break_prev: Hash32, // hash of the last line fed + pub break_seq: u64, // the seq an AcceptedBreak appended now must carry (rule 7) + pub tail_torn: bool, // the last line fed had no newline +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TornTail { + pub at: Location, pub has_newline: bool, pub bytes: u64, pub sha256: Hash32, + pub recovery_prev: Hash32, // hash of the last line that verified + pub recovery_seq: u64, // the seq a Recovery appended now must carry +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChainReport { + pub records: u64, pub head: Option, pub next_seq: u64, + pub failure: Option, + pub recoveries: Vec, pub accepted_breaks: Vec, + pub abandoned: Vec, pub unfinished: Vec, + pub clock_warnings: Vec, pub torn_tail: Option, +} +pub struct ChainVerifier { /* private */ } +impl ChainVerifier { + pub fn new() -> Self; // expects seq 0 and prev all zeros + pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue after a known line + pub fn file(&mut self, name: &str); // starts the next file; line 1 is next + pub fn line(&mut self, bytes: &[u8], has_newline: bool); // bytes exclude the newline + pub fn feed(&mut self, name: &str, content: &[u8]); // file(name), then each line + pub fn finish(self) -> ChainReport; +} +``` + +Also `impl Default` (it calls `new`). `feed` splits `content` at every `\n`; bytes after the +last `\n` are one more line with `has_newline` false; an empty `content` is no lines. + +## How it judges + +The hash of a line is `proto::sha256` of its bytes without the newline. The state is: the `seq` +expected next, `prev` (hash of the last line that verified; zeros or the `resume` value at the +start), the hash of the last line fed whether it verified or not, and the first failure if any. + +**Hold each line back until the next one arrives**, and judge it then, or in `finish`. The reason +is rule 2: whether a line is a record can depend on the line after it. + +1. **A verified record.** The line parses as `AuditRecord`, its `seq` is the expected one and its + `prev` equals `prev`. Then: `records += 1`, `head` and `prev` become its hash, `next_seq` is + its `seq + 1`. If its `time` is earlier than the previous verified record's, push its location + to `clock_warnings`. Track what it opens and closes: + `Decision` with `Allowed {}` opens a run under its own `seq`; with `Ask {}` opens an ask under + its own `seq`. `Approval { decision, outcome }` closes the ask `decision`, and if `outcome` is + `Allowed {}` opens a run under `decision`. `Result { decision }` closes that run. At `finish`, + open asks are `abandoned` and open runs are `unfinished`, both in ascending order. +2. **A recovered line.** Before judging a held line, look at the next line. If the next line + parses as a record whose event is `Recovery { torn_bytes, torn_sha256 }`, with `torn_bytes` + the held line's length, `torn_sha256` its hash, `prev` equal to the current `prev` and `seq` + equal to the expected `seq`, the held line is not a record: push its location to `recoveries` + and change nothing else. **It makes no difference whether the held line parses.** A crash can + cut a record exactly before its newline, leaving complete JSON. The `Recovery` line is then + judged in its turn by rule 1 and takes that `seq`. +3. A `Recovery` record that reaches rule 1 without having recovered the line before it fails: + `a recovery record that does not describe the line before it`. +4. **Failures**, in this order, with exactly these texts as `what`: + `does not parse as an audit record`; `seq is {got}, expected {want}`; + for line 1 of any file but the first one ever, `does not chain from the last line of the file + before`, otherwise `prev is not the hash of the line before`. (For a resumed verifier line 1 + of its first file gets the "file before" text too.) Only the first failure is kept. On a + failure nothing is updated: `last_good` is the current `prev`. If `sha256` returns an error + for a line, that line fails with `the line is too long to hash`. +5. **After a failure** no line is checked. Count them: the failing line is 1. Each held line is + only tested for being the break record: it parses, its event is + `AcceptedBreak { file, line, last_good }`, its `prev` is the hash of the line fed just before + it, `file` and `line` name the failure, `last_good` equals the failure's, and its `seq` equals + the failure's expected `seq` plus the count so far (failure at line 7, break at line 10: plus + 3). If so: clear the failure, push the break's location to `accepted_breaks`, and treat it as + a verified record (rule 1), so `next_seq` is its `seq + 1`. If not, add 1 to the count. + Use `checked_add`; a line in the region may claim `seq` 18446744073709551615. +6. **A break with no failure before it.** A line that parses with an `AcceptedBreak` event and + is not in a failed region skips rule 1's checks and comes here. It is accepted in one case only: the + verifier was made with `resume`, and the break's `file` sorts before the first file this + verifier was given (`file < first`, as strings). Then check only `prev`, accept it as in rule + 5 and continue from its `seq`. The same exception applies inside a failed region in place of + the four checks on `file`, `line`, `last_good` and `seq`. Otherwise it fails: + `an accepted break with no failure before it`. +7. **`finish`.** If a line is still held and there is no failure: when it has no newline, or has + one but does not parse, it is a torn tail. Fill `torn_tail` and judge it no further. Otherwise + judge it with no next line. If there is a failure, the held line is one more line of the + region (rule 5). `break_seq` is the failure's expected `seq` plus the final count; + `break_prev` is the hash of the last line fed. + +## Steps + +- [ ] **1. Copy.** `cp docs/plans/M3a/files/crates/proto/tests/chain.rs crates/proto/tests/` and + `cp -r docs/plans/M3a/files/crates/proto/tests/fixtures/audit crates/proto/tests/fixtures/` +- [ ] **2. See the test fail.** `cargo test -p proto --test chain`. Expected: it does not compile. +- [ ] **3. Write `chain.rs`**, add the `mod` and `use` lines. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p proto --test chain`. Expected: `13 passed`. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/proto docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p proto --test chain` reports 13 passed; `make gate` prints `gate: ok`. + +## Stop and report if + +- A fixture seems wrong. Read the comment beside its case in `chain.rs` first: each directory + name says what was done to the log, and several are meant to verify. +- `chain.rs` would pass 500 lines. diff --git a/docs/plans/M3a/04-brokerd-config.md b/docs/plans/M3a/04-brokerd-config.md new file mode 100644 index 0000000..c5afab9 --- /dev/null +++ b/docs/plans/M3a/04-brokerd-config.md @@ -0,0 +1,121 @@ +# M3a task 04: `brokerd`'s configuration + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add brokerd's configuration` + +## Goal + +`brokerd.toml` into a typed `Config`. This is a format we define: unknown keys are errors, in +every table. It works like `crates/loopd/src/config.rs`; read that file first and follow its style. + +```toml +# brokerd.toml — every key is optional +[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" # default: under home +admin = "/var/lib/boxmaker/run/owner-broker/admin.sock" # default: under home + +[approvals] +ttl_ms = 900000 # 15 min +``` + +## Files + +- Copy: `crates/brokerd/tests/config.rs`, and the six files in + `crates/brokerd/tests/fixtures/config/` +- Create: `crates/brokerd/src/config.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod config;`), `crates/brokerd/Cargo.toml`, + `docs/dependencies.md`, `docs/implementer-log.md` + +`brokerd` gains three dependencies, all already in `[workspace.dependencies]`. Add to +`crates/brokerd/Cargo.toml` under `[dependencies]`: + +```toml +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +``` + +In `docs/dependencies.md`, add `brokerd` to the "Used by" cell of the `serde` row and of the +`serde_json` row (for example `` `proto`, `brokerd` ``). The `toml` row already names `brokerd`. +Change nothing else in that file. + +## Interfaces + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Paths { pub home: PathBuf, pub grants: PathBuf } + +/// An empty path means "the default under `home`". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct Sockets { pub broker: PathBuf, pub admin: PathBuf } + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Approvals { pub ttl_ms: u64 } + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] pub paths: Paths, + #[serde(default)] pub sockets: Sockets, + #[serde(default)] pub approvals: Approvals, +} + +#[derive(Debug)] +pub enum ConfigError { Read(PathBuf, std::io::Error), Parse(PathBuf, toml::de::Error) } + +impl Config { + pub fn parse(text: &str) -> Result; + pub fn load(path: &Path) -> Result; + pub fn broker_socket(&self) -> PathBuf; + pub fn admin_socket(&self) -> PathBuf; + pub fn audit_dir(&self) -> PathBuf; + pub fn state_dir(&self) -> PathBuf; +} +``` + +## Rules + +1. `impl Default for Paths`: `home` is `$BOXMAKER_HOME` if the variable is set, otherwise + `/var/lib/boxmaker`; `grants` is `/etc/boxmaker/grants`. Copy how `loopd`'s `Paths` reads the + variable (`std::env::var_os`). `impl Default for Approvals`: `ttl_ms` is `900_000`. +2. `deny_unknown_fields` goes on **all four structs**: `Paths`, `Sockets`, `Approvals` and + `Config`. The test checks an unknown key in each table and at the top level. +3. `broker_socket()`: if `sockets.broker` is empty, `home` joined with + `run/loop-broker/broker.sock`; otherwise `sockets.broker`. `admin_socket()`: the same with + `run/owner-broker/admin.sock`. Test for empty with `as_os_str().is_empty()`, as `loopd`'s + `channel_socket` does. +4. `audit_dir()` is `home` joined with `audit`. `state_dir()` is `home` joined with + `broker/sessions`. Neither can be set on its own. +5. `load`: a file that cannot be read is `ConfigError::Read(path, err)`, including a missing file; + a file that does not parse is `ConfigError::Parse(path, err)`. `ConfigError` implements + `Display` (the path, `: `, the inner error) and `std::error::Error`, by hand. +6. Do not set or change environment variables anywhere, tests included. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `mkdir -p crates/brokerd/tests/fixtures && cp docs/plans/M3a/files/crates/brokerd/tests/config.rs crates/brokerd/tests/ && cp -r docs/plans/M3a/files/crates/brokerd/tests/fixtures/config crates/brokerd/tests/fixtures/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test config`. Expected: it does not + compile. +- [ ] **3. Add the dependencies, write `config.rs`, add `pub mod config;`.** Run `cargo build` + once so `Cargo.lock` is updated, then `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test config`. Expected: `7 passed`. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** + `git add crates/brokerd Cargo.lock docs/dependencies.md docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p brokerd --test config` reports 7 passed; `make gate` prints `gate: ok`. + +## Stop and report if + +- `[workspace.dependencies]` in the root `Cargo.toml` lacks `serde`, `serde_json` or `toml`. +- A test seems to need an environment variable to be set. diff --git a/docs/plans/M3a/05-brokerd-args.md b/docs/plans/M3a/05-brokerd-args.md new file mode 100644 index 0000000..0372490 --- /dev/null +++ b/docs/plans/M3a/05-brokerd-args.md @@ -0,0 +1,137 @@ +# M3a task 05: tool arguments, paths, hosts and URLs + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add typed tool arguments and the form checks for paths, hosts and URLs` + +## Goal + +`brokerd::args` turns the model's argument string into a typed value, and says whether a path, a +host or a URL is well formed. It knows nothing about grants. Paths are compared as written and +never normalised, so every form that would need normalising is refused here. + +This module is pure: no I/O, no clock. Everything it reads was written by the model, so treat it as +hostile: no `unwrap`, no indexing, no slicing with `[a..b]`. + +## Files + +- Copy: `crates/brokerd/tests/args.rs` +- Create: `crates/brokerd/src/args.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod args;`), `docs/implementer-log.md` + +## Interfaces + +```rust +pub const MAX_PATH: usize = 4096; +pub const MAX_URL: usize = 2048; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolName { ReadFile, WriteFile, Shell, HttpFetch } +impl ToolName { + pub const ALL: [ToolName; 4]; // in the order above + pub fn parse(name: &str) -> Option; // "read_file" | "write_file" | "shell" | "http_fetch" + pub fn as_str(self) -> &'static str; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolArgs { + ReadFile { path: String }, + WriteFile { path: String, content: String }, + Shell { command: String, cwd: Option }, + HttpFetch { url: String, host: String }, // host is taken from url, not an argument +} +impl ToolArgs { + pub fn tool(&self) -> ToolName; + pub fn canonical_json(&self) -> String; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgsError { Shape(String), Path(String), Url(String) } // Display + Error, by hand + +pub fn parse(tool: ToolName, arguments: &str) -> Result; +pub fn valid_path(path: &str) -> bool; +pub fn inside(grant_path: &str, path: &str) -> bool; +pub fn valid_host(host: &str) -> bool; +pub fn valid_host_pattern(pattern: &str) -> bool; +pub fn host_matches(pattern: &str, host: &str) -> bool; +pub fn url_host(url: &str) -> Option<&str>; // Some(host) if and only if the URL is valid +``` + +## Rules + +**`parse`.** Decode with four private structs, one per tool, each +`#[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)]`, fields in this order: + +| Tool | Struct fields | +|---|---| +| `read_file` | `path: String` | +| `write_file` | `path: String`, `content: String` | +| `shell` | `command: String`, `cwd: Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]` | +| `http_fetch` | `url: String` | + +1. `serde_json::from_str` fails (not JSON, not an object, unknown field, missing field, wrong + type, a key given twice, text after the object): `ArgsError::Shape(the error's text)`. serde + rejects a repeated key in a derived struct by itself. +2. `path`, and `cwd` when present, must pass `valid_path`, else `ArgsError::Path(that path)`. This + applies in **all three** tools that take a path: `read_file`, `write_file` and `shell`. +3. `url` must give `Some(host)` from `url_host`, else `ArgsError::Url(that url)`. Keep the host. +4. `command` and `content` are never inspected. Empty is fine. + +**`canonical_json`.** Serialise the same private struct again from the parsed value, so +`{ "path" : "\u002fetc" }` and `{"path":"/etc"}` both give `{"path":"/etc"}`. Fields come out in +the table's order. An absent `cwd` is left out. `host` is never written. If `to_string` fails, +return `"{}"`; do not `unwrap`. + +**`valid_path`.** All of: at most `MAX_PATH` bytes; no NUL (`'\0'`); starts with `/`; and the rest, +split on `/`, has no empty part, no `.` and no `..`. The root `/` alone is valid (the rest is +empty; check that before splitting). So `/a/` and `//` are invalid, `/a/...` and `/..a` are valid. + +**`inside(grant_path, path)`.** By whole components, never by bytes: +`path.strip_prefix(grant_path)` is `Some(rest)` and `rest` is empty, or starts with `/`, or +`grant_path` is `/`. So `/home/kyle/notes2` is not inside `/home/kyle/notes`. + +**`valid_host`.** All of: 1 to 253 bytes; split on `.` gives at least two labels; each label is 1 +to 63 bytes of `a-z`, `0-9`, `-`, and neither starts nor ends with `-`; **the last label starts +with a letter `a-z`**. That last rule is what keeps out every spelling of an IPv4 address +(`127.0.0.1`, `127.1`, `10.0.0.0x1`): their labels are otherwise legal. + +**`valid_host_pattern`.** A valid host, or `*.` followed by a valid host. Nothing else: `*.com`, +`*example.com`, `www.*.com` and `*.*.example.com` are invalid. + +**`host_matches(pattern, host)`.** Without `*.`: equal strings. With `*.base`: `host` ends with +`base`, the byte before it is `.`, and something comes before that `.`. So `*.example.com` +matches `www.example.com` and `a.b.example.com`, and does not match `example.com`, +`badexample.com` or `.example.com`. Use `strip_suffix` twice; do not index. + +**`url_host`.** In this order; any failure is `None`: + +1. At most `MAX_URL` bytes. +2. `strip_prefix("https://")`. Lowercase only. +3. The host is the longest run of `a-z`, `0-9`, `.`, `-` at the start of what is left. Find its + end with `find(|c| !allowed(c))`, take it with `split_at_checked` (stable since Rust 1.80; + returns `Option`). It must pass `valid_host`. +4. What follows the host may start with `:443`; strip it if so. Any other port fails at step 5. +5. What is left must be empty, or `/` followed only by bytes `0x21..=0x7e` (printable ASCII, no + space). + +Because the host ends at the first byte that cannot be in a host name, userinfo +(`https://user@example.com/`), other ports, `?` or `#` straight after the host, `[::1]` and +uppercase all fail without a rule of their own. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/args.rs crates/brokerd/tests/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test args`. Expected: it does not compile. +- [ ] **3. Write `args.rs`** and add `pub mod args;` to `lib.rs`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test args`. Expected: `13 passed`. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p brokerd --test args` reports 13 passed; `make gate` prints `gate: ok`. + +## Stop and report if + +- A test wants a path to be cleaned up (`//` collapsed, `..` resolved) instead of refused. +- You find you need a URL or host-name crate. None is allowed; the rules above are the whole job. diff --git a/docs/plans/M3a/06-brokerd-grants.md b/docs/plans/M3a/06-brokerd-grants.md new file mode 100644 index 0000000..5e367b6 --- /dev/null +++ b/docs/plans/M3a/06-brokerd-grants.md @@ -0,0 +1,138 @@ +# M3a task 06: loading grant files + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Load grant files, failing closed on any invalid file` + +## Goal + +`brokerd::grants` reads `grants/*.toml` into a `GrantSet`. **If any file is invalid the whole set +is invalid.** The reason, from the spec: a mistyped `deny` grant that was skipped would silently +turn into an allow wherever another grant matches. So `load` returns either a complete valid set +or the full list of problems, never a partial set. It reports **every** problem in every file, not +only the first. + +`brokerd` calls `load` at the start of every decision (a later task). There is no cache here. + +## Files + +- Copy: `crates/brokerd/tests/grants.rs`, `crates/brokerd/tests/support/tmp.rs`, and the directory + `crates/brokerd/tests/fixtures/grants/` (four sub-directories) +- Create: `crates/brokerd/src/grants.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod grants;`), `docs/implementer-log.md` + +## Interfaces + +```rust +pub const RUNBOOK: &str = "see docs/runbook.md#grants-invalid"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedGrant { + pub id: String, // the file stem + pub grant: proto::Grant, + pub sha256: proto::Hash32, // of the file's bytes as read +} + +/// A set that passed every rule, in id order. Its field is private: `from_grants` and `load` are +/// the only ways to make one that is not empty. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GrantSet { /* grants: Vec */ } + +impl GrantSet { + pub fn from_grants(grants: Vec) -> Result>; + pub fn grants(&self) -> &[LoadedGrant]; +} + +pub fn valid_id(id: &str) -> bool; // 1 to 64 characters of a-z, 0-9 and - +pub fn load(dir: &Path) -> Result>; +pub fn render(problems: &[GrantProblem]) -> String; +``` + +`GrantProblem { file, line, problem }` is `proto::GrantProblem`. `file` is the file's name with +`.toml`, without the directory. + +## `from_grants`: the rules about a grant's values + +Sort by `id` (byte order). Then check every grant and collect every problem; `file` is +`".toml"`, `line` is `None`. The problem text must contain the words in quotes. + +| # | A problem when | Text | +|---|---|---| +| 2 | `valid_id(id)` is false | "the file name is not a valid grant id" | +| – | two grants have the same id | "two grants have this id" | +| 3 | `ToolName::parse(&grant.tool)` is `None` | "unknown tool", and "read_file, write_file, shell and http_fetch" | +| 4 | `grant.secret` is `Some` | "secrets are not supported until M4" | +| 5 | `constraints.patterns` is not empty | "patterns are not supported" | +| 6 | `read_file` or `write_file` with no paths | "`` needs at least one path" (the tool's name, no backquotes) | +| 6 | `read_file`, `write_file` or `shell` with hosts | "`` does not take hosts" | +| 6 | `http_fetch` with no hosts | "http_fetch needs at least one host" | +| 6 | `http_fetch` with paths | "http_fetch does not take paths" | +| 7 | a path is `/` | "a grant of the whole file system is not supported" | +| 7 | a path fails `args::valid_path` | the path with `{:?}`, then "is not a valid absolute path" | +| 8 | a host fails `args::valid_host_pattern` | the host with `{:?}`, then "is not a valid host pattern" | +| 9 | `mode` is `Deny` and `max_taint` is not `Secret` | "a deny grant must apply at every taint" | + +`shell` may have paths or not. An empty list is the same as no list. One grant can have several +problems (rule 6 can fire twice for one file; rule 7 once per bad path): **push every one**. If the +tool is unknown, skip rule 6 for that grant; the other rules still apply. + +Return `Ok(GrantSet)` only if there is no problem at all. + +## `load`: the rules about files (rule 1), then `from_grants` + +1. `std::fs::read_dir(dir)` fails (missing, not a directory, no permission): return one problem, + `file` = the directory's path as text, text "the grants directory cannot be read: ". + **A missing directory is not an empty set.** An entry that cannot be read is the same problem; + keep going. +2. Collect the file names and **sort them**, so problems come out in the same order every time. +3. For each name: if it does not end in `.toml`, skip it silently (`README.md`, `x.toml~`, + `x.toml.bak`, sub-directories). Otherwise the id is the name without `.toml`. +4. `std::fs::read` fails (no permission, or it is a directory): problem "cannot be read: ". + Go on to the next file. **Never treat an unreadable file as absent.** +5. `std::str::from_utf8` fails: problem "is not UTF-8". Next file. +6. `toml::from_str::` fails (bad syntax, unknown field, missing field, bad mode): + problem text = `error.message()`, line = the 1-based line of `error.span()`'s start. Next file. +7. Otherwise `sha256 = proto::sha256(&bytes)` (an error here is a problem too), and keep the grant. +8. Run `from_grants` on what was kept. **Even if step 4, 5 or 6 found a problem**, so the owner + sees every problem at once. Join both lists, sort by `file` (a stable sort), and return `Err` + if the joined list is not empty. + +Verified in `toml` 1.1.6 (`src/de/error.rs`): + +```rust +impl toml::de::Error { + pub fn message(&self) -> &str; + /// The start/end index into the original document where the error occurred + pub fn span(&self) -> Option>; +} +``` + +The line of a byte offset: count the `\n` bytes in `text.get(..offset)` and add 1. Use `get`, not +`[..offset]`; if `get` returns `None`, use the whole text. + +## `render` + +One line per problem, `:: ` when there is a line and `: ` when +not; then `RUNBOOK` on a line of its own; every line ends with `\n`. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `mkdir -p crates/brokerd/tests/support crates/brokerd/tests/fixtures && cp docs/plans/M3a/files/crates/brokerd/tests/grants.rs crates/brokerd/tests/ && cp docs/plans/M3a/files/crates/brokerd/tests/support/tmp.rs crates/brokerd/tests/support/ && cp -r docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants crates/brokerd/tests/fixtures/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test grants`. Expected: it does not + compile. +- [ ] **3. Write `grants.rs`** and add `pub mod grants;` to `lib.rs`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test grants`. Expected: `17 passed`. +- [ ] **5. Walk the exits of `load`.** For each of steps 1, 4, 5, 6 and 7 above, find the line in + your code and check that it records a problem and does not return early with a partial set. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p brokerd --test grants` reports 17 passed; `make gate` prints `gate: ok`. + +## Stop and report if + +- A test expects a set to load although one of its files is invalid. +- `git status` does not show `crates/brokerd/tests/fixtures/grants/empty/README.md` after the + copy (the empty-directory fixture needs a file in it for git to keep it). diff --git a/docs/plans/M3a/07-brokerd-policy.md b/docs/plans/M3a/07-brokerd-policy.md new file mode 100644 index 0000000..d523e00 --- /dev/null +++ b/docs/plans/M3a/07-brokerd-policy.md @@ -0,0 +1,173 @@ +# M3a task 07: the policy functions + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Decide tool calls against grants, taint and time` + +## Goal + +Replace the M1 stub in `crates/brokerd/src/policy.rs` with the real thing: `decide` and +`redecide`. The module does **no I/O and reads no clock**; the grants, the session's state and the +time are arguments. It is the only place a `Decision` or an `Ask` is built. + +Two facts the types must carry, not the callers' good behaviour: + +- `decide` **never** returns a `Decision` for an `ask` grant. It returns an `Ask`. +- `redecide` is the **only** thing that turns an `Ask` into a `Decision`. + +## Files + +- Copy: `crates/brokerd/tests/policy.rs`, `policy_matching.rs`, `policy_redecide.rs`, + `policy_property.rs`, `crates/brokerd/tests/support/build.rs`, `support/oracle.rs` +- Modify: `crates/brokerd/src/policy.rs` (rewrite; delete its `mod tests`), + `docs/implementer-log.md` +- Do not touch `crates/brokerd/src/runner.rs`: `run(decision: Decision)` still compiles. + +## Interfaces + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionState { pub taint: DataClass, pub untrusted: bool } +impl Default for SessionState { /* taint: Private, untrusted: false */ } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Label { pub class: DataClass, pub untrusted: bool } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Denial { + pub reason: DenyReason, + pub grant: Option, // Some only for DeniedByGrant: the deny grant's id + pub grant_sha256: Option, // Some only for DeniedByGrant +} +impl Denial { pub fn new(reason: DenyReason) -> Denial; } // both options None + +#[derive(Debug)] struct Matched { // private + grant: String, grant_sha256: Hash32, matched_path: Option, + paths: Vec, hosts: Vec, expires: Option, label: Label, +} +#[derive(Debug)] pub struct Decision { request: ToolRequest, args: ToolArgs, matched: Matched } +#[derive(Debug)] pub struct Ask { request: ToolRequest, args: ToolArgs, matched: Matched } + +#[derive(Debug)] +pub enum Outcome { Allowed(Decision), Ask(Ask), Denied(Denial) } + +pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp) -> Outcome; +pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp) -> Result; +``` + +`Decision` and `Ask`: **fields private, exactly these three names, only `#[derive(Debug)]`**. No +`Clone`, no `Serialize`, no `Deserialize`, no `new`. Both have the same nine getters: +`request() -> &ToolRequest`, `args() -> &ToolArgs`, `grant() -> &str`, +`grant_sha256() -> Hash32`, `matched_path() -> Option<&str>`, `paths() -> &[String]`, +`hosts() -> &[String]` (all the winning grant's paths and hosts; the runner mounts them), +`expires() -> Option`, `label() -> Label`. + +## `decide`, in this order; the first that applies is the answer + +1. `ToolName::parse(&request.tool)` is `None` → `Denied`, `NoGrant`. The arguments are **not** + parsed. +2. `args::parse(tool, &request.arguments)` fails → `Denied`, `InvalidArguments`. +3. Matching (below). No grant left → `Denied` with the reason from step M5. +4. The winner's mode: `Deny` → `Denied { DeniedByGrant, Some(id), Some(sha256) }`; `Ask` → + `Ask`; `Auto` → `Allowed`. + +For every reason except `DeniedByGrant`, `grant` and `grant_sha256` are `None`. + +## Matching + +`grants.grants()` is in id order. For each grant: + +- **M1.** Skip it unless `grant.tool == tool.as_str()`. +- **M2.** Does it cover the arguments? If not, skip it; it plays no further part. + + | Arguments | Covered when | Matched path | + |---|---|---| + | `ReadFile { path }` | `args::inside(p, path)` for some path `p` of the grant | the longest such `p` | + | `WriteFile { path, .. }` | the same, **but a `p` equal to `path` does not count** | the longest `p` that counts | + | `Shell { cwd: None }` | the grant has no paths | none | + | `Shell { cwd: Some(c) }` | `inside(p, c)` for some `p` | the longest such `p` | + | `HttpFetch { host, .. }` | `args::host_matches(pattern, host)` for some pattern | none | + + A `shell` grant with paths does not cover a call without `cwd`, and one without paths does not + cover a call with `cwd`. +- **M3.** `expired = expires.is_some_and(|at| now >= at)` (expired **at** the instant, not after). + `too_tainted = state.taint > grant.max_taint`. Neither → the grant is **left**. Only expired → + remember "some grant was ruled out only by expiry". Only too tainted → remember the same for + taint. Both → remember nothing. +- **M4.** The label, over **every** grant left, not the winner alone: `class` is the highest + `result_class`, `untrusted` is true if any says so. +- **M5.** The winner among those left: the most restrictive mode (`Deny`, then `Ask`, then + `Auto`); within it the longest matched path (none counts as length 0); then the lowest id. + If none is left: `GrantExpired` if M3 remembered expiry, else `TaintTooHigh` if it remembered + taint, else `NoGrant`. + +`state.untrusted` is not an input to matching. + +## `redecide` + +Run matching again with `ask`'s own arguments (`ask.args.tool()` gives the tool) against the grants +and state given **now**. Winner `Ask` or `Auto` → `Ok(Decision)` built from the `Ask`'s request and +arguments and **the grant that won now**. Winner `Deny` → `Err` with `DeniedByGrant` and the id. +None left → `Err` with the M5 reason. + +## The doctests + +Keep a module doc comment with seven examples; they are how the gate proves the two facts above. +Six are `compile_fail`, one for each of `Decision` and `Ask` in each of three kinds, and one +compiles: + +```rust +//! ```compile_fail +//! let request = proto::ToolRequest { +//! session: proto::SessionId::new("s1").unwrap(), +//! call: proto::CallId(1), +//! tool: "shell".to_string(), +//! arguments: r#"{"command":"ls"}"#.to_string(), +//! }; +//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap(); +//! let _ = brokerd::policy::Decision { request, args, matched: todo!() }; +//! ``` +//! +//! ```compile_fail +//! fn needs_clone() {} +//! needs_clone::(); +//! ``` +//! +//! ```compile_fail +//! fn needs_decoding() {} +//! needs_decoding::(); +//! ``` +``` + +Write the same three again with `Ask` in place of `Decision`. The seventh has the same `request` +and `args` setup, calls `needs_clone::()` and +`needs_decoding::()`, then calls `decide(request, &GrantSet::default(), +SessionState::default(), now)` and asserts the outcome is `Denied` with `NoGrant`. It shows the +other six fail because of `Decision` and `Ask`, not because of a mistake in the example. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/policy*.rs crates/brokerd/tests/ && cp docs/plans/M3a/files/crates/brokerd/tests/support/build.rs docs/plans/M3a/files/crates/brokerd/tests/support/oracle.rs crates/brokerd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p brokerd --test policy`. Expected: it does not + compile. +- [ ] **3. Rewrite `policy.rs`.** Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** + `cargo test -p brokerd --test policy --test policy_matching --test policy_redecide --test policy_property`. + Expected: `7 passed`, `10 passed`, `7 passed`, `4 passed`. Then `cargo test -p brokerd --doc`: + 7 doctests pass. If the property test fails, its message names a seed and a case and prints + the grants: **the oracle in `support/oracle.rs` is the specification**; find where your code + differs from it. +- [ ] **5. Prove the doctests have teeth.** Make the three fields of `Ask` `pub` (and `Matched` + `pub`). `cargo test -p brokerd --doc` must now **fail** on the `Ask` struct-literal example. + Change it back, run again, all pass. Say in the log that you did this. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- The four test files and the seven doctests pass; step 5 was done; `make gate` prints `gate: ok`. + +## Stop and report if + +- The property test and a table test disagree about one case. +- A test needs `decide` to read a file, the clock or the environment. diff --git a/docs/plans/M3a/08-brokerd-state.md b/docs/plans/M3a/08-brokerd-state.md new file mode 100644 index 0000000..80d70d5 --- /dev/null +++ b/docs/plans/M3a/08-brokerd-state.md @@ -0,0 +1,119 @@ +# M3a task 08: session state files + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Keep each session's taint and untrusted flag in a file` + +## Goal + +`brokerd::state` keeps what `brokerd` knows about a session in +`/broker/sessions/.json`, one line: + +```json +{"taint":"private","untrusted":false} +``` + +Policy reads it to decide; a result raises it. If `brokerd` cannot read it, it does not know how +sensitive the session is, so that is an **error**, never "a new session". Neither value ever goes +down. Nothing here locks: the caller holds the ledger lock around `read` and `raise` (task 12). + +## Files + +- Copy: `crates/brokerd/tests/state.rs` (`support/tmp.rs` is already there from task 06) +- Create: `crates/brokerd/src/state.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod state;`), `docs/implementer-log.md` + +## Interfaces + +`SessionState` and `Label` come from `crate::policy` (task 07). Do not define them again. + +```rust +pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged"; + +#[derive(Debug)] +pub enum StateError { + Unreadable(PathBuf, String), // the file exists and cannot be read, or is not a state + Write(PathBuf, std::io::Error), // the new state could not be put on disk +} + +#[derive(Debug, Clone)] +pub struct StateStore { /* dir: PathBuf */ } +impl StateStore { + pub fn new(dir: &Path) -> StateStore; // does not touch the disk + pub fn path(&self, session: &SessionId) -> PathBuf; // /.json + pub fn read(&self, session: &SessionId) -> Result; + pub fn raise(&self, session: &SessionId, current: SessionState, label: Label) + -> Result; +} +``` + +The file's format is a private struct, so `SessionState` itself stays free of serde: + +```rust +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StateFile { taint: DataClass, untrusted: bool } +``` + +`StateError`'s `Display`, by hand: `cannot read the session state : ; ` or +`cannot write the session state : ; `, and then `RUNBOOK`. **Both messages end with +`RUNBOOK`.** Implement `std::error::Error` too. + +## `read`: every exit + +1. `read_to_string` fails with `ErrorKind::NotFound` → `Ok(SessionState::default())`. This is the + **only** case that counts as "no file". Reading creates nothing on disk. +2. `read_to_string` fails any other way (no permission, not UTF-8, it is a directory) → + `Err(Unreadable(path, the error's text))`. +3. `serde_json::from_str::` fails (empty, cut short, unknown field, missing field, wrong + type, text after the object) → `Err(Unreadable(path, the error's text))`. A final newline, or + none, is fine. +4. `taint` is `Public` → `Err(Unreadable(path, "a session's taint is never below private"))`. + `brokerd` never writes such a file, so someone else did. +5. Otherwise `Ok`. + +## `raise`: every exit + +The new state is `taint = max(current.taint, label.class, Private)` and +`untrusted = current.untrusted || label.untrusted`. `raise` trusts `current`; it does not read the +file again. It **always writes**, even when nothing changed, so the file exists from the session's +first result on. Write atomically, and map an error from **any** of these steps to +`Err(Write(path of the .json file, error))`: + +1. Create the directory and any missing parents with mode 0700: + `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)` + (`std::os::unix::fs::DirBuilderExt`; an existing directory is not an error with `recursive`). +2. Serialise `StateFile` with `serde_json::to_string` and add `\n`. Map a serde error with + `std::io::Error::other`. +3. Open `.json.tmp` (`path.with_extension("json.tmp")`) with `OpenOptions`: `write`, `create`, + `truncate`, and `.mode(0o600)` (`std::os::unix::fs::OpenOptionsExt`). `truncate` is what lets a + leftover `.tmp` from a crash be replaced. +4. `write_all`, then `sync_all` on the file. +5. `std::fs::rename` the `.tmp` over `.json`. +6. `std::fs::File::open(&dir)?.sync_all()`, so the rename itself is on disk. + +Only after all six return `Ok(new state)`. On an error the old `.json` is untouched, because +nothing wrote to it. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/state.rs crates/brokerd/tests/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test state`. Expected: it does not + compile. +- [ ] **3. Write `state.rs`** and add `pub mod state;` to `lib.rs`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test state`. Expected: `9 passed`. Two of + the nine print "skipped" and pass if you are root; you should not be. +- [ ] **5. Walk the exits.** Go down the two numbered lists above and point at the line of your + code for each number. Check in particular that `read` has exactly one path that returns the + default state. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p brokerd --test state` reports 9 passed; `make gate` prints `gate: ok`. + +## Stop and report if + +- A test wants a damaged or unreadable file to be treated as a fresh session. +- A test wants taint or the untrusted flag to go down. diff --git a/docs/plans/M3a/09-brokerd-audit-writer.md b/docs/plans/M3a/09-brokerd-audit-writer.md new file mode 100644 index 0000000..ae7f335 --- /dev/null +++ b/docs/plans/M3a/09-brokerd-audit-writer.md @@ -0,0 +1,138 @@ +# M3a task 09: the audit writer + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the audit writer with its startup check` + +## Goal + +`brokerd::audit::Writer` is the only thing that writes the audit log. It appends, never edits. +`open` checks the log it finds and refuses, recovers or accepts a break; `append` adds one +chained record and syncs it. The verifier from task 03 does all the judging. + +## Files + +- Copy: `crates/brokerd/tests/audit.rs`, `crates/brokerd/tests/audit_startup.rs`, + `crates/brokerd/tests/support/audit_dir.rs` +- Create: `crates/brokerd/src/audit.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod audit;`), `docs/implementer-log.md` + +The tests read the fixture logs of task 03 from `crates/proto/tests/fixtures/audit/`. + +## Interfaces + +```rust +pub const RECOVERED_NOTICE: &str = + "audit: recovered a torn final line\nsee docs/runbook.md#audit-recovered"; + +#[derive(Debug)] +pub enum AuditError { + Locked, + Broken(Box), // boxed: clippy's result_large_err rejects it unboxed + NothingToAccept, + Io { what: String, source: std::io::Error }, + Stopped, +} +#[derive(Debug)] pub struct Writer { /* private */ } +#[derive(Debug)] pub struct Opened { + pub writer: Writer, + pub recovered: bool, // a torn tail was recovered + pub accepted: Option, // the failure --accept-break accepted +} +impl Writer { + pub fn open(dir: &Path, accept_break: bool) -> Result; + pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; + pub fn next_seq(&self) -> u64; +} +/// Verifies every log file in `dir`, in name order, from `ChainVerifier::new()`. +pub fn verify_dir(dir: &Path) -> std::io::Result; +``` + +`AuditError` implements `Display` and `Error` (`source()` for `Io`). `Display`, exactly: + +| Variant | Text | +|---|---| +| `Locked` | `brokerd is already running\nsee docs/runbook.md#brokerd-already-running` | +| `Broken(f)` | `{f.file}:{f.line}: {f.what}\nsee docs/runbook.md#audit-chain-broken` | +| `NothingToAccept` | `nothing to accept: the audit log verifies` | +| `Io` | `audit: {what}: {source}\nsee docs/runbook.md#audit-unavailable` | +| `Stopped` | `audit: an earlier write failed; restart brokerd\nsee docs/runbook.md#audit-unavailable` | + +Verified in the std docs of Rust 1.98.1 (`rust-version` is 1.95): + +```rust +// std::fs::File, stable since 1.89. pub enum TryLockError { Error(io::Error), WouldBlock } +pub fn try_lock(&self) -> Result<(), TryLockError>; // released when the File is dropped +pub fn sync_all(&self) -> io::Result<()>; // File::open(dir)?.sync_all() syncs a directory +// std::os::unix::fs::{OpenOptionsExt, DirBuilderExt}: fn mode(&mut self, mode: u32) -> &mut Self +``` + +A log file is a name of the form `YYYY-MM-DD.jsonl`: ten characters, digits with `-` at +positions 4 and 7, then `.jsonl`. Everything else in the directory (`.lock`, `notes.txt`, +`x.jsonl.bak`) is ignored, in `open`, in `append` and in `verify_dir`. + +## What `open` does, in order + +1. Create `dir` and its parents if missing, mode 0700 (`DirBuilder`, `recursive(true)`). +2. Open `dir/.lock` (create, write, mode 0600, do not truncate) and `try_lock` it. + `WouldBlock` → `Err(Locked)`. `Error(e)` → `Err(Io)`. Keep the `File` in the `Writer`: the + lock lasts as long as the writer. +3. Verify. With `accept_break`: `verify_dir`. Without: **the short check.** If there are two or + more log files, read the one before the latest, take its last line (the bytes after the last + `\n`, once one trailing `\n` is removed), and if it parses as an `AuditRecord`, use + `ChainVerifier::resume(its seq + 1, its hash)` and feed only the latest file. If there is one + file, no file, or that last line does not parse, use `verify_dir`. +4. The report has a failure and `accept_break` is false → `Err(Broken)`. **Write nothing.** +5. The report has a failure and `accept_break` is true → append (rule 8) an + `AcceptedBreak { file, line, last_good }` from the failure, with `seq = break_seq` and + `prev = break_prev`; if `tail_torn`, write one `\n` first. `accepted` is the failure. +6. No failure and `accept_break` is true → `Err(NothingToAccept)`, even if there is a torn tail. + **Write nothing.** +7. No failure, a torn tail → append (rule 8) a `Recovery { torn_bytes: bytes, torn_sha256: + sha256 }` with `seq = recovery_seq` and `prev = recovery_prev`; if `has_newline` is false, + write one `\n` first. `recovered` is true. +8. Records written by `open` use `Timestamp::now()` and **always go in the latest log file, + whatever today's date is** (in today's file only if there is no log file at all). The `\n` + and the line go out in the same single write as in `append`. +9. Otherwise the writer continues from the report: `next_seq`, and `prev` is `head`, or the + resume hash if the latest file was empty, or zeros for an empty log. +10. Every I/O error on the way is `Err(Io)` with the path in `what`. A directory or file that + cannot be read is an error, never an empty log. + +## What `append` does + +1. If an earlier write failed → `Err(Stopped)`. Write nothing. +2. The file is `.jsonl`. **Never go back:** if that + name sorts before the latest log file's name, use the latest file. (A clock stepped back over + midnight must not put a record in an older file: files are verified in name order.) +3. The record is `AuditRecord { seq: next_seq, time, prev, event }`, serialised by `serde_json`. + Write the line and its `\n` with **one** `write_all`, then `sync_all`. A new file is created + with mode 0600, and the directory is synced after the create. +4. On success `prev` becomes the hash of the line (without `\n`), `next_seq` goes up by one, and + the record's `seq` is returned. +5. **On any error, from any step above, including the create and both syncs, set the stopped + flag before returning `Err(Io)`.** Part of a line may be on disk; only the next start deals + with that. The same holds for the writes `open` makes. + +## Steps + +- [ ] **1. Copy.** `mkdir -p crates/brokerd/tests/support`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/audit.rs docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs crates/brokerd/tests/` + and `cp docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs crates/brokerd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p brokerd --test audit`. Expected: does not compile. +- [ ] **3. Write `audit.rs`.** Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test audit --test audit_startup`, five + times. Expected: `9 passed` and `7 passed` every time. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- The two suites report 9 and 7 passed, five runs in a row; `make gate` prints `gate: ok`. + +## Stop and report if + +- `a_failed_write_stops_the_writer` prints `skipped`: the tests are running as root. +- `crates/brokerd/Cargo.toml` does not already have `serde_json.workspace = true` (task 04 adds + it). Do not add a dependency in this task. +- You need anything from `libc`, or `unsafe`, for the lock or the file modes. +- A test seems to need `open` to truncate, rewrite or delete anything. Nothing ever does. diff --git a/docs/plans/M3a/10-brokerd-runner.md b/docs/plans/M3a/10-brokerd-runner.md new file mode 100644 index 0000000..6637e3b --- /dev/null +++ b/docs/plans/M3a/10-brokerd-runner.md @@ -0,0 +1,137 @@ +# M3a task 10: the runner seam + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Replace the runner stub with the Runtime seam and RunSpec` + +## Goal + +`brokerd::runner` is where an allowed call becomes something a runtime can run. `run` takes a +`Decision` by value, builds a `RunSpec` from it (the tool, the typed arguments, the directories +to mount, the hosts it may reach) and asks a `Runtime`. A `RunSpec` can only be built here, from +a `Decision`, so a runtime never sees a call policy did not allow. M3a's production runtime, +`Refusing`, runs nothing; M3b adds Podman. + +## Files + +- Copy: `crates/brokerd/tests/runner.rs`, `crates/brokerd/tests/support/runtime.rs` + (`support/build.rs` is already there from task 07) +- Modify: `crates/brokerd/src/runner.rs` (it holds the M1 stub; replace all of it), + `docs/implementer-log.md` + +`lib.rs` already has `pub mod runner;`. Nothing else calls the old `run(decision)`. + +## Interfaces + +```rust +pub const REFUSING: &str = "the runner arrives in M3b"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mount { pub path: String, pub writable: bool } + +#[derive(Debug)] +pub struct RunSpec { // fields private, in this order; no constructor, no Clone + tool: ToolName, // crate::args::ToolName + arguments: ToolArgs, // crate::args::ToolArgs + mounts: Vec, + egress: Option>, // None: no network at all +} +impl RunSpec { + pub fn tool(&self) -> ToolName; + pub fn arguments(&self) -> &ToolArgs; + pub fn mounts(&self) -> &[Mount]; + pub fn egress(&self) -> Option<&[String]>; // self.egress.as_deref() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunOutput { pub content: String, pub truncated: bool } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunError { Failed(String), Unavailable(String) } + +pub trait Runtime: Send + Sync { + fn run(&self, spec: &RunSpec) -> Result; +} + +pub struct Refusing; // Runtime: always Err(RunError::Unavailable(REFUSING.to_string())) + +pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse; +``` + +What `Decision` gives you (task 07): `args() -> &ToolArgs` (and `args().tool() -> ToolName`), +`matched_path() -> Option<&str>`, `paths() -> &[String]`, `hosts() -> &[String]`, +`label() -> Label` with `class: DataClass` and `untrusted: bool`. + +## What `run` puts in the spec + +`tool` is `decision.args().tool()`; `arguments` is `decision.args().clone()`. + +| `ToolArgs` variant | `mounts` | `egress` | +|---|---|---| +| `ReadFile` | the matched path, `writable: false` (none if `matched_path()` is `None`) | `None` | +| `WriteFile` | the matched path, `writable: true` (none if `None`) | `None` | +| `Shell` | **every** path in `decision.paths()`, in order, each `writable: true` | `None` | +| `HttpFetch` | none | `Some(decision.hosts().to_vec())` | + +Only `HttpFetch` has network. Write the table as one `match` on the `ToolArgs` variant, with no +`_` arm. + +## What `run` answers + +1. Take `let label = decision.label();` before building the spec. +2. `runtime.run(&spec)` is `Ok(output)` → `ToolResponse::Result { content: output.content, + class: label.class, untrusted: label.untrusted, truncated: output.truncated }`. +3. `Err(RunError::Failed(m))` or `Err(RunError::Unavailable(m))` → `ToolResponse::Failed { + message: m }`, unchanged. + +There is no other exit. A `RunError`'s text reaches the model as a `failed` result labelled public +and trusted, so a runtime may only put a fixed sentence in it; say so in its doc comment. + +## The doctests + +Put these two in `runner.rs`'s module doc comment (`//!`), as `task 07` did for `Decision`. The +first must fail to compile because the fields are private (`todo!()` fits any type, so it would +compile if they were public); the second proves the getters are public: + +````rust +//! ```compile_fail +//! let _ = brokerd::runner::RunSpec { +//! tool: brokerd::args::ToolName::Shell, +//! arguments: todo!(), +//! mounts: Vec::new(), +//! egress: None, +//! }; +//! ``` +//! +//! ``` +//! fn tool_of(spec: &brokerd::runner::RunSpec) -> brokerd::args::ToolName { +//! spec.tool() +//! } +//! ``` +```` + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/runner.rs crates/brokerd/tests/` and + `cp docs/plans/M3a/files/crates/brokerd/tests/support/runtime.rs crates/brokerd/tests/support/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test runner`. Expected: it does not + compile. +- [ ] **3. Write `runner.rs`.** Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test runner`. Expected: `8 passed`. + `cargo test -p brokerd --doc`: 9 doctests in all pass (7 from task 07, 2 new; cargo may print + them on two `test result` lines). +- [ ] **5. Prove the doctest has teeth.** Make the four fields of `RunSpec` `pub`, run + `cargo test -p brokerd --doc`, and see one doctest fail ("test compiled"). Make them private + again and see it pass. Say in the log's Notes that you did this. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `runner` reports 8 passed, the doctests pass, step 5 was done and logged, and `make gate` prints + `gate: ok`. + +## Stop and report if + +- A test needs `RunSpec` to have a public constructor or to be `Clone`. +- A test expects a runtime's error text to be changed, prefixed or labelled other than `failed`. diff --git a/docs/plans/M3a/11-brokerd-approvals.md b/docs/plans/M3a/11-brokerd-approvals.md new file mode 100644 index 0000000..b3ab2db --- /dev/null +++ b/docs/plans/M3a/11-brokerd-approvals.md @@ -0,0 +1,92 @@ +# M3a task 11: the pending-approval table + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the table of pending approvals` + +## Goal + +A call an `ask` grant matched waits in `brokerd::approvals::Table` until someone answers it: +`bxctl approve`, `bxctl refuse`, the expiry thread, or the waiting thread itself when `loopd` has +gone. They can race. One rule settles every race: **whoever takes the entry out of the table +answers it**, and everyone else finds it gone. So there is no "look, then remove": `take` is one +step under the lock. The table is in memory only. + +## Files + +- Copy: `crates/brokerd/tests/approvals.rs` +- Create: `crates/brokerd/src/approvals.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod approvals;`), `docs/implementer-log.md` + +## Interfaces + +```rust +use std::collections::BTreeMap; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Mutex, MutexGuard}; +use proto::{DenyReason, PendingApproval, Timestamp}; +use crate::policy::{Ask, Decision}; + +/// What the waiting thread is told. Boxed: clippy's `large_enum_variant` rejects it unboxed. +#[derive(Debug)] +pub enum Verdict { Run(Box), Denied(DenyReason) } + +#[derive(Debug)] +pub struct Entry { pub info: PendingApproval, pub ask: Ask, pub reply: Sender } + +#[derive(Debug, Default)] +pub struct Table { entries: Mutex> } + +impl Table { + pub fn new() -> Table; + pub fn insert(&self, info: PendingApproval, ask: Ask) -> Receiver; + pub fn take(&self, id: u64) -> Option; + pub fn take_expired(&self, now: Timestamp) -> Vec; + pub fn list(&self) -> Vec; +} +``` + +`PendingApproval` is `proto`'s (task 02); its `approval` field is the id. `Ask` is not `Clone`, +which is why `take` returns the entry itself. + +Verified in the std docs: `std::sync::mpsc::channel::() -> (Sender, Receiver)`; +`Sender::send(&self, t) -> Result<(), SendError>`, an error only when the receiver is gone; +`Mutex::lock() -> LockResult>`, and a poisoned lock's guard is +`poisoned.into_inner()`; `BTreeMap::remove(&mut self, &k) -> Option`; `values()` iterates in +key order. + +## Rules + +1. **One private helper takes the lock**, and every method uses it: + `self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner())`. Nothing in this module + can leave the map half-changed, so a lock poisoned by a panic elsewhere is taken over as it is + (the ledger lock of task 12 is different; do not copy this there). +2. `insert`: make a channel, insert `Entry { info, ask, reply: sender }` under `info.approval`, + return the receiver. Ids come from the audit log's `seq` and never repeat. +3. `take`: `remove` under the lock and return it. `None` when the id is not there. +4. `take_expired`: under **one** lock, collect the ids of the entries with `now >= expires` + (exactly at `expires` is expired), remove each, and return the entries in id order. Taking + the lock once per id would let an `approve` slip in between; the tests race the two. +5. `list`: the `info` of every entry, cloned, in id order. + +No method sends on `reply`: whoever took the entry does that (tasks 13 and 14). + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/approvals.rs crates/brokerd/tests/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test approvals`. Expected: it does not + compile. +- [ ] **3. Write `approvals.rs`** and add `pub mod approvals;` to `lib.rs`. `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test approvals`, five times. Expected: + `7 passed` every time. Two tests race threads a hundred times each; a failure now and then + means a method looks and removes under two separate locks. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `approvals` reports 7 passed five runs in a row, and `make gate` prints `gate: ok`. + +## Stop and report if + +- A test needs the table to write anything to disk, or to answer an entry itself. diff --git a/docs/plans/M3a/12-brokerd-ledger.md b/docs/plans/M3a/12-brokerd-ledger.md new file mode 100644 index 0000000..5d1b034 --- /dev/null +++ b/docs/plans/M3a/12-brokerd-ledger.md @@ -0,0 +1,153 @@ +# M3a task 12: the ledger + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the ledger: the audit writer and session state behind one lock` + +## Goal + +`brokerd::ledger` holds the audit writer and every session's state behind **one** `Mutex`, and +has the three steps that hold it, each as a whole: decide and record; re-decide and record an +approval; raise the state and record a result. Without the one lock, two results for one session +could each read `private` and the second write would put the taint back down. After **any** +failed append, or a panic while the lock was held, the ledger refuses every later step: part of a +line may be on disk, and only the next start's check puts that right. + +## Files + +- Copy: `crates/brokerd/tests/ledger.rs`, `ledger_answer.rs`, and `support/rig.rs`, + `support/sink.rs` (under `crates/brokerd/tests/support/`) +- Create: `crates/brokerd/src/ledger.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod ledger;`), `docs/implementer-log.md` + +## Interfaces + +```rust +pub const NOT_RECORDED: &str = "the result could not be recorded"; +pub const POISONED: &str = "brokerd: a thread panicked while holding the ledger; every call is \ + denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable"; +pub const STOPPED: &str = "brokerd: an earlier audit write failed; every call is denied until \ + brokerd is restarted\nsee docs/runbook.md#audit-unavailable"; +pub type Grants = Result>; // what grants::load returns + +pub trait AuditSink: Send { + fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; +} +impl AuditSink for crate::audit::Writer { /* Writer::append(self, time, event) */ } + +#[derive(Debug)] +pub enum Decided { + Allowed { decision: Decision, seq: u64 }, + Ask { ask: Ask, seq: u64, state: SessionState }, + Denied(DenyReason), +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Answer { + Approved { by: Option }, + Refused { by: Option, reason: Option }, + Expired, +} +#[derive(Debug)] +pub struct Answered { pub verdict: Verdict, pub outcome: DecisionRecord } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Call { pub session: SessionId, pub call: CallId, pub decision: u64, pub label: Label } +impl Call { pub fn of(decision: &Decision, seq: u64) -> Call; } // from request(), label() + +pub struct Ledger { /* inner: Mutex, log: Box */ } +// struct Inner { audit: Box, state: StateStore, stopped: bool } +impl Ledger { + pub fn new(audit: Box, state: StateStore, log: Box) -> Ledger; + pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided; + pub fn answer(&self, ask: Ask, decision: u64, answer: Answer, grants: &Grants, now: Timestamp) -> Answered; + pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse; +} +``` + +`POISONED` and `STOPPED` are single string literals continued with `\` at the line end; the +pointer is written out in full, as `scripts/check-runbook.sh` requires. `Verdict` is task 11's. + +## Shared rules (every step) + +- **Getting the lock.** `self.inner.lock()`: `Err` (poisoned) → call `log(POISONED)`, step fails; + `Ok` with `stopped` true → `log(STOPPED)`, step fails. "Step fails" is: `decide` → + `Denied(AuditUnavailable)`; `answer` → verdict `Denied(AuditUnavailable)` and outcome + `Denied { reason: AuditUnavailable }`; `finish` → `Failed { NOT_RECORDED }`. Never + `into_inner` this lock. +- **Appending.** Through `inner.audit.append(now, event)`. On `Err(e)`: set `stopped = true`, + `log(&format!("brokerd: {e}"))`, and the step fails as above. Nothing is appended after that. +- **Reading state.** `inner.state.read(&session)`. On `Err(e)`: `log(&format!("brokerd: {e}"))`. + The state recorded is then `taint: Secret, untrusted: true` ("unknown" is the most sensitive). +- `now` is the record's time. Every record's `grant_sha256` is set exactly when `grant` is. + +## `decide`: every exit + +Copy `session`, `call`, `tool`, `arguments` out of the request first; `policy::decide` consumes it. + +1. Lock fails → `Denied(AuditUnavailable)`, nothing written. +2. Read the state (keep the `Result`). Outcome, **first that applies**: `grants` is `Err` → + denied `GrantsInvalid`; the state is unreadable → denied `StateUnreadable`; otherwise + `policy::decide(request, set, state, now)`. +3. Write `AuditEvent::Decision { session, call, tool, arguments, outcome, grant, grant_sha256, + taint, untrusted }`: allowed → `Allowed {}` with the decision's `grant()`/`grant_sha256()`; + ask → `Ask {}` with the ask's; denied → `Denied { reason }` with the `Denial`'s `grant` and + `grant_sha256` (set only for `denied_by_grant`). `taint`/`untrusted`: the state read. +4. Append fails → `Denied(AuditUnavailable)`. Otherwise return `Allowed { decision, seq }`, + `Ask { ask, seq, state }` or `Denied(reason)`. + +## `answer`: every exit + +1. Lock fails → step fails. +2. Read the state of `ask.request().session`. +3. The re-decision: `Approved` → `grants` `Err` → `GrantsInvalid`; state unreadable → + `StateUnreadable`; else `policy::redecide(ask, set, state, now)`. `Refused` → denied + `ApprovalRefused`; `Expired` → denied `ApprovalExpired`. +4. For a `Decision` from `redecide`, find the mode of the grant it names: + `set.grants().iter().find(|g| g.id == decision.grant())`; `Mode::Ask` → outcome `Ask {}`, + anything else → `Allowed {}`. The grant fields are the decision's. A denial → `Denied { reason + }` with the `Denial`'s grant fields (none for refused and expired). +5. Write `AuditEvent::Approval { session, call, decision, answer, by, post: None, reason, + outcome, grant, grant_sha256, taint, untrusted }`: `answer` is `Approved`/`Refused`/`Expired`; + `by` from the `Answer` (`None` for `Expired`); `reason` only for `Refused`; the state read. +6. Append fails → step fails. Otherwise `Answered { verdict, outcome }`, verdict + `Run(Box::new(decision))` or `Denied(reason)`. + +## `finish`: every exit + +1. Lock fails → `Failed { NOT_RECORDED }`. +2. Read the state of `call.session`. +3. `response` is `Result { content, truncated, .. }`: state unreadable → `Failed { NOT_RECORDED + }`, nothing written. Else `inner.state.raise(&call.session, current, call.label)`; `Err(e)` → + `log("brokerd: {e}")`, `Failed { NOT_RECORDED }`, nothing written. `status: Result`, + `taint_after` is the raised taint, hashed text is `content`. +4. `Failed { message }`: no state change. `status: Failed`, `truncated: false`, `taint_after` is + the taint read (or `Secret` if unreadable), hashed text is `message`. +5. `Denied` or `PendingApproval` (`runner::run` never returns them) → `Failed { NOT_RECORDED }`. +6. `proto::sha256(text.as_bytes())` is `Err` → `Failed { NOT_RECORDED }`. +7. Write `AuditEvent::Result { session, call, decision: call.decision, status, class: + call.label.class, untrusted: call.label.untrusted, truncated, bytes, sha256, taint_after }`, + `bytes` = `u64::try_from(text.len())` (fall back to `u64::MAX`). Append fails → + `Failed { NOT_RECORDED }`. **Only now** return `response` unchanged. The content never goes + back unless the raised taint and the record are both on disk. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/{ledger,ledger_answer}.rs crates/brokerd/tests/` + and `cp docs/plans/M3a/files/crates/brokerd/tests/support/{rig,sink}.rs crates/brokerd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p brokerd --test ledger`. Expected: no compile. +- [ ] **3. Write `ledger.rs`**, add `pub mod ledger;`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test ledger --test ledger_answer`. + Expected: `11 passed` and `9 passed`. One test panics on purpose and prints a panic message. +- [ ] **5. Walk the exits.** Point at the line for each numbered exit above, and check that every + `Err` from `append` goes through the one place that sets `stopped`. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- The two suites report 11 and 9 passed; step 5 is in the log's Notes; `make gate` prints + `gate: ok`. + +## Stop and report if + +- A test expects content to come back when the state or the record could not be written. +- You need to recover the poisoned ledger lock with `into_inner`. diff --git a/docs/plans/M3a/13-brokerd-broker.md b/docs/plans/M3a/13-brokerd-broker.md new file mode 100644 index 0000000..b6cd479 --- /dev/null +++ b/docs/plans/M3a/13-brokerd-broker.md @@ -0,0 +1,129 @@ +# M3a task 13: one tool request on `broker.sock` + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Handle a tool request from decision to answer` + +## Goal + +`brokerd::broker::handle` serves one `broker.sock` connection: `loopd` sends one `tool_request`; +`brokerd` decides, records, runs if allowed, records the result, and answers with one final +`tool_response`. For an `ask` call it first sends one `pending_approval` frame (`final: false`) +and waits for whoever takes the table entry to send the verdict. Every frame carries the +request's `id`. The tests answer entries by hand, as task 14's `admin` will. + +## Files + +- Copy: `crates/brokerd/tests/broker.rs`, `broker_pending.rs`, `broker_sequence.rs`, and + `crates/brokerd/tests/support/client.rs` +- Create: `crates/brokerd/src/broker.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod broker;`), `docs/implementer-log.md` + +## Interfaces + +```rust +pub const GONE: &str = "the requester went away"; +pub type Log = Box; + +pub struct Broker { /* cfg: Config, ledger: Ledger, table: Table, runtime: Box, + log: Log, printed: Mutex>> */ } +impl Broker { + pub fn new(cfg: Config, ledger: Ledger, runtime: Box, log: Log) -> Broker; // Table::new() + pub fn cfg(&self) -> &Config; + pub fn ledger(&self) -> &Ledger; + pub fn table(&self) -> &Table; + pub fn log(&self, line: &str); + pub fn grants(&self) -> Grants; +} +pub fn kind(msg: &Message) -> &'static str; // snake_case wire name, all 14 kinds, no `_` +pub fn send(stream: &mut UnixStream, id: u64, last: bool, msg: Message) -> bool; // write_frame(..).is_ok() +pub fn read_request(stream: &mut UnixStream) -> Option; +pub fn forbid(broker: &Broker, stream: &mut UnixStream, id: u64, msg: &Message, socket: &str); +pub fn alive(stream: &UnixStream) -> bool; +pub fn handle(stream: UnixStream, broker: &Broker); +``` + +Task 14 uses `send`, `read_request`, `forbid` and `grants` too; they are `pub` for that. + +## The helpers + +- `grants()`: `grants::load(&self.cfg.paths.grants)`. `Ok` → set `printed` to `None`. `Err(p)` + and `printed` is not `Some(p)` → `log(grants::render(&p).trim_end())`, then `printed = + Some(p.clone())`. So each distinct set of problems is printed once. Recover a poisoned `printed` + lock with `into_inner`. Return what `load` returned. +- `read_request`: `read_frame`. `Ok` → `Some`. `Closed` → `None`. Any other error → send an + error frame (`id` 0, `final: true`) with code `BadVersion` for `FrameError::BadVersion(_)`, + `BadMessage` for `FrameError::Json(_)`, `BadFrame` otherwise, detail the error's text; `None`. +- `forbid`: `log(&format!("brokerd: refused the message kind {kind} on {socket}\nsee docs/runbook.md#socket-forbidden"))`, + then send `Error { code: Forbidden, detail: format!("{kind} is not accepted on {socket}") }`, + `final: true`, the request's `id`. +- `alive`: `set_read_timeout(Some(Duration::from_millis(10)))` (`Err` → `false`; a zero duration + is an error in std). Then `read` one byte through `&UnixStream` (`Read` is implemented for it): + `Ok(0)` → `false` (gone); `Ok(_)` → `false` (bytes break the protocol); `Err` with kind + `WouldBlock` or `TimedOut` → `true`; any other `Err` → `false`. `loopd` never half-closes and + sends nothing more, so waiting means it is there. + +## `handle`: every exit + +1. `read_request` is `None` → return. +2. The message is not `Message::ToolRequest` → `forbid(.., "broker.sock")`, return. Nothing is + written to the audit log. +3. `now = Timestamp::now()`, `grants = broker.grants()`, `broker.ledger.decide(request, &grants, + now)`: + - `Denied(reason)` → answer `ToolResponse::Denied { reason }`. + - `Allowed { decision, seq }` → answer `run(decision, seq)` (below). + - `Ask { .. }` → the pending path (below). `None` from it → return, sending nothing more. +4. Send the answer, `final: true`, the request's `id`. A failed send is ignored: the records are + already written. + +`run(decision, seq)`: `let call = Call::of(&decision, seq)`, then `runner::run(decision, +runtime)`, then return `ledger.finish(&call, response, Timestamp::now())`. + +## The pending path: every exit + +1. `expires` = `Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl_ms))`, or + `Timestamp::MAX` if that is `Err`; then the earlier of that and `ask.expires()` if the grant + has one. +2. `info = PendingApproval { approval: seq, session, call, tool: ask.request().tool, arguments: + ask.args().canonical_json(), grant: ask.grant(), taint: state.taint, created: now, expires }`, + then `let verdict = table.insert(info, ask)`. +3. Send `PendingApproval { approval: seq, expires }` with **`final: false`**. If the send fails + and `table.take(seq)` is `Some` → return `None` (nothing written: the log shows it abandoned). + If the send fails and the entry is already gone, someone is answering it: go on to 4. +4. Wait: loop on `verdict.recv_timeout(Duration::from_secs(1))`: + - `Ok(v)` → go to 5 with `v`. + - `Err(Timeout)`: `alive(stream)` → loop. Gone and `table.take(seq)` is `Some` → return `None`. + Gone and the entry is already taken → `verdict.recv()`: `Ok(v)` → 5; `Err` → + `Denied(AuditUnavailable)`. + - `Err(Disconnected)` (the taker dropped it unanswered) → `Denied(AuditUnavailable)`. +5. `Denied(reason)` → answer `Denied { reason }`. `Run(decision)` → unbox it; **one more look**: + if `!alive(stream)`, `ledger.finish(&Call::of(&decision, seq), Failed { GONE }, now)` and + return `None` without running. Otherwise answer `run(decision, seq)`. + +Verified in the std docs: `Receiver::recv_timeout(Duration) -> Result` with +variants `Timeout` and `Disconnected`; `UnixStream::set_read_timeout(Option)`. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/{broker,broker_pending,broker_sequence}.rs crates/brokerd/tests/` + and `cp docs/plans/M3a/files/crates/brokerd/tests/support/client.rs crates/brokerd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p brokerd --test broker`. Expected: no compile. +- [ ] **3. Write `broker.rs`**, add `pub mod broker;`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** + `cargo test -p brokerd --test broker --test broker_pending --test broker_sequence`, five times. + Expected: `9 passed`, `5 passed`, `2 passed` every time. `broker_pending` takes about a second: + it waits for the one-second look. +- [ ] **5. Walk the exits.** Point at the line of each numbered exit in both lists above. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- The three suites pass five runs in a row with the counts in step 4; step 5 is in the log's + Notes; `make gate` prints `gate: ok`. + +## Stop and report if + +- A test expects a call to run without a `Decision` record allowing it or an `Approval` record + whose re-decision allows it. +- A test needs `handle` to write an audit record itself: every record goes through the ledger. diff --git a/docs/plans/M3a/14-brokerd-admin.md b/docs/plans/M3a/14-brokerd-admin.md new file mode 100644 index 0000000..e5a7514 --- /dev/null +++ b/docs/plans/M3a/14-brokerd-admin.md @@ -0,0 +1,97 @@ +# M3a task 14: one owner request on `admin.sock`, and expiry + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Handle approvals, refusals and grant checks on admin.sock` + +## Goal + +`brokerd::admin::handle` serves one `admin.sock` connection from `bxctl`: `approvals`, `approve`, +`refuse` or `check_grants`, one final answer. `expire_due` answers approvals that have run out. +Both use one function, `answer`, because they do the same thing: **whoever takes an entry out of +the table answers it**: the ledger writes the `Approval` record, then the verdict goes to the +waiting thread (task 13), and only then is `bxctl` answered. + +## Files + +- Copy: `crates/brokerd/tests/admin.rs` +- Create: `crates/brokerd/src/admin.rs` +- Modify: `crates/brokerd/src/lib.rs` (add `pub mod admin;`), `docs/implementer-log.md` + +## Interfaces + +```rust +use crate::approvals::Entry; +use crate::broker::{Broker, forbid, read_request, send}; +use crate::ledger::{Answer, Answered}; + +pub const BY: &str = "bxctl"; // the Approval record's `by` for answers through admin.sock + +pub fn answer(broker: &Broker, entry: Entry, answer: Answer, now: Timestamp) -> DecisionRecord; +pub fn expire_due(broker: &Broker, now: Timestamp) -> usize; +pub fn handle(stream: UnixStream, broker: &Broker); +``` + +## `answer` + +1. `let grants = broker.grants();` (it reads the grant files now: an approval decides again + with the grants as they are). +2. Destructure `Entry { info, ask, reply }`, then `let Answered { verdict, outcome } = + broker.ledger().answer(ask, info.approval, answer, &grants, now);` +3. `reply.send(verdict)`. If it is `Err` (the waiting thread has gone), `broker.log` the line + `brokerd: approval {id} was answered after its requester had gone`. The record is written + either way; there is nothing else to do. +4. Return `outcome`. + +`expire_due`: `broker.table().take_expired(now)`, call `answer(.., Answer::Expired, now)` on each +entry, return how many there were. + +## `handle`: every exit + +1. `read_request` is `None` → return. +2. `now = Timestamp::now()`. Then, by message kind: + +| Message | Does | Answer (`final: true`, the request's `id`) | +|---|---|---| +| `Approvals(Empty {})` | `table().list()` | `ApprovalList { items }` | +| `Approve { approval }` | `table().take(approval)`: `None` | error `NoSuchApproval`, detail `approval {id} is not pending` | +| | `Some(e)` → `answer(.., Answer::Approved { by: Some(BY) }, now)` | `ApproveResult { outcome }` | +| `Refuse { approval, reason }` | `take`: `None` | error `NoSuchApproval` as above | +| | `Some(e)` → `answer(.., Answer::Refused { by: Some(BY), reason }, now)`, outcome is `Denied { reason: ApprovalRefused }` | `Ok(Empty {})` | +| | the same, any other outcome (the record could not be written) | error `Internal`, detail below | +| `CheckGrants(Empty {})` | `grants::load(&broker.cfg().paths.grants)` | `GrantsReport { problems }`: the `Err`'s list, or empty | +| anything else | `forbid(broker, &mut stream, id, &msg, "admin.sock")` | (sent by `forbid`) | + +The `Internal` detail is one literal: `"the refusal could not be recorded; the call is denied; +see docs/runbook.md#audit-unavailable"`. The waiting call is denied whatever happens: the ledger +sends `Denied(AuditUnavailable)` when it cannot record. + +`CheckGrants` does not go through `broker.grants()`: `bxctl grants check` shows the problems +itself and must not use up the "print once" of task 13. + +3. Send the answer. A failed send is ignored. + +Notice what is **not** here: nothing looks at an entry and then removes it in a second step; +`take` is the only way in. The test `approve_and_refuse_at_once_give_exactly_one_answer` races the +two a hundred times and checks for exactly one `Approval` record each time. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/admin.rs crates/brokerd/tests/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test admin`. Expected: no compile. +- [ ] **3. Write `admin.rs`**, add `pub mod admin;`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test admin`, five times. Expected: + `12 passed` every time. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `admin` reports 12 passed five runs in a row, and `make gate` prints `gate: ok`. + +## Stop and report if + +- A test expects `approve` to run the call on the admin thread. It never does: the waiting thread + of task 13 runs it. +- `bxctl`'s tests (`cargo test -p bxctl`) stop passing. They talk to a fake, but the kinds and + answers above are the ones they expect; report the difference. diff --git a/docs/plans/M3a/15-brokerd-serve.md b/docs/plans/M3a/15-brokerd-serve.md new file mode 100644 index 0000000..f3cab72 --- /dev/null +++ b/docs/plans/M3a/15-brokerd-serve.md @@ -0,0 +1,112 @@ +# M3a task 15: `brokerd serve` + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add brokerd serve: startup, both sockets, and the expiry thread` + +## Goal + +`brokerd serve --config [--accept-break]` takes the audit lock, checks the log, makes its +two socket directories private, binds `broker.sock` and `admin.sock`, and serves each connection +on its own thread, with one more thread expiring approvals every second. The runtime is +`Refusing` (task 10). **The audit lock comes first**: it is what proves that a socket file left +behind is stale and not another `brokerd`'s. `serve` is the only module that starts threads. + +## Files + +- Copy: `crates/brokerd/tests/serve.rs` +- Create: `crates/brokerd/src/serve.rs` +- Modify: `crates/brokerd/src/main.rs` (it holds the "not implemented" stub; replace all of it), + `crates/brokerd/src/lib.rs` (add `pub mod serve;`), `docs/implementer-log.md` + +## Interfaces + +```rust +#[derive(Debug)] +pub enum ServeError { + Audit(AuditError), // Display: the AuditError's text + Dir(PathBuf, std::io::Error), // "cannot prepare {path}: {e}" + Socket(PathBuf, std::io::Error), // "cannot listen on {path}: {e}" +} // + std::error::Error + +pub struct Started { + pub broker: Arc, + pub recovered: bool, // from audit::Opened + pub accepted: Option, // from audit::Opened + /* private: tools: UnixListener, admin: UnixListener */ +} +pub fn start(cfg: Config, accept_break: bool, runtime: Box, + log: Arc) -> Result; +impl Started { pub fn run(self) -> std::io::Result<()>; } +``` + +## `start`, in this order + +1. `audit::Writer::open(&cfg.audit_dir(), accept_break)`; `Err(e)` → `Err(Audit(e))`. Nothing + else has happened yet: no directory, no socket. +2. `listen(&cfg.broker_socket())`, then `listen(&cfg.admin_socket())`; each error returned. +3. `Ledger::new(Box::new(opened.writer), StateStore::new(&cfg.state_dir()), log1)` and + `Broker::new(cfg, ledger, runtime, log2)`, where `log1` and `log2` are boxes that call the one + `Arc` (`let l = Arc::clone(&log); Box::new(move |line| l(line))`). + +`listen(socket)`, every exit (the directory is `socket.parent()`, or `/` if it has none): + +1. `DirBuilder::new().recursive(true).mode(0o700).create(dir)`; `Err` → `Dir`. +2. `std::fs::set_permissions(dir, Permissions::from_mode(0o700))` **always**, also when the + directory was already there at 0755; `Err` → `Dir`. +3. `std::fs::remove_file(socket)`: `Ok` or `NotFound` go on; any other `Err` → `Socket`. +4. `UnixListener::bind(socket)`; `Err` → `Socket`. +5. `set_permissions(socket, from_mode(0o600))`; `Err` → `Socket`. Return the listener. + +## `run` + +1. Spawn the expiry thread: forever, `sleep(1 s)`, then `admin::expire_due(&broker, + Timestamp::now())`. +2. Spawn one accept thread per listener. Each does: for every `incoming()` stream, `stream?`, + then spawn a thread calling `broker::handle` (tools) or `admin::handle` (admin) with its own + `Arc` clone. When `incoming` gives an error, the accept thread sends that `Err` on an + `mpsc` channel. +3. `run` waits for the first message on that channel and returns it. Either listener failing + stops the daemon. (If the channel is closed, return `Err(io::Error::other("a listener thread + ended"))`.) A send after the first has nobody to receive it; ignoring that result is correct, + and a comment should say so. + +## `main.rs` + +Usage is exactly `usage: brokerd serve --config [--accept-break]`. The first argument must +be `serve`; then `--config ` exactly once and `--accept-break` at most once, in any order. +Anything else (no arguments, a missing value, a flag twice, an unknown word) → print the usage to +stderr, exit 2. + +1. `Config::load(&path)`; `Err(e)` → `brokerd: {e}`, exit 1. +2. `start(cfg, accept_break, Box::new(Refusing), Arc::new(|line: &str| eprintln!("{line}")))` + (keep `cfg.broker_socket()` and `cfg.admin_socket()` first, for step 4). + `Err(ServeError::Audit(AuditError::NothingToAccept))` → `brokerd: {e}`, exit **2**. Any other + `Err(e)` → `brokerd: {e}`, exit 1. (`AuditError`'s text already ends with its runbook pointer.) +3. `recovered` → `eprintln!("{RECOVERED_NOTICE}")`. `accepted: Some(f)` → + `audit: accepted the break at {file}:{line}: {what}`. +4. `brokerd: serving tools on {broker socket} and approvals on {admin socket}`. +5. `run()`: `Err(e)` → `brokerd: {e}`, exit 1. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/brokerd/tests/serve.rs crates/brokerd/tests/` +- [ ] **2. See the test fail.** `cargo test -p brokerd --test serve`. Expected: it compiles (it + runs the binary) and all 9 fail, because `brokerd` still says "not implemented"; one of + them waits ten seconds for sockets that never appear. +- [ ] **3. Write `serve.rs`**, add `pub mod serve;`, rewrite `main.rs`. Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p brokerd --test serve`, five times. Expected: + `9 passed` every time. +- [ ] **5. Run all of `brokerd`.** `cargo test -p brokerd`. Every suite passes. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` + +## Done when + +- `serve` reports 9 passed five runs in a row, and `make gate` prints `gate: ok`. + +## Stop and report if + +- A test wants a socket bound, or a directory made, before the audit lock is taken. +- You need `libc`, `unsafe` or a signal handler. `brokerd` is stopped by being killed; the audit + log is safe at any moment because every record is synced before anything acts on it. diff --git a/docs/plans/M3a/16-loopd-tools.md b/docs/plans/M3a/16-loopd-tools.md new file mode 100644 index 0000000..afc45df --- /dev/null +++ b/docs/plans/M3a/16-loopd-tools.md @@ -0,0 +1,148 @@ +# M3a task 16: the tool port, the registry and denials in `loopd` + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Give loopd's tool port approvals, its own clock and plain denials` + +## Goal + +Get `loopd`'s tool path ready for `brokerd`, without a socket yet: the port can report a pending +approval, `clock` is answered by `loopd` itself, the registry offers the four tools `brokerd` +decides on, and a denial reaches the model as one fixed sentence and the owner as an event. + +Nothing here may change an earlier byte of any session's prompt. It does not: the `tools` array +(`core_schemas()`) stays `clock`, `find_tool`, `call_tool` with the same schemas, and a test checks +that. The registry change only alters what a *new* `find_tool` call returns. + +## Files + +- Copy: `crates/loopd/tests/tools.rs`, `crates/loopd/tests/turn.rs`, + `crates/loopd/tests/turn_broker.rs`, `crates/loopd/tests/support/mod.rs` +- Modify: `crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`, `crates/loopd/src/main.rs`, + `docs/implementer-log.md` + +## Interfaces (`tools.rs`) + +```rust +pub trait ToolPort: Send + Sync { + /// Returns the final answer, never `PendingApproval`. If the broker says the call is waiting + /// for the owner, the port calls `on_pending` once and goes on waiting. + fn call(&self, request: &proto::ToolRequest, on_pending: &mut dyn FnMut(&Pending)) + -> proto::ToolResponse; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Pending { pub approval: u64, pub expires: proto::Timestamp } + +pub const CLOCK: &str = "clock"; +impl Registry { pub fn m3a() -> Registry; } // m2b() stays: the tests use it +pub fn read_file_schema() -> ToolSchema; // and write_file_, shell_, http_fetch_ +pub fn denial_text(reason: proto::DenyReason) -> &'static str; +``` + +## Rules + +1. **`Registry::m3a()`**: five entries in this order: `clock` (`core: true`, `clock_schema()` + unchanged), then `read_file`, `write_file`, `shell`, `http_fetch` (`core: false`). Keep + `Registry::m2b()`; change its doc comment to say it is the test registry (`echo` exists only in + `FakeTools` and the recorded conversations) and that `loopd serve` never uses it. +2. **The four schemas.** Every property is `{"type": "string", "description": "…"}`. Use exactly + these descriptions; the tests search them. + + | Tool | Description | Properties (description) | `required` | + |---|---|---|---| + | `read_file` | Read a text file. Needs a grant from the owner for the path. | `path` (The absolute path of the file.) | `["path"]` | + | `write_file` | Write a text file, replacing it. Needs a grant from the owner for the path. | `path` (The absolute path of the file.), `content` (The whole new content of the file.) | `["path", "content"]` | + | `shell` | Run a shell command in a sandbox. Needs a grant from the owner. | `command` (The command line to run.), `cwd` (The absolute path of the directory to run it in.) | `["command"]` | + | `http_fetch` | Fetch an https URL. Needs a grant from the owner for the host. | `url` (The URL. It must start with https://.) | `["url"]` | + + One of them in full, as the pattern for the rest: + + ```rust + pub fn shell_schema() -> ToolSchema { + ToolSchema { + name: "shell".to_string(), + description: "Run a shell command in a sandbox. Needs a grant from the owner.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "The command line to run." }, + "cwd": { "type": "string", "description": "The absolute path of the directory to run it in." } + }, + "required": ["command"] + }), + } + } + ``` +3. **`clock` is local.** In `dispatch`, after the `find_tool` and `call_tool` checks: if + `name == CLOCK`, return `Dispatch::Local(proto::Timestamp::now().to_rfc3339())`, whatever the + arguments are and whatever the registry holds. The time is not authority and needs no broker. + It is volatile, which is fine here: a tool result is always the newest message. Remove the + `"clock"` arm from `FakeTools::call`; `echo` stays. +4. **`denial_text`**: one `match` with no `_` arm, exactly these sentences: + + | Reason | Sentence | + |---|---| + | `NoGrant` | Denied: no grant allows this call. | + | `GrantExpired` | Denied: the grant for this call has expired. | + | `TaintTooHigh` | Denied: this session has seen data too sensitive for this call. | + | `DeniedByGrant` | Denied: a grant forbids this call. | + | `ApprovalRefused` | Denied: the owner refused this call. | + | `ApprovalExpired` | Denied: the approval request expired without an answer. | + | `InvalidArguments` | Denied: the arguments are not valid for this tool. | + | `GrantsInvalid` | Denied: the grant files have an error; the owner has been told. | + | `AuditUnavailable` | Denied: the audit log cannot be written; the owner has been told. | + | `StateUnreadable` | Denied: this session's broker state is damaged; the owner has been told. | +5. **`run_call` in `turn.rs`** gains a last parameter `on_event: &mut dyn FnMut(&TurnEvent)`; + `run_turn` passes its own `on_event`. In the `Dispatch::Port` arm, build the `ToolRequest` as + now, then: + + ```rust + let response = rt.port.call(&request, &mut |pending| { + on_event(&TurnEvent::ApprovalPending { + approval: pending.approval, + tool: request.tool.clone(), + expires: pending.expires, + }); + }); + ``` + + Then match `response`. There are four arms and each one returns `(text, class, untrusted)`: + - `Result { content, class, untrusted, .. }`: as now. + - `Failed { message }`: as now, `"The tool failed: {message}"`, `Public`, `false`. No event. + - `Denied { reason }`: first `on_event(&TurnEvent::ToolDenied { name: request.tool.clone(), + reason })`, then `(denial_text(reason).to_string(), Public, false)`. The old + `"The call was denied: {reason:?}"` text goes away. + - `PendingApproval { .. }`: `"The tool failed: the tool broker gave no final answer"`, + `Public`, `false`, no event. A pending frame is never a final answer; a port that returns + one has failed, and that is not a decision about the call. Replace the old text. + + Both events carry `request.tool`, the tool `brokerd` decides on, not `call.name`: for a + `call_tool` call the owner must see `read_file`, because that is what grants are written for. + The existing `ToolCallStarted` and `ToolResult` events keep `call.name`. The `Dispatch::Local` + arm and the "already called" repeat path send neither new event. +6. **`main.rs`**: `Registry::m2b()` becomes `Registry::m3a()`. `FakeTools` stays until task 17. +7. `FakeTools::call` takes the new parameter and ignores it (`_on_pending`). + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/loopd/tests/{tools,turn,turn_broker}.rs crates/loopd/tests/` and + `cp docs/plans/M3a/files/crates/loopd/tests/support/mod.rs crates/loopd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p loopd --test tools`. Expected: it does not compile. +- [ ] **3. Change `tools.rs`** (rules 1 to 4 and 7), then `turn.rs` (rule 5), then `main.rs`. + Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** + `cargo test -p loopd --test tools --test turn --test turn_broker --test limits --test channel`. + Expected: `10 passed`, `6 passed`, `5 passed`, `9 passed`, `6 passed`. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit` + +## Done when + +- The five test files in step 4 report those counts, and `make gate` prints `gate: ok`. + +## Stop and report if + +- `the_m3a_registry_has_the_same_core_and_the_four_broker_tools` fails on `core_schemas()`: the + baseline would change, and that must not be worked around. +- A test needs `loopd` to parse or check a tool's arguments. It must not: `brokerd` does that. diff --git a/docs/plans/M3a/17-loopd-broker-port.md b/docs/plans/M3a/17-loopd-broker-port.md new file mode 100644 index 0000000..87f78ea --- /dev/null +++ b/docs/plans/M3a/17-loopd-broker-port.md @@ -0,0 +1,156 @@ +# M3a task 17: `BrokerPort`, `[broker]` config and the runbook pointers + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add BrokerPort: loopd asks brokerd for every tool call` + +## Goal + +`loopd serve` sends every tool call to `brokerd` over `broker.sock`. Whatever goes wrong there, the +model gets a plain failure and the turn goes on. Every fail-closed message `loopd` prints ends with +its entry in `docs/runbook.md`. + +## Files + +- Copy: `crates/loopd/tests/broker_port.rs`, `broker_port_bad.rs`, `pointers.rs`, `config.rs`, + `device.rs`, and `crates/loopd/tests/support/broker.rs` +- Create: `crates/loopd/src/broker_port.rs` (add `pub mod broker_port;` to `lib.rs`) +- Modify: `crates/loopd/src/config.rs`, `main.rs`, `session.rs`, `baseline.rs`, + `docs/implementer-log.md` + +## Interfaces + +```rust +// config.rs: same style as `Loop`. `Config` gains `#[serde(default)] pub broker: Broker`. +#[serde(deny_unknown_fields, default)] +pub struct Broker { pub socket: Option, pub timeout_ms: u64 } +// Default: socket None, timeout_ms 120_000. No default path: absent means no broker. + +// broker_port.rs +pub const UNAVAILABLE: &str = "the tool broker is unavailable"; +pub const NOT_CONFIGURED: &str = "no tool broker is configured"; +pub const TOO_LARGE: &str = "the request is too large for the tool broker"; +pub const POINTER: &str = "see docs/runbook.md#broker-unavailable"; +/// "loopd: {UNAVAILABLE}: {why}; {POINTER}" +pub fn unavailable_line(why: &str) -> String; +/// "loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}" +pub fn not_configured_line() -> String; + +pub struct BrokerPort { /* socket: PathBuf, timeout: Duration, log: Box */ } +impl BrokerPort { + pub fn new(socket: PathBuf, timeout: Duration) -> BrokerPort; // log = |l| eprintln!("{l}") + pub fn with_log(socket: PathBuf, timeout: Duration, log: Box) -> BrokerPort; +} +impl ToolPort for BrokerPort { /* below */ } +pub struct NoBroker; // ToolPort: always Failed { NOT_CONFIGURED }, prints nothing +``` + +## The protocol + +One connection per call. `loopd` sends one frame: `v: PROTOCOL_VERSION`, `id` = `request.call.0`, +`final: true`, `Message::ToolRequest(request.clone())`. `brokerd` answers with frames carrying the +same `id`: optionally one `ToolResponse::PendingApproval` with `final: false`, then exactly one +`Result`, `Failed` or `Denied` with `final: true`, and closes. + +## The timeout is a deadline, not a per-read timeout + +A read timeout set once lets a peer that sends one byte now and then hold the turn for ever. Read +through this, which allows each `read` only what is left: + +```rust +struct Deadline<'a> { stream: &'a UnixStream, until: Instant } +impl std::io::Read for Deadline<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let left = self.until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::from(std::io::ErrorKind::TimedOut)); + } + self.stream.set_read_timeout(Some(left))?; + let mut stream = self.stream; // `Read` is implemented for `&UnixStream` + stream.read(buf) + } +} +``` + +Verified in the std docs of Rust 1.98.1: `set_read_timeout` and `set_write_timeout` "An Err is +returned if the zero Duration is passed to this method", hence the `is_zero` check first. A read +that times out fails with kind `WouldBlock` on Linux (`TimedOut` elsewhere); treat both as a +timeout, as `http.rs` does. `Instant::checked_add(Duration) -> Option`. + +- First deadline: `Instant::now().checked_add(timeout)`, taken before connecting. +- After a pending frame: `wait = expires.unix_millis().saturating_sub(Timestamp::now().unix_millis())`, + then `Instant::now().checked_add(Duration::from_millis(wait))` and `.checked_add(timeout)` on + that. An `expires` in the past leaves `timeout`. `expires` comes from a peer: never add without + `checked_add`. + +## What `call` does. Every exit is listed; there are no others + +Exits marked **U** do the same three things: call `log` exactly once with +`unavailable_line(why)`, return `ToolResponse::Failed { message: UNAVAILABLE.to_string() }`, and +drop the stream. Write one helper for it and use it at every **U**. Never panic, never return +`PendingApproval`. + +1. First deadline is `None` → **U**, why `the timeout is too large`. +2. `UnixStream::connect(&socket)` fails → **U**, `cannot connect to {socket}: {e}`. +3. `set_write_timeout(Some(timeout))` fails (it does for a zero timeout) → **U**, + `cannot set a timeout: {e}`. +4. `write_frame` fails with `FrameError::TooLarge(_)` → return `Failed { TOO_LARGE }` and do + **not** call `log`: the broker is fine. Any other `write_frame` error → **U**, + `cannot send the request: {e}`. +5. `read_frame(&mut Deadline { .. })` fails. `FrameError::Closed` → **U**, + `the connection closed before the final answer`. `FrameError::Io(e)` with kind `WouldBlock` or + `TimedOut` → **U**, `no answer in time`. Any other error (including `TooLarge` here) → **U**, + `bad frame: {e}`. +6. The frame's `id` is not the request's → **U**, `an answer for request {got}, not {want}`. +7. `msg` is `Message::Error(e)` → **U**, `the broker reported an error: {detail}`. Any other + message that is not `Message::ToolResponse` → **U**, `an unexpected message`. +8. `PendingApproval` with `final: true` → **U**, `a pending frame marked final`. +9. `Result`, `Failed` or `Denied` with `final: false` → **U**, `an answer not marked final`. +10. `PendingApproval` with `final: false`: if one was already seen → **U**, + `a second pending frame`. Otherwise compute the new deadline (`None` → **U**, + `an expiry too far away`), call `on_pending(&Pending { approval, expires })` once, and go + back to step 5 with the new deadline. +11. `Result`, `Failed` or `Denied` with `final: true` → return it unchanged. Nothing is logged: a + denial is not an outage. + +Steps 5 to 10 apply to the frame after a pending frame exactly as to the first. + +## Wiring and pointers + +- `main.rs`, `run_serve`: `Some(path)` → `BrokerPort::new(path.clone(), + Duration::from_millis(cfg.broker.timeout_ms))`; `None` → `eprintln!("{}", + not_configured_line())` once, then `NoBroker`. `FakeTools` is no longer used by `main.rs`. A + socket that does not exist yet is not a startup error: `loopd` connects per call. +- `main.rs`, `run_selftest_check`: the failure line becomes + `selftest: FAILED: {e}; see docs/runbook.md#loopd-selftest-failed`. It serves both commands. +- `session.rs`: only `SessionError::Torn` changes, to + `{path}:{line}: {why}; see docs/runbook.md#session-log-damaged`. +- `baseline.rs`: add `BaselineError::Core(PathBuf, std::io::Error)`, displayed as + `{path}: {err}; see docs/runbook.md#core-memory-unreadable`, and return it (not `Read`) for an + unreadable `memory/core.md`. `Read` stays as it is for `system.md`. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/loopd/tests/{broker_port,broker_port_bad,pointers,config,device}.rs crates/loopd/tests/` + and `cp docs/plans/M3a/files/crates/loopd/tests/support/broker.rs crates/loopd/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p loopd --test broker_port`. Expected: no compile. +- [ ] **3. Write** `config.rs`, then `broker_port.rs`, then the wiring and pointers. `cargo fmt --all`. +- [ ] **4. See the tests pass.** + `cargo test -p loopd --test broker_port --test broker_port_bad --test pointers --test config`, + five times in a row. Expected every time: `15 passed`, `2 passed`, `7 passed`, `11 passed`. + `broker_port` takes a few seconds: it waits for real timeouts. +- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit` + +## Done when + +- Step 4's counts, five runs in a row, and `make gate` prints `gate: ok`. +- You have checked each of the eleven exits above against your code, one by one, and the log's + Notes say so. + +## Stop and report if + +- A test needs the port to keep a connection open between calls, or to retry. +- `the_timeout_is_a_deadline_for_the_frame_not_for_each_read` fails although you read through + `Deadline`. Do not loosen it. +- `device.rs` does not compile. Do not run it: it needs straylight and is the owner's to run. diff --git a/docs/plans/M3a/18-bxctl-admin.md b/docs/plans/M3a/18-bxctl-admin.md new file mode 100644 index 0000000..5d4f8b0 --- /dev/null +++ b/docs/plans/M3a/18-bxctl-admin.md @@ -0,0 +1,184 @@ +# M3a task 18: `bxctl` admin commands and printing text as data + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add bxctl approvals, approve, refuse and grants check` + +## Goal + +`bxctl` gains four commands that talk to `brokerd` over `admin.sock`, a command-line parser that +tests can drive, and a module that prints model-written text as data. `bxctl chat` keeps working +exactly as it does; task 20 changes it. + +## Files + +- Copy: `crates/bxctl/tests/escape.rs`, `cli.rs`, `admin.rs`, `support/mod.rs` +- Create: `crates/bxctl/src/escape.rs`, `cli.rs`, `admin.rs`, `verify.rs` +- Modify: `crates/bxctl/src/lib.rs`, `main.rs`, `chat.rs` (one word), `docs/implementer-log.md` + +No new dependency. The wire messages are our format: `proto` already rejects unknown fields. + +## Interfaces + +```rust +// escape.rs +pub fn escape_json_text(text: &str) -> String; +pub fn escape_model_text(text: &str) -> String; + +// cli.rs +pub const USAGE: &str = "usage: bxctl chat [--socket ] [--admin-socket ] [--session ] [--no-thinking] [--say ] [--json] + bxctl approvals [--admin-socket ] + bxctl approve [--admin-socket ] + bxctl refuse [--reason ] [--admin-socket ] + bxctl grants check [--admin-socket ] + bxctl audit verify [--home ]"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatOptions { pub socket: PathBuf, pub admin_socket: PathBuf, pub session: Option, + pub show_thinking: bool, pub say: Option, pub json: bool } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Chat(ChatOptions), + Approvals { admin_socket: PathBuf }, + Approve { admin_socket: PathBuf, approval: u64 }, + Refuse { admin_socket: PathBuf, approval: u64, reason: Option }, + GrantsCheck { admin_socket: PathBuf }, + AuditVerify { home: PathBuf }, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsageError; +pub fn parse(args: &[String], home: &Path) -> Result; + +// admin.rs +#[derive(Debug)] +pub enum AdminError { Connect(PathBuf, std::io::Error), Frame(proto::FrameError), + Refused(proto::WireError), Protocol(String), Io(std::io::Error) } +pub fn reason_name(reason: DenyReason) -> &'static str; +pub fn request(socket: &Path, msg: Message) -> Result; +pub fn list(socket: &Path) -> Result, AdminError>; +pub fn write_block(out: &mut dyn Write, item: &PendingApproval, now: Timestamp) -> std::io::Result<()>; +pub fn cmd_approvals(socket: &Path, now: Timestamp, out: &mut dyn Write) -> Result; +pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result; +pub fn cmd_refuse(socket: &Path, approval: u64, reason: Option<&str>, out: &mut dyn Write) -> Result; +pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result; + +// verify.rs: a placeholder. Task 19 replaces the body. +pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result { + let _ = home; + writeln!(out, "audit verify: not built yet")?; + Ok(false) +} +``` + +`AdminError` implements `Display` and `std::error::Error`. Every `cmd_` returns `Ok(true)` for exit +status 0 and `Ok(false)` for exit status 1. + +## Rules + +**`escape.rs`.** These code points are printed as `\uXXXX`: a backslash, `u`, and four lowercase hex +digits (`format!("\\u{:04x}", u32::from(c))`). Everything else is copied unchanged. + +| From | To | | From | To | +|---|---|---|---|---| +| U+0000 | U+001F | | U+2028 | U+202E | +| U+007F | U+009F | | U+2060 | U+2069 | +| U+200B | U+200F | | U+FEFF | U+FEFF | + +`escape_model_text` is the same, except that newline (U+000A) and tab (U+0009) are copied +unchanged. A carriage return is still escaped. The test walks every code point; do not sample. + +**`cli.rs`.** `parse` reads no environment and prints nothing. `home` gives the defaults: +`/run/loop/loop.sock`, `/run/owner-broker/admin.sock`, and `` for `--home`. + +1. The first word, or first two (`grants check`, `audit verify`), pick the command. Anything else, + including no words and a flag before the command, is `UsageError`. +2. After the command, a word that starts with `--` is a flag and the next word is its value, + whatever that word looks like (`--reason --admin-socket` has the value `--admin-socket`). + `chat` keeps its two flags without values, `--no-thinking` and `--json`. +3. `UsageError` for each of: a flag the command does not take; a flag with no word after it; a + flag given twice (`chat` excepted); a positional word where none is taken; no id, or more than + one; an invalid `--session`. +4. An id is one or more ASCII digits that fit a `u64`. Check the digits before `str::parse`: + `"+41".parse::()` succeeds, and `+41` is not an id. + +**`admin.rs`, `request`.** Every exit: + +1. `UnixStream::connect` fails: `Connect(socket.to_path_buf(), e)`. +2. Write one frame: `v: PROTOCOL_VERSION`, `id: 1`, `final: true`. Failure: `Frame`. +3. Read one frame. Failure, including a closed connection: `Frame`. +4. Its `id` is not 1, or `final` is false: `Protocol`. +5. It is `Message::Error(w)`: `Refused(w)`. Otherwise return the message. + +`Display`: `Connect(p, e)` is `cannot reach brokerd at {p}: {e}` (`p.display()`); `Refused(w)` +is `{code}: {detail}` with `chat::code_name` (make that function `pub`); the rest print their +inner value. `list` sends `Message::Approvals(Empty {})` and expects `ApprovalList`; any other +kind is `Protocol`. `reason_name` is a `match` with all ten reasons and no `_` arm, giving the +snake_case wire name (`DenyReason::NoGrant` is `"no_grant"`). + +**`write_block`** writes exactly two lines: + +``` +41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private + shell {"command":"ls"} +``` + +- Line 1: `{approval} {span} ago {expiry} session {session} grant {grant} taint {taint}`, + two spaces between parts. Line 2: four spaces, the tool, one space, the arguments. +- `span` of a number of milliseconds: under 60 s, `{n} s`; under 60 min, `{n} min`; otherwise + `{n} h`; always rounded down. "ago" is `now - created`, and 0 if `created` is after `now` + (`saturating_sub`). `expiry` is `expired` when `now >= expires`, otherwise `expires in {span}`. +- A session id longer than 10 characters is shown as its first 9 and `…`. +- `taint` is `public`, `private` or `secret`. +- `tool`, `grant` and `arguments` each go through `escape_json_text`. Nothing else is changed: + the arguments are printed as `brokerd` sent them. + +**The commands.** Each makes one `request`. `Refused` with `ErrorCode::NoSuchApproval` is handled +as below; every other `Err` from `request`, and every answer of the wrong kind (`Protocol`), is +returned and nothing is printed. A failed write to `out` is `Io`. + +| Command | Sends | Answer | Prints | Returns | +|---|---|---|---|---| +| `cmd_approvals` | `Approvals` | `ApprovalList`, empty | `no pending approvals` | `true` | +| | | `ApprovalList` | one block per item, in order | `true` | +| `cmd_approve` | `Approve` | `ApproveResult`, `Allowed {}` or `Ask {}` | `approved 41: runs` | `true` | +| | | `ApproveResult`, `Denied { reason }` | `approved 41: denied (no_grant)` | `false` | +| `cmd_refuse` | `Refuse` | `Ok` | `refused 41` | `true` | +| both of those | | error `no_such_approval` | `41: no such approval (already answered or expired)` | `false` | +| `cmd_grants_check` | `CheckGrants` | `GrantsReport`, empty | `grants: ok` | `true` | +| | | `GrantsReport` | every problem, one line each | `false` | + +A problem is `{file}:{line}: {problem}`, or `{file}: {problem}` when `line` is `None`; `file` and +`problem` go through `escape_json_text`, so a problem is always one line. + +**`main.rs`.** Read `BOXMAKER_HOME` (default `/var/lib/boxmaker`) and call `cli::parse`. On +`UsageError` print `USAGE` to stderr and exit 2. Delete `Options`, `parse_chat` and +`default_socket`; the chat functions stay as they are and take `&ChatOptions`. The four commands +write to locked stdout with `Timestamp::now()`; `audit verify` calls `bxctl::verify::run`. For all +five: `Ok(true)` exits 0, `Ok(false)` exits 1, and `Err(e)` prints to stderr and exits 1. The four +commands print `bxctl: {e}`; `audit verify` prints +`bxctl: cannot read the audit log under {home}: {e}` (`home.display()`). + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `mkdir -p crates/bxctl/tests/support && cp docs/plans/M3a/files/crates/bxctl/tests/{escape,cli,admin}.rs crates/bxctl/tests/ && cp docs/plans/M3a/files/crates/bxctl/tests/support/mod.rs crates/bxctl/tests/support/` +- [ ] **2. See the tests fail.** `cargo test -p bxctl --test escape`. Expected: it does not compile. +- [ ] **3. Write `escape.rs`**, add the four `pub mod` lines to `lib.rs`, write the `verify.rs` + placeholder. `cargo test -p bxctl --test escape`. Expected: `8 passed`. +- [ ] **4. Check the bytes.** `grep -c 'u{:04x}' crates/bxctl/src/escape.rs` prints at least 1. If + your editor turned an escape in a file into the character it names, fix the file. +- [ ] **5. Write `cli.rs`.** `cargo build -p bxctl`. Expected: it compiles. Its tests run in step 6, + because one of them needs the new `main.rs`. +- [ ] **6. Write `admin.rs`, then change `main.rs`.** `cargo test -p bxctl`. Expected: `admin` + 21 passed, `chat` 12 passed, `cli` 12 passed, `escape` 8 passed. +- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`. +- [ ] **8. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p bxctl` reports 21, 12, 12 and 8 passed for `admin`, `chat`, `cli` and `escape`; + `make gate` prints `gate: ok`; `main.rs` is under 500 lines. + +## Stop and report if + +- A test needs `bxctl` to read a grant file or an audit file itself in this task. +- `proto` lacks `Message::Approvals`, `PendingApproval`, `GrantProblem` or + `ErrorCode::NoSuchApproval`: task 02 has not been done. diff --git a/docs/plans/M3a/19-bxctl-audit-verify.md b/docs/plans/M3a/19-bxctl-audit-verify.md new file mode 100644 index 0000000..f421a14 --- /dev/null +++ b/docs/plans/M3a/19-bxctl-audit-verify.md @@ -0,0 +1,93 @@ +# M3a task 19: `bxctl audit verify` + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add bxctl audit verify` + +## Goal + +`bxctl audit verify [--home ]` reads `/audit/` itself and verifies the whole hash +chain with `proto::ChainVerifier`. It asks no daemon, so it works when `brokerd` refuses to +start, which is exactly when the owner needs it. + +## Files + +- Copy: `crates/bxctl/tests/verify.rs` +- Modify: `crates/bxctl/src/verify.rs` (task 18 made it with a placeholder body; replace the body), + `docs/implementer-log.md` + +Do not touch `lib.rs` or `main.rs`: task 18 already added `pub mod verify;` and the command. + +The tests read the fixture logs of task 03 from `crates/proto/tests/fixtures/audit/`. + +## Interfaces + +```rust +/// Verifies the whole audit log under `home` and prints the report to `out`. +/// `Ok(true)`: the chain verifies. `Ok(false)`: it does not. `Err`: the log cannot be read. +pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result; +``` + +## What `run` does + +1. List `/audit/`. A log file is a name of the form `YYYY-MM-DD.jsonl`: ten characters, + digits with `-` at positions 4 and 7, then `.jsonl`. Ignore every other name (`.lock` is + always there). Sort the names. +2. `ChainVerifier::new()`, then `feed(name, &bytes)` for each file in order, then `finish()`. +3. **If the report has a failure**, print exactly two lines and return `Ok(false)`: + + ``` + 2026-09-17.jsonl:4: prev is not the hash of the line before + see docs/runbook.md#audit-chain-broken + ``` + + That is `{file}:{line}: {what}` from the failure. Print nothing else. +4. **Otherwise** print the first line below, then one line for each entry of each list, in this + order, and return `Ok(true)`. A list that is empty prints nothing. + + | From | Line | + |---|---| + | always | `audit: ok, {records} records, head {hex}` (`head none` when `head` is `None`) | + | `recoveries` | `recovered line: {file}:{line}` | + | `accepted_breaks` | `accepted break: {file}:{line}` | + | `abandoned` | `pending or abandoned: approval {seq}` | + | `unfinished` | `running or unfinished: decision {seq}` | + | `clock_warnings` | `clock went backwards: {file}:{line}` | + | `torn_tail` | `torn final line: {file}:{line} (brokerd recovers it at its next start)` | + + The double names are deliberate. `bxctl` reads the files without asking `brokerd`, so an + approval still waiting and a call still running look the same as ones a crash cut off. A torn + tail is not a failure: it is also what a `brokerd` in the middle of a write looks like. +5. **Errors.** If `/audit` cannot be listed, or a log file cannot be read, return the + `io::Error` (use `?`) and print nothing. A missing directory is an error, not an empty log. An + existing directory with no log files is an empty log: `audit: ok, 0 records, head none`. + A `write` to `out` that fails is returned with `?` as well. + +## The command (already in `main.rs` from task 18) + +`bxctl audit verify [--home ]` calls `run`. `Ok(true)` is exit status 0, `Ok(false)` is 1, +and `Err(e)` prints `bxctl: cannot read the audit log under {home}: {e}` and is 1. Check that +`main.rs` does this (`grep -n 'cannot read the audit log' crates/bxctl/src/main.rs`). If it does +not, stop and report; do not edit `main.rs`. + +## Steps + +- [ ] **1. Copy.** `cp docs/plans/M3a/files/crates/bxctl/tests/verify.rs crates/bxctl/tests/` +- [ ] **2. See the test fail.** `cargo test -p bxctl --test verify`. Expected: it compiles, and + the tests fail against the placeholder. +- [ ] **3. Replace the placeholder body in `verify.rs`.** Run `cargo fmt --all`. +- [ ] **4. See the tests pass.** `cargo test -p bxctl --test verify`. Expected: `6 passed`. +- [ ] **5. Try it.** `cargo run -q -p bxctl -- audit verify --home /nonexistent; echo $?` + Expected: one line on stderr starting `bxctl: cannot read the audit log under /nonexistent`, + then `1`. And `cargo run -q -p bxctl -- audit; echo $?` prints the usage and `2`. +- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p bxctl --test verify` reports 6 passed; step 5 printed what it says; `make gate` + prints `gate: ok`. + +## Stop and report if + +- `bxctl` would need to depend on `brokerd`. It must not: the directory walk here is a second, + small copy of the one in `brokerd::audit`, on purpose. diff --git a/docs/plans/M3a/20-bxctl-chat-approvals.md b/docs/plans/M3a/20-bxctl-chat-approvals.md new file mode 100644 index 0000000..5d1f98f --- /dev/null +++ b/docs/plans/M3a/20-bxctl-chat-approvals.md @@ -0,0 +1,142 @@ +# M3a task 20: approvals in `bxctl chat` + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Show and answer approvals in bxctl chat` + +## Goal + +When `loopd` reports that a tool call is waiting for approval, `bxctl chat` fetches the approval +from `brokerd`, shows it, and in interactive mode asks the owner. It shows why a call was denied. +Everything the model wrote is printed as data. + +Two rules come from the threat model, and the tests hold you to both: + +- **What the owner is shown comes from `brokerd`, never from `loopd`'s event.** A compromised + `loopd` must not choose what the owner approves. The event gives the id and nothing else. +- **Only the id, typed in full, approves.** Lines typed while the turn ran are already waiting in + stdin. A stray line must refuse, never approve. + +## Files + +- Copy: `crates/bxctl/tests/chat_print.rs`, `crates/bxctl/tests/chat_approvals.rs` +- Modify: `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs`, `docs/implementer-log.md` + +`crates/bxctl/tests/chat.rs` and `tests/support/mod.rs` stay as they are. No new dependency. + +## Interfaces + +```rust +// chat.rs, added +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnPending { EventOnly, Show, Ask } // --json | --say | interactive + +pub struct Approvals<'a> { pub admin_socket: &'a Path, pub on_pending: OnPending } + +pub struct TurnIo<'a> { + pub printer: &'a mut Printer, + pub input: &'a mut dyn BufRead, // the reader the chat lines come from + pub out: &'a mut dyn Write, // stderr +} + +pub fn handle_pending(admin_socket: &Path, approval: u64, ask: bool, now: Timestamp, + input: &mut dyn BufRead, out: &mut dyn Write) -> std::io::Result<()>; + +/// The outer error is a failed write to `io.out`; the inner one is the turn's. +pub fn stream_turn(socket: &Path, session: &SessionId, text: &str, resume: bool, + approvals: &Approvals<'_>, io: &mut TurnIo<'_>) + -> std::io::Result>; +``` + +`stream_turn` is `main.rs`'s function of that name, moved into the library so tests can drive it. +`Sink` moves with it, or becomes an `Option` local to `stream_turn`. + +## What `Printer::event` does now + +JSON mode is unchanged: one JSON line for every event, the two new ones included, nothing else. +Otherwise: + +1. `Reasoning` and `Content`: write `escape_model_text(text)`, not `text`. +2. **Every tool name** goes through `escape_model_text`: in `ToolCallStarted`, in both forms of + `ToolResult` (truncated or not), and in `ToolDenied`. The model chooses tool names too. +3. `ToolDenied { name, reason }`: end an open reasoning block (`close_dimmed`), then write + `[denied {name}: {reason}]` with `admin::reason_name(reason)`. Then, for three reasons only, + one more line, written out in full in the source (the gate script reads these pointers, and + cannot read one built with `format!`): + + | Reason | Next line | + |---|---| + | `GrantsInvalid` | `see docs/runbook.md#grants-invalid` | + | `AuditUnavailable` | `see docs/runbook.md#audit-unavailable` | + | `StateUnreadable` | `see docs/runbook.md#broker-state-damaged` | + + Use a `match` with all ten reasons and no `_` arm. +4. `ApprovalPending`: end an open reasoning block and print nothing. The block is + `handle_pending`'s job. + +## What `handle_pending` does + +Every exit is listed. "Report" means write one line to `out` and return `Ok(())`: the turn goes +on, and the call is still pending at `brokerd`. **Every write to `out` uses `?`**; a failed write +returns `Err` at once and nothing more is sent to `brokerd`. + +1. `admin::list(admin_socket)`. `Err(e)`: report `approval 41: cannot ask brokerd: {e}`. +2. Find the item whose `approval` is the id. None: report `approval 41 is no longer pending`. + Nothing is read from `input`. +3. Write `\x1b[0m` (no newline), then `admin::write_block(out, item, now)`. Text printed before + may have left the terminal dimmed or worse; the reset comes first. +4. `ask` is false: return `Ok(())`. Nothing is read from `input`. +5. Write `type 41 to approve, anything else refuses: ` (no newline) and flush. +6. `input.read_line(&mut line)?`, once. Remove one trailing `\n`, then one trailing `\r`. Nothing + else is trimmed. +7. The line equals `approval.to_string()`: `admin::cmd_approve(admin_socket, approval, out)`. + **Anything else**, including `y`, ` 41`, `041`, an empty line and the end of the input: + `admin::cmd_refuse(admin_socket, approval, None, out)`. +8. `Ok(_)`: return `Ok(())`; the command has printed its line (`approved 41: runs`, + `refused 41`, or the "no such approval" line). `Err(AdminError::Io(e))`: return `Err(e)`. Any + other `Err(e)`: report `approval 41: {e}`. + +## What `stream_turn` does + +For each event, in this order: `io.printer.event(io.out, event)`; then, if the event is +`ApprovalPending { approval, .. }` and `on_pending` is not `EventOnly`, `handle_pending` with +`ask = (on_pending == OnPending::Ask)`, `Timestamp::now()`, `io.input` and `io.out`. After the +first failed write nothing more is written, and that error is returned once `run_turn` ends. If +there was none, `io.printer.end_reasoning(io.out)?` and return `Ok(outcome)`. + +## `main.rs` + +1. `run_chat` makes **one** `BufReader` on locked stdin and passes it, as `&mut dyn BufRead`, to + both modes. The chat loop reads its lines from it, and every turn's `TurnIo.input` is that same + reader. A second reader on stdin would lose what the first has buffered; the test + `interactive_mode_reads_the_answer_from_the_same_input_as_the_chat` catches that. +2. `on_pending`: `--json` gives `EventOnly`; otherwise `--say` gives `Show`; otherwise `Ask`. + `admin_socket` is `opts.admin_socket`. `out` is locked stderr. +3. `write_answer` writes `escape_model_text(&done.content)` to stdout. The JSON line is unchanged: + JSON escapes for itself. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/bxctl/tests/chat_print.rs docs/plans/M3a/files/crates/bxctl/tests/chat_approvals.rs crates/bxctl/tests/` +- [ ] **2. See the tests fail.** `cargo test -p bxctl --test chat_approvals`. Expected: it does not + compile. +- [ ] **3. Change `chat.rs`:** `Printer::event`, the three new types, `handle_pending`, + `stream_turn`. `cargo build -p bxctl --lib`. Expected: it compiles. +- [ ] **4. Change `main.rs`.** `cargo test -p bxctl`. Expected: `admin` 21, `chat` 12, + `chat_approvals` 20, `chat_print` 9, `cli` 12, `escape` 8 passed. Run it five times. +- [ ] **5. Check every exit.** Go through the eight numbered exits of `handle_pending` above and + find each one in your code. Then find every `write` to `out`: each must end in `?`. +- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`. +- [ ] **7. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit` + +## Done when + +- `cargo test -p bxctl` reports the six counts of step 4, five runs in a row; `make gate` prints + `gate: ok`; `chat.rs` and `main.rs` are each under 500 lines. + +## Stop and report if + +- A test can only pass by taking the tool name, the arguments or the grant from the + `ApprovalPending` event. +- `bxctl::admin` lacks `list`, `write_block`, `cmd_approve` or `cmd_refuse`: task 18 has not been + done. diff --git a/docs/plans/M3a/21-runbook-check.md b/docs/plans/M3a/21-runbook-check.md new file mode 100644 index 0000000..d07fb0a --- /dev/null +++ b/docs/plans/M3a/21-runbook-check.md @@ -0,0 +1,103 @@ +# M3a task 21: the runbook gate check + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Check that every runbook pointer has an entry` + +## Goal + +Every message for a fail-closed state ends with `see docs/runbook.md#`. A new gate script +fails if the code names an entry that `docs/runbook.md` does not have. + +## Files + +- Copy: `scripts/test-gate-scripts.sh`, and `Makefile` from `docs/plans/M3a/files/Makefile-task21` + (`files/Makefile` is task 22's, with a line that needs a test this task does not have yet) +- Create: `scripts/check-runbook.sh` +- Modify: `docs/implementer-log.md` + +You never edit `docs/runbook.md`. If the script finds a missing entry in the real tree, the +pointer in the code is wrong, or the entry is missing: stop and report which. + +## What the script does + +`sh scripts/check-runbook.sh [ROOT]`, `ROOT` defaulting to `.`, in POSIX `sh` like the other +scripts in `scripts/`. Read `scripts/check-lines.sh` first for the style. + +1. A **pointer** is the text `docs/runbook.md#` followed by zero or more characters of + `[A-Za-z0-9_-]`, anywhere in a `*.rs` file under `ROOT/crates`. Files under any `target/` + directory are ignored (`find ... -type d -name target -prune -o -type f -name '*.rs' -print`). + Test files count. A line can hold more than one pointer, and each counts. +2. The pointer's **anchor** is what follows the `#`. Its **entry** is a line of + `ROOT/docs/runbook.md` that is exactly `## `: the whole line, nothing before or after + it (`grep -q -x -F -e "## $anchor"`). `### grants-invalid`, `## grants-invalid and more`, a + mention in prose and `## Grants-Invalid` are not the entry for `grants-invalid`. +3. Exit status 0 if every anchor has its entry. Otherwise 1. + +Every way it can fail, each with a message on stderr that starts with `check-runbook:`: + +| Condition | Why | +|---|---| +| `ROOT/crates` is not a directory | nothing to check: fail closed | +| `ROOT/docs/runbook.md` is not a file | nothing to check against | +| `find`, `awk`, `sort` or `mktemp` fails, or a file cannot be read | a check that cannot do its job fails | +| `grep` exits with a status other than 0 or 1 | 1 means "no such line"; 2 means `grep` itself failed | +| no pointer is found anywhere | the harness has fail-closed states, so the search is broken | +| an anchor is empty, as in `docs/runbook.md#{anchor}` or `docs/runbook.md#` | the script cannot read an anchor that is not written out | +| an anchor has no entry | the point of the script | + +Rules from earlier reviews, which the self-test checks: + +- **Report every problem, not only the first.** Go through all the anchors, print a message for + each one that is missing, and exit 1 at the end. Do not `exit` inside the loop over anchors. For + each missing anchor, name the files that point to it. +- **Never hide an error.** No `2>/dev/null`, no `|| true`. Test each command's exit status. +- A `while read` loop at the end of a pipeline runs in a subshell, and an `exit` or a variable + set inside it is lost. Write the list to a file made with `mktemp -d` and read it with + `while IFS= read -r line; do ...; done < "$file"`. Remove the directory with a `trap ... EXIT`. +- `grep -o` is not POSIX. Take the pointers out of a line with `awk`: + +```sh +awk '{ + s = $0 + while (match(s, /docs\/runbook\.md#[A-Za-z0-9_-]*/)) { + printf "%s\t%s\n", substr(s, RSTART + 16, RLENGTH - 16), FILENAME + s = substr(s, RSTART + RLENGTH) + } +}' "$f" +``` + + `docs/runbook.md#` is 16 characters, so this prints the anchor, a tab, and the file. + +## The Makefile + +The copied `Makefile` runs `sh scripts/check-runbook.sh` in `gate`, after `check-dep-docs.sh`. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/scripts/test-gate-scripts.sh scripts/ && cp docs/plans/M3a/files/Makefile-task21 Makefile` +- [ ] **2. See the self-test fail.** `sh scripts/test-gate-scripts.sh`. Expected: failures that + name `check-runbook.sh` cases, and exit status 1. +- [ ] **3. Write `scripts/check-runbook.sh`.** +- [ ] **4. See the self-test pass.** `sh scripts/test-gate-scripts.sh`. Expected: + `test-gate-scripts: ok`. +- [ ] **5. Prove the self-test has teeth.** Change `-x` to nothing in your `grep` line and run the + self-test: it must fail with "the entry is the whole line, at level two". Change it back. Then + put `exit 1` where a missing anchor is reported and run it: it must fail with "both missing + entries and their files are reported". Change it back. Write both results in the log's Notes. +- [ ] **6. Run it on the real tree.** `sh scripts/check-runbook.sh; echo $?`. Expected: no output + and `0`. If it names a missing entry, stop and report: do not edit the runbook, the pointer or + a copied test. +- [ ] **7. Run the gate.** `make gate`. Expected last line: `gate: ok`. +- [ ] **8. Log and commit.** + `git add scripts/check-runbook.sh scripts/test-gate-scripts.sh Makefile docs/implementer-log.md && git commit` + +## Done when + +- `sh scripts/test-gate-scripts.sh` prints `test-gate-scripts: ok`; `sh scripts/check-runbook.sh` + exits 0 on the repository; `make gate` prints `gate: ok`. + +## Stop and report if + +- Step 6 reports a missing entry or an empty anchor. +- `sh` on this machine lacks something the task relies on (`mktemp -d`, `awk`'s `match`). diff --git a/docs/plans/M3a/22-end-to-end.md b/docs/plans/M3a/22-end-to-end.md new file mode 100644 index 0000000..4c13803 --- /dev/null +++ b/docs/plans/M3a/22-end-to-end.md @@ -0,0 +1,61 @@ +# M3a task 22: end to end, in two processes + +**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) +**Commit subject:** `Add the end-to-end test: loopd against the real brokerd binary` + +## Goal + +One test that runs the whole decision path across the real socket: `loopd`'s turn loop, with a +`BrokerPort`, against the real `brokerd` binary on a temporary home, and the fake llama server. +The recorded model calls `read_file` with no grant; the next request to the model holds +"Denied: no grant allows this call."; the audit log verifies and holds one `Decision` record, +`no_grant`. It cannot be one process: `loopd` may not depend on `brokerd`, even as a +dev-dependency (`scripts/check-crate-deps.sh` checks that too). So the test finds the binary +through `BOXMAKER_BROKERD`, is `#[ignore]`d without it, and `make gate` sets it. + +This task writes no code: every piece is from tasks 10 to 21. If the test fails, an earlier +task's code is wrong. Find which and **stop and report**; do not fix it here. + +## Files + +- Copy: `crates/loopd/tests/end_to_end.rs`, `Makefile`, `tools/check-m3a-device.sh` +- Modify: `docs/implementer-log.md` + +The `Makefile` gains two lines in `gate`, after `cargo test`: + +```make + cargo build --workspace --locked --offline + BOXMAKER_BROKERD=$(CURDIR)/target/debug/brokerd \ + cargo test -p loopd --test end_to_end --locked --offline -- --ignored +``` + +`tools/check-m3a-device.sh` is the owner's check on straylight. **Do not run it**: it needs the +real server, which is shared. Copy it so that it is in the repository. + +## Steps + +- [ ] **1. Copy.** `git switch m3a`, then + `cp docs/plans/M3a/files/crates/loopd/tests/end_to_end.rs crates/loopd/tests/`, + `cp docs/plans/M3a/files/Makefile Makefile` and + `cp docs/plans/M3a/files/tools/check-m3a-device.sh tools/` +- [ ] **2. Without the variable.** `cargo test -p loopd --test end_to_end`. Expected: + `0 passed; 0 failed; 1 ignored`. +- [ ] **3. With it.** `cargo build --workspace`, then + `BOXMAKER_BROKERD=$PWD/target/debug/brokerd cargo test -p loopd --test end_to_end -- --ignored`, + five times. Expected: `1 passed` every time. +- [ ] **4. See it fail closed.** `cargo test -p loopd --test end_to_end -- --ignored` without the + variable. Expected: it **fails** with "set BOXMAKER_BROKERD". A test that passed here would be + a test that checks nothing. +- [ ] **5. Run the gate.** `make gate`. Expected: the end-to-end line reports `1 passed`, and the + last line is `gate: ok`. +- [ ] **6. Log and commit.** `git add crates/loopd/tests/end_to_end.rs Makefile tools/check-m3a-device.sh docs/implementer-log.md && git commit` + +## Done when + +- Steps 2 to 5 gave what they say, and `make gate` prints `gate: ok`. + +## Stop and report if + +- Step 3 fails. Say which assertion, and paste `brokerd`'s audit directory listing if the audit + part failed. Do not change any code in this task. +- `make gate` cannot find `target/debug/brokerd` (for example because `CARGO_TARGET_DIR` is set). diff --git a/docs/plans/M3a/HANDOFF.md b/docs/plans/M3a/HANDOFF.md deleted file mode 100644 index 3c6f680..0000000 --- a/docs/plans/M3a/HANDOFF.md +++ /dev/null @@ -1,292 +0,0 @@ -# M3a plan: handoff, 2026-09-18 - -The M3a plan is half built. This file says what exists, where, and what is left. Delete it when -the plan is complete and `README.md` replaces it. - -Read first: `CLAUDE.md`, `docs/specs/2026-09-18-m3a-decision-path.md` (revised after review; the -"Spec review of M3a" rows in `docs/decisions.md` say what changed and why), the decision of -2026-09-18 on reference implementations (top row of `docs/decisions.md`, tip T17 in -`docs/implementer-lessons.md`), and `docs/plans/M2b/` as the model for format -(`README.md`, `07-loopd-channel.md`). - -## How the plan is being built - -The given tests are checked before hand-over at one of three levels: a full reference -implementation (audit chain, approval handoff), the oracle inside the property test (policy), or -a skeleton (real signatures, `todo!()` bodies, tests must compile and be clippy-clean). Each area -records what its check exposed in `docs/plans/M3a/checks-.md`; the final `README.md` folds -those into one table per task. That record decides what M3b gets. - -Work happens in git worktrees on unpushed branches, never in `/tmp`: - -| Worktree | Branch | Holds | -|---|---|---| -| `~/src/boxmaker-ref` | `m3a-ref` | Integration. Commit `3ba5f83`: the `proto` contract (new audit types, admin wire messages, `chain.rs` skeleton) and an empty file for every new module, so that areas never share a file. | -| `~/src/boxmaker-ref-a` | `m3a-ref-a` | Area A, audit: tasks 01, 03, 09, 19 | -| `~/src/boxmaker-ref-b` | `m3a-ref-b` | Area B, policy side: tasks 02, 04 to 08 | -| `~/src/boxmaker-ref-c` | `m3a-ref-c` | Area C, `loopd`: tasks 16, 17 | -| `~/src/boxmaker-ref-d` | `m3a-ref-d` | Area D, `bxctl` and the runbook script: tasks 18, 20, 21 | - -Each area branch holds, under its worktree: the tests in place under `crates/`, the reference or -skeleton source, copies of everything the implementer must copy under -`docs/plans/M3a/files/`, its task files `docs/plans/M3a/NN-name.md`, and -`docs/plans/M3a/checks-.md`. - -## Task numbering (fixed; task files use these names) - -| # | Task | Area | Check | -|---|---|---|---| -| 01 | `proto-audit-types` | A | reference (types; generates fixtures) | -| 02 | `proto-admin-wire` | B | reference (types) | -| 03 | `proto-chain-verifier` | A | reference | -| 04 | `brokerd-config` | B | minimal reference (needed under E) | -| 05 | `brokerd-args` | B | minimal reference | -| 06 | `brokerd-grants` | B | minimal reference | -| 07 | `brokerd-policy` | B | oracle, plus minimal reference | -| 08 | `brokerd-state` | B | minimal reference | -| 09 | `brokerd-audit-writer` | A | reference | -| 10 | `brokerd-runner` | E | reference | -| 11 | `brokerd-approvals` | E | reference | -| 12 | `brokerd-ledger` | E | reference | -| 13 | `brokerd-broker` | E | reference | -| 14 | `brokerd-admin` | E | reference | -| 15 | `brokerd-serve` | E | reference | -| 16 | `loopd-tools` | C | skeleton | -| 17 | `loopd-broker-port` | C | skeleton | -| 18 | `bxctl-admin` | D | skeleton | -| 19 | `bxctl-audit-verify` | A | reference | -| 20 | `bxctl-chat-approvals` | D | skeleton | -| 21 | `runbook-check` | D | run for real | -| 22 | `end-to-end` | E | compiles only, unless C's `BrokerPort` gets a body | - -## Area E: not started - -Tasks 10 to 15 and 22: `runner`, `approvals`, `ledger`, `broker`, `admin`, `serve`, the -two-process end-to-end test, and the scripted check on straylight. It builds on A's -`brokerd::audit::Writer` and B's `config`, `args`, `grants`, `policy`, `state`, so it starts from -a merge of those two branches. The design it must follow, beyond the spec: - -- `ledger.rs`: `pub trait AuditSink: Send { fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; }` - implemented by `audit::Writer`, so tests can wrap a real writer in one that fails on demand. - `Ledger { audit: Box, state: StateStore }` behind one `Mutex`, with the three - locked steps as functions: decide and record; re-decide and record the approval; raise state - and record the result. A poisoned ledger lock or any failed append means `audit_unavailable` - for every later call. -- `approvals.rs`: `Verdict { Run(Decision), Denied(DenyReason) }`; `Table` over - `Mutex>` with `insert(info, ask) -> Receiver`, `take(id)`, - `take_expired(now)`, `list()`. Whoever takes the entry answers it. -- `broker.rs`: `pub struct Broker { cfg, ledger, table, runtime }`, `handle(stream, &Broker)`. - While pending: `recv_timeout(1 s)`, then a `read` with a 10 ms read timeout (`Ok(0)` is gone, - `WouldBlock`/`TimedOut` is alive, bytes are a protocol error). One more check before running. -- `runner.rs`: spec section 7, with `Refusing` as the production runtime and a recording fake in - the tests. `RunError` text is fixed text. -- `serve.rs`: the audit lock first, then directories (0700 whether made or found), stale socket - removal, bind, 0600; one thread per connection; the expiry thread. -- The state-write failure test uses a read-only directory; the audit failure test uses the - `AuditSink` seam. -- The end-to-end test lives in `loopd`, is `#[ignore]`d without `BOXMAKER_BROKERD`, and - `make gate` builds the workspace and runs it with the variable set. -- The straylight script checks `GET /slots?model=ornith-1.5-35b-a3b` and uses slot 0 only. - -The prompts given to areas A to D are the model for E's: they are in this session's transcript; -their substance is the numbered conventions below. - -## Conventions every area follows - -1. Tests carry "Do not edit." Deterministic, robust under CPU load, no `tempfile` crate (temp - directories under `std::env::temp_dir()` with pid and a counter, as `crates/loopd/tests/support/`). - `cargo fmt --all`; `cargo clippy --workspace --all-targets --offline -- -D warnings` clean. -2. A reference follows the code rules in `AGENTS.md`. -3. Everything the implementer copies is also under `docs/plans/M3a/files/`. Reference source is not. -4. Task files follow `docs/plans/M2b/07-loopd-channel.md`: branch `m3a`, at most about 3,000 - tokens, rules that walk every exit (T14, I11, I12), verified API snippets, expected test counts. -5. A `checks-.md` per area, honest about what ran green and what only compiled. - -## Left to do, in order - -1. Read each area's final report (below) and its `checks-.md`. Resolve contract changes they - asked for. `crates/proto/tests/strict.rs` may have been edited by both A and B. -2. Merge `m3a-ref-a` and `m3a-ref-b` into `m3a-ref`; run the whole gate there. -3. Build area E on top. Merge C and D. Add the `audit verify` arm to `bxctl`'s `main.rs` (D owns - the file, A specified the arm). Add the end-to-end line to the `Makefile` in `files/`. -4. In `m3a-ref`: `make gate` as far as skeletons allow; every test compiles; reference-backed - suites pass ten times under load. -5. Copy `docs/plans/M3a/` (task files and `files/`) from `m3a-ref` to `master`. Write - `docs/plans/M3a/README.md` in the M2b README's shape, with the per-task check table and what - each check exposed. Fold any spec defects the areas found into the spec and `decisions.md`. -6. Update `CLAUDE.md`'s "Current state" and `docs/milestones.md` if needed. Delete this file. -7. Then the owner runs `tools/run-plan.sh docs/plans/M3a` with Ornith, on branch `m3a`. - -Already known spec defects found while planning (both fixed in the spec, commit `66fa143`): the -one-process end-to-end test would have broken the crate-dependency gate; the locked steps needed a -module of their own (`ledger`). One finding about the process itself: the approval-handoff -reference cannot run without working `config`, `args`, `grants`, `policy` and `state` under it, so -those got a minimal reference too, and the saving from skeletons is confined to `loopd`, `bxctl` -and the gate script. - -## Area reports - -(Each area's own record is `docs/plans/M3a/checks-.md` on its branch.) - -### Area C, `loopd`: done, commit `56319ab` on `m3a-ref-c` - -- Tasks 16 and 17 written. Task 16's changes are real code and its tests ran green (`tools` 10, - `turn` 6, `turn_broker` 5, `config` 11, `pointers` 7, rest of the suite unchanged). - `broker_port.rs` is a skeleton: only `BrokerPort::call` is `todo!()`, so 14 of 15 tests in - `broker_port` and both in `broker_port_bad` compile and have never run. -- API settled: `ToolPort::call(&self, &ToolRequest, &mut dyn FnMut(&Pending)) -> ToolResponse`; - `Pending { approval: u64, expires: Timestamp }`; `Registry::m3a()` (and `m2b()` kept as the - test registry); `denial_text`; `BrokerPort::new(socket, timeout)` and `with_log`; `NoBroker`; - `config::Broker { socket: Option, timeout_ms }`; the envelope `id` is `request.call.0` - (area E's `brokerd` must echo it). -- Spec defects it found, with wording in `checks-c.md`, NOT yet applied to the spec: the section 8 - timeout must be a deadline per frame wait, not a per-read socket timeout; `tool_denied.name` and - `approval_pending.tool` carry the target tool, not `call_tool` (task 20 must know); "`echo` stays - in `FakeTools` only" is not enough, because recorded conversations find it through the registry; - five silent cases resolved and tested (oversized request, far-future `expires`, envelope id, - `timeout_ms = 0`, a pending frame as the final answer). -- **Needs a run on straylight before hand-over:** `make verify-device` breaks at task 16 because - `device.rs` asks Ornith to use `echo`. Task 17 ships a changed `device.rs` that compiles but has - never run on the device and depends on how the model behaves. - -### Area A, audit: done, commits `e3065e1`, `89f696e`, `7da817d` on `m3a-ref-a` - -- Tasks 01, 03, 09, 19 written, all with a reference. Passing against it: `records` 3; - `chain` 13 with 30 fixture logs under `crates/proto/tests/fixtures/audit/`; `brokerd` `audit` 9 - and `audit_startup` 7 (five runs); `bxctl` `verify` 6. Eight deliberate mutations of the - reference each broke a given test. 66 files under `files/`, byte-identical to the tree. The - fixture generator `crates/proto/tests/gen_audit_fixtures.rs` (`#[ignore]`d) stays in the - reference tree only. -- In that worktree one `strict.rs` test fails and `proto`'s `wire.rs` test does not compile: both - wait on area B's wire fixtures and should clear at the merge. -- **Contract changes other areas must absorb:** - 1. `DecisionRecord::Allowed {}` and `Ask {}` are empty struct variants, not unit variants: serde - ignores `deny_unknown_fields` on unit variants of an internally tagged enum. JSON unchanged; - every construction and match needs the braces (areas B, D, E). Check the other fieldless - variants in internally tagged enums in `wire.rs` for the same hole. - 2. `strict.rs` changed in the audit test only (expected object count); B edits the envelope list. - 3. `ChainFailure` gained `last_good`, `break_prev`, `break_seq`, `tail_torn`; - `ChainVerifier::feed(name, content)` added and used by both `bxctl` and `brokerd`. - 4. Writer API as specified, plus `RECOVERED_NOTICE` and `AuditError::Broken(Box)`. - 5. Tasks 09 and 19 say "Create" the module file and add the `pub mod` line (the stub files exist - only in the reference tree). Every area's task files need the same check. - 6. Task 09 assumes task 04 adds `serde_json` to `brokerd`'s `Cargo.toml`. Check task 04 does. -- **Spec defects it found, wording in its report and `checks-a.md`, NOT yet applied to the spec:** - the resumed verifier's break exception must also hold inside a failed region; startup step 2 - should fall back to verifying the whole log when the previous file's last line does not parse - (otherwise an accepted break there deadlocks); the tampering suite fails at `brokerd`'s startup - only when the damaged file is the latest; an unpaired `Recovery` is a failure; `unfinished` - reports the decision's `seq` for approved calls too; `--accept-break` with a torn tail and no - failure writes nothing and exits 2; section 9's verify output lines are fixed in task 19, - including one for a torn tail. -- Left open: task 19 describes the `audit verify` arm in prose; reconcile with D's `main.rs`. - -### Area D, `bxctl` and the runbook script: done, commits `292780f`, `5f27808`, `39f3cc7` on `m3a-ref-d` - -- Tasks 18, 20, 21 written. Tests under `crates/bxctl/tests/`: `escape` 8, `cli` 12, `admin` 21, - `chat_print` 9, `chat_approvals` 20, existing `chat` 12 untouched. `check-runbook.sh` runs for - real; its self-test passes and fails under each of five mutations. -- At the skeleton state (`5f27808`) 38 tests only compiled. `cli.rs`, the `Printer` changes, - `stream_turn` and `main.rs` had to be real code, because existing tests drive the binary. The - area then wrote the remaining bodies (about 150 lines, `admin.rs` and `handle_pending`) as a - measurement: all 38 passed first time, six more runs under load clean. **For this area the - reference found nothing the skeleton and a desk-check had missed.** The bodies stay on the - branch (the straylight check needs a working `bxctl approve`) and are not handed over. -- API settled: `escape::{escape_json_text, escape_model_text}`; `cli::{USAGE, ChatOptions, Command, - UsageError, parse(args, home)}` (a new module: `main.rs` was near the line limit); - `admin::{AdminError, reason_name, request, list, write_block, cmd_approvals, cmd_approve, - cmd_refuse, cmd_grants_check}`, each `cmd_*` returning `Result`; - `chat::{OnPending, Approvals, TurnIo, handle_pending, stream_turn}`; - `verify::run(home, out) -> io::Result`. -- **To act on when integrating:** - 1. `check-runbook.sh` fails on any pointer under `crates/**/*.rs` whose anchor is not written - out literally (`format!("…#{anchor}")`, a `#` placeholder in a comment), and if it - finds no pointer at all. Run it on the merged tree; areas A, B, C, E may trip it. Proposed - for spec section 11: "An anchor must be written out in the source; a pointer whose anchor the - script cannot read is an error." - 2. **Conflict with area A:** task 18 creates `verify.rs` with a placeholder body and adds all - four `pub mod` lines to `lib.rs`, so task 19 must MODIFY `verify.rs` and touch neither - `lib.rs` nor `main.rs`. Area A wrote task 19 as "Create". Fix task 19. - 3. `GrantProblem.problem` can be multi-line because `toml` errors are. Task 06 should produce - one-line problems at the source; check area B did. - 4. Spec section 9's example block shows spaced JSON; section 6 says `serde_json` output, which - is compact. The tests use compact. Fix the example. - 5. "`--say` and `--json` print the event only" was ambiguous. Chosen: `--say` shows the block - from `brokerd` and asks nothing; `--json` prints the event's JSON line and never contacts - `brokerd`. Put that in the spec. - 6. Escaping now covers tool names and the answer on stdout too. Spec section 9 should say - "everything the model wrote". - 7. `main.rs` must hold one `BufReader` on stdin for chat lines and approval answers alike; a - binary-level test enforces it. - 8. Details the tests now fix that the spec leaves open: time spans and rounding; `expired` once - `now >= expires`; a session id over 10 characters shown as 9 plus `…`; `no pending approvals`; - an `ask` re-decision printed as `runs`; an approval id is ASCII digits only - (`"+41".parse::()` succeeds); end of input at the prompt refuses; `brokerd` unreachable - or an approval expired before the answer is one line and the turn goes on. - 9. `tests/admin.rs` is 497 lines and `tests/chat_approvals.rs` 487: no room to grow. - 10. `DecisionRecord::Allowed {}` / `Ask {}` (area A's contract change) must be applied to this - branch's code and tests at the merge. -- The `Makefile` under `files/` has the `check-runbook.sh` line and no end-to-end line yet. - -### Area B, policy side: done, commit `e89e2ce` on `m3a-ref-b` - -- Tasks 02, 04 to 08 written, all with a minimal reference. Passing: proto `wire` 10, `turn_wire` 5, - `admin_wire` 10 (new), 17 wire fixtures; `brokerd` 81 tests (`config` 7, `args` 13, `grants` 17, - `policy` 7, `policy_matching` 10, `policy_redecide` 7, `policy_property` 4, `state` 9, doctests - 7), three runs and one under load. Support files are pulled in with `#[path]` - (`tests/support/{tmp,build,oracle}.rs`), so there is no `support/mod.rs` for area E to collide - with (area A added `tests/support/audit_dir.rs`; check how it is included). -- **Oracle teeth:** six mutants of the reference policy were each caught by the oracle alone. - **The policy reference caught nothing the oracle would not have.** Second data point for the - decision on references. -- API for area E, in full in its report and `checks-b.md`: `Config` with `broker_socket()`, - `admin_socket()`, `audit_dir()`, `state_dir()`; `args::{ToolName, ToolArgs, ArgsError, parse, - valid_path, inside, valid_host, valid_host_pattern, host_matches, url_host}` and - `ToolArgs::canonical_json()`; `grants::{LoadedGrant, GrantSet, from_grants, load, render, - valid_id, RUNBOOK}`; `policy::{SessionState, Label, Denial, Decision, Ask, Outcome, decide, - redecide -> Result}` (`SessionState` and `Label` live in `policy`); - `state::{StateStore, StateError, RUNBOOK}` with `read` and `raise`. -- **CONFLICT with area A, to resolve first:** both found that `DecisionRecord` accepted unknown - fields and fixed it differently. A made `Allowed {}` and `Ask {}` empty struct variants (call - sites need braces). B kept unit variants and decodes through a private `RawDecision` with - `#[serde(try_from = "RawDecision")]` (public type and JSON unchanged, no call-site change; - `{"outcome":"allowed","reason":null}` is still accepted). Task 02 greps for `RawDecision` and - stops without it. Pick one. B's keeps the public type as the spec wrote it and touches no other - area; A's is stricter about `reason: null`. Whichever wins goes into task 01, and the loser's - tests and task text change. Then check the other internally tagged enums (`TurnEvent`, - `ToolResponse`, `AuditEvent`, `LogRecord`) for unit variants with the same hole. -- `strict.rs` is edited by tasks 01 and 02. The copy handed over with task 02 must carry A's audit - object-count change as well as B's. -- Area E's tests must include: one invalid grant file denying a call a valid file would allow - (`grants` only proves the set does not load), and section 12's sequence properties (taint never - down; every `Result` follows a `Decision`). -- **Spec defects it found, NOT yet applied to the spec:** - 1. IPv4 literals pass the host grammar (`127.0.0.1`, `127.1`, `10.0.0.0x1`), so "no IP literals" - was false. Rule added and tested; proposed wording: "...and the last label starts with a - letter, which excludes every spelling of an IPv4 address." - 2. `write_file` with overlapping grant paths `/s` and `/s/out` and a write to `/s/out`: a grant - path equal to the argument does not count, and the longest path that does count is matched. - 3. `redecide` returns `Result`, not `DenyReason` (the spec's signature is wrong). - 4. Gaps settled in the tasks: an empty socket path means "under home"; a missing grants directory - is a problem, not an empty set; a state file that says `public` is damaged; an absent `cwd` is - left out of the canonical JSON. -- Other: serde's derived structs accept the JSON array form, so `"body":[]` decodes as `Empty`; - true across `proto` since M1, not fixed, worth a decision. `docs/dependencies.md`'s "Used by" - column is stale; task 04 has the implementer fix the `serde` rows. The owner's global gitignore - swallows `*.toml~`, so that fixture is made in a temp dir. - -## Tool warning for whoever continues - -The Write and Edit tools in this environment decode a JSON escape such as backslash-u-002f into -the character itself when it appears in the text being written. Build such strings from pieces or -patch them in with a script, and check the bytes on disk afterwards. - -## What the checks have shown so far (for the README and for M3b) - -| Area | Check | Exposed | -|---|---|---| -| A, audit | reference | Five spec defects in section 5, two of which would have stopped `brokerd` starting; the serde unit-variant hole. The reference earned its cost. | -| B, policy | oracle, plus a minimal reference | One spec falsehood (IPv4), one unspecified case, one wrong signature, the same serde hole. The reference added nothing over the oracle. | -| C, `loopd` | skeleton | A timeout that had to be a deadline, five silent cases, a broken `verify-device`. 16 tests never run. | -| D, `bxctl` | skeleton, then bodies as a measurement | Spec ambiguities and an example that contradicted the text. The bodies found nothing the skeleton had not. | -| Planning itself | none | The one-process end-to-end test against the crate gate; the missing `ledger` module. | diff --git a/docs/plans/M3a/README.md b/docs/plans/M3a/README.md new file mode 100644 index 0000000..709b2dd --- /dev/null +++ b/docs/plans/M3a/README.md @@ -0,0 +1,108 @@ +# M3a implementation plan: the decision path + +> **For the implementing model:** do not work from this file. The owner gives you one task file at +> a time (`01-…` to `22-…`). This file is the index for the owner and the reviewer. + +**Goal:** every tool call `loopd` makes is decided by `brokerd` against owner-written grants and +the session's taint, recorded in a hash-chained audit log before anything happens, and, for `ask` +grants, approved or refused by the owner through `bxctl`. Tools do not run yet: the runner is a +trait whose production implementation refuses (M3b adds Podman). + +**Architecture:** `proto` gains the audit records, the chain verifier (shared by `brokerd` and +`bxctl`) and the admin messages. `brokerd` is built bottom up: config, arguments, grants, policy, +session state and the audit writer (tasks 04 to 09), then the runner seam, the pending table, the +ledger (the audit writer and the state files behind one lock), the two socket handlers and +`brokerd serve` (10 to 15). `loopd` gets the real `ToolPort` over `broker.sock` (16, 17). `bxctl` +gets the admin commands, `audit verify` and approvals inside `chat` (18 to 20). A gate script +checks every runbook pointer (21), and one test runs `loopd` against the real `brokerd` binary (22). + +**Tech stack:** as M2b. `brokerd` gains `serde`, `serde_json` and `toml`, all already vetted. + +**Spec:** `docs/specs/2026-09-18-m3a-decision-path.md`. Brief: `docs/design.md`. Every fail-closed +message ends with a pointer into `docs/runbook.md`. + +## Global constraints + +- Everything in `AGENTS.md`, including "Lessons from earlier reviews". +- No new dependency beyond the three above. Formats we define reject unknown fields, at every depth + (`proto/tests/strict.rs` walks every object of every fixture). +- `DecisionRecord::Allowed {}` and `Ask {}` are written with braces everywhere (task 01 says why). +- A `Decision` is built only in `policy`, a `RunSpec` only in `runner`; `compile_fail` doctests + prove both. +- Branch `m3a`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the + gate. Review happens once, after task 22. + +## Tasks + +The last column is how the given tests were checked before hand-over (decision of 2026-09-18, +tip T17) and what that check exposed. The detail is in `checks-a.md` to `checks-e.md`. + +| # | File | Delivers | Tests that define it | Check | What the check exposed | +|---|---|---|---|---|---| +| 01 | `01-proto-audit-types.md` | `AuditRecord`, `AuditEvent`, `DecisionRecord` and friends | `proto/tests/records.rs`, `strict.rs`, `fixtures/records/audit.jsonl` | reference (the types) | `DecisionRecord`'s unit variants accepted unknown fields (serde ignores `deny_unknown_fields` there); found by `strict.rs` | +| 02 | `02-proto-admin-wire.md` | the admin messages, `u64` approval ids, `approval_pending`, `tool_denied` | `wire.rs`, `turn_wire.rs`, `admin_wire.rs`, `strict.rs`, 17 wire fixtures | reference (the types) | the same serde hole, found independently; serde's array form for structs (left open in `decisions.md`) | +| 03 | `03-proto-chain-verifier.md` | `ChainVerifier`, `ChainReport` | `proto/tests/chain.rs`, 30 fixture logs with real hashes | reference, generated fixtures, 8 mutants | four spec gaps: which `seq` the lists report, the resumed verifier inside a failed region, a `Recovery` describing nothing, the report fields a writer needs | +| 04 | `04-brokerd-config.md` | `brokerd.toml` into `Config` | `brokerd/tests/config.rs`, 6 fixtures | minimal reference | socket paths when only `home` is set; stale "Used by" column in `dependencies.md` | +| 05 | `05-brokerd-args.md` | typed tool arguments; path, host and URL checks | `brokerd/tests/args.rs` | minimal reference | **IPv4 literals passed the host grammar**; found by writing the test table | +| 06 | `06-brokerd-grants.md` | `GrantSet` loading, every rule | `brokerd/tests/grants.rs`, grant fixtures | minimal reference | a missing grants directory; a fixture name swallowed by a global gitignore | +| 07 | `07-brokerd-policy.md` | `decide`, `redecide`, `Denial` | `policy.rs`, `policy_matching.rs`, `policy_redecide.rs`, `policy_property.rs` (oracle) | oracle, 6 mutants; plus a minimal reference | the `write_file` overlap case; `redecide`'s signature; the M1 `compile_fail` doctests had lost their teeth. The reference found nothing the oracle did not | +| 08 | `08-brokerd-state.md` | session state files | `brokerd/tests/state.rs` | minimal reference | a file saying `public` is damaged | +| 09 | `09-brokerd-audit-writer.md` | the audit writer, startup, recovery, `--accept-break` | `brokerd/tests/audit.rs`, `audit_startup.rs` | reference, mutants | **two startup defects that would have stopped `brokerd` starting** (the resume deadlock; tampering only visible in the latest file) | +| 10 | `10-brokerd-runner.md` | `Runtime`, `RunSpec`, `run`, `Refusing` | `brokerd/tests/runner.rs`, `support/runtime.rs` | reference | none | +| 11 | `11-brokerd-approvals.md` | the pending table | `brokerd/tests/approvals.rs` | reference | `Verdict::Run` must box the `Decision` (clippy) | +| 12 | `12-brokerd-ledger.md` | the three locked steps, `AuditSink` | `ledger.rs`, `ledger_answer.rs`, `support/{rig,sink}.rs` | reference | unreadable state recorded as `secret`; no `Result` when state fails; the rig broke task order (fixed) | +| 13 | `13-brokerd-broker.md` | one request on `broker.sock` | `broker.rs`, `broker_pending.rs`, `broker_sequence.rs`, `support/client.rs` | reference | every request is recorded, `grants_invalid` included; an unsendable pending frame | +| 14 | `14-brokerd-admin.md` | one request on `admin.sock`; expiry | `brokerd/tests/admin.rs` | reference | a refusal that cannot be recorded must not answer `ok`; the re-decision's mode | +| 15 | `15-brokerd-serve.md` | `brokerd serve` | `brokerd/tests/serve.rs` (drives the binary) | reference | no read timeout on a request frame (accepted for M3a, section 14) | +| 16 | `16-loopd-tools.md` | `ToolPort` with the pending callback, `Registry::m3a()`, denial texts | `tools.rs`, `turn.rs`, `turn_broker.rs` | real code (a skeleton was impossible) | `echo` must stay in the test registry; which tool name the events carry | +| 17 | `17-loopd-broker-port.md` | `BrokerPort`, `[broker]`, three runbook pointers | `broker_port.rs`, `broker_port_bad.rs`, `pointers.rs`, `config.rs`, `device.rs` | skeleton; later a body as a measurement | **the timeout must be a deadline**; five silent cases. The body found nothing more | +| 18 | `18-bxctl-admin.md` | `approvals`, `approve`, `refuse`, `grants check`; escaping | `escape.rs`, `cli.rs`, `admin.rs` | skeleton; later bodies as a measurement | the spec's example contradicted section 6; `"+41"` parses as a `u64`. The bodies found nothing more | +| 19 | `19-bxctl-audit-verify.md` | `bxctl audit verify` | `bxctl/tests/verify.rs` | reference | the output lines, torn tail included; a clash with task 18 over `verify.rs` (fixed) | +| 20 | `20-bxctl-chat-approvals.md` | the approval block and question in `chat` | `chat_print.rs`, `chat_approvals.rs` | skeleton; later a body as a measurement | `--say`/`--json` ambiguous; tool names were printed raw; one stdin reader | +| 21 | `21-runbook-check.md` | `scripts/check-runbook.sh` in the gate | `scripts/test-gate-scripts.sh` | the real script, 5 mutants | a computed anchor cannot be checked; no pointer at all must fail. It then caught such a pointer in task 17's test | +| 22 | `22-end-to-end.md` | the two-process test; the straylight script | `loopd/tests/end_to_end.rs` | reference (the real binary) | the recorded model calls `read_file` directly, not through `call_tool` | + +`files/` holds everything the tasks copy in, byte-identical to the reference tree +(`~/src/boxmaker-ref`, branch `m3a-ref`, unpushed). `files/Makefile-task21` is task 21's +`Makefile`; `files/Makefile` is task 22's, with the end-to-end line. + +**State of the checks at hand-over.** In the reference tree `make gate` passes in full, the +end-to-end line included, so every given test has passed against some implementation. Area E's +suites (with `broker_port` and `end_to_end`) ran ten times idle and ten under load; area A's +startup suite five times, area B's three times and once under load. The merged tree then ran all +suites five times under sixteen busy loops on eight cores with no failure. For tasks 10 to 15 the tree was cut back to each task's +starting point and the tests compiled at each step. Mutation checks: audit 8 of 8, policy oracle +6 of 6, `check-runbook.sh` 5 of 5, broker 14 of 14. `make verify-device` with task 17's `device.rs` +passed twice on straylight (Ornith, slot 0). + +**What the record says for M3b** (the decision on references): the references found real defects +only where the logic was stateful and security-bearing (the audit chain and startup, the broker's +ledger and approvals). For plumbing (`config`, `args`, `grants`, `state`) and for the clients +(`loopd`'s port, `bxctl`), the tests, the exit lists and a skeleton found everything; the bodies +written afterwards as measurements found nothing more. For `policy` the mutation-tested oracle was +enough. Most spec defects were found by writing test tables and exit lists, not by running code. + +## For the owner: running a task + +`tools/run-plan.sh docs/plans/M3a`, or one fresh OpenCode session per task with +"Read `docs/plans/M3a/01-proto-audit-types.md` and do exactly that task." + +- Do not run `make verify-device` between tasks 16 and 17: task 16 removes `echo` from the + registry `loopd serve` uses, and task 17 brings the `device.rs` that expects that. +- After task 22, run `tools/check-m3a-device.sh` once on this machine against straylight. It + checks `/slots` first and uses Ornith slot 0 only; it has not been run yet by anyone. + +## For the reviewer: after task 22 + +1. `git log --oneline master..m3a`: twenty-two commits with the trailer. +2. Copied files unchanged: + `for f in $(cd docs/plans/M3a/files && find . -type f ! -name Makefile-task21); do cmp "docs/plans/M3a/files/$f" "$f"; done` +3. `git diff master..m3a --stat -- docs/design.md docs/specs docs/plans docs/runbook.md AGENTS.md CLAUDE.md deny.toml` + is empty. +4. `make gate`, `make audit`, `make verify-device`, `tools/check-m3a-device.sh`. +5. Read every `brokerd` source file against its task and the spec, the ledger and the approval + handoff first. Probe from outside: a grant file edited while a call waits for approval, two + `bxctl approve` at once, `brokerd` killed mid-call then restarted, an audit file edited by hand, + `loopd` killed while its call is pending. +6. Run `brokerd`'s suites, `broker_port` and `end_to_end` repeatedly under CPU load. +7. Findings to `docs/implementer-log.md`; lessons to `docs/implementer-lessons.md`. diff --git a/docs/plans/M3a/checks-a.md b/docs/plans/M3a/checks-a.md new file mode 100644 index 0000000..e7f2f28 --- /dev/null +++ b/docs/plans/M3a/checks-a.md @@ -0,0 +1,24 @@ +# M3a checks, fork A: the audit chain (tasks 01, 03, 09, 19) + +What each task's tests were checked against before hand-over, and what writing that check +exposed. The reference lives on branch `m3a-ref-a` (worktree `~/src/boxmaker-ref-a`). + +The fixture logs in `files/crates/proto/tests/fixtures/audit/` were made by +`crates/proto/tests/gen_audit_fixtures.rs` in the reference tree +(`cargo test -p proto --test gen_audit_fixtures -- --ignored`). It builds a good two-day log with +real `proto::sha256` hashes and then damages it one way per directory. The generator is not +handed over: the implementer gets the directories and `tests/chain.rs`, which says what each +must verify as. + +Mutation check: eight deliberate defects were put into the reference one at a time (recovered +line must fail to parse; writer goes back to an older file; `--accept-break` uses the short +check; break `seq` read from the record; `last_good` unchecked; break `prev` unchecked; sticky +failure removed; an unparseable last line with a newline is a failure). Each made at least one +given test fail. + +| Task | Check | What the check exposed | +|---|---|---| +| 01 proto audit types | Reference (the types), plus the existing walk-every-object test `strict.rs` | **Contract defect, security-relevant.** `DecisionRecord::Allowed` and `Ask` were unit variants. serde does not apply `deny_unknown_fields` to a unit variant of an internally tagged enum, so `{"outcome":"allowed","zz":true}` decoded. `strict.rs` (tip T2) caught it on the first run. Fixed by making them empty struct variants, `Allowed {}` and `Ask {}`; the JSON is unchanged, but every construction and match site writes the braces. `strict.rs` had to change: a decision or approval line now holds three objects, not two. | +| 03 proto chain verifier | Reference, 30 generated fixture logs, mutation check | **Spec gap 1.** The spec gives `abandoned` and `unfinished` as "seq of …" but `Result.decision` and `Approval.decision` both name the *decision's* `seq`, so an approved call can only be tracked under the decision's `seq`. The reference reports the decision's `seq` in both lists (it is also the approval id the owner saw). **Spec gap 2.** The resumed-verifier exception was specified only outside a failed region. If the latest file also has damage before the break record, the resumed verifier is already in a failed region when it meets the break naming an older file, its four checks cannot match, and `brokerd` would refuse to start after a correctly accepted break while `--accept-break` says "nothing to accept". The exception must apply inside a region too. **Spec gap 3.** A chain-valid `Recovery` record that describes nothing (hash and length of no neighbouring line) was not addressed; the reference makes it a failure. **Spec gap 4.** `ChainReport` as specified could not tell the writer what a `Recovery` or `AcceptedBreak` must carry, so each consumer would recompute `seq` and `prev`; `ChainFailure` gained `last_good`, `break_prev`, `break_seq`, `tail_torn`, and `torn_tail` became a `TornTail` with `recovery_prev` and `recovery_seq`. **Test defect found by running:** the generator's day-2 approval named a fixed decision `seq`, wrong once a recovery shifted the numbering. | +| 09 brokerd audit writer | Reference, mutation check | **Spec defect 1.** "Each tampering fixture must fail in `brokerd`'s startup" cannot hold as written: an ordinary start reads only the latest file, so damage in an older file is, by design, not seen. The startup tests copy the damaged file alone so that it is the latest. **Spec defect 2.** "Resumed from the last line of the file before; that line must parse, or it is the failure" deadlocks: accept a break whose failure *is* that last line, and every later ordinary start fails on it while `--accept-break` says "nothing to accept". The reference falls back to verifying the whole log when there is no record to resume from. **Spec gap.** Whether `--accept-break` with a torn tail and no failure recovers the tail before refusing: the reference writes nothing and returns "nothing to accept". **Task defect.** `AuditError::Broken(ChainFailure)` fails clippy's `result_large_err`; it is boxed. **Also:** a fallback "if the short check fails, run the full check" was written and then removed, because the region exception (gap 2 above) makes it unreachable and an untested rule is one the implementer will get wrong silently. | +| 19 bxctl audit verify | Reference | Spec gap: the exact lines were not given, and a torn tail had no line at all, although a live `brokerd` mid-write produces one. The task fixes the wording. `main.rs` was not touched here (fork D owns it); the task describes the command's behaviour for the implementer to add in task 18's style. | diff --git a/docs/plans/M3a/checks-b.md b/docs/plans/M3a/checks-b.md new file mode 100644 index 0000000..c46c7a7 --- /dev/null +++ b/docs/plans/M3a/checks-b.md @@ -0,0 +1,160 @@ +# M3a checks, part B: the policy side (tasks 02, 04 to 08) + +What each task's given tests were checked against before hand-over, and every defect that the +checking exposed in the spec, the task or the tests. Written for the record that decides how much +checking the next milestone gets (decision of 2026-09-18, tip T17). + +**What happened to the plan.** The decision gave `policy` the oracle and `config`, `args`, +`grants`, `state` a compiling skeleton. All five got a minimal reference implementation instead +(about 900 lines in all), because the reference for the broker and the approval handoff (tasks 10 +to 15) cannot run without working policy, grants, arguments and state underneath it. So the +saving the decision expected from these five did not happen in M3a. The record below says what +the references caught that a skeleton or the oracle would not have, so the next milestone can +judge: it is very little. + +| Task | Check it had | Tests | First run against the check | +|---|---|---|---| +| 02 proto admin wire | the contract types, run | 30 in four files | 2 failures, both real (below) | +| 04 config | reference | 7 | all passed | +| 05 args | reference | 13 | all passed | +| 06 grants | reference | 17 | all passed | +| 07 policy | oracle, and a reference | 28 in four files, 7 doctests | all passed | +| 08 state | reference | 9 | all passed | + +All of `brokerd`'s suites pass three times in a row and once under four busy loops; clippy with +`-D warnings` is clean on `brokerd`, `bxctl`, `loopd` and on `proto`'s library and the four test +files of task 02. `proto/tests/records.rs` does not compile in this worktree: it is task 01's. + +## Task 02: the contract types, run against the tests + +1. **Contract defect, real.** `DecisionRecord` accepted unknown fields: + `{"outcome":"allowed","zz":1}` decoded. serde does not apply `deny_unknown_fields` to the unit + variants of an internally tagged enum. Found by `strict.rs`, which walks every object of every + fixture (tip T2), on the first `approve_result` fixture. It affects the audit records of task 01 + as much as the wire. Fixed in the worktree's `proto/src/audit.rs` by decoding through a private + `RawDecision { outcome, reason: Option }` with `#[serde(try_from = …)]`; the public + type, its variants and its JSON are unchanged. A hand-picked test now pins it + (`an_outcome_rejects_unknown_and_misplaced_fields`). Left over: `{"outcome":"allowed", + "reason":null}` still decodes, because an absent and a null `Option` look alike to serde. + **Task 01 must carry this fix**; task 02 checks for it and stops if it is missing. +2. **Observation, not fixed.** serde's derived `Deserialize` for a struct also accepts a JSON + array of the fields in order, so `"body":[]` decodes as `Empty` and a four-element array decodes + as a `ToolRequest`. This has been true of every `proto` struct since M1. It is not an authority + problem (the values are validated the same way), and closing it means a hand-written + `Deserialize` for every struct. The test that tried to forbid `"body":[]` was removed. Worth a + decision, not worth a task now. +3. **Plan defect.** `strict.rs` is touched by task 01 (the audit fixture's object count changes + with the nested `event`) and by task 02 (sixteen wire fixtures). Task 02's copy overwrites task + 01's, so the file handed over with task 02 must contain both changes. The version here has only + task 02's. +4. Task 02 adds a test the directive did not list: an approval id is a JSON number, and a string + or a negative number is refused. + +Neither defect would have been found by a compiling skeleton. Both were found by running the +tests against real types, which for `proto` costs almost nothing. + +## Task 04: config + +- **Spec gap.** The spec shows the socket paths written out under the default home and does not + say what they are when only `home` is set. Settled as in `loopd`: an empty socket path means + "under `home`", read through `broker_socket()` and `admin_socket()`. +- **Repo defect found on the way.** The "Used by" column of `docs/dependencies.md` is stale: it + names only `proto` for `serde` and `serde_json`, though `loopd` and `bxctl` use both. + `check-dep-docs.sh` checks crate names only. The task has the implementer add `brokerd`. +- Test design: the default-home test does not set `BOXMAKER_HOME` (in edition 2024 `set_var` is + `unsafe`, and it would race); it computes what the default must be in this process. +- The reference caught nothing. A skeleton would have done. + +## Task 05: args + +- **Spec defect.** Section 3 "Hosts" says a URL's host "is a host name as above (so no IP + literals…)". That does not follow: `127.0.0.1`, `127.1` and `10.0.0.0x1` all fit the host-name + grammar (labels of `[a-z0-9-]`), and curl normalises each to an IPv4 address. The runbook also + tells the owner that an IP address in a grant is a problem, which the grammar did not make + true. Rule added: **the last label starts with a letter `a-z`**. Proposed wording for the spec: + "…each label 1 to 63 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." Found by writing the + test table (listing the spellings), not by the reference. +- **Spec gaps settled in the task:** an absent `cwd` is left out of `canonical_json`, and + `"cwd": null` means absent; `host` is never in `canonical_json`; `https://example.com?x` is + invalid because what follows the host must be the end, `:443` or `/`; the root `/` is a valid + argument path (it is only invalid in a grant). +- **Tooling.** The editing tool decodes backslash-u escapes in text it writes (the same fault that + hit the spec). The test that needs the escaped slash builds it from pieces at run time. +- The reference caught nothing the tests had wrong. + +## Task 06: grants + +- **Spec gaps settled in the task:** a missing or unreadable grants directory is a problem, not + an empty set (I12); an empty directory is a valid empty set; problems come out sorted by file + name; when `tool` is unknown the constraint table (rule 6) is skipped for that grant; a hidden + file such as `.toml` has an invalid id; `from_grants` refuses two grants with one id, which + files cannot produce but the property test's generator could. +- **Fixture defect, caught before hand-over.** A fixture named `notes-read.toml~` (to prove editor + backups are ignored) is matched by the owner's global gitignore, so it would never have been + committed and the implementer's copy would have differed from the plan's. The case now lives in + a temporary directory the test makes. Rule for fixture authors: run `git check-ignore` on odd + names. +- The reference caught nothing: 17 of 17 on the first run, line numbers included. A skeleton would + have done, at the cost of not knowing that `toml`'s span for an unknown field points at the key. + +## Task 07: policy + +- **Spec ambiguity.** "`write_file` … is inside one of the grant's `paths` and is not the grant + path itself", together with "the longest one is the matched path", does not say what happens + when a grant lists `/s` and `/s/out` and the call writes `/s/out`. Settled: a grant path equal to + the argument does not count; the longest path that counts is the matched one, so the write is + covered through `/s`. Both the reference and the oracle were written to this reading, so neither + could have caught the other; the ambiguity was found while writing the table test. +- **Test defect inherited from M1.** The M1 pattern of `compile_fail` doctests loses its teeth + under the new shape: a struct literal that leaves a field out fails to compile whether or not + the fields are private, and a call to `Decision::new` fails because there is no `new`. Replaced + by a literal that names all three fields with `matched: todo!()` (compiles if and only if the + fields are public) and by trait-bound probes for `Clone` and `DeserializeOwned`. Teeth checked: + with the fields made `pub`, the literal doctest fails. The task has the implementer repeat that. +- **Spec gap.** `redecide` returning `Result` loses the `deny` grant's id and + file hash, which the `Approval` record needs. `Denial { reason, grant, grant_sha256 }` carries + them, for `decide` too. +- **Scope note for tasks 12 and 13.** Section 12's properties over sequences of calls (taint never + goes down, every `Result` follows a `Decision`, the runtime sees a call only after `allowed` or + an approval) cannot be tested in a pure `policy` test. They belong with the ledger and broker. + So does "one invalid file among valid ones denies a call a valid file would allow": `grants` + proves the set does not load; the denial with `grants_invalid` is the broker's. +- **Oracle against reference.** Zero disagreements in 15,000 cases on the first run. That is weak + evidence by itself, since one author wrote both (T5), so the oracle was tested: six mutants + of the reference (expiry `>` for `>=`, highest id for lowest, prefix match by bytes, taint `>=` + for `>`, write allowed at the grant path itself, the `untrusted` label dropped) were each + caught by the oracle alone, and each also by a table test. **The reference caught nothing the oracle would not have.** Its uses were to show that + the generator reaches every kind of outcome (each test asserts more than 200 cases of each) and + to stand under the broker's reference. + +## Task 08: state + +- **Spec gaps settled in the task:** a state file that says `public` is treated as damaged (a + session is never below `private`, so `brokerd` did not write it); `raise` always writes, so the + file exists from the first result; `raise` trusts the state it is given and does not read + again, because the caller holds the ledger lock; the file ends with a newline and reads with or + without one; the directory is made 0700 with its parents, the file 0600. +- Test design: the write-failure test makes the directory read-only, so it skips itself with a + message when the user can read a mode 000 file (root). `TempDir`'s `Drop` puts the permissions + back before removing the directory. +- The reference caught nothing. A skeleton would have done. + +## Summary for the process decision + +| Found by | Defects | +|---|---| +| Running real types against the tests (task 02) | 2: `DecisionRecord` accepts unknown fields; the array form | +| Writing the test tables | 3: IPv4 spellings pass the host grammar; the `write_file` ambiguity; toothless doctests | +| Writing the task files | 5 spec gaps settled (config sockets, grants directory, `Denial`, state `public`, canonical `cwd`) and the stale dependency table | +| Preparing fixtures | 1: a fixture name swallowed by a global gitignore | +| The references for tasks 04 to 08 | 0 | +| The oracle, against six mutants of the reference | 6 of 6 | + +For plumbing the skeleton level would have been enough. For `policy` the oracle is enough, +provided it is mutation-tested once: an oracle that has never failed has not been shown to work. + +**Resolved at the merge (2026-09-18).** Area A fixed the same `DecisionRecord` hole with empty +struct variants, `Allowed {}` and `Ask {}`. That fix was kept: it is four lines of derive rather +than 35 of hand-written decoding, and it also refuses `{"outcome":"allowed","reason":null}`, which +`admin_wire.rs` now pins. Task 02's "Check first" looks for `Allowed {},`. diff --git a/docs/plans/M3a/checks-c.md b/docs/plans/M3a/checks-c.md new file mode 100644 index 0000000..789a233 --- /dev/null +++ b/docs/plans/M3a/checks-c.md @@ -0,0 +1,107 @@ +# M3a checks, fork C: `loopd` (tasks 16 and 17) + +Reference tree: `~/src/boxmaker-ref-c`, branch `m3a-ref-c`. The rest of the workspace does not +build its tests there (`proto`'s `records.rs` and `wire.rs` belong to other forks), so every +command below is `-p loopd`. `cargo clippy -p loopd -p bxctl --all-targets -- -D warnings` is +clean, and `scripts/check-lines.sh` passes. + +## Task 16: the tool port, the registry and denials + +**Check: in effect a reference.** The task changes working code, and the smallest change that lets +the updated tests compile is the whole change (about 150 lines in `tools.rs`, 30 in `turn.rs`). A +`todo!()` skeleton was not possible: the existing turn-loop tests run through this code. + +Run to green, three times: `tools` (10), `turn` (6), `turn_broker` (5, new), and unchanged +`limits` (9), `channel` (6), `session` (7), `baseline` (7), `serve` (3) and the rest of the suite. + +Defects the check exposed: + +1. **Spec, section 8.** "`echo` stays in `FakeTools` for tests only" is not enough: two recorded + conversations (`find_tool.http`, `call_tool.http`) and four test files discover `echo` + through the registry. Removing `Registry::m2b()` would have meant new copies of `baseline.rs`, + `channel.rs` and `support/turn.rs` for a one-word change each. Resolved: `m2b()` stays as the + test registry, `m3a()` is what `loopd serve` uses. Proposed wording for section 8: "`echo` + stays in `FakeTools` and in the test registry `Registry::m2b()`; `loopd serve` uses + `Registry::m3a()`." +2. **Spec, section 8, unclear.** `tool_denied { name, reason }` does not say which name. For a + `call_tool` call, `call.name` is `call_tool`, which tells the owner nothing. Resolved: both new + events carry the tool `brokerd` decides on (`request.tool`); `tool_call_started` and + `tool_result` keep `call.name`. A test pins it (`a_denied_call_tool_names_the_target_tool`). + Task 20 (`bxctl chat`) should know that `ApprovalPending.tool` is the target tool. +3. **Spec, silent.** What `run_call` does if a port returns `PendingApproval` as its answer. It + cannot be removed (the `match` must be exhaustive while `ToolPort` returns `ToolResponse`). + Resolved: a plain failure, "The tool failed: the tool broker gave no final answer", no event. + It is a failure of the port, not a decision about the call. +4. **Tests.** `turn.rs` grew past 500 lines; the new tests moved to `turn_broker.rs`. +5. **Verified claim.** "The registry change affects only new `find_tool` results; the tools array + is unchanged" is true: `Baseline::assemble` takes only `registry.core_schemas()`, which is + `clock`, `find_tool`, `call_tool` with identical schemas for `m2b()` and `m3a()`; resumed + sessions use their snapshot. `the_m3a_registry_has_the_same_core…` asserts equality. +6. **Plan, outside this fork: `make verify-device` breaks at task 16.** `device.rs` asks Ornith to + "use the echo tool" through `loopd serve`, and asserts that `find_tool` and `call_tool` were + called. With `m3a()` there is no `echo` to find. Task 17's files carry a changed `device.rs` + (find a file-reading tool, call it on `/etc/hostname`, which fails with "no tool broker is + configured"; the restart check asks for the first turn's words instead of the echoed word). It + compiles and is clippy-clean. **It has not been run on straylight** (this fork was told not to + contact it), and it depends on model behaviour, so the owner should run it once before the + plan is handed over. Between tasks 16 and 17 `verify-device` is broken either way. + +## Task 17: `BrokerPort`, `[broker]` and the pointers + +**Check: skeleton** for `BrokerPort::call` (`todo!()`); everything else in the task is small +enough that the skeleton is the code (`config::Broker`, `NoBroker`, the two line functions, the +`main.rs` wiring, the three pointers). + +Run to green: `config` (11, two new), `pointers` (7, new; three of them run the `loopd` binary), +`broker_port::without_a_broker…` (1). +**Compiled only, never run to green:** the other 14 tests of `broker_port` and both tests of +`broker_port_bad`. They were desk-checked against the exit list in the task, not executed. The +likeliest faults, if any: a timing bound too tight under load (`a_broker_that_never_answers…` +allows 250 to 1,100 ms for a 300 ms timeout; `a_pending_call_nobody_answers…` allows 550 to +1,700 ms), and a fake-broker thread that panics noisily, but harmlessly, when the port closes +without sending. + +Defects the check exposed: + +1. **Spec, section 8, ambiguous.** "The read timeout is `timeout_ms` until the first frame" can be + read as a per-read socket timeout. That lets a peer that trickles bytes hold a turn for ever, + and it cannot express "until `expires` plus `timeout_ms`" at all. Resolved as a deadline per + frame wait, with a test whose answer (not its timing) tells the two apart. Note this is a total + deadline, unlike the inference path's liveness rule; the spec already implies it ("M3b must + keep its tool time limit under `timeout_ms`"). Proposed wording: "`BrokerPort` waits for the + first frame until `timeout_ms` after the call began, and after a pending frame until the + frame's `expires` plus `timeout_ms`. These are deadlines, not per-read timeouts." +2. **Spec, silent: a request too large for a frame.** `arguments` near 1 MiB makes `write_frame` + fail with `TooLarge`. Calling that "the tool broker is unavailable" with a runbook pointer + would send the owner looking for an outage. Resolved: its own failure text, + "the request is too large for the tool broker", nothing printed. +3. **Spec, silent: `expires` is a peer's number.** A far-future `expires` overflows + `Instant + Duration`, which panics. Resolved: `checked_add`, and `None` is exit 10. +4. **Spec, silent: the envelope `id`.** Resolved: `request.call.0`; every answer must carry it. +5. **Spec, silent: `timeout_ms = 0`.** `set_write_timeout(Some(0))` is an error in std. Resolved: + no config validation; the call fails closed at exit 3, and a test checks there is no panic. +6. **Spec: `core-memory-unreadable` needs a new error variant.** `BaselineError::Read` serves both + `system.md` and `core.md`; the pointer belongs to one. Added `BaselineError::Core`. The M2b + test that checks the unreadable `core.md` only looks for "core.md", so it still passes. +7. **Testability.** "`loopd` prints …" cannot be asserted in-process through `eprintln!`. + Resolved with `BrokerPort::with_log` and the two `*_line()` functions; the startup and + self-test lines are asserted by running the binary. +8. **Task-writing.** The line-limit hit again: `broker_port.rs` was 546 lines. Split into + `broker_port.rs`, `broker_port_bad.rs` and `support/broker.rs`. + +## For the record of the experiment + +What skeleton-only cost here: 16 of about 40 new or changed tests in this area were never run. What +it saved: about 110 lines of `BrokerPort`. The five spec gaps under task 17 (1 to 5) were all found +while writing the exit list and the tests, not by running anything, so a reference would not have +found them sooner; what a reference would add is only confidence in the timing bounds. + +## Run on straylight (2026-09-18, at the merge) + +Task 17's changed `device.rs` was run with `make verify-device` on the merged reference tree +(`f9195ec`: areas A to D, `loopd` at the task 17 state with no broker configured) against Ornith +on straylight, slot 0, after checking `/slots` showed both slots idle. It passed twice in a row, +6 of 6 each time (43 s and 39 s). What the test asserts is that Ornith called `find_tool` and +then `call_tool`, that `clock`, `find_tool` and `call_tool` each left a result, and that the +restart check passed; which tool it found and what the call returned are not asserted. Between +tasks 16 and 17 `verify-device` is still broken, as noted above; the owner should not run it there. diff --git a/docs/plans/M3a/checks-d.md b/docs/plans/M3a/checks-d.md new file mode 100644 index 0000000..9e79640 --- /dev/null +++ b/docs/plans/M3a/checks-d.md @@ -0,0 +1,100 @@ +# M3a checks, fork D: `bxctl` (tasks 18, 20) and the runbook gate script (task 21) + +What each task's given tests were checked against before hand-over, and what doing so exposed. +Branch `m3a-ref-d`, three commits: `292780f` (the task 18 state), `5f27808` (the task 20 state, +skeleton), and the one after it (reference bodies, written afterwards as a measurement). + +## What was checked how + +| Task | Check | Ran green | Compiled only | +|---|---|---|---| +| 21 `check-runbook.sh` | Real script, run for real. Self-test passes; five mutations of the script (no `-x`, exit at the first missing anchor, no `-prune`, a narrower anchor pattern, no "no pointers" check) each make the self-test fail. | all | none | +| 18 `escape.rs` | Written for real: 12 lines of logic, cheaper than a stub. | `escape` 8 of 8 | none | +| 18 `cli.rs`, `main.rs` | Written for real. Not by choice: the 12 existing `chat` tests drive the binary, and the binary cannot parse its arguments through a `todo!()`. | `cli` 11 of 12; `chat` 12 of 12 | `cli` 1 (it needs `admin`) | +| 18 `admin.rs` | **Skeleton** (`todo!()` bodies; `reason_name` real, because the printer calls it). | `admin` 1 of 21 | `admin` 20 | +| 20 `Printer`, `stream_turn`, `main.rs` | Written for real, for the same reason as `cli.rs`. | `chat_print` 9 of 9; `chat_approvals` 3 of 20 | none | +| 20 `handle_pending` | **Skeleton.** | none | `chat_approvals` 17 | + +So "skeleton" came to two things: the bodies of `admin.rs` and one function in `chat.rs`. 38 of +the 82 tests only compiled at hand-over quality. Everything was clippy-clean and formatted, and +the task 18 state (new command dispatch, M2b chat code) was built and tested on its own commit, +because `main.rs` is rewritten in both tasks. + +## Measurement: what a reference found that the skeleton had not + +After the skeleton state was committed, the missing bodies were written (about 150 lines) and the +38 compile-only tests were run: **all passed on the first run**, then six more runs under CPU +load with no failure. The reference exposed no defect that the skeleton and a desk-check had +missed. For this kind of task (formatting and a request/answer client, exact strings) the +skeleton was enough. The reference bodies are kept in the last commit because the straylight +check needs a working `bxctl approve`; they are not handed to the implementer. + +## Defects the checks exposed + +### Task 21 + +1. **Spec 11 does not say what a pointer is when its anchor is not written out.** A pointer built + with `format!("see docs/runbook.md#{anchor}")`, or the placeholder `docs/runbook.md#` + in a comment, has no anchor the script can read, and a script that skipped these would let a + computed pointer through unchecked. The script fails on them. **This binds every crate:** under + `crates/`, in source and in tests, a pointer must be a literal, and no comment may hold + `docs/runbook.md#` followed by a placeholder. Run `sh scripts/check-runbook.sh` on the merged + reference tree before hand-over. Proposed wording for spec 11: "An anchor must be written out + in the source; a pointer whose anchor the script cannot read is an error." +2. **Spec 11 says "in `crates/`" without saying which files.** Chosen: `*.rs`, test files + included, `target/` excluded. `grep` on the binary frame fixture would otherwise need `-a`, + which is not POSIX. +3. **No pointer at all is a failure** (tip I2): otherwise a change to the message format would + turn the check off silently. Not in the spec; proposed: "It fails if it finds no pointer." +4. Not fixed, out of scope: `scripts/check-lines.sh` still exits at the first long file, inside a + pipeline subshell (M1 finding 8). + +### Task 18 + +5. **Spec 9's example block contradicts spec 6.** The example shows + `{"command": "rm …", "cwd": "…"}` with spaces; section 6 says the arguments are `serde_json`'s + serialisation of the typed value, which has none. `bxctl` prints what `brokerd` sent, escaped, + and the tests use the compact form. The example should be compact. +6. **Spec 9 leaves the block's details open**; the tests now fix them: two spaces between parts; + spans are whole seconds under a minute, whole minutes under an hour, else whole hours, rounded + down; `expired` once `now >= expires`; a clock behind the broker's shows `0 s ago`; a session + id over 10 characters is its first 9 and `…`; an empty list prints `no pending approvals`; + `approve_result` with outcome `ask` prints `runs` (the re-decision lets an `ask` run); any + other error frame prints `bxctl: : ` on stderr and exits 1. +7. **`GrantProblem.problem` can be several lines** (`toml`'s errors are), which would break + `:: `. `bxctl` puts `file` and `problem` through `escape_json_text`, so + each problem is one line whatever arrives. Better fixed at the source too: task 06 should give + one-line problems. +8. **The error type had to change while the test was being written** (the T16 kind of defect): + a message that names the socket needs `AdminError::Connect(PathBuf, io::Error)`, not + `Connect(io::Error)`. Found by the skeleton. +9. **`"+41".parse::()` succeeds.** An id is now "ASCII digits only, then parse". +10. **Task order.** Task 18 creates `main.rs`'s `audit verify` arm, but `verify::run` is task + 19's. Task 18 therefore creates `crates/bxctl/src/verify.rs` with the final signature and a + placeholder body, and adds all four `pub mod` lines. **Task 19 must say "Modify + `verify.rs`", not "Create", and must not touch `lib.rs` or `main.rs`.** +11. **A contract file changed:** `crates/bxctl/src/lib.rs` gained `pub mod cli;`, and + `chat::code_name` became `pub`. + +### Task 20 + +12. **Spec 9, "`--say` and `--json` print the event only", is ambiguous**: without `--json` + there is no printed form of the event. Chosen, and tested: `--json` prints the event's JSON + line and never contacts `brokerd`; `--say` shows `brokerd`'s block and asks nothing, and the + owner answers with `bxctl approve` from another terminal. Proposed wording: "`--say` shows + the block and does not ask. `--json` prints the event as JSON and nothing else." +13. **Spec 9 escapes "reasoning and content" only.** The model also chooses tool names, which + were printed raw in three places, and the final answer on stdout was raw. All are escaped + now. Proposed wording: "everything the model wrote: reasoning, content, tool names, and the + answer on stdout." +14. **Exits the spec does not cover**, now defined: the end of the input at the question + refuses; `brokerd` unreachable, an approval that expired between the list and the answer, + and any other failed answer are each reported on one line and the turn goes on; a failed + write to the terminal is an error and nothing is answered. +15. **One reader for stdin.** Designing the binary-level test showed that the approval's answer + must come from the same `BufReader` as the chat lines: with a pipe, everything typed is in the + first reader's buffer, and a second reader sees the end of the input. `main.rs` holds one + reader for the whole run; `interactive_mode_reads_the_answer_from_the_same_input_as_the_chat` + fails otherwise. +16. `tests/admin.rs` is 497 lines and `tests/chat_approvals.rs` 487, against the limit of 500. + A test added later to either needs a new file. diff --git a/docs/plans/M3a/checks-e.md b/docs/plans/M3a/checks-e.md new file mode 100644 index 0000000..917a8f9 --- /dev/null +++ b/docs/plans/M3a/checks-e.md @@ -0,0 +1,138 @@ +# M3a checks, area E: the broker (tasks 10 to 15) and end to end (task 22) + +What each task's given tests were checked against before hand-over, and what doing so exposed. +Branch `m3a-ref-e` (worktree `~/src/boxmaker-ref-e`), built on the merge of areas A to D +(`f9195ec`). Every task here had a full reference implementation, as the plan decided. + +## What ran + +| Task | Check | Tests | First run against the reference | +|---|---|---|---| +| 10 runner | reference | `runner` 8, doctests 2 (9 in `brokerd` with task 07's 7) | all passed | +| 11 approvals | reference | `approvals` 7 | all passed | +| 12 ledger | reference | `ledger` 11, `ledger_answer` 9 | all passed | +| 13 broker | reference | `broker` 9, `broker_pending` 5, `broker_sequence` 2 | all passed after two test-side compile fixes | +| 14 admin | reference | `admin` 12 | all passed | +| 15 serve | reference, through the binary | `serve` 9 | all passed | +| 22 end to end | reference (the real `brokerd` binary) | `end_to_end` 1 | passed | +| 17 `BrokerPort::call` (area C's skeleton) | a reference body, as a measurement; **not handed over** | `broker_port` 15, `broker_port_bad` 2 | all 17 passed first time | + +**Nothing only compiled.** With the `BrokerPort` body written, `make gate` passes in full on this +branch, the end-to-end line included. + +**Runs.** Every area E suite, and `broker_port`, `broker_port_bad` and `end_to_end`, ten times in a +row idle and ten times under sixteen busy loops on eight cores: 20 of 20 green, no flakes. The +timing bounds in area C's `broker_port` (250 to 1,100 ms for a 300 ms timeout; 550 to 1,700 ms for +a pending call nobody answers) held under that load. + +**Task order.** For each of tasks 10 to 15, the reference tree was cut back to what the +implementer will have at that task (later modules empty, `main.rs` the stub) and that task's tests +and all earlier ones were compiled. Each compiled. This found one plan defect (below). + +**Mutation check.** Fourteen deliberate defects, one at a time; each broke at least one given +test: no stop after a failed append; state not raised by the result's label; no last look before +running; a gone requester not taking its own entry; the re-decision ignoring the current state; +shell mounts read-only; expiry at `>` instead of `>=`; a found socket directory left at 0755; +`RunSpec` fields public (the `compile_fail` doctest); grant problems printed on every call; state +checked before grants; a refusal recorded without the owner's reason; result content passed on +when its record fails; the pending frame sent with `final: true`. + +## Spec defects and gaps (not applied to the spec; proposed wording) + +1. **Section 5, write order: denials before `decide` are recorded too.** Section 3 says "the first + two need no `Decision`-like guard: anyone may deny", which reads as if `grants_invalid` and + `state_unreadable` skip the record. The reference records every tool request, those included. + Proposed: "Every tool request gets a `Decision` record, including one denied with + `grants_invalid` or `state_unreadable` before `decide` runs. A message of a forbidden kind is + not a tool request and gets none." +2. **Section 5, records: the state of a session whose state cannot be read.** `Decision`, + `Approval` and a failed `Result` carry `taint`/`untrusted` (or `taint_after`), and the spec does + not say what they hold when the file is damaged. Chosen: `secret` and `true`. Proposed: "When + the session's state cannot be read, records carry `taint: secret` and `untrusted: true`: + `brokerd` does not know how sensitive the session is." +3. **Section 5, step 3: a result whose state cannot be read or written leaves no `Result`.** The + answer is `failed`, "the result could not be recorded", and nothing more is written, so + `bxctl audit verify` lists the call as running or unfinished. Proposed, appended to step 3: + "Nothing is written for it, so the log shows the call as unfinished; the state entry of the + runbook explains that." +4. **Section 6, approve: the re-decision's mode.** `approve_result.outcome` and the `Approval` + record say `ask` or `allowed`, but `redecide` returns a `Decision`, which does not carry the + mode. The ledger looks the matched grant up in the set it passed. It works; a cleaner fix for + M3b is for `redecide` to return the mode with the decision. Proposed: "The outcome is `ask` if + the grant matched now is an `ask` grant, `allowed` if it is `auto`." +5. **Section 6, refuse: a refusal whose record cannot be written.** The table says `ok {}`. The + call is denied either way, but `bxctl` would report success for something that was not + recorded. Chosen: `error internal`, detail "the refusal could not be recorded; the call is + denied; see docs/runbook.md#audit-unavailable" (`bxctl refuse` prints it and exits 1). Proposed: + "If the `Approval` record of a refusal cannot be written, the answer is `error internal` with + a pointer to `audit-unavailable`; the call is denied with `audit_unavailable`." +6. **Section 6, lost connection: the pending frame itself cannot be sent.** Chosen: the thread + tries to take its own entry at once, exactly as when its one-second look finds `loopd` gone. + Proposed: add "The same holds if the pending frame cannot be sent." +7. **Section 6: the request frame has no read timeout.** A client that connects and sends nothing + holds a thread for ever. Only `loopd` can reach `broker.sock` and only the owner `admin.sock`, + so it is not fixed in M3a. Worth a line in section 14, or a timeout in M3b. +8. **Section 12, end to end: the recorded model calls `read_file` directly**, not through + `call_tool` (the `tool_call` recording). `loopd` forwards both the same way, so the test proves + the same thing. Proposed: "The scripted model calls `read_file` with no grant". +9. **Section 2, "Threads and locks": where the expiry lives.** Expiry answers entries the same way + `approve` does, so it is `admin::expire_due`, called by `serve`'s thread; `approvals` stays a + plain table (it comes before the ledger in task order). + +## Plan defects found by the checks + +1. **The test rig broke task order.** The first `support/rig.rs` held both the ledger rig and the + broker client, so task 12's tests would not have compiled before task 13's `broker` module + existed. Found by the task-order simulation above; the client moved to `support/client.rs`, + which only task 13's and 14's tests include. +2. **`files/Makefile` could not serve both task 21 and task 22.** With the end-to-end line in it, + task 21's gate would fail on a test target that does not exist yet. Task 21 now copies + `files/Makefile-task21` (area D's version, byte for byte); `files/Makefile` is task 22's. **The + parent should confirm this, since task 21 is area D's.** +3. **The editing tool decoded a backslash-u escape again**, this time in a test comment + (`admin.rs`). The escaped string itself is built from pieces and checked at run time + (`escaped.contains("u002f")`); the comment was rewritten without an escape. +4. `Verdict::Run` holds a `Box`: clippy's `large_enum_variant` rejects it unboxed. The + handoff's design had `Run(Decision)`. + +## Area C's `BrokerPort`, measured + +About 130 lines, written from task 17's exit list. All 17 compile-only tests passed on the first +run and on all 20 later runs, idle and loaded. **The body found nothing the skeleton and the +desk-checked exit list had missed.** The end-to-end test now exercises the same port against the +real `brokerd`. + +## The straylight check + +`tools/check-m3a-device.sh` (copied by task 22, never run by the implementer). It reads +`GET /slots?model=ornith-1.5-35b-a3b` first and stops unless slot 0 reports +`"is_processing": false`; `loopd` uses slot 0 for the turn. It starts a private `inferproxy`, +`brokerd serve` and `loopd serve` on a temporary home with one `ask` grant for `read_file`, runs +`bxctl chat --say`, waits for the approval in `bxctl approvals`, checks that the block names the +grant and the call, approves it, and checks the audit log verifies with `decision`, `approval` and +`result` records. **It has not run**: this area had no access to straylight. Its `jq` test was +checked on sample `/slots` answers only (a first version used `jq -e`, which exits 1 on `false`, +the idle answer, and would have refused every run). + +`docs/egress.md` lists no development call to straylight, although `verify-device` and this +script both make one. Proposed row: "Development | `make verify-device`, +`tools/check-m3a-device.sh` | straylight's `llama-server` | Checks against the real model, through +a private `inferproxy`; the script also reads `/slots` with `curl`." + +## Runbook + +No new anchor. The `audit-unavailable` entry gained the new messages that point at it (the +"earlier write failed" line on every later call, "the result could not be recorded", the refusal +error, and the panicked-ledger variant). Carry that edit to `master`. `check-runbook.sh` passes on +this branch. + +## For the process record + +| Found by | Defects | +|---|---| +| Writing the reference | 7 spec gaps (1 to 7 above) | +| Writing the tests and fixtures | 1 spec inaccuracy (8), 1 tool defect (the escape) | +| Simulating the implementer's tree per task | 2 plan defects (the rig, the Makefile) | +| Running the tests against the reference | 0 test defects beyond compile errors | +| The `BrokerPort` body | 0 | +| Mutation check | 14 of 14 mutants caught | diff --git a/docs/plans/M3a/files/Makefile b/docs/plans/M3a/files/Makefile new file mode 100644 index 0000000..619d497 --- /dev/null +++ b/docs/plans/M3a/files/Makefile @@ -0,0 +1,33 @@ +# Boxmaker gate. `make gate` must pass before any work is called done. It needs no network. + +.PHONY: gate audit verify-device + +gate: + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked --offline -- -D warnings + cargo test --workspace --locked --offline + cargo build --workspace --locked --offline + BOXMAKER_BROKERD=$(CURDIR)/target/debug/brokerd \ + cargo test -p loopd --test end_to_end --locked --offline -- --ignored + cargo deny --offline check bans licenses sources + sh scripts/check-lines.sh + sh scripts/check-crate-deps.sh + sh scripts/check-dep-docs.sh + sh scripts/check-runbook.sh + sh scripts/test-gate-scripts.sh + @echo "gate: ok" + +# Fetches the RustSec advisory database. Listed in docs/egress.md. +audit: + cargo deny check advisories + +# Checks that need straylight. They go through a private inferproxy to the real server. +# Override the server with: make verify-device UPSTREAM=host:port +UPSTREAM ?= straylight:11434 + +verify-device: + cargo build --workspace --locked + BOXMAKER_INFERPROXY=$(CURDIR)/target/debug/inferproxy BOXMAKER_BXCTL=$(CURDIR)/target/debug/bxctl \ + BOXMAKER_UPSTREAM=$(UPSTREAM) \ + cargo test -p loopd --test device --locked -- --ignored --test-threads=1 + @echo "verify-device: ok" diff --git a/docs/plans/M3a/files/Makefile-task21 b/docs/plans/M3a/files/Makefile-task21 new file mode 100644 index 0000000..ff348f5 --- /dev/null +++ b/docs/plans/M3a/files/Makefile-task21 @@ -0,0 +1,30 @@ +# Boxmaker gate. `make gate` must pass before any work is called done. It needs no network. + +.PHONY: gate audit verify-device + +gate: + cargo fmt --all --check + cargo clippy --workspace --all-targets --locked --offline -- -D warnings + cargo test --workspace --locked --offline + cargo deny --offline check bans licenses sources + sh scripts/check-lines.sh + sh scripts/check-crate-deps.sh + sh scripts/check-dep-docs.sh + sh scripts/check-runbook.sh + sh scripts/test-gate-scripts.sh + @echo "gate: ok" + +# Fetches the RustSec advisory database. Listed in docs/egress.md. +audit: + cargo deny check advisories + +# Checks that need straylight. They go through a private inferproxy to the real server. +# Override the server with: make verify-device UPSTREAM=host:port +UPSTREAM ?= straylight:11434 + +verify-device: + cargo build --workspace --locked + BOXMAKER_INFERPROXY=$(CURDIR)/target/debug/inferproxy BOXMAKER_BXCTL=$(CURDIR)/target/debug/bxctl \ + BOXMAKER_UPSTREAM=$(UPSTREAM) \ + cargo test -p loopd --test device --locked -- --ignored --test-threads=1 + @echo "verify-device: ok" diff --git a/docs/plans/M3a/files/crates/brokerd/tests/admin.rs b/docs/plans/M3a/files/crates/brokerd/tests/admin.rs new file mode 100644 index 0000000..bf59046 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/admin.rs @@ -0,0 +1,385 @@ +//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which +//! answers an approval the same way. Do not edit. + +#[path = "support/client.rs"] +mod client; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Barrier}; +use std::time::{Duration, Instant}; + +use brokerd::admin; +use brokerd::broker::{self, Broker}; +use brokerd::policy::Label; +use client::{Serve, next, open}; +use proto::{ + ApprovalAnswer, Approve, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, ErrorCode, + Message, Refuse, SessionId, Timestamp, ToolResponse, +}; +use rig::{Rig, grant_text, request}; +use runtime::Recording; + +fn ask_notes(rig: &Rig) { + rig.grant( + "notes", + &grant_text("read_file", "ask", "", "paths = [\"/n\"]"), + ); +} + +/// Sends a call that waits for approval; returns its connection once the pending frame is read. +fn pending(broker: &Arc, call: u64, arguments: &str) -> (UnixStream, u64) { + let req = request("s1", call, "read_file", arguments); + let mut stream = open(broker, broker::handle, call, Message::ToolRequest(req)); + match next(&mut stream).msg { + Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval), + other => panic!("{other:?}"), + } +} + +/// One admin request; returns the answer's message after checking its id and `final`. +fn admin(broker: &Arc, msg: Message) -> Message { + let mut stream = open(broker, admin::handle, 5, msg); + let env = next(&mut stream); + assert_eq!((env.id, env.r#final), (5, true), "{env:?}"); + env.msg +} + +fn approve(broker: &Arc, approval: u64) -> Message { + admin(broker, Message::Approve(Approve { approval })) +} + +fn refuse(broker: &Arc, approval: u64, reason: Option<&str>) -> Message { + let reason = reason.map(str::to_string); + admin(broker, Message::Refuse(Refuse { approval, reason })) +} + +fn final_answer(stream: &mut UnixStream) -> ToolResponse { + match next(stream).msg { + Message::ToolResponse(r) => r, + other => panic!("{other:?}"), + } +} + +fn outcome(msg: Message) -> DecisionRecord { + match msg { + Message::ApproveResult(r) => r.outcome, + other => panic!("{other:?}"), + } +} + +fn error_code(msg: &Message) -> Option { + match msg { + Message::Error(e) => Some(e.code), + _ => None, + } +} + +fn denied(reason: DenyReason) -> ToolResponse { + ToolResponse::Denied { reason } +} + +#[test] +fn approvals_lists_the_arguments_as_policy_parsed_them() { + let rig = Rig::new("admin-list"); + ask_notes(&rig); + let broker = rig.broker(&Recording::answering("x")); + assert!(matches!( + admin(&broker, Message::Approvals(Empty {})), + Message::ApprovalList(list) if list.items.is_empty() + )); + // A backslash-u escape of `/` is `/` in JSON; the owner sees the one spelling policy matched. + let escaped = format!(r#"{{"path":"{}n{}a"}}"#, "\\u002f", "\\u002f"); + assert!( + escaped.contains("u002f"), + "the escape must survive: {escaped}" + ); + let (_stream, approval) = pending(&broker, 1, &escaped); + match admin(&broker, Message::Approvals(Empty {})) { + Message::ApprovalList(list) => { + assert_eq!(list.items.len(), 1); + assert_eq!(list.items[0].approval, approval); + assert_eq!(list.items[0].arguments, r#"{"path":"/n/a"}"#); + assert_eq!(list.items[0].grant, "notes"); + } + other => panic!("{other:?}"), + } + // The audit log keeps the raw string. + match &rig.events()[0] { + AuditEvent::Decision { arguments, .. } => assert_eq!(arguments, &escaped), + other => panic!("{other:?}"), + } +} + +#[test] +fn approve_runs_the_call_and_answers_with_the_re_decision() { + let rig = Rig::new("admin-approve"); + ask_notes(&rig); + let rt = Recording::answering("the notes"); + let broker = rig.broker(&rt); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + assert_eq!(outcome(approve(&broker, approval)), DecisionRecord::Ask {}); + assert!(matches!( + final_answer(&mut stream), + ToolResponse::Result { .. } + )); + assert_eq!(rt.count(), 1); + match &rig.events()[1] { + AuditEvent::Approval { answer, by, .. } => { + assert_eq!(*answer, ApprovalAnswer::Approved); + assert_eq!(by.as_deref(), Some("bxctl")); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn refuse_denies_the_call_and_records_the_reason() { + let rig = Rig::new("admin-refuse"); + ask_notes(&rig); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + assert!(matches!( + refuse(&broker, approval, Some("not today")), + Message::Ok(Empty {}) + )); + assert_eq!( + final_answer(&mut stream), + denied(DenyReason::ApprovalRefused) + ); + assert_eq!(rt.count(), 0); + match &rig.events()[1] { + AuditEvent::Approval { answer, reason, .. } => { + assert_eq!(*answer, ApprovalAnswer::Refused); + assert_eq!(reason.as_deref(), Some("not today")); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_unknown_or_answered_id_is_no_such_approval() { + let rig = Rig::new("admin-unknown"); + ask_notes(&rig); + let broker = rig.broker(&Recording::answering("x")); + assert_eq!( + error_code(&approve(&broker, 41)), + Some(ErrorCode::NoSuchApproval) + ); + assert_eq!( + error_code(&refuse(&broker, 41, None)), + Some(ErrorCode::NoSuchApproval) + ); + let (_stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + refuse(&broker, approval, None); + assert_eq!( + error_code(&approve(&broker, approval)), + Some(ErrorCode::NoSuchApproval) + ); + assert_eq!(rig.events().len(), 2, "one decision, one approval"); +} + +#[test] +fn approve_and_refuse_at_once_give_exactly_one_answer() { + let rig = Rig::new("admin-race"); + ask_notes(&rig); + let broker = rig.broker(&Recording::answering("x")); + for round in 0..100u64 { + let (mut stream, approval) = pending(&broker, round, r#"{"path":"/n/a"}"#); + let start = Arc::new(Barrier::new(2)); + let racers: Vec<_> = [true, false] + .into_iter() + .map(|approving| { + let (broker, start) = (Arc::clone(&broker), Arc::clone(&start)); + std::thread::spawn(move || { + start.wait(); + if approving { + approve(&broker, approval) + } else { + refuse(&broker, approval, None) + } + }) + }) + .collect(); + let answers: Vec = racers.into_iter().map(|r| r.join().unwrap()).collect(); + let losers = answers + .iter() + .filter(|m| error_code(m) == Some(ErrorCode::NoSuchApproval)) + .count(); + assert_eq!(losers, 1, "round {round}: {answers:?}"); + final_answer(&mut stream); + let approvals = rig + .events() + .iter() + .filter(|e| matches!(e, AuditEvent::Approval { decision, .. } if *decision == approval)) + .count(); + assert_eq!(approvals, 1, "round {round}"); + } +} + +#[test] +fn approve_after_the_grant_file_is_removed_is_denied() { + let rig = Rig::new("admin-removed"); + ask_notes(&rig); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + rig.remove_grant("notes"); + let reason = DenyReason::NoGrant; + assert_eq!( + outcome(approve(&broker, approval)), + DecisionRecord::Denied { reason } + ); + assert_eq!(final_answer(&mut stream), denied(reason)); + assert_eq!(rt.count(), 0); +} + +#[test] +fn approve_after_the_taint_rose_past_the_grant_is_denied() { + let rig = Rig::new("admin-taint"); + let text = grant_text("read_file", "ask", "", "paths = [\"/n\"]") + .replace("max_taint = \"secret\"", "max_taint = \"private\""); + rig.grant("notes", &text); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + let s1 = SessionId::new("s1").unwrap(); + let secret = Label { + class: DataClass::Secret, + untrusted: false, + }; + rig.state() + .raise(&s1, rig.state().read(&s1).unwrap(), secret) + .unwrap(); + let reason = DenyReason::TaintTooHigh; + assert_eq!( + outcome(approve(&broker, approval)), + DecisionRecord::Denied { reason } + ); + assert_eq!(final_answer(&mut stream), denied(reason)); + assert_eq!(rt.count(), 0); +} + +#[test] +fn an_approval_that_cannot_be_recorded_is_denied_on_both_sides() { + let rig = Rig::new("admin-norecord"); + ask_notes(&rig); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + rig.switch.fail(true); + let reason = DenyReason::AuditUnavailable; + assert_eq!( + outcome(approve(&broker, approval)), + DecisionRecord::Denied { reason } + ); + assert_eq!(final_answer(&mut stream), denied(reason)); + assert_eq!(rt.count(), 0); +} + +#[test] +fn a_refusal_that_cannot_be_recorded_is_an_error_for_bxctl() { + let rig = Rig::new("admin-norefuse"); + ask_notes(&rig); + let broker = rig.broker(&Recording::answering("x")); + let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + rig.switch.fail(true); + match refuse(&broker, approval, None) { + Message::Error(e) => { + assert_eq!(e.code, ErrorCode::Internal); + assert!( + e.detail.ends_with("see docs/runbook.md#audit-unavailable"), + "{}", + e.detail + ); + } + other => panic!("{other:?}"), + } + assert_eq!( + final_answer(&mut stream), + denied(DenyReason::AuditUnavailable) + ); +} + +#[test] +fn expiry_denies_an_approval_nobody_answered() { + let rig = Rig::with_ttl("admin-expire", 100); + ask_notes(&rig); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, _) = pending(&broker, 1, r#"{"path":"/n/a"}"#); + let until = Instant::now() + Duration::from_secs(10); + while admin::expire_due(&broker, Timestamp::now()) == 0 { + assert!(Instant::now() < until, "never expired"); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + final_answer(&mut stream), + denied(DenyReason::ApprovalExpired) + ); + match &rig.events()[1] { + AuditEvent::Approval { answer, by, .. } => { + assert_eq!((*answer, by.as_deref()), (ApprovalAnswer::Expired, None)); + } + other => panic!("{other:?}"), + } + assert_eq!(rt.count(), 0); +} + +#[test] +fn check_grants_reports_every_problem_or_none() { + let rig = Rig::new("admin-grants"); + ask_notes(&rig); + let broker = rig.broker(&Recording::answering("x")); + match admin(&broker, Message::CheckGrants(Empty {})) { + Message::GrantsReport(r) => assert!(r.problems.is_empty(), "{r:?}"), + other => panic!("{other:?}"), + } + rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n"); + rig.grant( + "Worse", + &grant_text("read_file", "ask", "", "paths = [\"/n\"]"), + ); + match admin(&broker, Message::CheckGrants(Empty {})) { + Message::GrantsReport(r) => { + let files: Vec<&str> = r.problems.iter().map(|p| p.file.as_str()).collect(); + assert!(files.contains(&"bad.toml"), "{r:?}"); + assert!(files.contains(&"Worse.toml"), "{r:?}"); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn every_other_kind_on_admin_sock_is_forbidden() { + let rig = Rig::new("admin-forbidden"); + let broker = rig.broker(&Recording::answering("x")); + let req = request("s1", 1, "read_file", r#"{"path":"/n/a"}"#); + for msg in [ + Message::ToolRequest(req), + Message::Ok(Empty {}), + Message::ToolResponse(denied(DenyReason::NoGrant)), + ] { + assert_eq!(error_code(&admin(&broker, msg)), Some(ErrorCode::Forbidden)); + } + let lines = rig.lines.with("on admin.sock"); + assert_eq!(lines.len(), 3, "{:?}", rig.lines.all()); + assert!(lines[0].contains("tool_request"), "{}", lines[0]); + assert!( + lines + .iter() + .all(|l| l.ends_with("see docs/runbook.md#socket-forbidden")) + ); + assert!( + rig.events().is_empty(), + "a refused tool request decides nothing" + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/approvals.rs b/docs/plans/M3a/files/crates/brokerd/tests/approvals.rs new file mode 100644 index 0000000..ce395a3 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/approvals.rs @@ -0,0 +1,159 @@ +//! The pending-approval table: whoever takes an entry answers it, and everyone else finds it +//! gone. Do not edit. + +#[path = "support/build.rs"] +mod build; + +use std::sync::{Arc, Barrier}; + +use brokerd::approvals::{Table, Verdict}; +use brokerd::policy::{Ask, Outcome, SessionState, decide}; +use build::{grant, now, read, set, ts}; +use proto::{CallId, DataClass, DenyReason, Mode, PendingApproval, SessionId, Timestamp}; + +fn ask() -> Ask { + let grants = set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]); + match decide(read("/n/a"), &grants, SessionState::default(), now()) { + Outcome::Ask(ask) => ask, + other => panic!("the test's call does not ask: {other:?}"), + } +} + +fn info(approval: u64, expires: &str) -> PendingApproval { + PendingApproval { + approval, + session: SessionId::new("s1").unwrap(), + call: CallId(approval + 100), + tool: "read_file".to_string(), + arguments: r#"{"path":"/n/a"}"#.to_string(), + grant: "n".to_string(), + taint: DataClass::Private, + created: now(), + expires: ts(expires), + } +} + +const LATER: &str = "2026-09-18T12:15:00.000Z"; + +#[test] +fn a_new_table_is_empty_and_lists_in_id_order() { + let table = Table::new(); + assert_eq!(table.list(), []); + let _a = table.insert(info(7, LATER), ask()); + let _b = table.insert(info(3, LATER), ask()); + assert_eq!(table.list(), [info(3, LATER), info(7, LATER)]); +} + +#[test] +fn an_entry_can_be_taken_once() { + let table = Table::new(); + let _rx = table.insert(info(5, LATER), ask()); + let entry = table.take(5).expect("the entry is there"); + assert_eq!(entry.info, info(5, LATER)); + assert_eq!(entry.ask.grant(), "n"); + assert!(table.take(5).is_none(), "taken twice"); + assert_eq!(table.list(), []); +} + +#[test] +fn an_id_never_added_is_not_there() { + let table = Table::new(); + let _rx = table.insert(info(5, LATER), ask()); + assert!(table.take(6).is_none()); + assert_eq!(table.list().len(), 1); +} + +#[test] +fn the_verdict_goes_to_the_waiting_side() { + let table = Table::new(); + let rx = table.insert(info(5, LATER), ask()); + let entry = table.take(5).unwrap(); + entry + .reply + .send(Verdict::Denied(DenyReason::ApprovalRefused)) + .unwrap(); + match rx.recv().unwrap() { + Verdict::Denied(reason) => assert_eq!(reason, DenyReason::ApprovalRefused), + Verdict::Run(_) => panic!("the verdict changed on the way"), + } +} + +#[test] +fn an_entry_expires_at_its_expiry_and_not_before() { + let table = Table::new(); + let _a = table.insert(info(9, "2026-09-18T12:00:01.000Z"), ask()); + let _b = table.insert(info(2, "2026-09-18T12:00:00.500Z"), ask()); + let _c = table.insert(info(4, LATER), ask()); + + let before = ts("2026-09-18T12:00:00.499Z"); + assert!(table.take_expired(before).is_empty()); + + // Exactly at `expires` is expired. + let at = ts("2026-09-18T12:00:00.500Z"); + let due: Vec = table + .take_expired(at) + .iter() + .map(|e| e.info.approval) + .collect(); + assert_eq!(due, [2]); + + let after = ts("2026-09-18T13:00:00.000Z"); + let due: Vec = table + .take_expired(after) + .iter() + .map(|e| e.info.approval) + .collect(); + assert_eq!(due, [4, 9], "in id order"); + assert_eq!(table.list(), []); + assert!(table.take_expired(Timestamp::MAX).is_empty()); +} + +#[test] +fn two_takers_at_once_one_gets_it() { + for round in 0..100 { + let table = Arc::new(Table::new()); + let _rx = table.insert(info(1, LATER), ask()); + let start = Arc::new(Barrier::new(2)); + let takers: Vec<_> = (0..2) + .map(|_| { + let table = Arc::clone(&table); + let start = Arc::clone(&start); + std::thread::spawn(move || { + start.wait(); + table.take(1).is_some() + }) + }) + .collect(); + let got: Vec = takers.into_iter().map(|t| t.join().unwrap()).collect(); + assert_eq!( + got.iter().filter(|g| **g).count(), + 1, + "round {round}: {got:?}" + ); + } +} + +#[test] +fn a_taker_and_the_expiry_at_once_one_gets_it() { + for round in 0..100 { + let table = Arc::new(Table::new()); + let _rx = table.insert(info(1, "2026-09-18T12:00:00.000Z"), ask()); + let start = Arc::new(Barrier::new(2)); + let t = { + let (table, start) = (Arc::clone(&table), Arc::clone(&start)); + std::thread::spawn(move || { + start.wait(); + usize::from(table.take(1).is_some()) + }) + }; + let e = { + let (table, start) = (Arc::clone(&table), Arc::clone(&start)); + std::thread::spawn(move || { + start.wait(); + table.take_expired(now()).len() + }) + }; + let total = t.join().unwrap() + e.join().unwrap(); + assert_eq!(total, 1, "round {round}"); + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/args.rs b/docs/plans/M3a/files/crates/brokerd/tests/args.rs new file mode 100644 index 0000000..bbbc835 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/args.rs @@ -0,0 +1,428 @@ +//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit. +//! +//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here. + +use brokerd::args::{ + ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host, + valid_host, valid_host_pattern, valid_path, +}; + +#[test] +fn the_four_tool_names() { + let names = ["read_file", "write_file", "shell", "http_fetch"]; + for (tool, name) in ToolName::ALL.into_iter().zip(names) { + assert_eq!(tool.as_str(), name); + assert_eq!(ToolName::parse(name), Some(tool)); + } + for other in [ + "", + "echo", + "clock", + "call_tool", + "Read_File", + "read_file ", + "readfile", + ] { + assert_eq!(ToolName::parse(other), None, "{other:?}"); + } +} + +#[test] +fn valid_paths() { + let longest = format!("/{}", "a".repeat(MAX_PATH - 1)); + assert_eq!(longest.len(), MAX_PATH); + for path in [ + "/", + "/etc", + "/home/kyle/notes/a.md", + "/home/kyle/notes", + "/with space/and\ttab", + "/dots.in.names/..hidden/...", + "/unicode/\u{e9}t\u{e9}", + longest.as_str(), + ] { + assert!(valid_path(path), "{path:?} should be valid"); + } +} + +#[test] +fn invalid_paths() { + let too_long = format!("/{}", "a".repeat(MAX_PATH)); + assert_eq!(too_long.len(), MAX_PATH + 1); + for path in [ + "", + "notes/a.md", + "./notes", + "~/notes", + "/home/kyle/notes/../.ssh/id", + "/home/kyle//notes/./a.md", + "/home//kyle", + "/home/./kyle", + "/home/kyle/", + "/home/kyle/..", + "/..", + "/.", + "//", + "/nul\0byte", + too_long.as_str(), + ] { + assert!(!valid_path(path), "{path:?} should be invalid"); + } +} + +/// The table in the spec, row by row, for the rows about form and containment. +#[test] +fn inside_is_by_whole_components() { + let grant = "/home/kyle/notes"; + assert!(inside(grant, "/home/kyle/notes/a.md")); + assert!(inside(grant, "/home/kyle/notes")); + assert!(inside(grant, "/home/kyle/notes/deep/er/b.md")); + assert!(!inside(grant, "/home/kyle/notes2/a.md")); + assert!(!inside(grant, "/home/kyle/note")); + assert!(!inside(grant, "/home/kyle")); + assert!(!inside(grant, "/")); + assert!(!inside(grant, "/other/home/kyle/notes/a.md")); + // A grant of the root is refused when grants are loaded, but the function is still right. + assert!(inside("/", "/etc/passwd")); + assert!(inside("/", "/")); +} + +#[test] +fn valid_hosts_and_patterns() { + let label63 = "a".repeat(63); + let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57)); + assert_eq!(long.len(), 253); + for host in [ + "example.com", + "www.example.com", + "a.b.example.com", + "xn--bcher-kva.example", + "1password.com", + "3.example.org", + "a-b.c-d.io", + long.as_str(), + ] { + assert!(valid_host(host), "{host:?} should be a valid host"); + assert!( + valid_host_pattern(host), + "{host:?} should be a valid pattern" + ); + let wild = format!("*.{host}"); + assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host"); + } + assert!(valid_host_pattern("*.example.com")); + assert!(valid_host_pattern("*.a.b.example.com")); +} + +#[test] +fn invalid_hosts_and_patterns() { + let label64 = format!("{}.com", "a".repeat(64)); + let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join(".")); + assert!(too_long.len() > 253); + for host in [ + "", + "localhost", + "com", + "Example.com", + "example.COM", + "example.com.", + ".example.com", + "example..com", + "-example.com", + "example-.com", + "exa_mple.com", + "example.com:443", + "example.com/path", + "user@example.com", + "exa mple.com", + "[::1]", + "::1", + // Every spelling of an IPv4 address: the last label does not start with a letter. + "127.0.0.1", + "127.1", + "10.0.0.0x1", + "1.2.3.4", + "example.123", + "b\u{fc}cher.example", + label64.as_str(), + too_long.as_str(), + ] { + assert!(!valid_host(host), "{host:?} should not be a valid host"); + assert!( + !valid_host_pattern(host), + "{host:?} should not be a valid pattern" + ); + } + for pattern in [ + "*", + "*.", + "*.com", + "*example.com", + "www.*.com", + "*.*.example.com", + "**.example.com", + "*.Example.com", + "*.127.0.0.1", + ] { + assert!(!valid_host_pattern(pattern), "{pattern:?}"); + } +} + +/// The host table in the spec, row by row. +#[test] +fn host_matching() { + assert!(host_matches("example.com", "example.com")); + assert!(!host_matches("example.com", "www.example.com")); + assert!(host_matches("*.example.com", "www.example.com")); + assert!(host_matches("*.example.com", "a.b.example.com")); + assert!(!host_matches("*.example.com", "example.com")); + // A suffix is not enough: the match is by whole labels. + assert!(!host_matches("*.example.com", "badexample.com")); + assert!(!host_matches("*.example.com", "www.example.com.evil.org")); + assert!(!host_matches("example.com", "example.com.evil.org")); + assert!(!host_matches("*.example.com", ".example.com")); +} + +#[test] +fn valid_urls_and_their_hosts() { + let base = "https://example.com/"; + let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len())); + assert_eq!(longest.len(), MAX_URL); + for (url, host) in [ + ("https://example.com", "example.com"), + ("https://example.com/", "example.com"), + ("https://example.com:443", "example.com"), + ("https://example.com:443/", "example.com"), + ("https://www.example.com/a/b.html", "www.example.com"), + ("https://example.com/search?q=a+b&x=%20#frag", "example.com"), + ("https://example.com/@user", "example.com"), + ("https://example.com/a:8080/b", "example.com"), + ("https://example.com/https://other.org/", "example.com"), + ("https://example.com/back\\slash", "example.com"), + (longest.as_str(), "example.com"), + ] { + assert_eq!(url_host(url), Some(host), "{url}"); + } +} + +#[test] +fn invalid_urls() { + let base = "https://example.com/"; + let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1)); + assert_eq!(too_long.len(), MAX_URL + 1); + for url in [ + "", + "example.com", + "http://example.com/", + "HTTPS://example.com/", + "https:/example.com/", + "https://", + "https:///path", + "ftp://example.com/", + "file:///etc/passwd", + // userinfo + "https://user@example.com/", + "https://user:pw@example.com/", + "https://example.com@evil.org/", + // ports + "https://example.com:8443/", + "https://example.com:80/", + "https://example.com:/", + "https://example.com:443x/", + "https://example.com:4433/", + "https://example.com:443:443/", + // what follows the host must be the end, `:443` or `/` + "https://example.com?q=1", + "https://example.com#frag", + "https://example.com\\@evil.org/", + // hosts that are not host names + "https://localhost/", + "https://127.0.0.1/", + "https://127.1/", + "https://[::1]/", + "https://Example.com/", + "https://example.com./", + "https://b\u{fc}cher.example/", + // the rest must be printable ASCII with no space + "https://example.com/a b", + "https://example.com/a\tb", + "https://example.com/a\nb", + "https://example.com/caf\u{e9}", + "https://example.com/\u{7f}", + " https://example.com/", + "https://example.com/ ", + too_long.as_str(), + ] { + assert_eq!(url_host(url), None, "{url:?} should be invalid"); + } +} + +#[test] +fn each_tool_parses_its_own_arguments() { + assert_eq!( + parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#), + Ok(ToolArgs::ReadFile { + path: "/home/kyle/notes/a.md".to_string() + }) + ); + assert_eq!( + parse( + ToolName::WriteFile, + r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"# + ), + Ok(ToolArgs::WriteFile { + path: "/home/kyle/notes/a.md".to_string(), + content: "line\n".to_string() + }) + ); + assert_eq!( + parse(ToolName::Shell, r#"{"command":"ls -l"}"#), + Ok(ToolArgs::Shell { + command: "ls -l".to_string(), + cwd: None + }) + ); + assert_eq!( + parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#), + Ok(ToolArgs::Shell { + command: "ls".to_string(), + cwd: Some("/home/kyle".to_string()) + }) + ); + assert_eq!( + parse( + ToolName::HttpFetch, + r#"{"url":"https://www.example.com/a"}"# + ), + Ok(ToolArgs::HttpFetch { + url: "https://www.example.com/a".to_string(), + host: "www.example.com".to_string() + }) + ); + // Field order and white space in the request do not matter. + assert_eq!( + parse( + ToolName::WriteFile, + " { \"content\" : \"x\" , \"path\" : \"/a/b\" } " + ), + Ok(ToolArgs::WriteFile { + path: "/a/b".to_string(), + content: "x".to_string() + }) + ); + // `command` and `content` are not inspected. + assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok()); + assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok()); + assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok()); +} + +#[test] +fn arguments_of_the_wrong_shape_are_refused() { + let cases: [(ToolName, &str); 17] = [ + (ToolName::ReadFile, ""), + (ToolName::ReadFile, "null"), + (ToolName::ReadFile, "[]"), + (ToolName::ReadFile, r#""/etc/hosts""#), + (ToolName::ReadFile, "{}"), + (ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#), + (ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#), + (ToolName::ReadFile, r#"{"path":7}"#), + (ToolName::ReadFile, r#"{"path":null}"#), + (ToolName::ReadFile, r#"{"path":"/a"} trailing"#), + (ToolName::WriteFile, r#"{"path":"/a/b"}"#), + (ToolName::WriteFile, r#"{"content":"x"}"#), + ( + ToolName::WriteFile, + r#"{"path":"/a/b","content":"x","append":true}"#, + ), + (ToolName::Shell, r#"{"cwd":"/a"}"#), + (ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#), + (ToolName::Shell, r#"{"command":["ls"]}"#), + ( + ToolName::HttpFetch, + r#"{"url":"https://example.com/","method":"POST"}"#, + ), + ]; + for (tool, text) in cases { + match parse(tool, text) { + Err(ArgsError::Shape(_)) => {} + other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"), + } + } + // One tool's arguments do not fit another tool. + assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err()); + assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err()); +} + +#[test] +fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() { + for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] { + let quoted = serde_json::to_string(bad).unwrap(); + let read = format!(r#"{{"path":{quoted}}}"#); + let write = format!(r#"{{"path":{quoted},"content":"x"}}"#); + let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#); + assert_eq!( + parse(ToolName::ReadFile, &read), + Err(ArgsError::Path(bad.to_string())) + ); + assert_eq!( + parse(ToolName::WriteFile, &write), + Err(ArgsError::Path(bad.to_string())) + ); + assert_eq!( + parse(ToolName::Shell, &shell), + Err(ArgsError::Path(bad.to_string())) + ); + } + assert_eq!( + parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#), + Err(ArgsError::Url("http://example.com/".to_string())) + ); + // A NUL can only arrive as a JSON escape; it is refused once decoded. + let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\'); + assert!(matches!( + parse(ToolName::ReadFile, &nul), + Err(ArgsError::Path(_)) + )); + // `cwd: null` is the same as no `cwd`. + assert_eq!( + parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#), + Ok(ToolArgs::Shell { + command: "ls".to_string(), + cwd: None + }) + ); +} + +/// What the owner is shown is the parsed value written out again, so two spellings of one path +/// look the same. The escape is built from pieces so that no tool rewrites it on the way here. +#[test] +fn canonical_json_shows_what_was_parsed() { + let escaped_slash = format!("{}u002f", '\\'); + let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}"); + assert!(sneaky.contains("u002fetc")); + let args = parse(ToolName::ReadFile, &sneaky).unwrap(); + assert_eq!( + args, + ToolArgs::ReadFile { + path: "/etc/hosts".to_string() + } + ); + assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#); + + // Fields come out in the spec's order whatever order they came in. + let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap(); + assert_eq!( + write.canonical_json(), + r#"{"path":"/a/b","content":"x\ny"}"# + ); + let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap(); + assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#); + // An absent cwd is left out, and the host is never written: it is not an argument. + let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap(); + assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#); + let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap(); + assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#); + assert_eq!(fetch.tool(), ToolName::HttpFetch); + assert_eq!(write.tool(), ToolName::WriteFile); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/audit.rs b/docs/plans/M3a/files/crates/brokerd/tests/audit.rs new file mode 100644 index 0000000..a7b1b8b --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/audit.rs @@ -0,0 +1,258 @@ +//! The audit writer: the chain it writes, the lock, rollover, and stopping after a failed +//! write. Do not edit. Startup checks, recovery and accepted breaks are in `audit_startup.rs`. + +#[path = "support/audit_dir.rs"] +mod audit_dir; + +use std::os::unix::fs::PermissionsExt; + +use audit_dir::{D1, D2, TempDir, denied, lines, ts}; +use brokerd::audit::{AuditError, RECOVERED_NOTICE, Writer, verify_dir}; +use proto::{AuditRecord, Hash32, sha256}; + +fn mode(path: &std::path::Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +#[test] +fn the_first_record_starts_the_chain() { + let dir = TempDir::unmade("first"); + let opened = Writer::open(&dir.path, false).unwrap(); + assert!(!opened.recovered); + assert!(opened.accepted.is_none()); + let mut writer = opened.writer; + assert_eq!(writer.next_seq(), 0); + + let seq = writer + .append(ts("2026-09-17T08:00:00.000Z"), denied(1)) + .unwrap(); + assert_eq!(seq, 0); + assert_eq!(writer.next_seq(), 1); + + let text = std::fs::read_to_string(dir.path.join(D1)).unwrap(); + assert!(text.ends_with('\n'), "a record is one line and its newline"); + assert_eq!(text.lines().count(), 1); + let record: AuditRecord = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!((record.seq, record.prev), (0, Hash32::ZERO)); + assert_eq!(record.time, ts("2026-09-17T08:00:00.000Z")); + assert_eq!(record.event, denied(1)); + + assert_eq!(mode(&dir.path), 0o700, "the directory open() made"); + assert_eq!(mode(&dir.path.join(D1)), 0o600); +} + +#[test] +fn the_chain_runs_across_a_day_boundary() { + let dir = TempDir::unmade("days"); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + let times = [ + "2026-09-17T23:59:58.000Z", + "2026-09-17T23:59:59.999Z", + "2026-09-18T00:00:00.000Z", + "2026-09-18T00:00:01.000Z", + ]; + for (i, time) in times.iter().enumerate() { + assert_eq!(writer.append(ts(time), denied(i as u64)).unwrap(), i as u64); + } + let (day1, day2) = (lines(&dir.path, D1), lines(&dir.path, D2)); + assert_eq!((day1.len(), day2.len()), (2, 2)); + + // seq goes on across files, and the new file chains from the last line of the old one. + let first: AuditRecord = serde_json::from_str(&day2[0]).unwrap(); + assert_eq!(first.seq, 2); + assert_eq!(first.prev, sha256(day1[1].as_bytes()).unwrap()); + + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None); + assert_eq!((report.records, report.next_seq), (4, 4)); + assert!(report.clock_warnings.is_empty()); +} + +#[test] +fn reopening_continues_the_chain() { + let dir = TempDir::unmade("reopen"); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + writer + .append(ts("2026-09-17T08:00:00.000Z"), denied(1)) + .unwrap(); + writer + .append(ts("2026-09-17T08:00:01.000Z"), denied(2)) + .unwrap(); + drop(writer); + + // One file: the whole of it is checked. + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + assert_eq!(writer.next_seq(), 2); + assert_eq!( + writer + .append(ts("2026-09-18T08:00:00.000Z"), denied(3)) + .unwrap(), + 2 + ); + drop(writer); + + // Two files: the latest is checked, resumed from the last line of the one before. + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + assert_eq!(writer.next_seq(), 3); + assert_eq!( + writer + .append(ts("2026-09-18T08:00:01.000Z"), denied(4)) + .unwrap(), + 3 + ); + drop(writer); + + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None); + assert_eq!(report.records, 4); +} + +#[test] +fn an_empty_latest_file_gets_the_next_record_as_its_first_line() { + let dir = TempDir::case("empty-latest", None); + let opened = Writer::open(&dir.path, false).unwrap(); + assert!(!opened.recovered); + let mut writer = opened.writer; + assert_eq!( + writer + .append(ts("2026-09-18T08:00:00.000Z"), denied(9)) + .unwrap(), + 5 + ); + + let day2 = lines(&dir.path, D2); + assert_eq!(day2.len(), 1); + let record: AuditRecord = serde_json::from_str(&day2[0]).unwrap(); + assert_eq!( + record.prev, + sha256(lines(&dir.path, D1)[4].as_bytes()).unwrap() + ); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None); +} + +#[test] +fn a_second_writer_is_refused() { + let dir = TempDir::unmade("lock"); + let first = Writer::open(&dir.path, false).unwrap(); + let error = Writer::open(&dir.path, false).unwrap_err(); + assert!(matches!(error, AuditError::Locked), "{error}"); + let text = error.to_string(); + assert!(text.starts_with("brokerd is already running"), "{text}"); + assert!( + text.ends_with("see docs/runbook.md#brokerd-already-running"), + "{text}" + ); + + // The lock goes when the writer goes, however that happens. + drop(first); + assert!(Writer::open(&dir.path, false).is_ok()); +} + +#[test] +fn the_writer_never_goes_back_to_an_earlier_file() { + let dir = TempDir::unmade("clock"); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + writer + .append(ts("2026-09-18T00:00:05.000Z"), denied(1)) + .unwrap(); + // The clock is stepped back over midnight. + writer + .append(ts("2026-09-17T23:59:50.000Z"), denied(2)) + .unwrap(); + + assert!( + !dir.path.join(D1).exists(), + "a record went into an earlier file" + ); + assert_eq!(lines(&dir.path, D2).len(), 2); + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None); + assert_eq!(report.clock_warnings.len(), 1); + + // It holds across a restart too. + drop(writer); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + writer + .append(ts("2026-09-17T23:59:55.000Z"), denied(3)) + .unwrap(); + assert!(!dir.path.join(D1).exists()); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None); +} + +#[test] +fn files_that_are_not_log_files_are_ignored() { + let dir = TempDir::case("good", None); + std::fs::write(dir.path.join("notes.txt"), "not a log\n").unwrap(); + std::fs::write(dir.path.join("2026-09-19.jsonl.bak"), "not a log\n").unwrap(); + std::fs::write(dir.path.join("latest.jsonl"), "not a log\n").unwrap(); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + assert_eq!(writer.next_seq(), 10); + writer + .append(ts("2026-09-18T10:00:00.000Z"), denied(9)) + .unwrap(); + assert_eq!(verify_dir(&dir.path).unwrap().records, 11); +} + +/// After one failed write the writer writes nothing more, even when the cause has gone: part of +/// a line may be on disk, and only the next start deals with that. +#[test] +fn a_failed_write_stops_the_writer() { + let dir = TempDir::unmade("sticky"); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + writer + .append(ts("2026-09-17T08:00:00.000Z"), denied(1)) + .unwrap(); + + // A new day needs a new file, and the directory no longer allows one. + std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o500)).unwrap(); + if std::fs::write(dir.path.join("probe"), "").is_ok() { + eprintln!("skipped: this user can write to a read-only directory (root?)"); + std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap(); + return; + } + let error = writer + .append(ts("2026-09-18T08:00:00.000Z"), denied(2)) + .unwrap_err(); + assert!(matches!(error, AuditError::Io { .. }), "{error}"); + assert!( + error + .to_string() + .ends_with("see docs/runbook.md#audit-unavailable"), + "{error}" + ); + + std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap(); + for time in ["2026-09-18T08:00:01.000Z", "2026-09-17T08:00:02.000Z"] { + let error = writer.append(ts(time), denied(3)).unwrap_err(); + assert!(matches!(error, AuditError::Stopped), "{error}"); + assert!( + error + .to_string() + .ends_with("see docs/runbook.md#audit-unavailable"), + "{error}" + ); + } + assert_eq!( + lines(&dir.path, D1).len(), + 1, + "a stopped writer wrote something" + ); + assert!(!dir.path.join(D2).exists()); + + // A restart puts it right. + drop(writer); + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + assert_eq!( + writer + .append(ts("2026-09-18T08:00:03.000Z"), denied(4)) + .unwrap(), + 1 + ); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None); +} + +#[test] +fn the_recovered_notice_names_its_runbook_entry() { + assert!(RECOVERED_NOTICE.starts_with("audit: recovered a torn final line")); + assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered")); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs b/docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs new file mode 100644 index 0000000..79bea2a --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs @@ -0,0 +1,300 @@ +//! What `Writer::open` does with the log it finds: refuse a broken chain, recover a torn tail, +//! accept a break when told to. Do not edit. The fixture logs are in +//! `crates/proto/tests/fixtures/audit/`; each test works on a copy. + +#[path = "support/audit_dir.rs"] +mod audit_dir; + +use audit_dir::{D1, D2, TempDir, denied, lines, snapshot}; +use brokerd::audit::{AuditError, Writer, verify_dir}; +use proto::{AuditEvent, AuditRecord, Location, Timestamp}; + +type Case = ( + &'static str, + Option<&'static [&'static str]>, + &'static str, + u64, + &'static str, +); + +fn at(file: &str, line: u64) -> Location { + Location { + file: file.to_string(), + line, + } +} + +/// An ordinary start checks the latest file only, so each damaged file is copied alone: it is +/// then the latest. Nothing may be written to a log that does not verify. +#[test] +fn a_broken_chain_refuses_to_start_and_writes_nothing() { + let parse = "does not parse as an audit record"; + // (case, the files to copy, then the failure's file, line and text) + let cases: [Case; 9] = [ + ( + "changed-byte", + Some(&[D1]), + D1, + 4, + "prev is not the hash of the line before", + ), + ("deleted-line", Some(&[D1]), D1, 3, "seq is 3, expected 2"), + ("swapped-lines", Some(&[D1]), D1, 2, "seq is 2, expected 1"), + ("seq-gap", None, D1, 3, "seq is 3, expected 2"), + ("cut-short", Some(&[D1]), D1, 3, parse), + // Both files: the latest does not chain from the last line of the one before. + ( + "file-not-chained", + None, + D2, + 1, + "does not chain from the last line of the file before", + ), + ( + "break-without-failure", + None, + D2, + 6, + "an accepted break with no failure before it", + ), + ("recovery-wrong-hash", None, D2, 6, parse), + ("torn-recovery", None, D2, 6, parse), + ]; + for (case, only, file, line, what) in cases { + let dir = TempDir::case(case, only); + let before = snapshot(&dir.path); + let error = Writer::open(&dir.path, false) + .err() + .unwrap_or_else(|| panic!("{case}: started")); + let AuditError::Broken(failure) = &error else { + panic!("{case}: {error}"); + }; + assert_eq!( + (failure.file.as_str(), failure.line, failure.what.as_str()), + (file, line, what), + "{case}" + ); + let text = error.to_string(); + assert!( + text.starts_with(&format!("{file}:{line}: {what}")), + "{case}: {text}" + ); + assert!( + text.ends_with("see docs/runbook.md#audit-chain-broken"), + "{case}: {text}" + ); + assert_eq!( + snapshot(&dir.path), + before, + "{case}: the log was written to" + ); + } +} + +/// A torn tail is recovered: the torn bytes stay, a newline ends them if one is missing, and a +/// `Recovery` record follows in the same file, whatever today's date is. +#[test] +fn a_torn_tail_is_recovered() { + // (case, the torn line's file and number, newline already there) + let cases = [ + ("torn-tail", D2, 6, false), + ("torn-tail-complete-json", D2, 6, false), + ("torn-unparseable-newline", D2, 6, true), + ("torn-first-line", D2, 1, false), + ]; + for (case, file, line, has_newline) in cases { + let dir = TempDir::case(case, None); + let before = snapshot(&dir.path); + let opened = Writer::open(&dir.path, false).unwrap_or_else(|e| panic!("{case}: {e}")); + assert!(opened.recovered, "{case}"); + assert!(opened.accepted.is_none(), "{case}"); + + let after = snapshot(&dir.path); + assert_eq!( + after.len(), + before.len(), + "{case}: the Recovery went into a new file" + ); + let (old, new) = (&before[file], &after[file]); + assert!( + new.starts_with(old), + "{case}: bytes already on disk were changed" + ); + let added = &new[old.len()..]; + // One newline to end the torn line if it had none, then one line. + let added = if has_newline { + added + } else { + added.strip_prefix(b"\n").expect(case) + }; + assert_eq!(added.iter().filter(|b| **b == b'\n').count(), 1, "{case}"); + let record: AuditRecord = + serde_json::from_slice(added.strip_suffix(b"\n").expect(case)).expect(case); + assert!( + matches!(record.event, AuditEvent::Recovery { .. }), + "{case}" + ); + + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None, "{case}"); + assert_eq!(report.torn_tail, None, "{case}"); + assert_eq!(report.recoveries, vec![at(file, line)], "{case}"); + + // The chain goes on from the Recovery, and the next start finds nothing to recover. + let mut writer = opened.writer; + assert_eq!( + writer.append(Timestamp::now(), denied(9)).unwrap(), + record.seq + 1, + "{case}" + ); + drop(writer); + let opened = Writer::open(&dir.path, false).unwrap(); + assert!(!opened.recovered, "{case}"); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None, "{case}"); + } +} + +/// Damage in an older file is not seen by an ordinary start. `bxctl audit verify` sees it, and +/// `--accept-break` must too: it verifies the whole log. +#[test] +fn a_break_in_an_older_file_can_be_accepted() { + let dir = TempDir::case("changed-byte", None); + let before = snapshot(&dir.path); + drop(Writer::open(&dir.path, false).expect("the latest file verifies")); + assert_eq!(snapshot(&dir.path), before); + let failure = verify_dir(&dir.path).unwrap().failure.unwrap(); + assert_eq!((failure.file.as_str(), failure.line), (D1, 4)); + + let opened = Writer::open(&dir.path, true).unwrap(); + assert!(!opened.recovered); + assert_eq!( + opened.accepted.as_ref().map(|f| (f.file.as_str(), f.line)), + Some((D1, 4)) + ); + let after = snapshot(&dir.path); + assert_eq!(after[D1], before[D1], "nothing is repaired"); + assert!(after[D2].starts_with(&before[D2])); + let last: AuditRecord = serde_json::from_str(lines(&dir.path, D2).last().unwrap()).unwrap(); + assert_eq!( + last.event, + AuditEvent::AcceptedBreak { + file: D1.to_string(), + line: 4, + last_good: failure.last_good, + } + ); + assert_eq!( + last.seq, 10, + "seq 3 for the failing line, and seven lines to the break" + ); + + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None); + assert_eq!(report.accepted_breaks, vec![at(D2, 6)]); + + let mut writer = opened.writer; + assert_eq!(writer.append(Timestamp::now(), denied(9)).unwrap(), 11); + drop(writer); + + // The next ordinary start resumes at the latest file and meets a break that names a file + // it has not read. + let mut writer = Writer::open(&dir.path, false).unwrap().writer; + assert_eq!(writer.append(Timestamp::now(), denied(10)).unwrap(), 12); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None); +} + +#[test] +fn a_break_in_the_latest_file_can_be_accepted() { + // (case, failing line, seq of the break record) + for (case, line, seq) in [("recovery-wrong-hash", 6, 12), ("torn-recovery", 6, 12)] { + let dir = TempDir::case(case, None); + let before = snapshot(&dir.path); + let opened = Writer::open(&dir.path, true).unwrap_or_else(|e| panic!("{case}: {e}")); + assert_eq!( + opened.accepted.as_ref().map(|f| f.line), + Some(line), + "{case}" + ); + let after = snapshot(&dir.path); + assert!(after[D2].starts_with(&before[D2]), "{case}"); + + // torn-recovery ends without a newline: the break record must start on its own line. + let all = lines(&dir.path, D2); + let last: AuditRecord = serde_json::from_str(all.last().unwrap()).expect(case); + assert!( + matches!(last.event, AuditEvent::AcceptedBreak { .. }), + "{case}" + ); + assert_eq!(last.seq, seq, "{case}"); + assert_eq!(all.len(), 8, "{case}"); + + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None, "{case}"); + assert_eq!(report.accepted_breaks, vec![at(D2, 8)], "{case}"); + drop(opened); + assert!(Writer::open(&dir.path, false).is_ok(), "{case}"); + } +} + +/// The short check is a shortcut and never the last word: when it cannot be made, or fails, the +/// whole log is verified and that verdict stands. Here the last line of the older file is the +/// damage, so there is nothing to resume from. +#[test] +fn an_accepted_break_at_the_end_of_an_older_file_does_not_stop_later_starts() { + let dir = TempDir::case("good", None); + let day1 = std::fs::read_to_string(dir.path.join(D1)).unwrap(); + let cut = format!("{}\n", &day1[..day1.len() - 40]); + std::fs::write(dir.path.join(D1), cut).unwrap(); + + let error = Writer::open(&dir.path, false).unwrap_err(); + let AuditError::Broken(failure) = &error else { + panic!("{error}"); + }; + assert_eq!((failure.file.as_str(), failure.line), (D1, 5)); + + drop(Writer::open(&dir.path, true).unwrap()); + let mut writer = Writer::open(&dir.path, false) + .expect("the break was accepted") + .writer; + writer.append(Timestamp::now(), denied(9)).unwrap(); + assert_eq!(verify_dir(&dir.path).unwrap().failure, None); +} + +#[test] +fn accept_break_with_nothing_to_accept_is_an_error() { + for case in ["good", "torn-tail"] { + let dir = TempDir::case(case, None); + let before = snapshot(&dir.path); + let error = Writer::open(&dir.path, true).unwrap_err(); + assert!( + matches!(error, AuditError::NothingToAccept), + "{case}: {error}" + ); + assert!(error.to_string().starts_with("nothing to accept"), "{case}"); + assert_eq!( + snapshot(&dir.path), + before, + "{case}: the log was written to" + ); + } +} + +/// A second failure after an accepted break needs its own break. +#[test] +fn damage_after_a_break_is_a_new_failure() { + let dir = TempDir::case("accepted-break", None); + drop(Writer::open(&dir.path, false).expect("the fixture verifies")); + let mut text = std::fs::read_to_string(dir.path.join(D1)).unwrap(); + text.push_str("{}\n{}\n"); + std::fs::write(dir.path.join(D1), text).unwrap(); + + let error = Writer::open(&dir.path, false).unwrap_err(); + let AuditError::Broken(failure) = &error else { + panic!("{error}"); + }; + assert_eq!((failure.file.as_str(), failure.line), (D1, 8)); + drop(Writer::open(&dir.path, true).unwrap()); + let report = verify_dir(&dir.path).unwrap(); + assert_eq!(report.failure, None); + assert_eq!(report.accepted_breaks, vec![at(D1, 6), at(D1, 10)]); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/broker.rs b/docs/plans/M3a/files/crates/brokerd/tests/broker.rs new file mode 100644 index 0000000..5f5a20e --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/broker.rs @@ -0,0 +1,324 @@ +//! One `broker.sock` connection: decision, record, run, answer; and every way it fails closed. +//! Do not edit. + +#[path = "support/client.rs"] +mod client; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixStream; +use std::sync::Arc; + +use brokerd::broker; +use brokerd::ledger::NOT_RECORDED; +use brokerd::runner::{REFUSING, RunError}; +use client::{Serve, call, next, open}; +use proto::{ + ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, + Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse, + Turn, TurnDone, TurnEvent, Usage, WireError, +}; +use rig::{Rig, grant_text, request}; +use runtime::Recording; + +const NOTES: &str = r#"{"path":"/n/a"}"#; + +fn read_notes(call_id: u64) -> proto::ToolRequest { + request("s1", call_id, "read_file", NOTES) +} + +fn only(frames: &[Envelope]) -> &ToolResponse { + assert_eq!(frames.len(), 1, "{frames:?}"); + assert!(frames[0].r#final); + match &frames[0].msg { + Message::ToolResponse(r) => r, + other => panic!("{other:?}"), + } +} + +fn denied(reason: DenyReason) -> ToolResponse { + ToolResponse::Denied { reason } +} + +fn auto_notes(rig: &Rig, class: &str) { + let text = grant_text("read_file", "auto", "", "paths = [\"/n\"]").replace( + "result_class = \"private\"", + &format!("result_class = \"{class}\""), + ); + rig.grant("notes", &text); +} + +#[test] +fn a_call_no_grant_allows_is_denied_and_recorded() { + let rig = Rig::new("broker-nogrant"); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let frames = call(&broker, read_notes(41)); + assert_eq!(frames[0].id, 41, "the answer carries the request's id"); + assert_eq!(only(&frames), &denied(DenyReason::NoGrant)); + assert_eq!(rt.count(), 0); + match rig.events().as_slice() { + [AuditEvent::Decision { outcome, .. }] => assert_eq!( + *outcome, + DecisionRecord::Denied { + reason: DenyReason::NoGrant + } + ), + other => panic!("{other:?}"), + } +} + +#[test] +fn an_allowed_call_runs_and_its_result_raises_the_state() { + let rig = Rig::new("broker-allowed"); + auto_notes(&rig, "secret"); + let rt = Recording::answering("the notes"); + let broker = rig.broker(&rt); + let frames = call(&broker, read_notes(7)); + assert_eq!( + only(&frames), + &ToolResponse::Result { + content: "the notes".to_string(), + class: DataClass::Secret, + untrusted: false, + truncated: false, + } + ); + assert_eq!(rt.count(), 1); + let events = rig.events(); + assert!(matches!( + events.as_slice(), + [ + AuditEvent::Decision { .. }, + AuditEvent::Result { + decision: 0, + status: ResultStatus::Result, + taint_after: DataClass::Secret, + .. + } + ] + )); + let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); + assert_eq!(state.taint, DataClass::Secret); +} + +#[test] +fn a_runtime_failure_is_passed_on_and_recorded_as_failed() { + let rig = Rig::new("broker-refusing"); + auto_notes(&rig, "secret"); + let rt = Recording::with(Err(RunError::Unavailable(REFUSING.to_string()))); + let broker = rig.broker(&rt); + let frames = call(&broker, read_notes(1)); + assert_eq!( + only(&frames), + &ToolResponse::Failed { + message: REFUSING.to_string() + } + ); + match &rig.events()[1] { + AuditEvent::Result { + status, + taint_after, + .. + } => assert_eq!( + (*status, *taint_after), + (ResultStatus::Failed, DataClass::Private) + ), + other => panic!("{other:?}"), + } + assert!(!rig.state_file("s1").exists(), "a failure changes no state"); +} + +fn every_kind_but_tool_request() -> Vec { + let usage = Usage { + cache_n: 0, + prompt_n: 0, + predicted_n: 0, + reasoning_tokens: 0, + thinking_capped: false, + }; + vec![ + Message::ToolResponse(denied(DenyReason::NoGrant)), + Message::Error(WireError { + code: ErrorCode::Internal, + detail: String::new(), + }), + Message::Turn(Turn { + session: SessionId::new("s1").unwrap(), + content: String::new(), + resume: false, + }), + Message::TurnEvent(TurnEvent::Content { + text: String::new(), + }), + Message::TurnDone(TurnDone { + content: String::new(), + usage, + }), + Message::Approvals(Empty {}), + Message::ApprovalList(ApprovalList { items: Vec::new() }), + Message::Approve(Approve { approval: 0 }), + Message::ApproveResult(ApproveResult { + outcome: DecisionRecord::Allowed {}, + }), + Message::Refuse(Refuse { + approval: 0, + reason: None, + }), + Message::Ok(Empty {}), + Message::CheckGrants(Empty {}), + Message::GrantsReport(GrantsReport { + problems: Vec::new(), + }), + ] +} + +#[test] +fn every_other_kind_on_broker_sock_is_forbidden() { + let rig = Rig::new("broker-forbidden"); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let kinds = every_kind_but_tool_request(); + assert_eq!(kinds.len(), 13, "every Message variant but tool_request"); + for (i, msg) in kinds.into_iter().enumerate() { + let id = 100 + i as u64; + let mut stream = open(&broker, broker::handle, id, msg); + let env = next(&mut stream); + assert_eq!((env.id, env.r#final), (id, true)); + match env.msg { + Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden), + other => panic!("{other:?}"), + } + } + let lines = rig.lines.with("broker.sock"); + assert_eq!(lines.len(), 13, "{:?}", rig.lines.all()); + for line in &lines { + assert!( + line.ends_with("\nsee docs/runbook.md#socket-forbidden"), + "{line}" + ); + } + assert_eq!(rig.lines.with("kind approve on broker.sock").len(), 1); + assert!( + rig.events().is_empty(), + "nothing is recorded for a refused kind" + ); +} + +#[test] +fn a_frame_that_is_not_json_is_answered_with_bad_message() { + let rig = Rig::new("broker-badframe"); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut client, server) = UnixStream::pair().unwrap(); + let b = Arc::clone(&broker); + std::thread::spawn(move || broker::handle(server, &b)); + client.write_all(&3u32.to_be_bytes()).unwrap(); + client.write_all(b"{{{").unwrap(); + let env = next(&mut client); + assert_eq!((env.id, env.r#final), (0, true)); + match env.msg { + Message::Error(e) => assert_eq!(e.code, ErrorCode::BadMessage), + other => panic!("{other:?}"), + } + assert!(rig.events().is_empty()); +} + +#[test] +fn one_invalid_grant_file_denies_a_call_a_valid_file_would_allow() { + let rig = Rig::new("broker-invalid"); + auto_notes(&rig, "private"); + rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n"); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + for id in [1, 2] { + let frames = call(&broker, read_notes(id)); + assert_eq!(only(&frames), &denied(DenyReason::GrantsInvalid)); + } + assert_eq!(rt.count(), 0); + // Printed once for the two calls, with the pointer. + let printed = rig.lines.with("bad.toml"); + assert_eq!(printed.len(), 1, "{:?}", rig.lines.all()); + assert!( + printed[0].ends_with("see docs/runbook.md#grants-invalid"), + "{}", + printed[0] + ); + rig.remove_grant("bad"); + let frames = call(&broker, read_notes(3)); + assert!(matches!(only(&frames), ToolResponse::Result { .. })); + assert_eq!(rt.count(), 1); +} + +#[test] +fn a_damaged_session_state_is_state_unreadable() { + let rig = Rig::new("broker-state"); + auto_notes(&rig, "private"); + std::fs::create_dir_all(rig.cfg.state_dir()).unwrap(); + std::fs::write(rig.state_file("s1"), "{\"taint\":\"loud\"}").unwrap(); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let frames = call(&broker, read_notes(1)); + assert_eq!(only(&frames), &denied(DenyReason::StateUnreadable)); + assert_eq!(rt.count(), 0); + let lines = rig.lines.with("s1.json"); + assert!( + lines + .iter() + .any(|l| l.ends_with("see docs/runbook.md#broker-state-damaged")), + "{lines:?}" + ); +} + +#[test] +fn a_failed_audit_write_runs_nothing_now_or_later() { + let rig = Rig::new("broker-audit"); + auto_notes(&rig, "private"); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + rig.switch.fail(true); + let frames = call(&broker, read_notes(1)); + assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable)); + rig.switch.fail(false); + let frames = call(&broker, read_notes(2)); + assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable)); + assert_eq!(rt.count(), 0); + assert!( + !rig.lines + .with("see docs/runbook.md#audit-unavailable") + .is_empty() + ); +} + +#[test] +fn a_state_that_cannot_be_written_withholds_the_content() { + if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") { + return; + } + let rig = Rig::new("broker-ro"); + auto_notes(&rig, "secret"); + let dir = rig.cfg.state_dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let rt = Recording::answering("the secret"); + let broker = rig.broker(&rt); + let frames = call(&broker, read_notes(1)); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + only(&frames), + &ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert_eq!(rt.count(), 1, "it ran; its content is what is withheld"); + let text = format!("{frames:?}"); + assert!(!text.contains("the secret"), "{text}"); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/broker_pending.rs b/docs/plans/M3a/files/crates/brokerd/tests/broker_pending.rs new file mode 100644 index 0000000..54e8093 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/broker_pending.rs @@ -0,0 +1,230 @@ +//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester +//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit. + +#[path = "support/client.rs"] +mod client; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use brokerd::broker::{self, Broker, GONE}; +use brokerd::ledger::Answer; +use client::{Serve, next, open}; +use proto::{ + AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp, + ToolResponse, +}; +use rig::{Rig, grant_text, request}; +use runtime::Recording; + +fn ask_notes(rig: &Rig, extra: &str) { + rig.grant( + "notes", + &grant_text("read_file", "ask", extra, "paths = [\"/n\"]"), + ); +} + +/// Sends a call and reads its pending frame. Returns the connection and the frame's values. +fn start(broker: &Arc, arguments: &str) -> (UnixStream, u64, Timestamp) { + let req = request("s1", 9, "read_file", arguments); + let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req)); + let env = next(&mut stream); + assert_eq!((env.id, env.r#final), (9, false), "{env:?}"); + match env.msg { + Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => { + (stream, approval, expires) + } + other => panic!("{other:?}"), + } +} + +/// What `admin` does for an entry it took: record the answer, send the verdict. +fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord { + let entry = broker.table().take(id).expect("the entry is pending"); + answer_entry(broker, entry, answer) +} + +fn answer_entry( + broker: &Broker, + entry: brokerd::approvals::Entry, + answer: Answer, +) -> DecisionRecord { + let grants = broker.grants(); + let done = broker.ledger().answer( + entry.ask, + entry.info.approval, + answer, + &grants, + Timestamp::now(), + ); + entry.reply.send(done.verdict).unwrap(); + done.outcome +} + +fn approved() -> Answer { + Answer::Approved { + by: Some("bxctl".to_string()), + } +} + +fn final_answer(stream: &mut UnixStream) -> ToolResponse { + let env = next(stream); + assert!(env.r#final, "{env:?}"); + match env.msg { + Message::ToolResponse(r) => r, + other => panic!("{other:?}"), + } +} + +fn eventually(what: &str, mut done: impl FnMut() -> bool) { + let until = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < until, "never happened: {what}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn an_ask_call_waits_is_listed_and_runs_when_approved() { + let rig = Rig::new("pending-approve"); + ask_notes(&rig, ""); + let rt = Recording::answering("the notes"); + let broker = rig.broker(&rt); + let before = Timestamp::now(); + // Spaced JSON: the table shows the arguments as policy parsed them, not as sent. + let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#); + assert_eq!(approval, 0, "the approval id is the decision's seq"); + let items = broker.table().list(); + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!(item.approval, 0); + assert_eq!((item.session.as_str(), item.call.0), ("s1", 9)); + assert_eq!(item.tool, "read_file"); + assert_eq!(item.arguments, r#"{"path":"/n/a"}"#); + assert_eq!( + (item.grant.as_str(), item.taint), + ("notes", DataClass::Private) + ); + assert!(item.created >= before); + assert_eq!(item.expires, expires); + assert_eq!( + expires.unix_millis() - item.created.unix_millis(), + 900_000, + "now plus ttl_ms" + ); + assert_eq!(rt.count(), 0, "nothing runs while it waits"); + + assert_eq!( + answer_by_hand(&broker, 0, approved()), + DecisionRecord::Ask {} + ); + assert!(matches!( + final_answer(&mut stream), + ToolResponse::Result { .. } + )); + assert_eq!(rt.count(), 1); + let events = rig.events(); + assert!( + matches!( + events.as_slice(), + [ + AuditEvent::Decision { .. }, + AuditEvent::Approval { decision: 0, .. }, + AuditEvent::Result { decision: 0, .. } + ] + ), + "{events:?}" + ); +} + +#[test] +fn the_expiry_is_the_grants_when_that_is_earlier() { + let rig = Rig::new("pending-grant-expiry"); + let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap(); + ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339())); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#); + assert_eq!(expires, soon); +} + +#[test] +fn a_denied_verdict_reaches_the_requester_and_nothing_runs() { + let rig = Rig::new("pending-refuse"); + ask_notes(&rig, ""); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#); + let refused = Answer::Refused { + by: Some("bxctl".to_string()), + reason: None, + }; + answer_by_hand(&broker, approval, refused); + assert_eq!( + final_answer(&mut stream), + ToolResponse::Denied { + reason: DenyReason::ApprovalRefused + } + ); + assert_eq!(rt.count(), 0); +} + +#[test] +fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() { + let rig = Rig::new("pending-leave"); + ask_notes(&rig, ""); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#); + drop(stream); + // The waiting thread looks at its connection every second. + eventually("the entry is removed", || broker.table().list().is_empty()); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(rig.events().len(), 1, "only the decision"); + let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap(); + assert_eq!(report.abandoned, [0]); + assert!(report.failure.is_none()); + assert_eq!(rt.count(), 0); +} + +#[test] +fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() { + let rig = Rig::new("pending-lastlook"); + ask_notes(&rig, ""); + let rt = Recording::answering("x"); + let broker = rig.broker(&rt); + let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#); + // Take the entry first, so the waiting thread cannot take it back when it sees the + // connection gone; then approve. + let entry = broker.table().take(approval).unwrap(); + drop(stream); + answer_entry(&broker, entry, approved()); + eventually("the call is closed", || rig.events().len() == 3); + match &rig.events()[2] { + AuditEvent::Result { + status, + decision, + sha256, + bytes, + .. + } => { + assert_eq!((*status, *decision), (ResultStatus::Failed, 0)); + assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap()); + assert_eq!(*bytes, GONE.len() as u64); + } + other => panic!("{other:?}"), + } + assert_eq!(GONE, "the requester went away"); + assert_eq!(rt.count(), 0); + let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap(); + assert!(report.unfinished.is_empty() && report.abandoned.is_empty()); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/broker_sequence.rs b/docs/plans/M3a/files/crates/brokerd/tests/broker_sequence.rs new file mode 100644 index 0000000..9976f1d --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/broker_sequence.rs @@ -0,0 +1,238 @@ +//! Properties over sequences of calls, through `broker::handle` with many threads at once: +//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the +//! record that let its call run; the runtime sees a call only after `allowed` or an approval. +//! Do not edit. + +#[path = "support/client.rs"] +mod client; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/runtime.rs"] +mod runtime; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use brokerd::ledger::Answer; +use client::{Serve, call}; +use proto::{ + AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp, + ToolResponse, +}; +use rig::{Rig, grant_text, request}; +use runtime::Recording; + +fn grants(rig: &Rig) { + let with = |class: &str, max: &str| { + grant_text("read_file", "auto", "", "") + .replace( + "result_class = \"private\"", + &format!("result_class = \"{class}\""), + ) + .replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\"")) + }; + rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n")); + rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n")); + // Stops applying once a session has read a secret. + rig.grant( + "public", + &(with("public", "private") + "paths = [\"/p\"]\n"), + ); + rig.grant( + "never", + &grant_text("read_file", "deny", "", "paths = [\"/d\"]"), + ); + rig.grant( + "asked", + &grant_text("read_file", "ask", "", "paths = [\"/a\"]"), + ); +} + +/// Which call ran on what authority: (session, call) of every Decision that allowed and every +/// Approval whose re-decision let the call run, keyed by the decision's seq. +fn check_sequence(records: &[AuditRecord]) -> usize { + let mut may_run: BTreeMap = BTreeMap::new(); + let mut taint: BTreeMap = BTreeMap::new(); + let mut results = 0; + for (i, r) in records.iter().enumerate() { + assert_eq!(r.seq, i as u64, "no seq repeats or skips"); + match &r.event { + AuditEvent::Decision { + session, + call, + outcome: DecisionRecord::Allowed {}, + .. + } => { + may_run.insert(r.seq, (session.clone(), *call)); + } + AuditEvent::Approval { + session, + call, + decision, + outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {}, + .. + } => { + may_run.insert(*decision, (session.clone(), *call)); + } + AuditEvent::Result { + session, + call, + decision, + taint_after, + .. + } => { + results += 1; + let allowed = may_run.remove(decision); + assert_eq!( + allowed, + Some((session.clone(), *call)), + "seq {}: a Result with no record letting its call run", + r.seq + ); + let before = taint.insert(session.as_str().to_string(), *taint_after); + assert!( + before.is_none_or(|b| b <= *taint_after), + "seq {}: taint went down", + r.seq + ); + } + _ => {} + } + } + assert!( + may_run.is_empty(), + "calls allowed but never finished: {may_run:?}" + ); + results +} + +#[test] +fn eight_threads_two_sessions_fifty_calls_each() { + let rig = Rig::new("sequence-many"); + grants(&rig); + let rt = Recording::answering("content"); + let broker = rig.broker(&rt); + let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"]; + let threads: Vec<_> = (0..8u64) + .map(|t| { + let broker = Arc::clone(&broker); + std::thread::spawn(move || { + let session = if t % 2 == 0 { "s-even" } else { "s-odd" }; + for i in 0..50u64 { + let path = paths[((t + i) % 5) as usize]; + let args = format!(r#"{{"path":"{path}"}}"#); + let req = request(session, t * 1000 + i, "read_file", &args); + let frames = call(&broker, req); + assert_eq!(frames.len(), 1); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap(); + assert!(report.failure.is_none(), "{:?}", report.failure); + assert!(report.unfinished.is_empty()); + let records = rig.records(); + let decisions = records + .iter() + .filter(|r| matches!(r.event, AuditEvent::Decision { .. })) + .count(); + assert_eq!(decisions, 400); + let results = check_sequence(&records); + assert_eq!( + rt.count(), + results, + "the runtime saw exactly the allowed calls" + ); + assert!(results > 0 && results < 400); + // Both sessions read a secret. + for s in ["s-even", "s-odd"] { + let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap(); + assert_eq!(state.taint, DataClass::Secret); + } +} + +#[test] +fn approved_and_refused_calls_run_only_after_an_approval() { + let rig = Rig::new("sequence-ask"); + grants(&rig); + let rt = Recording::answering("content"); + let broker = rig.broker(&rt); + // An owner who approves even ids and refuses odd ones, as fast as they appear. + let stop = Arc::new(AtomicBool::new(false)); + let owner = { + let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop)); + std::thread::spawn(move || { + while !stop.load(Ordering::SeqCst) { + for item in broker.table().list() { + let Some(entry) = broker.table().take(item.approval) else { + continue; + }; + let answer = if item.approval % 2 == 0 { + Answer::Approved { by: None } + } else { + Answer::Refused { + by: None, + reason: None, + } + }; + let grants = broker.grants(); + let done = broker.ledger().answer( + entry.ask, + item.approval, + answer, + &grants, + Timestamp::now(), + ); + entry.reply.send(done.verdict).unwrap(); + } + std::thread::sleep(Duration::from_millis(2)); + } + }) + }; + let threads: Vec<_> = (0..4u64) + .map(|t| { + let broker = Arc::clone(&broker); + std::thread::spawn(move || { + for i in 0..10u64 { + let path = if i % 2 == 0 { "/a/x" } else { "/n/x" }; + let args = format!(r#"{{"path":"{path}"}}"#); + let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args)); + let last = frames.last().unwrap(); + assert!( + matches!( + &last.msg, + Message::ToolResponse( + ToolResponse::Result { .. } | ToolResponse::Denied { .. } + ) + ), + "{last:?}" + ); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + stop.store(true, Ordering::SeqCst); + owner.join().unwrap(); + let records = rig.records(); + let approvals = records + .iter() + .filter(|r| matches!(r.event, AuditEvent::Approval { .. })) + .count(); + assert_eq!(approvals, 20, "one Approval for each of the twenty asks"); + let results = check_sequence(&records); + assert_eq!(rt.count(), results); + let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap(); + assert!(report.failure.is_none() && report.abandoned.is_empty()); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/config.rs b/docs/plans/M3a/files/crates/brokerd/tests/config.rs new file mode 100644 index 0000000..5172a04 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/config.rs @@ -0,0 +1,118 @@ +//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour. + +use brokerd::config::{Approvals, Config, ConfigError, Sockets}; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config") + .join(name) +} + +/// What `home` must default to in this process. The test does not set the variable: changing the +/// environment of a running test binary would race with the other tests. +fn default_home() -> PathBuf { + std::env::var_os("BOXMAKER_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")) +} + +#[test] +fn an_empty_file_gets_every_default() { + let c = Config::load(&fixture("empty.toml")).unwrap(); + assert_eq!(c.paths.home, default_home()); + assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants")); + assert_eq!(c.sockets, Sockets::default()); + assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 }); + assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 }); + assert_eq!(c, Config::parse("").unwrap()); + assert_eq!(c, Config::default()); +} + +#[test] +fn sockets_and_directories_default_to_places_under_home() { + let c = Config::load(&fixture("home_only.toml")).unwrap(); + assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker")); + assert_eq!( + c.broker_socket(), + PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock") + ); + assert_eq!( + c.admin_socket(), + PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock") + ); + assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit")); + assert_eq!( + c.state_dir(), + PathBuf::from("/srv/boxmaker/broker/sessions") + ); + // The grants are not under home: the owner writes them, brokerd only reads them. + assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants")); +} + +#[test] +fn every_key_can_be_set() { + let c = Config::load(&fixture("full.toml")).unwrap(); + assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker")); + assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants")); + assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock")); + assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock")); + assert_eq!(c.approvals.ttl_ms, 60_000); + // The two directories always follow home. + assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit")); + assert_eq!( + c.state_dir(), + PathBuf::from("/srv/boxmaker/broker/sessions") + ); +} + +#[test] +fn one_socket_set_leaves_the_other_at_its_default() { + let c = + Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap(); + assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock")); + assert_eq!( + c.broker_socket(), + PathBuf::from("/h/run/loop-broker/broker.sock") + ); +} + +#[test] +fn unknown_keys_and_tables_are_errors() { + for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] { + match Config::load(&fixture(name)) { + Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)), + other => panic!("{name}: expected a parse error, got {other:?}"), + } + } + // In every table, not only the one the fixture shows. + for text in [ + "[paths]\nhome = \"/h\"\nhouse = \"/h\"\n", + "[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n", + "[approvals]\nttl_ms = 1\nttl_s = 1\n", + "top = 1\n", + "[approvals]\nttl_ms = -5\n", + ] { + assert!(Config::parse(text).is_err(), "accepted: {text}"); + } +} + +#[test] +fn a_missing_file_is_a_read_error_that_names_the_file() { + let path = fixture("does-not-exist.toml"); + match Config::load(&path) { + Err(ConfigError::Read(p, _)) => assert_eq!(p, path), + other => panic!("expected a read error, got {other:?}"), + } + let text = Config::load(&path).unwrap_err().to_string(); + assert!(text.contains("does-not-exist.toml"), "{text}"); +} + +#[test] +fn a_parse_error_names_the_file_and_the_key() { + let text = Config::load(&fixture("unknown_key.toml")) + .unwrap_err() + .to_string(); + assert!(text.contains("unknown_key.toml"), "{text}"); + assert!(text.contains("ttl"), "{text}"); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/empty.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/empty.toml new file mode 100644 index 0000000..8f75b0a --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/empty.toml @@ -0,0 +1 @@ +# Nothing set: every value is a default. diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/full.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/full.toml new file mode 100644 index 0000000..350811a --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/full.toml @@ -0,0 +1,11 @@ +# Every key set. +[paths] +home = "/srv/boxmaker" +grants = "/srv/boxmaker-grants" + +[sockets] +broker = "/run/bx/broker.sock" +admin = "/run/bx/admin.sock" + +[approvals] +ttl_ms = 60000 diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/home_only.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/home_only.toml new file mode 100644 index 0000000..fdd4cc6 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/home_only.toml @@ -0,0 +1,2 @@ +[paths] +home = "/srv/boxmaker" diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_key.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_key.toml new file mode 100644 index 0000000..041d445 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_key.toml @@ -0,0 +1,3 @@ +[approvals] +ttl_ms = 60000 +ttl = 5 diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_table.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_table.toml new file mode 100644 index 0000000..00b48d5 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/unknown_table.toml @@ -0,0 +1,2 @@ +[secrets] +store = "/etc/boxmaker/secrets" diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/wrong_type.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/wrong_type.toml new file mode 100644 index 0000000..95bf60d --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/config/wrong_type.toml @@ -0,0 +1,2 @@ +[approvals] +ttl_ms = "15 min" diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/empty/README.md b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/empty/README.md new file mode 100644 index 0000000..14b8916 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/empty/README.md @@ -0,0 +1 @@ +An empty set of grants is valid: every call is denied with no_grant. diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/a-secret.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/a-secret.toml new file mode 100644 index 0000000..73b849d --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/a-secret.toml @@ -0,0 +1,8 @@ +tool = "http_fetch" +mode = "auto" +max_taint = "private" +secret = "api-token" + +[constraints] +hosts = ["api.example.com"] +patterns = ["^GET "] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/b-paths.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/b-paths.toml new file mode 100644 index 0000000..f2e3644 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/b-paths.toml @@ -0,0 +1,7 @@ +tool = "read_file" +mode = "auto" +max_taint = "private" + +[constraints] +paths = ["notes", "/home/kyle/../etc", "/"] +hosts = ["example.com"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/c-syntax.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/c-syntax.toml new file mode 100644 index 0000000..187ae71 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/c-syntax.toml @@ -0,0 +1,3 @@ +tool = "shell" +mode = "auto" +max_taint = diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/notes-read.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/notes-read.toml new file mode 100644 index 0000000..95dc468 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/many-bad/notes-read.toml @@ -0,0 +1,10 @@ +# The owner's notes: only the owner writes them, so their content is trusted. +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"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/fetch-example.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/fetch-example.toml new file mode 100644 index 0000000..c1a51ce --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/fetch-example.toml @@ -0,0 +1,7 @@ +tool = "http_fetch" +mode = "auto" +max_taint = "private" +result_class = "public" + +[constraints] +hosts = ["example.com", "*.example.com"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/no-fetch-internal.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/no-fetch-internal.toml new file mode 100644 index 0000000..85db65d --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/no-fetch-internal.toml @@ -0,0 +1,7 @@ +# The owner meant `mode`. If this file were skipped, fetch-example would allow what it forbids. +tool = "http_fetch" +mdoe = "deny" +max_taint = "secret" + +[constraints] +hosts = ["internal.example.com"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/notes-read.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/notes-read.toml new file mode 100644 index 0000000..95dc468 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/one-bad/notes-read.toml @@ -0,0 +1,10 @@ +# The owner's notes: only the owner writes them, so their content is trusted. +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"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/README.md b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/README.md new file mode 100644 index 0000000..d5e6563 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/README.md @@ -0,0 +1 @@ +Grants for the tests. This file is not a grant and is ignored. diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/fetch-example.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/fetch-example.toml new file mode 100644 index 0000000..c1a51ce --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/fetch-example.toml @@ -0,0 +1,7 @@ +tool = "http_fetch" +mode = "auto" +max_taint = "private" +result_class = "public" + +[constraints] +hosts = ["example.com", "*.example.com"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/no-fetch-internal.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/no-fetch-internal.toml new file mode 100644 index 0000000..13a2013 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/no-fetch-internal.toml @@ -0,0 +1,6 @@ +tool = "http_fetch" +mode = "deny" +max_taint = "secret" + +[constraints] +hosts = ["internal.example.com"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/notes-read.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/notes-read.toml new file mode 100644 index 0000000..95dc468 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/notes-read.toml @@ -0,0 +1,10 @@ +# The owner's notes: only the owner writes them, so their content is trusted. +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"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/scratch-write.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/scratch-write.toml new file mode 100644 index 0000000..c7f9bdf --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/scratch-write.toml @@ -0,0 +1,6 @@ +tool = "write_file" +mode = "ask" +max_taint = "private" + +[constraints] +paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"] diff --git a/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/shell-bare.toml b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/shell-bare.toml new file mode 100644 index 0000000..dd97d27 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/fixtures/grants/valid/shell-bare.toml @@ -0,0 +1,4 @@ +# A shell with nothing mounted. +tool = "shell" +mode = "ask" +max_taint = "secret" diff --git a/docs/plans/M3a/files/crates/brokerd/tests/grants.rs b/docs/plans/M3a/files/crates/brokerd/tests/grants.rs new file mode 100644 index 0000000..fcdb89a --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/grants.rs @@ -0,0 +1,482 @@ +//! Tests for loading grant files. Do not edit these or the fixtures. +//! +//! One case per loading rule in the M3a spec, each with words its problem text must contain, and +//! the rule that matters most: one invalid file makes the whole set invalid. + +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::grants::{GrantSet, LoadedGrant, RUNBOOK, load, render, valid_id}; +use proto::{Constraints, DataClass, Grant, GrantProblem, Hash32, Mode}; +use std::path::{Path, PathBuf}; +use tmp::TempDir; + +fn fixture(case: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/grants") + .join(case) +} + +const GOOD: &str = "tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n\n\ + [constraints]\npaths = [\"/home/kyle/notes\"]\n"; + +/// A directory holding one good grant and the given files; returns the problems of loading it. +fn problems_of(files: &[(&str, &str)]) -> Vec { + let dir = TempDir::new("grants"); + dir.write("good.toml", GOOD); + for (name, text) in files { + dir.write(name, text); + } + load(dir.path()).expect_err("the set should be invalid") +} + +/// Exactly one problem, in `file`, whose text contains every one of `words`. +fn one_problem(files: &[(&str, &str)], file: &str, words: &[&str]) -> GrantProblem { + let problems = problems_of(files); + assert_eq!(problems.len(), 1, "{problems:?}"); + let p = problems.into_iter().next().unwrap(); + assert_eq!(p.file, file); + for word in words { + assert!(p.problem.contains(word), "{:?} lacks {word:?}", p.problem); + } + p +} + +fn body(tool: &str, mode: &str, rest: &str) -> String { + format!("tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n{rest}") +} + +#[test] +fn the_valid_fixture_loads_in_id_order_with_file_hashes() { + let set = load(&fixture("valid")).unwrap(); + let ids: Vec<&str> = set.grants().iter().map(|g| g.id.as_str()).collect(); + assert_eq!( + ids, + [ + "fetch-example", + "no-fetch-internal", + "notes-read", + "scratch-write", + "shell-bare" + ] + ); + let notes = &set.grants()[2]; + assert_eq!(notes.grant.tool, "read_file"); + assert_eq!(notes.grant.mode, Mode::Auto); + assert!(!notes.grant.untrusted); + assert_eq!(notes.grant.constraints.paths, ["/home/kyle/notes"]); + let bytes = std::fs::read(fixture("valid").join("notes-read.toml")).unwrap(); + assert_eq!(notes.sha256, proto::sha256(&bytes).unwrap()); + // Defaults from `proto::Grant`. + let scratch = &set.grants()[3]; + assert_eq!(scratch.grant.result_class, DataClass::Private); + assert!(scratch.grant.untrusted); + assert_eq!(scratch.grant.expires, None); +} + +#[test] +fn files_that_do_not_end_in_toml_are_ignored() { + let dir = TempDir::new("grants"); + dir.write("good.toml", GOOD); + dir.write("good.toml~", "not toml at all {{{"); + dir.write("good.toml.bak", "not toml at all {{{"); + dir.write("README.md", "# notes"); + dir.write("toml", "x"); + std::fs::create_dir(dir.path().join("archive")).unwrap(); + let set = load(dir.path()).unwrap(); + assert_eq!(set.grants().len(), 1); + assert_eq!(set.grants()[0].id, "good"); +} + +#[test] +fn an_empty_directory_is_a_valid_empty_set() { + let set = load(&fixture("empty")).unwrap(); + assert!(set.grants().is_empty()); + assert_eq!(set, GrantSet::default()); +} + +#[test] +fn a_missing_directory_is_a_problem_not_an_empty_set() { + let missing = fixture("does-not-exist"); + let problems = load(&missing).unwrap_err(); + assert_eq!(problems.len(), 1); + assert!(problems[0].file.contains("does-not-exist"), "{problems:?}"); + assert!(problems[0].problem.contains("cannot be read")); + // A file where the directory should be is the same. + let dir = TempDir::new("grants"); + let file = dir.write("grants", "x"); + assert!(load(&file).is_err()); +} + +/// The rule the whole design leans on. `fetch-example` alone would allow a fetch that the +/// mistyped `no-fetch-internal` was written to forbid, so nothing loads at all. +#[test] +fn one_invalid_file_makes_the_whole_set_invalid() { + let problems = load(&fixture("one-bad")).unwrap_err(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert_eq!(problems[0].file, "no-fetch-internal.toml"); + assert_eq!(problems[0].line, Some(3)); + assert!(problems[0].problem.contains("mdoe"), "{problems:?}"); + // The same directory without the bad file is fine. + let dir = TempDir::new("grants"); + for name in ["notes-read.toml", "fetch-example.toml"] { + let text = std::fs::read_to_string(fixture("one-bad").join(name)).unwrap(); + dir.write(name, &text); + } + assert_eq!(load(dir.path()).unwrap().grants().len(), 2); +} + +#[test] +fn every_problem_in_every_file_is_reported() { + let problems = load(&fixture("many-bad")).unwrap_err(); + let got: Vec<(&str, Option)> = + problems.iter().map(|p| (p.file.as_str(), p.line)).collect(); + assert_eq!( + got, + [ + ("a-secret.toml", None), + ("a-secret.toml", None), + ("b-paths.toml", None), + ("b-paths.toml", None), + ("b-paths.toml", None), + ("b-paths.toml", None), + ("c-syntax.toml", Some(3)), + ], + "{problems:?}" + ); + let all: String = problems + .iter() + .map(|p| format!("{}\n", p.problem)) + .collect(); + for words in [ + "secrets are not supported until M4", + "patterns are not supported", + "read_file does not take hosts", + "\"notes\" is not a valid absolute path", + "\"/home/kyle/../etc\" is not a valid absolute path", + "a grant of the whole file system is not supported", + ] { + assert!(all.contains(words), "missing {words:?} in:\n{all}"); + } +} + +#[test] +fn rule_1_unreadable_not_utf8_or_not_a_grant() { + one_problem(&[("bad.toml", "tool = ")], "bad.toml", &[]); + let p = one_problem( + &[("bad.toml", &body("shell", "auto", "colour = \"red\"\n"))], + "bad.toml", + &["colour"], + ); + assert_eq!(p.line, Some(4)); + one_problem( + &[("bad.toml", "tool = \"shell\"\nmode = \"auto\"\n")], + "bad.toml", + &["max_taint"], + ); + one_problem( + &[("bad.toml", &body("shell", "sometimes", ""))], + "bad.toml", + &["sometimes"], + ); + one_problem( + &[( + "bad.toml", + &body("shell", "auto", "[constraints]\ncwd = [\"/a\"]\n"), + )], + "bad.toml", + &["cwd"], + ); + + // Not UTF-8. + let dir = TempDir::new("grants"); + dir.write("good.toml", GOOD); + std::fs::write(dir.path().join("latin1.toml"), b"tool = \"caf\xe9\"\n").unwrap(); + let problems = load(dir.path()).unwrap_err(); + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].file, "latin1.toml"); + assert!(problems[0].problem.contains("UTF-8"), "{problems:?}"); + + // Exists but cannot be read: a directory with a grant's name. + let dir = TempDir::new("grants"); + dir.write("good.toml", GOOD); + std::fs::create_dir(dir.path().join("folder.toml")).unwrap(); + let problems = load(dir.path()).unwrap_err(); + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].file, "folder.toml"); + assert!( + problems[0].problem.contains("cannot be read"), + "{problems:?}" + ); +} + +#[test] +fn rule_1_a_file_without_read_permission_is_a_problem() { + use std::os::unix::fs::PermissionsExt; + if tmp::running_as_root("rule_1_a_file_without_read_permission_is_a_problem") { + return; + } + let dir = TempDir::new("grants"); + dir.write("good.toml", GOOD); + let locked = dir.write("locked.toml", GOOD); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let problems = load(dir.path()).unwrap_err(); + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].file, "locked.toml"); + assert!( + problems[0].problem.contains("cannot be read"), + "{problems:?}" + ); +} + +#[test] +fn rule_2_the_file_stem_is_the_id() { + for id in ["a", "notes-read", "0", "a-1-b", &"x".repeat(64)] { + assert!(valid_id(id), "{id:?}"); + } + for id in [ + "", + "Notes", + "notes_read", + "notes.read", + "notes read", + ".hidden", + &"x".repeat(65), + ] { + assert!(!valid_id(id), "{id:?}"); + } + one_problem( + &[("Bad_Name.toml", GOOD)], + "Bad_Name.toml", + &["not a valid grant id"], + ); + one_problem(&[(".toml", GOOD)], ".toml", &["not a valid grant id"]); + one_problem(&[("a.b.toml", GOOD)], "a.b.toml", &["not a valid grant id"]); +} + +#[test] +fn rule_3_the_tool_is_one_of_the_four() { + for tool in ["echo", "clock", "Read_File", ""] { + one_problem( + &[("bad.toml", &body(tool, "auto", ""))], + "bad.toml", + &[ + "unknown tool", + "read_file, write_file, shell and http_fetch", + ], + ); + } +} + +#[test] +fn rules_4_and_5_secrets_and_patterns_are_not_supported() { + one_problem( + &[("bad.toml", &body("shell", "ask", "secret = \"token\"\n"))], + "bad.toml", + &["secrets are not supported until M4"], + ); + one_problem( + &[( + "bad.toml", + &body("shell", "ask", "[constraints]\npatterns = [\"^ls\"]\n"), + )], + "bad.toml", + &["patterns are not supported"], + ); + // An empty list is the same as no list. + let dir = TempDir::new("grants"); + dir.write( + "ok.toml", + &body("shell", "ask", "[constraints]\npatterns = []\nhosts = []\n"), + ); + assert!(load(dir.path()).is_ok()); +} + +/// The table of rule 6, cell by cell. +#[test] +fn rule_6_each_tool_takes_its_own_constraints() { + let paths = "[constraints]\npaths = [\"/a\"]\n"; + let hosts = "[constraints]\nhosts = [\"example.com\"]\n"; + let both = "[constraints]\npaths = [\"/a\"]\nhosts = [\"example.com\"]\n"; + for tool in ["read_file", "write_file"] { + one_problem( + &[("bad.toml", &body(tool, "auto", ""))], + "bad.toml", + &[tool, "needs at least one path"], + ); + one_problem( + &[("bad.toml", &body(tool, "auto", both))], + "bad.toml", + &[tool, "does not take hosts"], + ); + } + one_problem( + &[("bad.toml", &body("shell", "auto", both))], + "bad.toml", + &["shell does not take hosts"], + ); + one_problem( + &[("bad.toml", &body("http_fetch", "auto", ""))], + "bad.toml", + &["http_fetch needs at least one host"], + ); + one_problem( + &[("bad.toml", &body("http_fetch", "auto", both))], + "bad.toml", + &["http_fetch does not take paths"], + ); + // The allowed cells. + let dir = TempDir::new("grants"); + dir.write("r.toml", &body("read_file", "auto", paths)); + dir.write("w.toml", &body("write_file", "auto", paths)); + dir.write("s1.toml", &body("shell", "auto", paths)); + dir.write("s2.toml", &body("shell", "auto", "")); + dir.write("h.toml", &body("http_fetch", "auto", hosts)); + assert_eq!(load(dir.path()).unwrap().grants().len(), 5); + // Two wrong cells in one file are two problems. + let wrong = body("http_fetch", "auto", paths); + assert_eq!(problems_of(&[("bad.toml", &wrong)]).len(), 2); +} + +#[test] +fn rule_7_paths_are_valid_absolute_paths_and_never_the_root() { + for bad in ["notes", "/a/../b", "/a//b", "/a/./b", "/a/", ""] { + let text = body( + "shell", + "auto", + &format!("[constraints]\npaths = [{bad:?}]\n"), + ); + one_problem( + &[("bad.toml", &text)], + "bad.toml", + &["is not a valid absolute path"], + ); + } + let root = body( + "shell", + "auto", + "[constraints]\npaths = [\"/home/kyle\", \"/\"]\n", + ); + one_problem( + &[("bad.toml", &root)], + "bad.toml", + &["a grant of the whole file system is not supported"], + ); +} + +#[test] +fn rule_8_hosts_are_valid_host_patterns() { + for bad in [ + "Example.com", + "example.com:443", + "127.0.0.1", + "localhost", + "*.com", + "https://example.com", + ] { + let text = body( + "http_fetch", + "auto", + &format!("[constraints]\nhosts = [{bad:?}]\n"), + ); + one_problem( + &[("bad.toml", &text)], + "bad.toml", + &[bad, "is not a valid host pattern"], + ); + } +} + +#[test] +fn rule_9_a_deny_grant_applies_at_every_taint() { + for taint in ["public", "private"] { + let text = format!("tool = \"shell\"\nmode = \"deny\"\nmax_taint = \"{taint}\"\n"); + one_problem( + &[("bad.toml", &text)], + "bad.toml", + &["a deny grant must apply at every taint"], + ); + } + let dir = TempDir::new("grants"); + dir.write("no-shell.toml", &body("shell", "deny", "")); + assert!(load(dir.path()).is_ok()); + // The rule is about deny only. + dir.write( + "ask.toml", + "tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"public\"\n", + ); + assert!(load(dir.path()).is_ok()); +} + +fn loaded(id: &str, tool: &str, mode: Mode) -> LoadedGrant { + LoadedGrant { + id: id.to_string(), + grant: Grant { + tool: tool.to_string(), + mode, + max_taint: DataClass::Secret, + result_class: DataClass::Private, + untrusted: true, + expires: None, + secret: None, + constraints: Constraints::default(), + }, + sha256: Hash32::ZERO, + } +} + +/// `from_grants` is how tests and the property test build a set without files. It applies the +/// same value rules, sorts by id, and refuses two grants with one id. +#[test] +fn from_grants_applies_the_value_rules() { + let set = GrantSet::from_grants(vec![ + loaded("zz", "shell", Mode::Ask), + loaded("aa", "shell", Mode::Deny), + ]) + .unwrap(); + assert_eq!(set.grants()[0].id, "aa"); + assert_eq!(set.grants()[1].id, "zz"); + + let problems = GrantSet::from_grants(vec![ + loaded("ok", "shell", Mode::Auto), + loaded("no-paths", "read_file", Mode::Auto), + loaded("Bad", "shell", Mode::Auto), + ]) + .unwrap_err(); + let files: Vec<&str> = problems.iter().map(|p| p.file.as_str()).collect(); + assert_eq!(files, ["Bad.toml", "no-paths.toml"]); + + let twice = GrantSet::from_grants(vec![ + loaded("same", "shell", Mode::Auto), + loaded("same", "shell", Mode::Ask), + ]) + .unwrap_err(); + assert!( + twice[0].problem.contains("two grants have this id"), + "{twice:?}" + ); +} + +#[test] +fn render_prints_every_problem_and_then_the_runbook_pointer() { + let problems = [ + GrantProblem { + file: "a.toml".to_string(), + line: Some(3), + problem: "unknown field `mdoe`".to_string(), + }, + GrantProblem { + file: "Bad_Name.toml".to_string(), + line: None, + problem: "the file name is not a valid grant id".to_string(), + }, + ]; + assert_eq!( + render(&problems), + "a.toml:3: unknown field `mdoe`\n\ + Bad_Name.toml: the file name is not a valid grant id\n\ + see docs/runbook.md#grants-invalid\n" + ); + assert_eq!(RUNBOOK, "see docs/runbook.md#grants-invalid"); + assert!(render(&problems).trim_end().ends_with(RUNBOOK)); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/ledger.rs b/docs/plans/M3a/files/crates/brokerd/tests/ledger.rs new file mode 100644 index 0000000..fca7785 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/ledger.rs @@ -0,0 +1,383 @@ +//! The ledger's first and third steps: decide and record; raise the state and record the result. +//! And what happens after a failed append or a panic. Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; +use std::panic::AssertUnwindSafe; + +use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED}; +use brokerd::policy::{Label, SessionState}; +use build::{grant, now, read, set}; +use proto::{ + AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus, + SessionId, ToolResponse, +}; +use rig::Rig; + +fn one(grants: Vec) -> Grants { + Ok(set(grants)) +} + +fn notes(mode: Mode) -> Grants { + one(vec![grant("n", "read_file", mode).paths(&["/n"])]) +} + +fn call(label: Label) -> Call { + Call { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + decision: 0, + label, + } +} + +fn secret() -> Label { + Label { + class: DataClass::Secret, + untrusted: true, + } +} + +fn result(content: &str, label: Label) -> ToolResponse { + ToolResponse::Result { + content: content.to_string(), + class: label.class, + untrusted: label.untrusted, + truncated: false, + } +} + +#[test] +fn an_allowed_call_is_recorded_in_full() { + let rig = Rig::new("ledger-allowed"); + let ledger = rig.ledger(); + let request = read("/n/a"); + let arguments = request.arguments.clone(); + match ledger.decide(request, ¬es(Mode::Auto), now()) { + Decided::Allowed { decision, seq } => { + assert_eq!(seq, 0); + assert_eq!(decision.grant(), "n"); + } + other => panic!("{other:?}"), + } + let records = rig.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].time, now()); + assert_eq!( + records[0].event, + AuditEvent::Decision { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + tool: "read_file".to_string(), + arguments, + outcome: DecisionRecord::Allowed {}, + grant: Some("n".to_string()), + grant_sha256: Some(proto::sha256(b"n").unwrap()), + taint: DataClass::Private, + untrusted: false, + } + ); +} + +#[test] +fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() { + let rig = Rig::new("ledger-ask"); + let ledger = rig.ledger(); + match ledger.decide(read("/n/a"), ¬es(Mode::Ask), now()) { + Decided::Ask { ask, seq, state } => { + assert_eq!((seq, ask.grant()), (0, "n")); + assert_eq!(state, SessionState::default()); + } + other => panic!("{other:?}"), + } + match &rig.events()[0] { + AuditEvent::Decision { outcome, grant, .. } => { + assert_eq!(*outcome, DecisionRecord::Ask {}); + assert_eq!(grant.as_deref(), Some("n")); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_denial_names_a_grant_only_when_a_grant_denied() { + let rig = Rig::new("ledger-denied"); + let ledger = rig.ledger(); + let none = ledger.decide(read("/n/a"), &one(Vec::new()), now()); + assert!( + matches!(none, Decided::Denied(DenyReason::NoGrant)), + "{none:?}" + ); + let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]); + let by = ledger.decide(read("/n/a"), &deny, now()); + assert!( + matches!(by, Decided::Denied(DenyReason::DeniedByGrant)), + "{by:?}" + ); + let events = rig.events(); + let named: Vec<(DecisionRecord, Option)> = events + .iter() + .map(|e| match e { + AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + named, + [ + ( + DecisionRecord::Denied { + reason: DenyReason::NoGrant + }, + None + ), + ( + DecisionRecord::Denied { + reason: DenyReason::DeniedByGrant + }, + Some("d".to_string()) + ), + ] + ); +} + +#[test] +fn invalid_grants_come_first_then_an_unreadable_state() { + let rig = Rig::new("ledger-order"); + let ledger = rig.ledger(); + std::fs::create_dir_all(rig.cfg.state_dir()).unwrap(); + std::fs::write(rig.state_file("s1"), "not json").unwrap(); + let invalid: Grants = Err(vec![GrantProblem { + file: "x.toml".to_string(), + line: None, + problem: "bad".to_string(), + }]); + let first = ledger.decide(read("/n/a"), &invalid, now()); + assert!( + matches!(first, Decided::Denied(DenyReason::GrantsInvalid)), + "{first:?}" + ); + let second = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(second, Decided::Denied(DenyReason::StateUnreadable)), + "{second:?}" + ); + // A state brokerd cannot read is recorded as the most sensitive one. + for event in rig.events() { + match event { + AuditEvent::Decision { + taint, untrusted, .. + } => assert_eq!((taint, untrusted), (DataClass::Secret, true)), + other => panic!("{other:?}"), + } + } + let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged"); + assert!(!damaged.is_empty(), "{:?}", rig.lines.all()); +} + +#[test] +fn a_failed_append_denies_this_call_and_every_later_one() { + let rig = Rig::new("ledger-stop"); + let ledger = rig.ledger(); + rig.switch.fail(true); + let first = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(first, Decided::Denied(DenyReason::AuditUnavailable)), + "{first:?}" + ); + assert_eq!(rig.switch.attempts(), 1); + // The sink would work again; the ledger does not try it. + rig.switch.fail(false); + let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), + "{later:?}" + ); + let finished = ledger.finish(&call(secret()), result("x", secret()), now()); + assert_eq!( + finished, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert_eq!(rig.switch.attempts(), 1, "nothing more was written"); + assert!(rig.records().is_empty()); + assert!(!rig.state_file("s1").exists(), "the state was not raised"); + assert!(!rig.lines.with(STOPPED).is_empty()); + assert!( + rig.lines + .all() + .iter() + .all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")), + "{:?}", + rig.lines.all() + ); +} + +#[test] +fn a_panic_while_holding_the_ledger_denies_every_later_call() { + let rig = Rig::new("ledger-poison"); + let ledger = rig.ledger(); + rig.switch.panic_next(); + let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| { + ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()) + })); + assert!(panicked.is_err()); + let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); + assert!( + matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), + "{later:?}" + ); + assert_eq!(rig.switch.attempts(), 1); + assert!( + !rig.lines.with(POISONED).is_empty(), + "{:?}", + rig.lines.all() + ); + assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable")); +} + +#[test] +fn a_result_raises_the_state_then_is_recorded() { + let rig = Rig::new("ledger-result"); + let ledger = rig.ledger(); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + assert_eq!(answer, result("key", secret()), "passed on unchanged"); + let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); + assert_eq!( + state, + SessionState { + taint: DataClass::Secret, + untrusted: true + } + ); + assert_eq!( + rig.events(), + [AuditEvent::Result { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + decision: 0, + status: ResultStatus::Result, + class: DataClass::Secret, + untrusted: true, + truncated: false, + bytes: 3, + sha256: proto::sha256(b"key").unwrap(), + taint_after: DataClass::Secret, + }] + ); +} + +#[test] +fn taint_never_goes_down() { + let rig = Rig::new("ledger-down"); + let ledger = rig.ledger(); + let public = Label { + class: DataClass::Public, + untrusted: false, + }; + ledger.finish(&call(secret()), result("a", secret()), now()); + ledger.finish(&call(public), result("b", public), now()); + let after: Vec = rig + .events() + .iter() + .map(|e| match e { + AuditEvent::Result { taint_after, .. } => *taint_after, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(after, [DataClass::Secret, DataClass::Secret]); + let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); + assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true)); +} + +#[test] +fn a_failure_changes_no_state_and_is_recorded_by_its_message() { + let rig = Rig::new("ledger-failed"); + let ledger = rig.ledger(); + let failed = ToolResponse::Failed { + message: "the tool timed out".to_string(), + }; + assert_eq!( + ledger.finish(&call(secret()), failed.clone(), now()), + failed + ); + assert!(!rig.state_file("s1").exists()); + match &rig.events()[0] { + AuditEvent::Result { + status, + class, + bytes, + sha256, + taint_after, + .. + } => { + assert_eq!(*status, ResultStatus::Failed); + assert_eq!( + *class, + DataClass::Secret, + "the label the result would have had" + ); + assert_eq!(*bytes, 18); + assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap()); + assert_eq!(*taint_after, DataClass::Private, "the taint before"); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_state_that_cannot_be_written_withholds_the_content() { + if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") { + return; + } + let rig = Rig::new("ledger-ro"); + let ledger = rig.ledger(); + let dir = rig.cfg.state_dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + answer, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert!( + rig.records().is_empty(), + "no Result for a state not on disk" + ); + assert!( + !rig.lines + .with("see docs/runbook.md#broker-state-damaged") + .is_empty() + ); +} + +#[test] +fn a_result_record_that_cannot_be_written_withholds_the_content() { + let rig = Rig::new("ledger-norecord"); + let ledger = rig.ledger(); + rig.switch.fail(true); + let answer = ledger.finish(&call(secret()), result("key", secret()), now()); + assert_eq!( + answer, + ToolResponse::Failed { + message: NOT_RECORDED.to_string() + } + ); + assert!( + !rig.lines + .with("see docs/runbook.md#audit-unavailable") + .is_empty() + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/ledger_answer.rs b/docs/plans/M3a/files/crates/brokerd/tests/ledger_answer.rs new file mode 100644 index 0000000..cd543d9 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/ledger_answer.rs @@ -0,0 +1,262 @@ +//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry +//! are recorded as denials. Do not edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::approvals::Verdict; +use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger}; +use brokerd::policy::{Ask, Label}; +use build::{grant, now, read, set}; +use proto::{ + ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId, +}; +use rig::Rig; + +fn asking() -> Grants { + Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])])) +} + +fn pending(ledger: &Ledger) -> Ask { + match ledger.decide(read("/n/deep/a"), &asking(), now()) { + Decided::Ask { ask, seq: 0, .. } => ask, + other => panic!("{other:?}"), + } +} + +fn bxctl() -> Answer { + Answer::Approved { + by: Some("bxctl".to_string()), + } +} + +fn denied(reason: DenyReason) -> DecisionRecord { + DecisionRecord::Denied { reason } +} + +fn verdict_reason(answered: &Answered) -> Option { + match &answered.verdict { + Verdict::Run(_) => None, + Verdict::Denied(reason) => Some(*reason), + } +} + +/// The one `Approval` record, as (answer, by, reason, outcome, grant). +type Row = ( + ApprovalAnswer, + Option, + Option, + DecisionRecord, + Option, +); + +fn approval(rig: &Rig) -> Row { + let events = rig.events(); + assert_eq!(events.len(), 2, "a decision and one approval: {events:?}"); + match &events[1] { + AuditEvent::Approval { + session, + call, + decision, + answer, + by, + post, + reason, + outcome, + grant, + .. + } => { + assert_eq!(session, &SessionId::new("s1").unwrap()); + assert_eq!((*call, *decision, post), (CallId(1), 0, &None)); + ( + *answer, + by.clone(), + reason.clone(), + outcome.clone(), + grant.clone(), + ) + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_approval_that_still_asks_lets_the_call_run() { + let rig = Rig::new("answer-ask"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &asking(), now()); + match &answered.verdict { + Verdict::Run(decision) => assert_eq!(decision.grant(), "n"), + other => panic!("{other:?}"), + } + assert_eq!(answered.outcome, DecisionRecord::Ask {}); + let row = approval(&rig); + assert_eq!( + row, + ( + ApprovalAnswer::Approved, + Some("bxctl".to_string()), + None, + DecisionRecord::Ask {}, + Some("n".to_string()) + ) + ); +} + +#[test] +fn an_approval_under_a_grant_that_is_now_auto_records_allowed() { + let rig = Rig::new("answer-auto"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let now_auto = Ok(set(vec![ + grant("n", "read_file", Mode::Auto).paths(&["/n"]), + ])); + let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now()); + assert!(matches!(answered.verdict, Verdict::Run(_))); + assert_eq!(answered.outcome, DecisionRecord::Allowed {}); +} + +#[test] +fn the_approval_names_the_grant_matched_now() { + let rig = Rig::new("answer-grant"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let wider = Ok(set(vec![ + grant("n", "read_file", Mode::Ask).paths(&["/n"]), + grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]), + ])); + let answered = ledger.answer(ask, 0, bxctl(), &wider, now()); + match &answered.verdict { + Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"), + other => panic!("{other:?}"), + } + assert_eq!(approval(&rig).4.as_deref(), Some("z-deep")); +} + +#[test] +fn an_approval_after_the_grant_is_gone_is_denied() { + let rig = Rig::new("answer-gone"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant)); + assert_eq!(answered.outcome, denied(DenyReason::NoGrant)); + let row = approval(&rig); + assert_eq!( + (row.0, row.3, row.4), + (ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None) + ); +} + +#[test] +fn an_approval_after_the_taint_rose_is_denied() { + let rig = Rig::new("answer-taint"); + let ledger = rig.ledger(); + let low = || { + Ok(set(vec![ + grant("n", "read_file", Mode::Ask) + .paths(&["/n"]) + .max_taint(DataClass::Private), + ])) + }; + let ask = match ledger.decide(read("/n/a"), &low(), now()) { + Decided::Ask { ask, .. } => ask, + other => panic!("{other:?}"), + }; + // Another call of the session read a secret while this one waited. + let s1 = SessionId::new("s1").unwrap(); + let label = Label { + class: DataClass::Secret, + untrusted: false, + }; + rig.state() + .raise(&s1, rig.state().read(&s1).unwrap(), label) + .unwrap(); + let answered = ledger.answer(ask, 0, bxctl(), &low(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh)); + match &rig.events()[1] { + AuditEvent::Approval { taint, outcome, .. } => { + assert_eq!(*taint, DataClass::Secret, "the state at the re-decision"); + assert_eq!(*outcome, denied(DenyReason::TaintTooHigh)); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn an_approval_with_invalid_grants_is_denied() { + let rig = Rig::new("answer-invalid"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid)); +} + +#[test] +fn a_refusal_is_recorded_with_the_owners_reason() { + let rig = Rig::new("answer-refuse"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let refused = Answer::Refused { + by: Some("bxctl".to_string()), + reason: Some("not now".to_string()), + }; + let answered = ledger.answer(ask, 0, refused, &asking(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused)); + assert_eq!( + approval(&rig), + ( + ApprovalAnswer::Refused, + Some("bxctl".to_string()), + Some("not now".to_string()), + denied(DenyReason::ApprovalRefused), + None + ) + ); +} + +#[test] +fn an_expiry_is_recorded_by_nobody() { + let rig = Rig::new("answer-expire"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now()); + assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired)); + assert_eq!( + approval(&rig), + ( + ApprovalAnswer::Expired, + None, + None, + denied(DenyReason::ApprovalExpired), + None + ) + ); +} + +#[test] +fn an_approval_that_cannot_be_recorded_does_not_run() { + let rig = Rig::new("answer-norecord"); + let ledger = rig.ledger(); + let ask = pending(&ledger); + rig.switch.fail(true); + let answered = ledger.answer(ask, 0, bxctl(), &asking(), now()); + assert_eq!( + verdict_reason(&answered), + Some(DenyReason::AuditUnavailable) + ); + assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable)); + assert_eq!(rig.events().len(), 1, "only the decision"); + assert!( + !rig.lines + .with("see docs/runbook.md#audit-unavailable") + .is_empty() + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/policy.rs b/docs/plans/M3a/files/crates/brokerd/tests/policy.rs new file mode 100644 index 0000000..9034235 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/policy.rs @@ -0,0 +1,213 @@ +//! Table tests for `policy::decide`: which arguments each tool's grants cover. Do not edit. +//! `policy_matching.rs` covers how a winner, a label and a reason are picked, `policy_redecide.rs` +//! covers approvals, and `policy_property.rs` checks all of it against an oracle. + +#[path = "support/build.rs"] +mod build; + +use brokerd::policy::decide; +use build::{allowed, fetch, grant, now, private, read, reason, request, set, shell, write}; +use proto::{DenyReason, Mode}; + +#[test] +fn no_grants_means_no_grant() { + let none = set(vec![]); + for req in [ + read("/etc/hosts"), + write("/tmp/x"), + shell(None), + fetch("https://example.com/"), + ] { + assert_eq!( + reason(decide(req, &none, private(), now())), + DenyReason::NoGrant + ); + } +} + +#[test] +fn a_tool_that_is_not_one_of_the_four_is_no_grant_and_its_arguments_are_not_parsed() { + let grants = set(vec![grant("s", "shell", Mode::Auto)]); + for tool in ["echo", "clock", "call_tool", "", "Shell"] { + for arguments in ["{}", "not json", r#"{"command":"ls"}"#] { + let outcome = decide(request(tool, arguments), &grants, private(), now()); + assert_eq!( + reason(outcome), + DenyReason::NoGrant, + "{tool:?} {arguments:?}" + ); + } + } +} + +/// Invalid arguments are refused before matching, so the answer is the same with a grant that +/// would cover them, with a `deny` grant, and with no grant at all. +#[test] +fn invalid_arguments_are_refused_before_matching() { + let covering = set(vec![ + grant("r", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]), + grant("no", "read_file", Mode::Deny).paths(&["/home/kyle"]), + ]); + let none = set(vec![]); + for grants in [&covering, &none] { + for req in [ + // The rows of the "Paths" table that are about form. + read("/home/kyle/notes/../.ssh/id"), + read("notes/a.md"), + read("/home/kyle//notes/./a.md"), + request("read_file", "{}"), + request( + "read_file", + r#"{"path":"/home/kyle/notes/a.md","mode":"r"}"#, + ), + request("read_file", "not json"), + write("/home/kyle/notes/"), + shell(Some("relative")), + fetch("http://example.com/"), + fetch("https://127.0.0.1/"), + fetch("https://user@example.com/"), + ] { + let text = req.arguments.clone(); + assert_eq!( + reason(decide(req, grants, private(), now())), + DenyReason::InvalidArguments, + "{text}" + ); + } + } +} + +/// The "Paths" table, the rows about containment. +#[test] +fn read_file_is_covered_inside_a_granted_path() { + let grants = set(vec![ + grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]), + ]); + for path in [ + "/home/kyle/notes/a.md", + "/home/kyle/notes", + "/home/kyle/notes/x/y/z", + ] { + let d = allowed(decide(read(path), &grants, private(), now())); + assert_eq!(d.grant(), "notes"); + assert_eq!(d.matched_path(), Some("/home/kyle/notes")); + } + for path in [ + "/home/kyle/notes2/a.md", + "/home/kyle", + "/", + "/etc/passwd", + "/home/kyle/note", + ] { + assert_eq!( + reason(decide(read(path), &grants, private(), now())), + DenyReason::NoGrant, + "{path}" + ); + } + // A grant is for one tool. + let outcome = decide(write("/home/kyle/notes/a.md"), &grants, private(), now()); + assert_eq!(reason(outcome), DenyReason::NoGrant); +} + +#[test] +fn write_file_is_covered_inside_a_granted_path_but_not_at_the_path_itself() { + let grants = set(vec![ + grant("scratch", "write_file", Mode::Auto) + .paths(&["/home/kyle/scratch", "/home/kyle/scratch/out"]), + ]); + let d = allowed(decide( + write("/home/kyle/scratch/a.txt"), + &grants, + private(), + now(), + )); + assert_eq!(d.matched_path(), Some("/home/kyle/scratch")); + // The longest path that holds the argument is the matched one. + let d = allowed(decide( + write("/home/kyle/scratch/out/b.txt"), + &grants, + private(), + now(), + )); + assert_eq!(d.matched_path(), Some("/home/kyle/scratch/out")); + // A granted path itself cannot be written, but it can lie inside another granted path. + let d = allowed(decide( + write("/home/kyle/scratch/out"), + &grants, + private(), + now(), + )); + assert_eq!(d.matched_path(), Some("/home/kyle/scratch")); + let outcome = decide(write("/home/kyle/scratch"), &grants, private(), now()); + assert_eq!(reason(outcome), DenyReason::NoGrant); +} + +#[test] +fn shell_is_covered_by_no_paths_and_no_cwd_or_by_a_cwd_inside_a_path() { + let bare = set(vec![grant("bare", "shell", Mode::Auto)]); + let d = allowed(decide(shell(None), &bare, private(), now())); + assert_eq!((d.matched_path(), d.paths().len()), (None, 0)); + assert_eq!( + reason(decide(shell(Some("/home/kyle")), &bare, private(), now())), + DenyReason::NoGrant + ); + + let scoped = set(vec![ + grant("scoped", "shell", Mode::Auto).paths(&["/home/kyle/a", "/srv/b"]), + ]); + let d = allowed(decide(shell(Some("/srv/b/sub")), &scoped, private(), now())); + assert_eq!(d.matched_path(), Some("/srv/b")); + // The runner mounts every path of the grant, so the decision carries them all. + assert_eq!(d.paths(), ["/home/kyle/a", "/srv/b"]); + assert_eq!( + reason(decide(shell(None), &scoped, private(), now())), + DenyReason::NoGrant + ); + assert_eq!( + reason(decide(shell(Some("/srv")), &scoped, private(), now())), + DenyReason::NoGrant + ); +} + +/// The "Hosts" table, row by row. +#[test] +fn http_fetch_is_covered_when_the_host_matches() { + let exact = set(vec![ + grant("exact", "http_fetch", Mode::Auto).hosts(&["example.com"]), + ]); + let wild = set(vec![ + grant("wild", "http_fetch", Mode::Auto).hosts(&["*.example.com"]), + ]); + let d = allowed(decide( + fetch("https://example.com/a?b=c"), + &exact, + private(), + now(), + )); + assert_eq!(d.hosts(), ["example.com"]); + assert_eq!(d.matched_path(), None); + assert_eq!( + reason(decide( + fetch("https://www.example.com/"), + &exact, + private(), + now() + )), + DenyReason::NoGrant + ); + for url in ["https://www.example.com/", "https://a.b.example.com:443/x"] { + allowed(decide(fetch(url), &wild, private(), now())); + } + for url in [ + "https://example.com/", + "https://badexample.com/", + "https://example.com.evil.org/", + ] { + assert_eq!( + reason(decide(fetch(url), &wild, private(), now())), + DenyReason::NoGrant, + "{url}" + ); + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/policy_matching.rs b/docs/plans/M3a/files/crates/brokerd/tests/policy_matching.rs new file mode 100644 index 0000000..055d88c --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/policy_matching.rs @@ -0,0 +1,363 @@ +//! Table tests for `policy::decide`: among the grants that cover a call, which one wins, what +//! the result is labelled, and which reason is given when none is left. Do not edit. + +#[path = "support/build.rs"] +mod build; + +use brokerd::policy::{Label, decide}; +use build::{ + allowed, asked, denied, fetch, grant, now, private, read, reason, request, secret, set, shell, +}; +use proto::{DataClass, DenyReason, Mode}; + +#[test] +fn the_most_restrictive_mode_wins_among_three_matching_grants() { + let paths = &["/home/kyle/notes"]; + let auto = || grant("b-auto", "read_file", Mode::Auto).paths(paths); + let ask = || grant("c-ask", "read_file", Mode::Ask).paths(paths); + let deny = || grant("a-deny", "read_file", Mode::Deny).paths(paths); + let req = || read("/home/kyle/notes/a.md"); + + let denial = denied(decide( + req(), + &set(vec![auto(), ask(), deny()]), + private(), + now(), + )); + assert_eq!(denial.reason, DenyReason::DeniedByGrant); + assert_eq!(denial.grant.as_deref(), Some("a-deny")); + assert_eq!(denial.grant_sha256, Some(deny().done().sha256)); + + let ask_wins = asked(decide(req(), &set(vec![auto(), ask()]), private(), now())); + assert_eq!(ask_wins.grant(), "c-ask"); + assert_eq!( + allowed(decide(req(), &set(vec![auto()]), private(), now())).grant(), + "b-auto" + ); + + // Deny beats a longer path and a lower id: the mode comes first. + let narrow_auto = grant("a-auto", "read_file", Mode::Auto).paths(&["/home/kyle/notes/deep"]); + let wide_deny = grant("z-deny", "read_file", Mode::Deny).paths(&["/home"]); + let outcome = decide( + read("/home/kyle/notes/deep/x"), + &set(vec![narrow_auto, wide_deny]), + private(), + now(), + ); + assert_eq!(denied(outcome).grant.as_deref(), Some("z-deny")); +} + +#[test] +fn within_a_mode_the_longest_matched_path_wins_and_then_the_lowest_id() { + let grants = set(vec![ + grant("a-wide", "read_file", Mode::Auto).paths(&["/home/kyle"]), + grant("z-narrow", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]), + ]); + let d = allowed(decide( + read("/home/kyle/notes/a.md"), + &grants, + private(), + now(), + )); + assert_eq!( + (d.grant(), d.matched_path()), + ("z-narrow", Some("/home/kyle/notes")) + ); + let d = allowed(decide(read("/home/kyle/other"), &grants, private(), now())); + assert_eq!(d.grant(), "a-wide"); + + // Equal paths: the lowest id in byte order, whatever order the grants were given in. + let tie = set(vec![ + grant("g-10", "read_file", Mode::Auto).paths(&["/srv"]), + grant("g-2", "read_file", Mode::Auto).paths(&["/srv"]), + grant("g-1z", "read_file", Mode::Auto).paths(&["/srv"]), + ]); + assert_eq!( + allowed(decide(read("/srv/x"), &tie, private(), now())).grant(), + "g-10" + ); + + // Grants with no matched path all tie, so the id decides. + let hosts = set(vec![ + grant("m", "http_fetch", Mode::Auto).hosts(&["*.example.com"]), + grant("b", "http_fetch", Mode::Auto).hosts(&["www.example.com"]), + ]); + let d = allowed(decide( + fetch("https://www.example.com/"), + &hosts, + private(), + now(), + )); + assert_eq!(d.grant(), "b"); +} + +/// The example in the spec. Whichever id sorts first, the read is labelled `secret`, and the +/// narrower grant is the one recorded and mounted. +#[test] +fn the_label_is_combined_over_every_matching_grant() { + for (home, keys) in [("a-home", "b-keys"), ("z-home", "b-keys")] { + let grants = set(vec![ + grant(home, "read_file", Mode::Auto) + .paths(&["/home/kyle"]) + .trusted(), + grant(keys, "read_file", Mode::Auto) + .paths(&["/home/kyle/keys"]) + .class(DataClass::Secret) + .trusted(), + ]); + let d = allowed(decide( + read("/home/kyle/keys/id"), + &grants, + private(), + now(), + )); + assert_eq!(d.grant(), keys); + assert_eq!( + d.label(), + Label { + class: DataClass::Secret, + untrusted: false + } + ); + // Outside `keys` only the wide grant matches, so only its label counts. + let d = allowed(decide(read("/home/kyle/todo"), &grants, private(), now())); + assert_eq!( + d.label(), + Label { + class: DataClass::Private, + untrusted: false + } + ); + } + + // The winner says trusted and public; another matching grant says otherwise, and it counts. + let grants = set(vec![ + grant("narrow", "read_file", Mode::Auto) + .paths(&["/srv/pub/docs"]) + .class(DataClass::Public) + .trusted(), + grant("wide", "read_file", Mode::Auto) + .paths(&["/srv/pub"]) + .class(DataClass::Private), + ]); + let d = allowed(decide(read("/srv/pub/docs/x"), &grants, private(), now())); + assert_eq!(d.grant(), "narrow"); + assert_eq!( + d.label(), + Label { + class: DataClass::Private, + untrusted: true + } + ); + + // An `ask` winner carries the combined label too. + let grants = set(vec![ + grant("asks", "read_file", Mode::Ask) + .paths(&["/srv"]) + .class(DataClass::Public) + .trusted(), + grant("labels", "read_file", Mode::Auto) + .paths(&["/srv"]) + .class(DataClass::Secret), + ]); + let ask = asked(decide(read("/srv/x"), &grants, private(), now())); + assert_eq!( + ask.label(), + Label { + class: DataClass::Secret, + untrusted: true + } + ); +} + +#[test] +fn a_grant_ruled_out_by_taint_or_expiry_adds_nothing_to_the_label() { + let grants = set(vec![ + grant("live", "read_file", Mode::Auto) + .paths(&["/srv"]) + .class(DataClass::Public) + .trusted(), + grant("old", "read_file", Mode::Auto) + .paths(&["/srv"]) + .class(DataClass::Secret) + .expires("2026-01-01T00:00:00.000Z"), + ]); + let d = allowed(decide(read("/srv/x"), &grants, private(), now())); + assert_eq!( + d.label(), + Label { + class: DataClass::Public, + untrusted: false + } + ); +} + +#[test] +fn a_grant_expires_exactly_at_its_time() { + let at = |when: &str| set(vec![grant("g", "shell", Mode::Auto).expires(when)]); + let d = allowed(decide( + shell(None), + &at("2026-09-18T12:00:00.001Z"), + private(), + now(), + )); + assert_eq!(d.expires(), Some(build::ts("2026-09-18T12:00:00.001Z"))); + for when in [ + "2026-09-18T12:00:00.000Z", + "2026-09-18T11:59:59.999Z", + "2020-01-01T00:00:00.000Z", + ] { + assert_eq!( + reason(decide(shell(None), &at(when), private(), now())), + DenyReason::GrantExpired, + "{when}" + ); + } + let never = set(vec![grant("g", "shell", Mode::Auto)]); + assert_eq!( + allowed(decide(shell(None), &never, private(), now())).expires(), + None + ); +} + +#[test] +fn a_grant_applies_up_to_its_max_taint() { + let grants = set(vec![ + grant("g", "shell", Mode::Auto).max_taint(DataClass::Private), + ]); + allowed(decide(shell(None), &grants, private(), now())); + allowed(decide( + shell(None), + &grants, + build::at(DataClass::Public), + now(), + )); + assert_eq!( + reason(decide(shell(None), &grants, secret(), now())), + DenyReason::TaintTooHigh + ); + // The untrusted flag is not an input to matching. + let mut state = private(); + state.untrusted = true; + allowed(decide(shell(None), &grants, state, now())); +} + +#[test] +fn the_reason_when_nothing_is_left() { + let expired = || grant("e", "shell", Mode::Auto).expires("2026-01-01T00:00:00.000Z"); + let tainted = || grant("t", "shell", Mode::Auto).max_taint(DataClass::Private); + let both = || { + grant("b", "shell", Mode::Auto) + .expires("2026-01-01T00:00:00.000Z") + .max_taint(DataClass::Private) + }; + let cases = [ + // One candidate expired and another too tainted: expiry is reported first. + (set(vec![expired(), tainted()]), DenyReason::GrantExpired), + (set(vec![tainted(), both()]), DenyReason::TaintTooHigh), + // Ruled out by both is ruled out "only" by neither. + (set(vec![both()]), DenyReason::NoGrant), + (set(vec![expired()]), DenyReason::GrantExpired), + ]; + for (grants, want) in cases { + assert_eq!(reason(decide(shell(None), &grants, secret(), now())), want); + } + // "Only by expiry" means it would have matched: an expired grant for other arguments, or + // for another tool, is no reason to say `grant_expired`. + let elsewhere = set(vec![ + grant("p", "read_file", Mode::Auto) + .paths(&["/srv"]) + .expires("2026-01-01T00:00:00.000Z"), + grant("w", "write_file", Mode::Auto) + .paths(&["/home"]) + .expires("2026-01-01T00:00:00.000Z"), + ]); + assert_eq!( + reason(decide(read("/home/kyle/x"), &elsewhere, private(), now())), + DenyReason::NoGrant + ); +} + +#[test] +fn a_deny_grant_denies_at_every_taint_until_it_expires() { + let grants = |deny_expires: Option<&str>| { + let deny = grant("no-internal", "http_fetch", Mode::Deny).hosts(&["internal.example.com"]); + let deny = match deny_expires { + Some(when) => deny.expires(when), + None => deny, + }; + set(vec![ + grant("any", "http_fetch", Mode::Auto).hosts(&["*.example.com"]), + deny, + ]) + }; + let url = "https://internal.example.com/"; + for state in [build::at(DataClass::Public), private(), secret()] { + let denial = denied(decide(fetch(url), &grants(None), state, now())); + assert_eq!(denial.reason, DenyReason::DeniedByGrant); + assert_eq!(denial.grant.as_deref(), Some("no-internal")); + } + allowed(decide( + fetch("https://www.example.com/"), + &grants(None), + secret(), + now(), + )); + // An expired deny no longer denies: `expires` on a deny grant means "forbid this until then". + let lapsed = grants(Some("2026-09-18T12:00:00.000Z")); + assert_eq!( + allowed(decide(fetch(url), &lapsed, private(), now())).grant(), + "any" + ); +} + +/// Documented, not liked: 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. +#[test] +fn an_ask_grant_with_a_lower_max_taint_stops_asking_when_taint_rises() { + let grants = set(vec![ + grant("asks", "shell", Mode::Ask).max_taint(DataClass::Private), + grant("runs", "shell", Mode::Auto), + ]); + assert_eq!( + asked(decide(shell(None), &grants, private(), now())).grant(), + "asks" + ); + assert_eq!( + allowed(decide(shell(None), &grants, secret(), now())).grant(), + "runs" + ); +} + +#[test] +fn a_decision_and_an_ask_carry_what_the_broker_and_the_runner_need() { + let grants = set(vec![ + grant("asks", "write_file", Mode::Ask) + .paths(&["/home/kyle/scratch"]) + .expires("2027-01-01T00:00:00.000Z") + .class(DataClass::Public), + ]); + let req = request( + "write_file", + r#"{ "content": "hello", "path": "/home/kyle/scratch/a.txt" }"#, + ); + let ask = asked(decide(req.clone(), &grants, private(), now())); + assert_eq!(ask.request(), &req); + assert_eq!( + ask.args().canonical_json(), + r#"{"path":"/home/kyle/scratch/a.txt","content":"hello"}"# + ); + assert_eq!(ask.grant(), "asks"); + assert_eq!(ask.grant_sha256(), proto::sha256(b"asks").unwrap()); + assert_eq!(ask.matched_path(), Some("/home/kyle/scratch")); + assert_eq!(ask.paths(), ["/home/kyle/scratch"]); + assert!(ask.hosts().is_empty()); + assert_eq!(ask.expires(), Some(build::ts("2027-01-01T00:00:00.000Z"))); + assert_eq!( + ask.label(), + Label { + class: DataClass::Public, + untrusted: true + } + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/policy_property.rs b/docs/plans/M3a/files/crates/brokerd/tests/policy_property.rs new file mode 100644 index 0000000..ef63382 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/policy_property.rs @@ -0,0 +1,234 @@ +//! Property test for `policy`: random grant sets, states and requests, each decided twice, once +//! by `policy::decide` and once by an oracle. Every case must agree. Do not edit. +//! +//! The generator and the oracle are in `support/oracle.rs`. If this test fails, the oracle is +//! the specification and `policy` is wrong. +//! +//! The generator is a seeded xorshift, so a failure can be replayed: the message names the seed +//! and the case. `BOXMAKER_POLICY_SEED=` runs one more seed, and `BOXMAKER_POLICY_CASES=` +//! changes how many cases each seed runs (default 3000). + +#[path = "support/oracle.rs"] +mod oracle; + +use brokerd::grants::{GrantSet, LoadedGrant}; +use brokerd::policy::{Outcome, SessionState, decide, redecide}; +use oracle::{CLASSES, Expected, NOW_MS, Rng, oracle, some_grant, some_grants, some_request}; +use proto::{DataClass, DenyReason, Mode, Timestamp}; + +const SEEDS: [u64; 5] = [1, 2, 3, 0xB0C5, 20_260_918]; + +// --------------------------------------------------------------------------------------------- +// The comparison. + +fn observed(outcome: &Outcome) -> Expected { + match outcome { + Outcome::Allowed(d) => Expected::Allowed { + grant: d.grant().to_string(), + path: d.matched_path().map(str::to_string), + class: d.label().class, + untrusted: d.label().untrusted, + }, + Outcome::Ask(a) => Expected::Ask { + grant: a.grant().to_string(), + path: a.matched_path().map(str::to_string), + class: a.label().class, + untrusted: a.label().untrusted, + }, + Outcome::Denied(denial) => Expected::Denied { + reason: denial.reason, + grant: denial.grant.clone(), + }, + } +} + +/// Allowed is 0, ask is 1, denied is 2. +fn restrictiveness(expected: &Expected) -> u8 { + match expected { + Expected::Allowed { .. } => 0, + Expected::Ask { .. } => 1, + Expected::Denied { .. } => 2, + } +} + +fn cases() -> usize { + match std::env::var("BOXMAKER_POLICY_CASES") { + Ok(text) => text + .parse() + .expect("BOXMAKER_POLICY_CASES must be a number"), + Err(_) => 3000, + } +} + +fn seeds() -> Vec { + let mut seeds = SEEDS.to_vec(); + if let Ok(text) = std::env::var("BOXMAKER_POLICY_SEED") { + seeds.push(text.parse().expect("BOXMAKER_POLICY_SEED must be a number")); + } + seeds +} + +fn now() -> Timestamp { + Timestamp::from_unix_millis(NOW_MS).unwrap() +} + +fn state(rng: &mut Rng) -> SessionState { + SessionState { + taint: rng.pick(&CLASSES), + untrusted: rng.chance(50), + } +} + +fn valid(grants: &[LoadedGrant]) -> GrantSet { + GrantSet::from_grants(grants.to_vec()).expect("the generator only makes valid grants") +} + +#[test] +fn decide_agrees_with_the_oracle() { + let mut kinds = [0usize; 3]; + for seed in seeds() { + let mut rng = Rng::new(seed); + for case in 0..cases() { + let grants = some_grants(&mut rng); + let request = some_request(&mut rng); + let state = state(&mut rng); + let want = oracle(&request, &grants, state); + let got = observed(&decide(request.clone(), &valid(&grants), state, now())); + assert_eq!( + got, want, + "seed {seed} case {case}\nrequest: {request:?}\nstate: {state:?}\ngrants: {grants:#?}" + ); + kinds[restrictiveness(&want) as usize] += 1; + } + } + // The generator must reach every kind of outcome, or the test proves little. + for (kind, count) in ["allowed", "ask", "denied"].iter().zip(kinds) { + assert!(count > 200, "only {count} cases were {kind}"); + } +} + +#[test] +fn redecide_agrees_with_the_oracle_under_new_grants_and_a_new_state() { + let mut approvals = 0; + for seed in seeds() { + let mut rng = Rng::new(seed ^ 0xA5A5); + for case in 0..cases() { + let grants = some_grants(&mut rng); + let request = some_request(&mut rng); + let first = state(&mut rng); + let Outcome::Ask(ask) = decide(request.clone(), &valid(&grants), first, now()) else { + continue; + }; + approvals += 1; + // Half the time nothing has changed; otherwise the grants or the state have. + let (later_grants, later) = if rng.chance(50) { + (grants.clone(), first) + } else { + (some_grants(&mut rng), state(&mut rng)) + }; + let want = oracle(&request, &later_grants, later); + let got = redecide(ask, &valid(&later_grants), later, now()); + let context = format!( + "seed {seed} case {case}\nrequest: {request:?}\nlater: {later:?}\ngrants: {later_grants:#?}" + ); + match (want, got) { + ( + Expected::Allowed { + grant, + path, + class, + untrusted, + }, + Ok(d), + ) + | ( + Expected::Ask { + grant, + path, + class, + untrusted, + }, + Ok(d), + ) => { + assert_eq!(d.grant(), grant, "{context}"); + assert_eq!(d.matched_path().map(str::to_string), path, "{context}"); + assert_eq!( + (d.label().class, d.label().untrusted), + (class, untrusted), + "{context}" + ); + assert_eq!(d.request(), &request, "{context}"); + } + (Expected::Denied { reason, grant }, Err(denial)) => { + assert_eq!((denial.reason, denial.grant), (reason, grant), "{context}"); + } + (want, got) => panic!("wanted {want:?}, got {got:?}\n{context}"), + } + } + } + assert!(approvals > 200, "only {approvals} cases asked"); +} + +/// Adding a `deny` grant to a set never makes any outcome less restrictive. +#[test] +fn adding_a_deny_grant_never_loosens_an_outcome() { + for seed in seeds() { + let mut rng = Rng::new(seed ^ 0x5A5A); + for case in 0..cases() { + let grants = some_grants(&mut rng); + let request = some_request(&mut rng); + let state = state(&mut rng); + let mut extra = some_grant(&mut rng, 90); + extra.grant.mode = Mode::Deny; + extra.grant.max_taint = DataClass::Secret; + let mut with_deny = grants.clone(); + with_deny.push(extra); + + let before = observed(&decide(request.clone(), &valid(&grants), state, now())); + let after = observed(&decide(request.clone(), &valid(&with_deny), state, now())); + assert!( + restrictiveness(&after) >= restrictiveness(&before), + "seed {seed} case {case}: {before:?} became {after:?}\nrequest: {request:?}\ngrants: {with_deny:#?}" + ); + } + } +} + +/// A call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint: +/// reading a secret can never switch off a prohibition. +#[test] +fn a_prohibition_holds_at_every_higher_taint() { + let mut prohibitions = 0; + for seed in seeds() { + let mut rng = Rng::new(seed ^ 0x0F0F); + for case in 0..cases() { + let grants = some_grants(&mut rng); + let request = some_request(&mut rng); + let mut denied_below = false; + for taint in CLASSES { + let state = SessionState { + taint, + untrusted: false, + }; + let outcome = observed(&decide(request.clone(), &valid(&grants), state, now())); + let by_grant = matches!( + outcome, + Expected::Denied { + reason: DenyReason::DeniedByGrant, + .. + } + ); + assert!( + by_grant || !denied_below, + "seed {seed} case {case}: a deny stopped applying at {taint:?}\nrequest: {request:?}\ngrants: {grants:#?}" + ); + denied_below = by_grant; + } + prohibitions += usize::from(denied_below); + } + } + assert!( + prohibitions > 200, + "only {prohibitions} cases were prohibited" + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/policy_redecide.rs b/docs/plans/M3a/files/crates/brokerd/tests/policy_redecide.rs new file mode 100644 index 0000000..a9de334 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/policy_redecide.rs @@ -0,0 +1,117 @@ +//! Table tests for `policy::redecide`: an approval lets a call through only if the grants and +//! the session's state, as they are when it is approved, still say `ask` or `auto`. Do not edit. + +#[path = "support/build.rs"] +mod build; + +use brokerd::grants::GrantSet; +use brokerd::policy::{Ask, Label, Outcome, decide, redecide}; +use build::{grant, now, private, read, secret, set}; +use proto::{DataClass, DenyReason, Mode}; + +const PATH: &str = "/home/kyle/notes/a.md"; + +fn asking() -> build::Build { + grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/notes"]) +} + +/// An `Ask` for `PATH`, decided under `asking()` alone at `private`. +fn pending() -> Ask { + match decide(read(PATH), &set(vec![asking()]), private(), now()) { + Outcome::Ask(ask) => ask, + other => panic!("expected ask, got {other:?}"), + } +} + +#[test] +fn still_ask_lets_the_call_run_under_the_same_grant() { + let decision = redecide(pending(), &set(vec![asking()]), private(), now()).unwrap(); + assert_eq!(decision.grant(), "asks"); + assert_eq!(decision.request(), &read(PATH)); + assert_eq!(decision.matched_path(), Some("/home/kyle/notes")); + assert_eq!( + decision.label(), + Label { + class: DataClass::Private, + untrusted: true + } + ); +} + +#[test] +fn auto_now_lets_the_call_run_under_the_grant_that_matches_now() { + // The owner has since replaced the ask grant with an auto grant of another name and label. + let grants = set(vec![ + grant("now-auto", "read_file", Mode::Auto) + .paths(&["/home/kyle"]) + .class(DataClass::Secret) + .trusted(), + ]); + let decision = redecide(pending(), &grants, private(), now()).unwrap(); + assert_eq!(decision.grant(), "now-auto"); + assert_eq!(decision.grant_sha256(), proto::sha256(b"now-auto").unwrap()); + assert_eq!(decision.matched_path(), Some("/home/kyle")); + assert_eq!(decision.paths(), ["/home/kyle"]); + assert_eq!( + decision.label(), + Label { + class: DataClass::Secret, + untrusted: false + } + ); +} + +#[test] +fn the_grant_file_was_removed() { + let denial = redecide(pending(), &GrantSet::default(), private(), now()).unwrap_err(); + assert_eq!(denial.reason, DenyReason::NoGrant); + assert_eq!(denial.grant, None); +} + +#[test] +fn the_taint_rose_past_max_taint_while_the_approval_waited() { + let narrow = || asking().max_taint(DataClass::Private); + let ask = match decide(read(PATH), &set(vec![narrow()]), private(), now()) { + Outcome::Ask(ask) => ask, + other => panic!("expected ask, got {other:?}"), + }; + let denial = redecide(ask, &set(vec![narrow()]), secret(), now()).unwrap_err(); + assert_eq!(denial.reason, DenyReason::TaintTooHigh); +} + +#[test] +fn the_grant_expired_while_the_approval_waited() { + let grants = set(vec![asking().expires("2026-09-18T12:10:00.000Z")]); + let ask = match decide(read(PATH), &grants, private(), now()) { + Outcome::Ask(ask) => ask, + other => panic!("expected ask, got {other:?}"), + }; + assert_eq!(ask.expires(), Some(build::ts("2026-09-18T12:10:00.000Z"))); + let later = build::ts("2026-09-18T12:10:00.000Z"); + let denial = redecide(ask, &grants, private(), later).unwrap_err(); + assert_eq!(denial.reason, DenyReason::GrantExpired); +} + +#[test] +fn a_deny_grant_was_added_while_the_approval_waited() { + let grants = set(vec![ + asking(), + grant("no-notes", "read_file", Mode::Deny).paths(&["/home/kyle"]), + ]); + let denial = redecide(pending(), &grants, private(), now()).unwrap_err(); + assert_eq!(denial.reason, DenyReason::DeniedByGrant); + assert_eq!(denial.grant.as_deref(), Some("no-notes")); + assert_eq!( + denial.grant_sha256, + Some(proto::sha256(b"no-notes").unwrap()) + ); +} + +#[test] +fn the_grants_now_cover_other_arguments_only() { + let grants = set(vec![ + grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/other"]), + ]); + let denial = redecide(pending(), &grants, private(), now()).unwrap_err(); + assert_eq!(denial.reason, DenyReason::NoGrant); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/runner.rs b/docs/plans/M3a/files/crates/brokerd/tests/runner.rs new file mode 100644 index 0000000..667c082 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/runner.rs @@ -0,0 +1,175 @@ +//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not +//! edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/runtime.rs"] +mod runtime; + +use brokerd::args::{ToolArgs, ToolName}; +use brokerd::policy::{Decision, Outcome, SessionState, decide}; +use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run}; +use build::{grant, now, read, request, set}; +use proto::{DataClass, Mode, ToolRequest, ToolResponse}; +use runtime::Recording; + +fn allowed(grants: Vec, request: ToolRequest) -> Decision { + match decide(request, &set(grants), SessionState::default(), now()) { + Outcome::Allowed(decision) => decision, + other => panic!("the test's call is not allowed: {other:?}"), + } +} + +fn mount(path: &str, writable: bool) -> Mount { + Mount { + path: path.to_string(), + writable, + } +} + +#[test] +fn read_file_mounts_the_matched_path_read_only_and_has_no_network() { + let d = allowed( + vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])], + read("/h/notes/deep/a.md"), + ); + let rt = Recording::answering("text"); + run(d, rt.as_ref()); + let seen = rt.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].tool, ToolName::ReadFile); + assert_eq!( + seen[0].arguments, + ToolArgs::ReadFile { + path: "/h/notes/deep/a.md".to_string() + } + ); + // The longest path that holds the argument, and only that one. + assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]); + assert_eq!(seen[0].egress, None); +} + +#[test] +fn write_file_mounts_the_matched_path_writable() { + // A grant path equal to the argument does not count, so `/s/out` is written through `/s`. + let d = allowed( + vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])], + request("write_file", r#"{"path":"/s/out","content":"x"}"#), + ); + let rt = Recording::answering(""); + run(d, rt.as_ref()); + let seen = rt.seen(); + assert_eq!(seen[0].tool, ToolName::WriteFile); + assert_eq!(seen[0].mounts, [mount("/s", true)]); + assert_eq!(seen[0].egress, None); +} + +#[test] +fn shell_mounts_every_path_of_the_grant_writable() { + let d = allowed( + vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])], + request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#), + ); + let rt = Recording::answering(""); + run(d, rt.as_ref()); + let seen = rt.seen(); + assert_eq!(seen[0].tool, ToolName::Shell); + assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]); + assert_eq!(seen[0].egress, None); +} + +#[test] +fn shell_without_paths_mounts_nothing() { + let d = allowed( + vec![grant("sh", "shell", Mode::Auto)], + request("shell", r#"{"command":"date"}"#), + ); + let rt = Recording::answering(""); + run(d, rt.as_ref()); + let seen = rt.seen(); + assert_eq!(seen[0].mounts, []); + assert_eq!(seen[0].egress, None); +} + +#[test] +fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() { + let d = allowed( + vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])], + request("http_fetch", r#"{"url":"https://www.example.org/x"}"#), + ); + let rt = Recording::answering(""); + run(d, rt.as_ref()); + let seen = rt.seen(); + assert_eq!(seen[0].tool, ToolName::HttpFetch); + assert_eq!(seen[0].mounts, []); + assert_eq!( + seen[0].egress, + Some(vec!["example.com".to_string(), "*.example.org".to_string()]) + ); +} + +#[test] +fn a_result_carries_the_label_combined_over_every_matching_grant() { + // `b-keys` has the longer path and wins the mount; the label is the highest class of both + // grants, and untrusted because `a-home` says so. + let d = allowed( + vec![ + grant("a-home", "read_file", Mode::Auto) + .paths(&["/home/kyle"]) + .class(DataClass::Private), + grant("b-keys", "read_file", Mode::Auto) + .paths(&["/home/kyle/keys"]) + .class(DataClass::Secret) + .trusted(), + ], + read("/home/kyle/keys/id"), + ); + let rt = Recording::with(Ok(RunOutput { + content: "key".to_string(), + truncated: true, + })); + let answer = run(d, rt.as_ref()); + assert_eq!( + answer, + ToolResponse::Result { + content: "key".to_string(), + class: DataClass::Secret, + untrusted: true, + truncated: true, + } + ); + assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]); +} + +#[test] +fn a_run_error_is_a_failure_with_the_runtimes_sentence() { + for error in [ + RunError::Failed("the tool timed out".to_string()), + RunError::Unavailable("the container could not start".to_string()), + ] { + let d = allowed( + vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])], + read("/n/a"), + ); + let text = match &error { + RunError::Failed(t) | RunError::Unavailable(t) => t.clone(), + }; + let rt = Recording::with(Err(error)); + assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text }); + } +} + +#[test] +fn the_production_runtime_refuses_every_call() { + assert_eq!(REFUSING, "the runner arrives in M3b"); + let d = allowed( + vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])], + read("/n/a"), + ); + assert_eq!( + run(d, &Refusing), + ToolResponse::Failed { + message: REFUSING.to_string() + } + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/serve.rs b/docs/plans/M3a/files/crates/brokerd/tests/serve.rs new file mode 100644 index 0000000..11e46d8 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/serve.rs @@ -0,0 +1,405 @@ +//! `brokerd serve` as a process: its startup, both sockets, and the expiry thread. Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +use brokerd::audit::RECOVERED_NOTICE; +use brokerd::runner::REFUSING; +use proto::{ + AuditEvent, CallId, DenyReason, Empty, Envelope, ErrorCode, Message, PROTOCOL_VERSION, + ResultStatus, SessionId, ToolRequest, ToolResponse, +}; +use tmp::TempDir; + +struct Home { + dir: TempDir, + config: PathBuf, +} + +impl Home { + fn new(tag: &str, ttl_ms: u64) -> Home { + let dir = TempDir::new(tag); + std::fs::create_dir_all(dir.path().join("grants")).unwrap(); + let text = format!( + "[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[approvals]\nttl_ms = {ttl_ms}\n", + home = dir.path().display() + ); + let config = dir.write("brokerd.toml", &text); + Home { dir, config } + } + + fn path(&self, relative: &str) -> PathBuf { + self.dir.path().join(relative) + } + + fn tools(&self) -> PathBuf { + self.path("run/loop-broker/broker.sock") + } + + fn admin(&self) -> PathBuf { + self.path("run/owner-broker/admin.sock") + } + + fn grant(&self, id: &str, mode: &str) { + let text = format!( + "tool = \"read_file\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n\ + result_class = \"private\"\nuntrusted = false\n[constraints]\npaths = [\"/n\"]\n" + ); + std::fs::write(self.path(&format!("grants/{id}.toml")), text).unwrap(); + } + + fn command(&self, extra: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_brokerd")); + command + .args(["serve", "--config"]) + .arg(&self.config) + .args(extra); + command + } + + /// Starts `brokerd serve` and waits until both sockets answer. + fn serve(&self) -> Running { + let child = self + .command(&[]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let running = Running(Some(child)); + let until = Instant::now() + Duration::from_secs(10); + while UnixStream::connect(self.tools()).is_err() + || UnixStream::connect(self.admin()).is_err() + { + assert!(Instant::now() < until, "brokerd never listened"); + std::thread::sleep(Duration::from_millis(20)); + } + running + } + + /// Runs `brokerd serve` expecting it to exit by itself. + fn run(&self, extra: &[&str]) -> Output { + self.command(extra).output().unwrap() + } + + fn events(&self) -> Vec { + let dir = self.path("audit"); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.ends_with(".jsonl")) + .collect(); + names.sort(); + names + .iter() + .flat_map(|n| { + let text = std::fs::read_to_string(dir.join(n)).unwrap(); + text.lines() + .map(|l| serde_json::from_str::(l).unwrap().event) + .collect::>() + }) + .collect() + } +} + +/// Kills the daemon when dropped; `stop` returns what it printed. +struct Running(Option); + +impl Running { + fn stop(mut self) -> String { + let mut child = self.0.take().unwrap(); + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + String::from_utf8_lossy(&output.stderr).to_string() + } +} + +impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = &mut self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +fn exchange(socket: &Path, id: u64, msg: Message) -> Vec { + let mut stream = UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let env = Envelope { + v: PROTOCOL_VERSION, + id, + r#final: true, + msg, + }; + proto::write_frame(&mut stream, &env).unwrap(); + let mut frames = Vec::new(); + loop { + let env = proto::read_frame(&mut stream).unwrap(); + let last = env.r#final; + frames.push(env); + if last { + return frames; + } + } +} + +fn read_notes(call: u64) -> Message { + Message::ToolRequest(ToolRequest { + session: SessionId::new("s1").unwrap(), + call: CallId(call), + tool: "read_file".to_string(), + arguments: r#"{"path":"/n/a"}"#.to_string(), + }) +} + +fn last_response(frames: &[Envelope]) -> &ToolResponse { + match &frames.last().unwrap().msg { + Message::ToolResponse(r) => r, + other => panic!("{other:?}"), + } +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +#[test] +fn it_makes_its_directories_0700_and_its_sockets_0600() { + let home = Home::new("serve-modes", 900_000); + // One directory found too open, one made. + std::fs::create_dir_all(home.path("run/owner-broker")).unwrap(); + std::fs::set_permissions( + home.path("run/owner-broker"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let running = home.serve(); + assert_eq!(mode(&home.path("run/loop-broker")), 0o700); + assert_eq!(mode(&home.path("run/owner-broker")), 0o700); + assert_eq!(mode(&home.tools()), 0o600); + assert_eq!(mode(&home.admin()), 0o600); + assert_eq!(mode(&home.path("audit")), 0o700); + let printed = running.stop(); + assert!(printed.contains("serving"), "{printed}"); +} + +#[test] +fn a_stale_socket_is_replaced() { + let home = Home::new("serve-stale", 900_000); + std::fs::create_dir_all(home.path("run/loop-broker")).unwrap(); + drop(UnixListener::bind(home.tools()).unwrap()); + assert!(home.tools().exists(), "the stale socket file is there"); + let _running = home.serve(); + let frames = exchange(&home.tools(), 3, read_notes(3)); + assert_eq!( + last_response(&frames), + &ToolResponse::Denied { + reason: DenyReason::NoGrant + } + ); +} + +#[test] +fn a_second_brokerd_on_the_same_home_refuses_to_start() { + let home = Home::new("serve-twice", 900_000); + let _running = home.serve(); + let second = home.run(&[]); + assert_eq!(second.status.code(), Some(1)); + let text = stderr(&second); + assert!(text.contains("brokerd is already running"), "{text}"); + assert!( + text.trim_end() + .ends_with("see docs/runbook.md#brokerd-already-running"), + "{text}" + ); + // The first still has its sockets. + let frames = exchange(&home.admin(), 1, Message::Approvals(Empty {})); + assert!(matches!(frames[0].msg, Message::ApprovalList(_))); +} + +#[test] +fn it_answers_each_socket_and_refuses_the_other_kinds() { + let home = Home::new("serve-kinds", 900_000); + home.grant("notes", "auto"); + let running = home.serve(); + let frames = exchange(&home.tools(), 7, read_notes(7)); + assert_eq!(frames[0].id, 7); + // The production runtime runs nothing. + assert_eq!( + last_response(&frames), + &ToolResponse::Failed { + message: REFUSING.to_string() + } + ); + let wrong = exchange(&home.tools(), 8, Message::Approvals(Empty {})); + assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden)); + let wrong = exchange(&home.admin(), 9, read_notes(9)); + assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden)); + let printed = running.stop(); + assert!( + printed.contains("approvals on broker.sock\nsee docs/runbook.md#socket-forbidden"), + "{printed}" + ); + assert!( + printed.contains("tool_request on admin.sock\nsee docs/runbook.md#socket-forbidden"), + "{printed}" + ); + assert!(matches!( + home.events().as_slice(), + [ + AuditEvent::Decision { .. }, + AuditEvent::Result { + status: ResultStatus::Failed, + .. + } + ] + )); +} + +#[test] +fn an_approval_nobody_answers_expires() { + let home = Home::new("serve-expire", 100); + home.grant("notes", "ask"); + let _running = home.serve(); + let started = Instant::now(); + let frames = exchange(&home.tools(), 2, read_notes(2)); + assert_eq!(frames.len(), 2, "{frames:?}"); + assert!(matches!( + &frames[0].msg, + Message::ToolResponse(ToolResponse::PendingApproval { approval: 0, .. }) + )); + assert_eq!( + last_response(&frames), + &ToolResponse::Denied { + reason: DenyReason::ApprovalExpired + } + ); + // The expiry thread looks every second. + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); +} + +fn copy_case(home: &Home, case: &str, only: &[&str]) { + let from = format!( + "{}/../proto/tests/fixtures/audit/{case}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::create_dir_all(home.path("audit")).unwrap(); + for name in only { + std::fs::copy( + format!("{from}/{name}"), + home.path(&format!("audit/{name}")), + ) + .unwrap(); + } +} + +fn snapshot(dir: &Path) -> Vec<(String, Vec)> { + let mut all: Vec<(String, Vec)> = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl")) + .map(|e| { + ( + e.file_name().into_string().unwrap(), + std::fs::read(e.path()).unwrap(), + ) + }) + .collect(); + all.sort(); + all +} + +#[test] +fn a_broken_chain_stops_it_before_any_socket_and_nothing_is_written() { + let home = Home::new("serve-broken", 900_000); + copy_case(&home, "changed-byte", &["2026-09-17.jsonl"]); + let before = snapshot(&home.path("audit")); + let output = home.run(&[]); + assert_eq!(output.status.code(), Some(1)); + let text = stderr(&output); + assert!(text.contains("2026-09-17.jsonl:4: "), "{text}"); + assert!( + text.trim_end() + .ends_with("see docs/runbook.md#audit-chain-broken"), + "{text}" + ); + assert_eq!(snapshot(&home.path("audit")), before); + assert!(!home.tools().exists() && !home.admin().exists()); +} + +#[test] +fn a_torn_tail_is_recovered_and_it_serves() { + let home = Home::new("serve-torn", 900_000); + copy_case( + &home, + "torn-tail", + &["2026-09-17.jsonl", "2026-09-18.jsonl"], + ); + let running = home.serve(); + let printed = running.stop(); + assert!(printed.contains(RECOVERED_NOTICE), "{printed}"); + assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered")); +} + +#[test] +fn accept_break_with_nothing_to_accept_exits_2() { + let home = Home::new("serve-nothing", 900_000); + let output = home.run(&["--accept-break"]); + assert_eq!(output.status.code(), Some(2)); + assert!( + stderr(&output).contains("nothing to accept"), + "{}", + stderr(&output) + ); +} + +#[test] +fn bad_arguments_and_bad_configs_do_not_start() { + let brokerd = env!("CARGO_BIN_EXE_brokerd"); + for args in [ + &[][..], + &["serve"][..], + &["serve", "--config"][..], + &["serve", "--config", "a", "--config", "b"][..], + &["serve", "--config", "a", "--loud"][..], + &["run", "--config", "a"][..], + ] { + let output = Command::new(brokerd).args(args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + assert!( + stderr(&output).starts_with("usage: brokerd serve"), + "{args:?}" + ); + } + let home = Home::new("serve-config", 900_000); + std::fs::write(&home.config, "[paths]\nhoem = \"/x\"\n").unwrap(); + let output = home.run(&[]); + assert_eq!(output.status.code(), Some(1)); + assert!( + stderr(&output).contains("brokerd.toml"), + "{}", + stderr(&output) + ); + std::fs::remove_file(&home.config).unwrap(); + assert_eq!(home.run(&[]).status.code(), Some(1)); + assert!( + !home.path("audit").exists(), + "nothing made before the config is read" + ); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/state.rs b/docs/plans/M3a/files/crates/brokerd/tests/state.rs new file mode 100644 index 0000000..dd16a3c --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/state.rs @@ -0,0 +1,281 @@ +//! Tests for the session state files. Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::policy::{Label, SessionState}; +use brokerd::state::{RUNBOOK, StateError, StateStore}; +use proto::{DataClass, SessionId}; +use std::os::unix::fs::PermissionsExt; +use tmp::TempDir; + +fn id(text: &str) -> SessionId { + SessionId::new(text).unwrap() +} + +fn label(class: DataClass, untrusted: bool) -> Label { + Label { class, untrusted } +} + +fn state(taint: DataClass, untrusted: bool) -> SessionState { + SessionState { taint, untrusted } +} + +/// The store's directory is two levels below the temporary one and does not exist yet, as on a +/// fresh install. +fn store(home: &TempDir) -> StateStore { + StateStore::new(&home.path().join("broker/sessions")) +} + +#[test] +fn a_session_with_no_file_is_private_and_trusted() { + let home = TempDir::new("state"); + let store = store(&home); + let fresh = store.read(&id("chat-1")).unwrap(); + assert_eq!(fresh, state(DataClass::Private, false)); + assert_eq!(fresh, SessionState::default()); + // Reading creates nothing. + assert!(!home.path().join("broker").exists()); +} + +#[test] +fn the_first_result_creates_the_file_and_its_directory() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("chat-1"); + let next = store + .raise( + &session, + SessionState::default(), + label(DataClass::Private, false), + ) + .unwrap(); + assert_eq!(next, state(DataClass::Private, false)); + + let path = home.path().join("broker/sessions/chat-1.json"); + assert_eq!(store.path(&session), path); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "{\"taint\":\"private\",\"untrusted\":false}\n" + ); + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&path), 0o600); + assert_eq!(mode(&home.path().join("broker/sessions")), 0o700); + assert_eq!(mode(&home.path().join("broker")), 0o700); + // No temporary file is left behind. + assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists()); + assert_eq!(store.read(&session).unwrap(), next); +} + +#[test] +fn taint_and_the_untrusted_flag_only_go_up() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let steps = [ + ( + label(DataClass::Public, false), + state(DataClass::Private, false), + ), + ( + label(DataClass::Private, true), + state(DataClass::Private, true), + ), + ( + label(DataClass::Secret, false), + state(DataClass::Secret, true), + ), + ( + label(DataClass::Public, false), + state(DataClass::Secret, true), + ), + ( + label(DataClass::Private, false), + state(DataClass::Secret, true), + ), + ]; + let mut current = store.read(&session).unwrap(); + for (result, want) in steps { + current = store.raise(&session, current, result).unwrap(); + assert_eq!(current, want); + assert_eq!(store.read(&session).unwrap(), want, "what is on disk"); + } + assert_eq!( + std::fs::read_to_string(store.path(&session)).unwrap(), + "{\"taint\":\"secret\",\"untrusted\":true}\n" + ); +} + +#[test] +fn sessions_do_not_share_state() { + let home = TempDir::new("state"); + let store = store(&home); + store + .raise( + &id("a"), + SessionState::default(), + label(DataClass::Secret, true), + ) + .unwrap(); + assert_eq!(store.read(&id("b")).unwrap(), SessionState::default()); + assert_eq!( + store.read(&id("a")).unwrap(), + state(DataClass::Secret, true) + ); +} + +/// A file that exists but does not hold a valid state is an error, never "no file". +#[test] +fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() { + let home = TempDir::new("state"); + let store = store(&home); + std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap(); + let session = id("hurt"); + for text in [ + "", + "{", + "null", + "[]", + "{\"taint\":\"secret\"}", + "{\"untrusted\":false}", + "{\"taint\":\"internal\",\"untrusted\":false}", + "{\"taint\":\"secret\",\"untrusted\":\"no\"}", + "{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}", + "{\"taint\":\"secret\",\"untrusted\":false} trailing", + // A session is never below private, so this file was not written by brokerd. + "{\"taint\":\"public\",\"untrusted\":false}", + ] { + std::fs::write(store.path(&session), text).unwrap(); + let err = store.read(&session).expect_err(text); + assert!( + matches!(err, StateError::Unreadable(..)), + "{text:?}: {err:?}" + ); + let shown = err.to_string(); + assert!(shown.contains("hurt.json"), "{shown}"); + assert!(shown.ends_with(RUNBOOK), "{shown}"); + } + assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged"); + // A good file with or without its final newline reads fine. + for text in [ + "{\"taint\":\"secret\",\"untrusted\":true}\n", + "{\"taint\":\"secret\",\"untrusted\":true}", + ] { + std::fs::write(store.path(&session), text).unwrap(); + assert_eq!( + store.read(&session).unwrap(), + state(DataClass::Secret, true) + ); + } + // Not valid UTF-8, and a directory where the file should be. + std::fs::write(store.path(&session), b"\xff\xfe").unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); + std::fs::remove_file(store.path(&session)).unwrap(); + std::fs::create_dir(store.path(&session)).unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); +} + +#[test] +fn a_file_without_read_permission_is_an_error_not_a_fresh_session() { + if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") { + return; + } + let home = TempDir::new("state"); + let store = store(&home); + let session = id("locked"); + store + .raise( + &session, + SessionState::default(), + label(DataClass::Secret, false), + ) + .unwrap(); + let path = store.path(&session); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + assert!(matches!( + store.read(&session), + Err(StateError::Unreadable(..)) + )); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[test] +fn a_failed_write_is_an_error_and_leaves_the_old_state() { + if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") { + return; + } + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let before = store + .raise( + &session, + SessionState::default(), + label(DataClass::Private, true), + ) + .unwrap(); + + let dir = home.path().join("broker/sessions"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = store + .raise(&session, before, label(DataClass::Secret, false)) + .expect_err("the directory is read-only"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + assert!(matches!(err, StateError::Write(..)), "{err:?}"); + let shown = err.to_string(); + assert!(shown.contains("s.json"), "{shown}"); + assert!(shown.ends_with(RUNBOOK), "{shown}"); + assert_eq!(store.read(&session).unwrap(), before); +} + +/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does +/// not stop the next write, and the next write replaces it. +#[test] +fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() { + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let dir = home.path().join("broker/sessions"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap(); + + assert_eq!(store.read(&session).unwrap(), SessionState::default()); + let next = store + .raise( + &session, + SessionState::default(), + label(DataClass::Secret, false), + ) + .unwrap(); + assert_eq!(store.read(&session).unwrap(), next); + assert!(!dir.join("s.json.tmp").exists()); +} + +#[test] +fn raise_trusts_the_state_it_is_given_not_the_file() { + // The caller read the state under the ledger lock a moment ago; `raise` does not read again. + let home = TempDir::new("state"); + let store = store(&home); + let session = id("s"); + let given = state(DataClass::Secret, true); + let next = store + .raise(&session, given, label(DataClass::Public, false)) + .unwrap(); + assert_eq!(next, given); + // Even a state below private is lifted to private on the way to disk. + let low = store + .raise( + &id("low"), + state(DataClass::Public, false), + label(DataClass::Public, false), + ) + .unwrap(); + assert_eq!(low, state(DataClass::Private, false)); +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs new file mode 100644 index 0000000..fc0f655 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs @@ -0,0 +1,98 @@ +//! Temporary audit directories for the audit tests. Do not edit. + +#![allow(dead_code)] // each test file uses its own part of this + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use proto::{AuditEvent, CallId, DataClass, DecisionRecord, SessionId, Timestamp}; + +pub const D1: &str = "2026-09-17.jsonl"; +pub const D2: &str = "2026-09-18.jsonl"; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +/// A directory under the system's temporary directory, removed when dropped. +pub struct TempDir { + pub path: PathBuf, +} + +impl TempDir { + /// A path that does not exist yet. + pub fn unmade(tag: &str) -> TempDir { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let name = format!("brokerd-{tag}-{}-{n}", std::process::id()); + let path = std::env::temp_dir().join(name); + let _ = std::fs::remove_dir_all(&path); + TempDir { path } + } + + /// A copy of the fixture log `case` from `crates/proto/tests/fixtures/audit/`. With `only`, + /// just those files: damage in an older file is not seen by an ordinary start, so tests of + /// the startup check copy the damaged file alone. + pub fn case(case: &str, only: Option<&[&str]>) -> TempDir { + let dir = TempDir::unmade(case); + std::fs::create_dir_all(&dir.path).unwrap(); + let from = format!( + "{}/../proto/tests/fixtures/audit/{case}", + env!("CARGO_MANIFEST_DIR") + ); + let mut copied = 0; + for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) { + let entry = entry.unwrap(); + let name = entry.file_name().into_string().unwrap(); + if only.is_none_or(|names| names.contains(&name.as_str())) { + std::fs::copy(entry.path(), dir.path.join(&name)).unwrap(); + copied += 1; + } + } + assert!(copied > 0, "{from}: nothing copied"); + dir + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// Every log file in `dir` with its bytes. +pub fn snapshot(dir: &Path) -> BTreeMap> { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl")) + .map(|entry| { + let name = entry.file_name().into_string().unwrap(); + (name, std::fs::read(entry.path()).unwrap()) + }) + .collect() +} + +pub fn lines(dir: &Path, file: &str) -> Vec { + let text = std::fs::read_to_string(dir.join(file)).unwrap(); + text.lines().map(str::to_string).collect() +} + +pub fn ts(s: &str) -> Timestamp { + Timestamp::parse(s).unwrap() +} + +/// A denied decision for call `call`: an event that leaves nothing open in the report. +pub fn denied(call: u64) -> AuditEvent { + AuditEvent::Decision { + session: SessionId::new("chat-1").unwrap(), + call: CallId(call), + tool: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + outcome: DecisionRecord::Denied { + reason: proto::DenyReason::NoGrant, + }, + grant: None, + grant_sha256: None, + taint: DataClass::Private, + untrusted: false, + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/build.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/build.rs new file mode 100644 index 0000000..e523ca0 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/build.rs @@ -0,0 +1,156 @@ +//! Builders for grants and requests, for the policy tests. Do not edit. +//! +//! Included with `#[path = "support/build.rs"] mod build;`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use brokerd::grants::{GrantSet, LoadedGrant}; +use brokerd::policy::{Ask, Decision, Denial, Outcome, SessionState}; +use proto::{ + CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest, +}; + +/// The moment every policy test decides at. +pub const NOW: &str = "2026-09-18T12:00:00.000Z"; + +pub fn ts(text: &str) -> Timestamp { + Timestamp::parse(text).unwrap() +} + +pub fn now() -> Timestamp { + ts(NOW) +} + +pub struct Build(LoadedGrant); + +/// A grant with the widest settings: it applies at every taint, never expires, and labels its +/// results `private` and untrusted. Each test narrows what it is about. +pub fn grant(id: &str, tool: &str, mode: Mode) -> Build { + Build(LoadedGrant { + id: id.to_string(), + grant: Grant { + tool: tool.to_string(), + mode, + max_taint: DataClass::Secret, + result_class: DataClass::Private, + untrusted: true, + expires: None, + secret: None, + constraints: Constraints::default(), + }, + // Stands in for the file's hash, and differs from grant to grant. + sha256: proto::sha256(id.as_bytes()).unwrap(), + }) +} + +impl Build { + pub fn paths(mut self, paths: &[&str]) -> Build { + self.0.grant.constraints.paths = paths.iter().map(|p| p.to_string()).collect(); + self + } + pub fn hosts(mut self, hosts: &[&str]) -> Build { + self.0.grant.constraints.hosts = hosts.iter().map(|h| h.to_string()).collect(); + self + } + pub fn max_taint(mut self, class: DataClass) -> Build { + self.0.grant.max_taint = class; + self + } + pub fn class(mut self, class: DataClass) -> Build { + self.0.grant.result_class = class; + self + } + pub fn trusted(mut self) -> Build { + self.0.grant.untrusted = false; + self + } + pub fn expires(mut self, at: &str) -> Build { + self.0.grant.expires = Some(ts(at)); + self + } + pub fn done(self) -> LoadedGrant { + self.0 + } +} + +pub fn set(grants: Vec) -> GrantSet { + GrantSet::from_grants(grants.into_iter().map(Build::done).collect()) + .unwrap_or_else(|problems| panic!("the test's grants are not valid: {problems:?}")) +} + +pub fn request(tool: &str, arguments: &str) -> ToolRequest { + ToolRequest { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + tool: tool.to_string(), + arguments: arguments.to_string(), + } +} + +pub fn read(path: &str) -> ToolRequest { + request("read_file", &format!(r#"{{"path":"{path}"}}"#)) +} + +pub fn write(path: &str) -> ToolRequest { + request( + "write_file", + &format!(r#"{{"path":"{path}","content":"x"}}"#), + ) +} + +pub fn shell(cwd: Option<&str>) -> ToolRequest { + match cwd { + Some(cwd) => request("shell", &format!(r#"{{"command":"ls","cwd":"{cwd}"}}"#)), + None => request("shell", r#"{"command":"ls"}"#), + } +} + +pub fn fetch(url: &str) -> ToolRequest { + request("http_fetch", &format!(r#"{{"url":"{url}"}}"#)) +} + +pub fn at(taint: DataClass) -> SessionState { + SessionState { + taint, + untrusted: false, + } +} + +pub fn private() -> SessionState { + at(DataClass::Private) +} + +pub fn secret() -> SessionState { + at(DataClass::Secret) +} + +pub fn allowed(outcome: Outcome) -> Decision { + match outcome { + Outcome::Allowed(decision) => decision, + other => panic!("expected allowed, got {other:?}"), + } +} + +pub fn asked(outcome: Outcome) -> Ask { + match outcome { + Outcome::Ask(ask) => ask, + other => panic!("expected ask, got {other:?}"), + } +} + +pub fn denied(outcome: Outcome) -> Denial { + match outcome { + Outcome::Denied(denial) => denial, + other => panic!("expected denied, got {other:?}"), + } +} + +/// The reason of a denial. Only `denied_by_grant` may name a grant. +pub fn reason(outcome: Outcome) -> DenyReason { + let denial = denied(outcome); + if denial.reason != DenyReason::DeniedByGrant { + assert_eq!(denial.grant, None, "only denied_by_grant names a grant"); + assert_eq!(denial.grant_sha256, None); + } + denial.reason +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/client.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/client.rs new file mode 100644 index 0000000..65ef1e2 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/client.rs @@ -0,0 +1,77 @@ +//! A client for `broker::handle` and `admin::handle` over a socket pair, and a `Broker` built on +//! a `Rig`. Do not edit. +//! +//! Included with `#[path = "support/client.rs"] mod client;`, beside `rig`, `runtime`, `sink` +//! and `tmp`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::time::Duration; + +use brokerd::broker::{self, Broker}; +use proto::{Envelope, Message, PROTOCOL_VERSION, ToolRequest}; + +use crate::rig::Rig; +use crate::runtime::{Recording, Shared}; + +pub trait Serve { + /// A `Broker` on this rig's home, with `runtime` and the rig's flaky sink and log. + fn broker(&self, runtime: &Arc) -> Arc; +} + +impl Serve for Rig { + fn broker(&self, runtime: &Arc) -> Arc { + let runtime = Box::new(Shared(Arc::clone(runtime))); + Arc::new(Broker::new( + self.cfg.clone(), + self.ledger(), + runtime, + self.lines.sink(), + )) + } +} + +/// A connection to `handler` running on its own thread, with `msg` already sent under `id`. +pub fn open( + broker: &Arc, + handler: fn(UnixStream, &Broker), + id: u64, + msg: Message, +) -> UnixStream { + let (mut client, server) = UnixStream::pair().unwrap(); + let broker = Arc::clone(broker); + std::thread::spawn(move || handler(server, &broker)); + let env = Envelope { + v: PROTOCOL_VERSION, + id, + r#final: true, + msg, + }; + proto::write_frame(&mut client, &env).unwrap(); + client +} + +/// The next frame, waiting at most ten seconds. +pub fn next(stream: &mut UnixStream) -> Envelope { + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + proto::read_frame(stream).unwrap() +} + +/// Sends a tool request to `broker::handle` and reads every frame up to the final one. +pub fn call(broker: &Arc, req: ToolRequest) -> Vec { + let id = req.call.0; + let mut stream = open(broker, broker::handle, id, Message::ToolRequest(req)); + let mut frames = Vec::new(); + loop { + let env = next(&mut stream); + let last = env.r#final; + frames.push(env); + if last { + return frames; + } + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/oracle.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/oracle.rs new file mode 100644 index 0000000..f07e230 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/oracle.rs @@ -0,0 +1,388 @@ +//! The generator and the oracle of the policy property test. Do not edit. +//! +//! Included with `#[path = "support/oracle.rs"] mod oracle;`. +//! +//! The oracle is written to be obviously right, not fast or short. It shares no code with +//! `brokerd`: it splits paths and host names into their parts itself and compares the parts. +//! If the property test fails, the oracle is the specification and `policy` is wrong. + +#![allow(dead_code)] // the property test does not use every helper in every build + +use brokerd::grants::LoadedGrant; +use brokerd::policy::SessionState; +use proto::{ + CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest, +}; + +/// The moment every case is decided at: 2026-09-18T12:00:00.000Z. +pub const NOW_MS: u64 = 1_789_732_800_000; + +// --------------------------------------------------------------------------------------------- +// The generator. + +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + // xorshift must not start at zero. + Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1) + } + pub fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + pub fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + pub fn pick(&mut self, items: &[T]) -> T { + items[self.below(items.len())] + } + pub fn chance(&mut self, percent: u64) -> bool { + self.next() % 100 < percent + } +} + +pub const TOOLS: [&str; 4] = ["read_file", "write_file", "shell", "http_fetch"]; +pub const CLASSES: [DataClass; 3] = [DataClass::Public, DataClass::Private, DataClass::Secret]; +pub const MODES: [Mode; 3] = [Mode::Auto, Mode::Ask, Mode::Deny]; +pub const HOSTS: [&str; 6] = [ + "example.com", + "www.example.com", + "a.b.example.com", + "other.org", + "www.other.org", + "badexample.com", +]; +pub const PATTERNS: [&str; 6] = [ + "example.com", + "*.example.com", + "www.example.com", + "*.b.example.com", + "other.org", + "*.other.org", +]; + +/// A path of one to four components over a tiny alphabet, so that grants and requests overlap +/// often: `/a`, `/a/b`, `/ab/a/c` and so on. `ab` is there to catch prefix matching by bytes. +pub fn path(rng: &mut Rng) -> String { + let depth = 1 + rng.below(4); + let mut text = String::new(); + for _ in 0..depth { + text.push('/'); + text.push_str(rng.pick(&["a", "b", "c", "ab"])); + } + text +} + +pub fn some_grant(rng: &mut Rng, id: usize) -> LoadedGrant { + let tool = rng.pick(&TOOLS); + let mode = rng.pick(&MODES); + let mut constraints = Constraints::default(); + match tool { + "http_fetch" => { + for _ in 0..1 + rng.below(2) { + constraints.hosts.push(rng.pick(&PATTERNS).to_string()); + } + } + "shell" if rng.chance(40) => {} + _ => { + for _ in 0..1 + rng.below(3) { + constraints.paths.push(path(rng)); + } + } + } + // Expiry around the moment of decision: before it, exactly at it, after it, or never. + let expires = match rng.below(5) { + 0 => Some(NOW_MS - 1), + 1 => Some(NOW_MS), + 2 => Some(NOW_MS + 1), + _ => None, + }; + LoadedGrant { + id: format!("g{id:02}"), + grant: Grant { + tool: tool.to_string(), + mode, + // A deny grant must apply at every taint, or the set is invalid. + max_taint: if mode == Mode::Deny { + DataClass::Secret + } else { + rng.pick(&CLASSES) + }, + result_class: rng.pick(&CLASSES), + untrusted: rng.chance(50), + expires: expires.map(|ms| Timestamp::from_unix_millis(ms).unwrap()), + secret: None, + constraints, + }, + sha256: proto::sha256(format!("file {id}").as_bytes()).unwrap(), + } +} + +/// Zero to seven grants, with ids handed out in a scrambled order so that the order of the list +/// says nothing about the order of the ids. +pub fn some_grants(rng: &mut Rng) -> Vec { + let count = rng.below(8); + let mut ids: Vec = (0..count).collect(); + for i in (1..ids.len()).rev() { + ids.swap(i, rng.below(i + 1)); + } + ids.into_iter().map(|id| some_grant(rng, id)).collect() +} + +pub fn some_request(rng: &mut Rng) -> ToolRequest { + let (tool, arguments) = match rng.below(20) { + 0 => ("echo".to_string(), "{}".to_string()), + 1 => ("read_file".to_string(), r#"{"path":"a/b"}"#.to_string()), + 2 => ( + "shell".to_string(), + r#"{"command":"ls","cwd":"/a/../b"}"#.to_string(), + ), + 3 => ( + "http_fetch".to_string(), + r#"{"url":"http://example.com/"}"#.to_string(), + ), + 4 => ("write_file".to_string(), r#"{"path":"/a/b"}"#.to_string()), + _ => match rng.pick(&TOOLS) { + "read_file" => ( + "read_file".to_string(), + format!(r#"{{"path":"{}"}}"#, path(rng)), + ), + "write_file" => ( + "write_file".to_string(), + format!(r#"{{"path":"{}","content":"x"}}"#, path(rng)), + ), + "shell" if rng.chance(40) => ("shell".to_string(), r#"{"command":"ls"}"#.to_string()), + "shell" => ( + "shell".to_string(), + format!(r#"{{"command":"ls","cwd":"{}"}}"#, path(rng)), + ), + _ => ( + "http_fetch".to_string(), + format!(r#"{{"url":"https://{}/x"}}"#, rng.pick(&HOSTS)), + ), + }, + }; + ToolRequest { + session: SessionId::new("prop").unwrap(), + call: CallId(1), + tool, + arguments, + } +} + +// --------------------------------------------------------------------------------------------- +// The oracle. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Expected { + Allowed { + grant: String, + path: Option, + class: DataClass, + untrusted: bool, + }, + Ask { + grant: String, + path: Option, + class: DataClass, + untrusted: bool, + }, + Denied { + reason: DenyReason, + grant: Option, + }, +} + +pub fn parts(path: &str) -> Vec<&str> { + path.split('/').filter(|part| !part.is_empty()).collect() +} + +/// `inner` is `outer` or lies under it: `outer`'s components are the first of `inner`'s. +pub fn under(outer: &str, inner: &str) -> bool { + let (outer, inner) = (parts(outer), parts(inner)); + outer.len() <= inner.len() && outer.iter().zip(&inner).all(|(a, b)| a == b) +} + +pub fn host_fits(pattern: &str, host: &str) -> bool { + let host: Vec<&str> = host.split('.').collect(); + match pattern.strip_prefix("*.") { + None => pattern.split('.').collect::>() == host, + Some(base) => { + let base: Vec<&str> = base.split('.').collect(); + host.len() > base.len() && host[host.len() - base.len()..] == base[..] + } + } +} + +/// What the oracle needs from a request: `None` if the broker must refuse it before matching. +pub enum Call { + UnknownTool, + Invalid, + Read(String), + Write(String), + Shell(Option), + Fetch(String), +} + +pub fn understand(request: &ToolRequest) -> Call { + if !TOOLS.contains(&request.tool.as_str()) { + return Call::UnknownTool; + } + // The generator only ever writes the five invalid forms below. + let text = request.arguments.as_str(); + let invalid = text.contains("\"a/b\"") + || text.contains("..") + || text.contains("http://") + || (request.tool == "write_file" && !text.contains("content")); + if invalid { + return Call::Invalid; + } + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + let field = |name: &str| value.get(name).and_then(|v| v.as_str()).map(str::to_string); + match request.tool.as_str() { + "read_file" => Call::Read(field("path").unwrap()), + "write_file" => Call::Write(field("path").unwrap()), + "shell" => Call::Shell(field("cwd")), + _ => { + let url = field("url").unwrap(); + let host = url + .trim_start_matches("https://") + .split('/') + .next() + .unwrap(); + Call::Fetch(host.to_string()) + } + } +} + +/// Whether the grant covers the call, and with which of its paths (the longest that holds it). +pub fn coverage(grant: &Grant, call: &Call) -> Option> { + let holding = |path: &str, itself_counts: bool| -> Option> { + let mut best: Option<&String> = None; + for candidate in &grant.constraints.paths { + if !under(candidate, path) || (!itself_counts && parts(candidate) == parts(path)) { + continue; + } + if best.is_none_or(|b| candidate.len() > b.len()) { + best = Some(candidate); + } + } + best.map(|b| Some(b.clone())) + }; + match call { + Call::Read(path) => holding(path, true), + Call::Write(path) => holding(path, false), + Call::Shell(None) if grant.constraints.paths.is_empty() => Some(None), + Call::Shell(None) => None, + Call::Shell(Some(cwd)) => holding(cwd, true), + Call::Fetch(host) => grant + .constraints + .hosts + .iter() + .any(|pattern| host_fits(pattern, host)) + .then_some(None), + Call::UnknownTool | Call::Invalid => None, + } +} + +pub fn oracle(request: &ToolRequest, grants: &[LoadedGrant], state: SessionState) -> Expected { + let denied = |reason| Expected::Denied { + reason, + grant: None, + }; + let call = understand(request); + match call { + Call::UnknownTool => return denied(DenyReason::NoGrant), + Call::Invalid => return denied(DenyReason::InvalidArguments), + _ => {} + } + + struct Left<'a> { + id: &'a str, + mode: Mode, + path: Option, + class: DataClass, + untrusted: bool, + } + let mut left: Vec = Vec::new(); + let (mut would_match_but_expired, mut would_match_but_tainted) = (false, false); + for loaded in grants { + let g = &loaded.grant; + if g.tool != request.tool { + continue; + } + let Some(path) = coverage(g, &call) else { + continue; + }; + let expired = g.expires.is_some_and(|at| at.unix_millis() <= NOW_MS); + let tainted = state.taint > g.max_taint; + if expired && !tainted { + would_match_but_expired = true; + } + if tainted && !expired { + would_match_but_tainted = true; + } + if !expired && !tainted { + left.push(Left { + id: &loaded.id, + mode: g.mode, + path, + class: g.result_class, + untrusted: g.untrusted, + }); + } + } + if left.is_empty() { + return if would_match_but_expired { + denied(DenyReason::GrantExpired) + } else if would_match_but_tainted { + denied(DenyReason::TaintTooHigh) + } else { + denied(DenyReason::NoGrant) + }; + } + + let class = left.iter().map(|l| l.class).max().unwrap(); + let untrusted = left.iter().any(|l| l.untrusted); + // The winner: try each mode from the most restrictive; within it the longest path, then + // the lowest id. + for mode in [Mode::Deny, Mode::Ask, Mode::Auto] { + let mut of_mode: Vec<&Left> = left.iter().filter(|l| l.mode == mode).collect(); + if of_mode.is_empty() { + continue; + } + of_mode.sort_by(|a, b| { + let (la, lb) = ( + a.path.as_ref().map_or(0, String::len), + b.path.as_ref().map_or(0, String::len), + ); + lb.cmp(&la).then(a.id.cmp(b.id)) + }); + let winner = of_mode[0]; + let (grant, path) = (winner.id.to_string(), winner.path.clone()); + return match mode { + Mode::Deny => Expected::Denied { + reason: DenyReason::DeniedByGrant, + grant: Some(grant), + }, + Mode::Ask => Expected::Ask { + grant, + path, + class, + untrusted, + }, + Mode::Auto => Expected::Allowed { + grant, + path, + class, + untrusted, + }, + }; + } + unreachable!("left is not empty, so one of the three modes has a grant") +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/rig.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/rig.rs new file mode 100644 index 0000000..ddbc24d --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/rig.rs @@ -0,0 +1,118 @@ +//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and +//! a log to read. Do not edit. +//! +//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker +//! tests add `client`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::path::PathBuf; + +use brokerd::audit::Writer; +use brokerd::config::{Approvals, Config, Paths, Sockets}; +use brokerd::ledger::Ledger; +use brokerd::state::StateStore; +use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest}; + +use crate::sink::{Flaky, Lines, Switch}; +use crate::tmp::TempDir; + +pub struct Rig { + pub dir: TempDir, + pub cfg: Config, + pub switch: Switch, + pub lines: Lines, +} + +impl Rig { + pub fn new(tag: &str) -> Rig { + Rig::with_ttl(tag, 900_000) + } + + pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig { + let dir = TempDir::new(tag); + let grants = dir.path().join("grants"); + std::fs::create_dir_all(&grants).unwrap(); + let cfg = Config { + paths: Paths { + home: dir.path().to_path_buf(), + grants, + }, + sockets: Sockets::default(), + approvals: Approvals { ttl_ms }, + }; + Rig { + dir, + cfg, + switch: Switch::default(), + lines: Lines::default(), + } + } + + pub fn state(&self) -> StateStore { + StateStore::new(&self.cfg.state_dir()) + } + + /// Opens the audit log (once: the writer holds its lock) behind the flaky sink. + pub fn ledger(&self) -> Ledger { + let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap(); + let sink = Flaky { + writer: opened.writer, + switch: self.switch.clone(), + }; + Ledger::new(Box::new(sink), self.state(), self.lines.sink()) + } + + /// Writes `grants/.toml`. + pub fn grant(&self, id: &str, text: &str) { + std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap(); + } + + pub fn remove_grant(&self, id: &str) { + std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap(); + } + + pub fn state_file(&self, session: &str) -> PathBuf { + self.cfg.state_dir().join(format!("{session}.json")) + } + + /// Every record in the audit log, in order. + pub fn records(&self) -> Vec { + let dir = self.cfg.audit_dir(); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.ends_with(".jsonl")) + .collect(); + names.sort(); + let mut out = Vec::new(); + for name in names { + let text = std::fs::read_to_string(dir.join(name)).unwrap(); + for line in text.lines() { + out.push(serde_json::from_str(line).unwrap()); + } + } + out + } + + pub fn events(&self) -> Vec { + self.records().into_iter().map(|r| r.event).collect() + } +} + +/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it. +pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String { + format!( + "tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\ + untrusted = false\n{extra}\n[constraints]\n{constraints}\n" + ) +} + +pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest { + ToolRequest { + session: SessionId::new(session).unwrap(), + call: CallId(call), + tool: tool.to_string(), + arguments: arguments.to_string(), + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/runtime.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/runtime.rs new file mode 100644 index 0000000..0547430 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/runtime.rs @@ -0,0 +1,70 @@ +//! A runtime that records what it is asked to run, for the runner and broker tests. Do not edit. +//! +//! Included with `#[path = "support/runtime.rs"] mod runtime;`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::sync::{Arc, Mutex}; + +use brokerd::args::{ToolArgs, ToolName}; +use brokerd::runner::{Mount, RunError, RunOutput, RunSpec, Runtime}; + +/// What one `run` was given, copied out of the `RunSpec`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seen { + pub tool: ToolName, + pub arguments: ToolArgs, + pub mounts: Vec, + pub egress: Option>, +} + +pub struct Recording { + seen: Mutex>, + answer: Result, +} + +impl Recording { + /// Answers every call with `content`, not truncated. + pub fn answering(content: &str) -> Arc { + Recording::with(Ok(RunOutput { + content: content.to_string(), + truncated: false, + })) + } + + pub fn with(answer: Result) -> Arc { + Arc::new(Recording { + seen: Mutex::new(Vec::new()), + answer, + }) + } + + pub fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + + pub fn count(&self) -> usize { + self.seen.lock().unwrap().len() + } +} + +impl Runtime for Recording { + fn run(&self, spec: &RunSpec) -> Result { + self.seen.lock().unwrap().push(Seen { + tool: spec.tool(), + arguments: spec.arguments().clone(), + mounts: spec.mounts().to_vec(), + egress: spec.egress().map(<[String]>::to_vec), + }); + self.answer.clone() + } +} + +/// Lets a test keep its `Arc` while the broker owns a `Box`. +pub struct Shared(pub Arc); + +impl Runtime for Shared { + fn run(&self, spec: &RunSpec) -> Result { + self.0.run(spec) + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/sink.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/sink.rs new file mode 100644 index 0000000..abea275 --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/sink.rs @@ -0,0 +1,78 @@ +//! An audit sink that fails on demand, and a log that tests can read. Do not edit. +//! +//! Included with `#[path = "support/sink.rs"] mod sink;`. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use brokerd::audit::{AuditError, Writer}; +use brokerd::ledger::AuditSink; +use proto::{AuditEvent, Timestamp}; + +/// Switches shared between a test and its `Flaky` sink. +#[derive(Clone, Default)] +pub struct Switch { + fail: Arc, + panic: Arc, + attempts: Arc, +} + +impl Switch { + /// Every append from now on fails, without writing anything. + pub fn fail(&self, on: bool) { + self.fail.store(on, Ordering::SeqCst); + } + /// The next append panics, as a bug part-way through a write would. + pub fn panic_next(&self) { + self.panic.store(true, Ordering::SeqCst); + } + /// How many appends the ledger has asked for. + pub fn attempts(&self) -> usize { + self.attempts.load(Ordering::SeqCst) + } +} + +/// A real `Writer` behind a switch. +pub struct Flaky { + pub writer: Writer, + pub switch: Switch, +} + +impl AuditSink for Flaky { + fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result { + self.switch.attempts.fetch_add(1, Ordering::SeqCst); + if self.switch.panic.swap(false, Ordering::SeqCst) { + panic!("a bug part-way through a write"); + } + if self.switch.fail.load(Ordering::SeqCst) { + return Err(AuditError::Io { + what: "cannot write to the test log".to_string(), + source: std::io::Error::other("the disk is full"), + }); + } + self.writer.append(time, event) + } +} + +/// Collects every line a ledger or broker prints. +#[derive(Clone, Default)] +pub struct Lines(Arc>>); + +impl Lines { + pub fn sink(&self) -> Box { + let lines = Arc::clone(&self.0); + Box::new(move |line| lines.lock().unwrap().push(line.to_string())) + } + pub fn all(&self) -> Vec { + self.0.lock().unwrap().clone() + } + /// The lines that hold `text`. + pub fn with(&self, text: &str) -> Vec { + self.all() + .into_iter() + .filter(|l| l.contains(text)) + .collect() + } +} diff --git a/docs/plans/M3a/files/crates/brokerd/tests/support/tmp.rs b/docs/plans/M3a/files/crates/brokerd/tests/support/tmp.rs new file mode 100644 index 0000000..e24c52f --- /dev/null +++ b/docs/plans/M3a/files/crates/brokerd/tests/support/tmp.rs @@ -0,0 +1,57 @@ +//! Temporary directories for tests. Do not edit. +//! +//! Included with `#[path = "support/tmp.rs"] mod tmp;`. No crate is used: the name is made from +//! the process id and a counter, and the directory is removed when the value is dropped. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> TempDir { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + /// Writes `text` to `name` inside the directory and returns the full path. + pub fn write(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, text).unwrap(); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Put back the permissions a test may have taken away, or the removal fails. + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700)); + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// True when the tests run as root, for whom file permissions do not apply. Tests that depend on +/// a permission error print why they are skipped and return. +pub fn running_as_root(test: &str) -> bool { + let probe = TempDir::new("rootprobe"); + let file = probe.write("probe", "x"); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap(); + let root = std::fs::read(&file).is_ok(); + if root { + eprintln!("{test}: skipped, because this user can read a mode 000 file (root?)"); + } + root +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/admin.rs b/docs/plans/M3a/files/crates/bxctl/tests/admin.rs new file mode 100644 index 0000000..ac382f6 --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/admin.rs @@ -0,0 +1,497 @@ +//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`. +//! Do not edit. + +mod support; + +use bxctl::admin::{ + AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name, + request, write_block, +}; +use proto::{ + Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport, + Message, PROTOCOL_VERSION, Refuse, +}; +use std::process::Command; +use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error}; + +const BACKSLASH: char = '\\'; + +/// The escape for one code point, built and never spelled out (see `tests/escape.rs`). +fn esc(code: u32) -> String { + format!("{BACKSLASH}u{code:04x}") +} + +fn text(out: Vec) -> String { + String::from_utf8(out).unwrap() +} + +const ALLOWED: DecisionRecord = DecisionRecord::Allowed {}; + +// ---- the client ---- + +#[test] +fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() { + let fake = fake_brokerd_frames(|request| { + assert_eq!(request.v, PROTOCOL_VERSION); + assert_eq!(request.id, 1); + assert!(request.r#final); + vec![Envelope { + v: PROTOCOL_VERSION, + id: 1, + r#final: true, + msg: Message::Ok(Empty {}), + }] + }); + let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap(); + assert_eq!(answer, Message::Ok(Empty {})); + assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]); +} + +#[test] +fn an_error_frame_is_refused_with_its_code_and_detail() { + let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket")); + match request(&fake.socket, Message::Approvals(Empty {})) { + Err(AdminError::Refused(w)) => { + assert_eq!(w.code, ErrorCode::Forbidden); + assert_eq!(w.detail, "not on this socket"); + } + other => panic!("{other:?}"), + } + let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err(); + assert_eq!(e.to_string(), "forbidden: not on this socket"); +} + +#[test] +fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() { + let not_final = fake_brokerd_frames(|request| { + vec![Envelope { + v: PROTOCOL_VERSION, + id: request.id, + r#final: false, + msg: Message::Ok(Empty {}), + }] + }); + assert!(matches!( + request(¬_final.socket, Message::Approvals(Empty {})), + Err(AdminError::Protocol(_)) + )); + let other_id = fake_brokerd_frames(|request| { + vec![Envelope { + v: PROTOCOL_VERSION, + id: request.id + 1, + r#final: true, + msg: Message::Ok(Empty {}), + }] + }); + assert!(matches!( + request(&other_id.socket, Message::Approvals(Empty {})), + Err(AdminError::Protocol(_)) + )); +} + +#[test] +fn a_connection_closed_without_an_answer_is_a_frame_error() { + let fake = fake_brokerd_frames(|_| Vec::new()); + assert!(matches!( + request(&fake.socket, Message::Approvals(Empty {})), + Err(AdminError::Frame(_)) + )); +} + +#[test] +fn no_brokerd_is_a_connect_error_that_names_the_socket() { + let missing = support::temp_socket("nobody-listens.sock"); + let e = request(&missing, Message::Approvals(Empty {})).unwrap_err(); + assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}"); + let message = e.to_string(); + assert!(message.starts_with("cannot reach brokerd at "), "{message}"); + assert!(message.contains(missing.to_str().unwrap()), "{message}"); +} + +#[test] +fn list_returns_the_items_and_rejects_any_other_kind() { + let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")]; + let fake = brokerd_with(items.clone(), ALLOWED); + assert_eq!(list(&fake.socket).unwrap(), items); + assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]); + + let wrong = fake_brokerd(|_| Message::Ok(Empty {})); + assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_)))); +} + +#[test] +fn reason_names_are_the_wire_names() { + for reason in [ + DenyReason::NoGrant, + DenyReason::GrantExpired, + DenyReason::TaintTooHigh, + DenyReason::DeniedByGrant, + DenyReason::ApprovalRefused, + DenyReason::ApprovalExpired, + DenyReason::GrantsInvalid, + DenyReason::AuditUnavailable, + DenyReason::InvalidArguments, + DenyReason::StateUnreadable, + ] { + let wire = serde_json::to_string(&reason).unwrap(); + assert_eq!(format!("\"{}\"", reason_name(reason)), wire); + } +} + +// ---- the block ---- + +fn block(item: &proto::PendingApproval, now: &str) -> String { + let mut out = Vec::new(); + write_block(&mut out, item, ts(now)).unwrap(); + text(out) +} + +#[test] +fn the_block_is_two_lines_in_this_exact_form() { + let item = pending( + 41, + "shell", + r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#, + ); + assert_eq!( + block(&item, "2026-09-18T12:02:00.000Z"), + concat!( + "41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n", + " shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n", + ) + ); +} + +#[test] +fn times_are_whole_seconds_minutes_or_hours_rounded_down() { + let item = pending(41, "shell", "{}"); + let first = |now: &str| block(&item, now).lines().next().unwrap().to_string(); + // created 12:00:00, expires 12:15:00 + assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min ")); + assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min ")); + assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min ")); + assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s ")); + // At or after `expires` there is nothing left to wait for. + assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired ")); + assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired ")); + // A clock that is behind the broker's must not underflow. + assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min ")); + + let mut long = pending(41, "shell", "{}"); + long.expires = ts("2026-09-18T15:30:00.000Z"); + assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h ")); +} + +#[test] +fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() { + let mut item = pending(41, "shell", "{}"); + for (id, shown) in [ + ("s1", "session s1 "), + ("abcdefghij", "session abcdefghij "), + ("abcdefghijk", "session abcdefghi… "), + ] { + item.session = proto::SessionId::new(id).unwrap(); + let got = block(&item, "2026-09-18T12:00:00.000Z"); + assert!(got.contains(shown), "{id}: {got}"); + } +} + +#[test] +fn taint_is_the_wire_name() { + let mut item = pending(41, "shell", "{}"); + item.taint = proto::DataClass::Secret; + assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n")); +} + +/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through +/// `escape_json_text`. +#[test] +fn nothing_in_the_block_reaches_the_terminal_raw() { + let rlo = char::from_u32(0x202e).unwrap(); + let zwsp = char::from_u32(0x200b).unwrap(); + let isolate = char::from_u32(0x2066).unwrap(); + let arguments = + format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}"); + let mut item = pending(41, "read\x1b[1mfile", &arguments); + item.grant = "notes\nread".to_string(); + let got = block(&item, "2026-09-18T12:00:00.000Z"); + for c in ['\x1b', rlo, zwsp, isolate] { + assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c)); + } + assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}"); + assert!( + got.contains(&format!( + "/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J", + esc(0x202e), + esc(0x200b), + esc(0x2066), + esc(0x1b), + esc(0x1b) + )), + "{got:?}" + ); + assert!( + got.contains(&format!(" read{}[1mfile ", esc(0x1b))), + "{got:?}" + ); + assert!( + got.contains(&format!("grant notes{}read ", esc(0x0a))), + "{got:?}" + ); +} + +// ---- the commands ---- + +#[test] +fn approvals_prints_one_block_per_item_in_the_order_given() { + let fake = brokerd_with( + vec![ + pending(41, "shell", r#"{"command":"ls"}"#), + pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#), + ], + ALLOWED, + ); + let mut out = Vec::new(); + let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap(); + assert!(ok); + let got = text(out); + let lines: Vec<&str> = got.lines().collect(); + assert_eq!(lines.len(), 4, "{got}"); + assert!(lines[0].starts_with("41 2 min ago "), "{got}"); + assert_eq!(lines[1], r#" shell {"command":"ls"}"#); + assert!(lines[2].starts_with("44 2 min ago "), "{got}"); + assert_eq!( + lines[3], + r#" read_file {"path":"/home/kyle/notes/a.md"}"# + ); +} + +#[test] +fn approvals_with_nothing_pending_says_so() { + let fake = brokerd_with(Vec::new(), ALLOWED); + let mut out = Vec::new(); + assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap()); + assert_eq!(text(out), "no pending approvals\n"); +} + +#[test] +fn approve_reports_the_re_decision() { + // `ask` and `allowed` both let the call run; only a denial stops it. + for (outcome, line, ok) in [ + (DecisionRecord::Allowed {}, "approved 41: runs\n", true), + (DecisionRecord::Ask {}, "approved 41: runs\n", true), + ( + DecisionRecord::Denied { + reason: DenyReason::NoGrant, + }, + "approved 41: denied (no_grant)\n", + false, + ), + ( + DecisionRecord::Denied { + reason: DenyReason::TaintTooHigh, + }, + "approved 41: denied (taint_too_high)\n", + false, + ), + ( + DecisionRecord::Denied { + reason: DenyReason::AuditUnavailable, + }, + "approved 41: denied (audit_unavailable)\n", + false, + ), + ] { + let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome); + let mut out = Vec::new(); + assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok); + assert_eq!(text(out), line); + assert_eq!( + fake.requests(), + vec![Message::Approve(Approve { approval: 41 })] + ); + } +} + +#[test] +fn refuse_sends_the_reason_when_there_is_one() { + for reason in [None, Some("not on a Friday")] { + let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED); + let mut out = Vec::new(); + assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap()); + assert_eq!(text(out), "refused 41\n"); + assert_eq!( + fake.requests(), + vec![Message::Refuse(Refuse { + approval: 41, + reason: reason.map(str::to_string) + })] + ); + } +} + +#[test] +fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() { + let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED); + let want = "99: no such approval (already answered or expired)\n"; + let mut out = Vec::new(); + assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap()); + assert_eq!(text(out), want); + let mut out = Vec::new(); + assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap()); + assert_eq!(text(out), want); +} + +/// Every exit of every command: any other error frame is an error, and so is an answer of the +/// wrong kind. Nothing is printed for them. +#[test] +fn other_errors_and_wrong_kinds_are_errors_for_every_command() { + let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom")); + let wrong = fake_brokerd(|_| { + Message::GrantsReport(GrantsReport { + problems: Vec::new(), + }) + }); + let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {})); + let now = ts("2026-09-18T12:00:00.000Z"); + + let mut out = Vec::new(); + assert!(matches!( + cmd_approvals(&refused.socket, now, &mut out), + Err(AdminError::Refused(_)) + )); + assert!(matches!( + cmd_approve(&refused.socket, 41, &mut out), + Err(AdminError::Refused(_)) + )); + assert!(matches!( + cmd_refuse(&refused.socket, 41, None, &mut out), + Err(AdminError::Refused(_)) + )); + assert!(matches!( + cmd_grants_check(&refused.socket, &mut out), + Err(AdminError::Refused(_)) + )); + assert!(matches!( + cmd_approvals(&wrong.socket, now, &mut out), + Err(AdminError::Protocol(_)) + )); + assert!(matches!( + cmd_approve(&wrong.socket, 41, &mut out), + Err(AdminError::Protocol(_)) + )); + assert!(matches!( + cmd_refuse(&wrong.socket, 41, None, &mut out), + Err(AdminError::Protocol(_)) + )); + assert!(matches!( + cmd_grants_check(&wrong_for_grants.socket, &mut out), + Err(AdminError::Protocol(_)) + )); + assert_eq!(text(out), "", "an error prints nothing on the output"); +} + +#[test] +fn grants_check_prints_ok_or_every_problem() { + let fine = fake_brokerd(|_| { + Message::GrantsReport(GrantsReport { + problems: Vec::new(), + }) + }); + let mut out = Vec::new(); + assert!(cmd_grants_check(&fine.socket, &mut out).unwrap()); + assert_eq!(text(out), "grants: ok\n"); + assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]); + + let broken = fake_brokerd(|_| { + Message::GrantsReport(GrantsReport { + problems: vec![ + GrantProblem { + file: "notes-read.toml".to_string(), + line: Some(3), + problem: "unknown field `mod`".to_string(), + }, + GrantProblem { + file: "Bad Name.toml".to_string(), + line: None, + problem: "the file name is not a valid grant id".to_string(), + }, + GrantProblem { + file: "x.toml".to_string(), + line: Some(1), + problem: "two\nlines".to_string(), + }, + ], + }) + }); + let mut out = Vec::new(); + assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap()); + assert_eq!( + text(out), + format!( + "notes-read.toml:3: unknown field `mod`\n\ + Bad Name.toml: the file name is not a valid grant id\n\ + x.toml:1: two{}lines\n", + esc(0x0a) + ), + "every problem, one line each; file and problem go through escape_json_text" + ); +} + +// ---- the binary ---- + +fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_bxctl")) + .args(args) + .arg("--admin-socket") + .arg(socket) + .output() + .unwrap() +} + +#[test] +fn the_binary_prints_on_stdout_and_sets_the_exit_status() { + let fake = brokerd_with( + vec![pending(41, "shell", r#"{"command":"ls"}"#)], + DecisionRecord::Denied { + reason: DenyReason::DeniedByGrant, + }, + ); + let output = bxctl(&["approvals"], &fake.socket); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.ends_with(" shell {\"command\":\"ls\"}\n"), + "{stdout}" + ); + + let output = bxctl(&["approve", "41"], &fake.socket); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "approved 41: denied (denied_by_grant)\n" + ); + + let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n"); + + let output = bxctl(&["refuse", "7"], &fake.socket); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "7: no such approval (already answered or expired)\n" + ); +} + +#[test] +fn the_binary_reports_an_error_on_stderr_with_status_1() { + let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom")); + let output = bxctl(&["grants", "check"], &fake.socket); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(String::from_utf8_lossy(&output.stdout), ""); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "bxctl: internal: boom\n" + ); +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/chat_approvals.rs b/docs/plans/M3a/files/crates/bxctl/tests/chat_approvals.rs new file mode 100644 index 0000000..e5dc2c4 --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/chat_approvals.rs @@ -0,0 +1,487 @@ +//! Tests for approvals in `bxctl chat`, against a fake `loopd` and a fake `brokerd`. Do not edit. +//! +//! What the owner is shown comes from `brokerd`, never from `loopd`'s event, and only the +//! approval's id, typed in full, approves. + +mod support; + +use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, handle_pending, stream_turn}; +use proto::{ + Approve, DecisionRecord, Empty, ErrorCode, Message, Refuse, SessionId, Timestamp, TurnEvent, +}; +use std::io::{BufRead, Cursor, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use support::{FakeBrokerd, brokerd_with, fake_brokerd, fake_loopd, pending, ts, wire_error}; + +const BACKSLASH: char = '\\'; +const NOW: &str = "2026-09-18T12:02:00.000Z"; +const PROMPT: &str = "type 41 to approve, anything else refuses: "; + +/// The escape for one code point, built and never spelled out (see `tests/escape.rs`). +fn esc(code: u32) -> String { + format!("{BACKSLASH}u{code:04x}") +} + +fn approvals_request() -> Message { + Message::Approvals(Empty {}) +} + +fn approve_request() -> Message { + Message::Approve(Approve { approval: 41 }) +} + +fn refuse_request() -> Message { + Message::Refuse(Refuse { + approval: 41, + reason: None, + }) +} + +/// A `brokerd` with approval 41 pending, for `shell`. +fn broker() -> FakeBrokerd { + brokerd_with( + vec![pending(41, "shell", r#"{"command":"ls"}"#)], + DecisionRecord::Allowed {}, + ) +} + +/// Runs `handle_pending` for approval 41 with `typed` waiting on stdin. Returns what was +/// printed and what was left unread. +fn handle(socket: &Path, ask: bool, typed: &str) -> (String, String) { + let mut input = Cursor::new(typed.as_bytes().to_vec()); + let mut out = Vec::new(); + handle_pending(socket, 41, ask, ts(NOW), &mut input, &mut out).unwrap(); + let mut left = String::new(); + std::io::Read::read_to_string(&mut input, &mut left).unwrap(); + (String::from_utf8(out).unwrap(), left) +} + +// ---- handle_pending ---- + +#[test] +fn the_block_then_the_question_and_the_id_approves() { + let fake = broker(); + let (out, left) = handle(&fake.socket, true, "41\n"); + assert_eq!( + out, + format!( + "\x1b[0m41 2 min ago expires in 13 min session chat-1758… grant shell-scratch \ + taint private\n shell {{\"command\":\"ls\"}}\n{PROMPT}approved 41: runs\n" + ), + "attributes reset, the block, the question, the outcome" + ); + assert_eq!(left, ""); + assert_eq!( + fake.requests(), + vec![approvals_request(), approve_request()] + ); +} + +#[test] +fn the_id_with_a_carriage_return_also_approves() { + let fake = broker(); + handle(&fake.socket, true, "41\r\n"); + assert_eq!( + fake.requests(), + vec![approvals_request(), approve_request()] + ); +} + +/// Everything that is not exactly the id refuses: `y`, a near miss, an empty line, the end of +/// the input. +#[test] +fn anything_else_refuses() { + for typed in [ + "y\n", + "Y\n", + "yes\n", + "\n", + "", + " 41\n", + "41 \n", + "041\n", + "+41\n", + "4 1\n", + "42\n", + "approve 41\n", + "41", + ] { + let fake = broker(); + let (out, _) = handle(&fake.socket, true, typed); + if typed == "41" { + // The last line of the input need not end in a newline; it is still the id. + assert_eq!( + fake.requests(), + vec![approvals_request(), approve_request()] + ); + continue; + } + assert_eq!( + fake.requests(), + vec![approvals_request(), refuse_request()], + "{typed:?}" + ); + assert!( + out.ends_with(&format!("{PROMPT}refused 41\n")), + "{typed:?}: {out:?}" + ); + } +} + +/// A line typed while the turn ran is already waiting when the question is asked. It is the +/// answer, it is not the id, so it refuses; and only that one line is read. +#[test] +fn a_line_already_waiting_refuses_and_only_one_line_is_read() { + let fake = broker(); + let (_, left) = handle(&fake.socket, true, "are you still there?\n41\n"); + assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]); + assert_eq!(left, "41\n", "the next line is left for the chat"); +} + +#[test] +fn an_id_that_is_not_listed_is_no_longer_pending_and_nothing_is_asked() { + let fake = brokerd_with( + vec![pending(40, "shell", "{}"), pending(42, "shell", "{}")], + DecisionRecord::Allowed {}, + ); + let (out, left) = handle(&fake.socket, true, "41\n"); + assert_eq!(out, "approval 41 is no longer pending\n"); + assert_eq!(left, "41\n", "nothing was read"); + assert_eq!(fake.requests(), vec![approvals_request()]); +} + +#[test] +fn without_ask_the_block_is_shown_and_nothing_is_asked_or_read() { + let fake = broker(); + let (out, left) = handle(&fake.socket, false, "41\n"); + assert!(out.starts_with("\x1b[0m41 2 min ago "), "{out:?}"); + assert!(out.ends_with(" shell {\"command\":\"ls\"}\n"), "{out:?}"); + assert!(!out.contains("to approve"), "{out:?}"); + assert_eq!(left, "41\n"); + assert_eq!(fake.requests(), vec![approvals_request()]); +} + +#[test] +fn a_brokerd_that_cannot_be_reached_is_reported_and_nothing_is_asked() { + let missing = support::temp_socket("nobody-listens.sock"); + let (out, left) = handle(&missing, true, "41\n"); + assert!( + out.starts_with("approval 41: cannot ask brokerd: cannot reach brokerd at "), + "{out:?}" + ); + assert!(out.ends_with('\n') && out.lines().count() == 1, "{out:?}"); + assert_eq!(left, "41\n"); +} + +/// The approval can expire between the list and the answer. +#[test] +fn an_answer_that_comes_too_late_is_reported() { + let fake = fake_brokerd(|msg| match msg { + Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList { + items: vec![pending(41, "shell", "{}")], + }), + _ => wire_error(ErrorCode::NoSuchApproval, "no such approval"), + }); + let (out, _) = handle(&fake.socket, true, "41\n"); + assert!( + out.ends_with(&format!( + "{PROMPT}41: no such approval (already answered or expired)\n" + )), + "{out:?}" + ); + let (out, _) = handle(&fake.socket, true, "no\n"); + assert!( + out.ends_with(&format!( + "{PROMPT}41: no such approval (already answered or expired)\n" + )), + "{out:?}" + ); +} + +/// Any other failure of the answer is reported on one line, and the turn goes on: the call is +/// still pending at `brokerd`, and `bxctl approve` from another terminal can answer it. +#[test] +fn an_answer_that_fails_is_reported_and_is_not_an_error() { + let fake = fake_brokerd(|msg| match msg { + Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList { + items: vec![pending(41, "shell", "{}")], + }), + _ => wire_error(ErrorCode::Internal, "boom"), + }); + let (out, _) = handle(&fake.socket, true, "41\n"); + assert!( + out.ends_with(&format!("{PROMPT}approval 41: internal: boom\n")), + "{out:?}" + ); +} + +/// What `brokerd` sends is printed as data here too. +#[test] +fn the_block_in_chat_is_escaped() { + let rlo = char::from_u32(0x202e).unwrap(); + let arguments = format!("{{\"path\":\"/home/kyle/{rlo}dm\x1b[8m\"}}"); + let fake = brokerd_with( + vec![pending(41, "read_file", &arguments)], + DecisionRecord::Allowed {}, + ); + let (out, _) = handle(&fake.socket, false, ""); + assert_eq!(out.matches('\x1b').count(), 1, "only the reset: {out:?}"); + assert!(!out.contains(rlo), "{out:?}"); + assert!( + out.contains(&format!("/home/kyle/{}dm{}[8m", esc(0x202e), esc(0x1b))), + "{out:?}" + ); +} + +struct Broken; + +impl Write for Broken { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("the terminal went away")) + } + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("the terminal went away")) + } +} + +/// Every write can fail, and none is ignored: not the "no longer pending" line, not the block, +/// not the question. Nothing is approved for an owner who was shown nothing. +#[test] +fn a_failed_write_is_an_error_and_nothing_is_answered() { + let fake = broker(); + let mut input = Cursor::new(b"41\n".to_vec()); + assert!(handle_pending(&fake.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err()); + assert_eq!(fake.requests(), vec![approvals_request()]); + + let none = brokerd_with(Vec::new(), DecisionRecord::Allowed {}); + let mut input = Cursor::new(Vec::new()); + assert!(handle_pending(&none.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err()); +} + +// ---- stream_turn ---- + +fn pending_event(tool: &str) -> TurnEvent { + TurnEvent::ApprovalPending { + approval: 41, + tool: tool.to_string(), + expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(), + } +} + +/// Runs one turn against a fake `loopd` that sends `events`. Returns what was printed. +fn turn( + events: Vec, + admin_socket: &Path, + on_pending: OnPending, + json: bool, + typed: &str, +) -> String { + let loopd = fake_loopd(events, "done"); + let mut printer = Printer::new(true, json); + let mut input: Box = Box::new(Cursor::new(typed.as_bytes().to_vec())); + let mut out = Vec::new(); + let approvals = Approvals { + admin_socket, + on_pending, + }; + let mut io = TurnIo { + printer: &mut printer, + input: &mut *input, + out: &mut out, + }; + let done = stream_turn( + &loopd.socket, + &SessionId::new("s1").unwrap(), + "go", + false, + &approvals, + &mut io, + ) + .unwrap() + .unwrap(); + assert_eq!(done.content, "done"); + String::from_utf8(out).unwrap() +} + +/// A compromised `loopd` must not choose what the owner sees: the event says `read_file`, the +/// broker's entry says `shell`, and the block says `shell`. +#[test] +fn the_block_comes_from_brokerd_not_from_the_event() { + let fake = broker(); + let out = turn( + vec![pending_event("read_file")], + &fake.socket, + OnPending::Ask, + false, + "41\n", + ); + assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}"); + assert!(!out.contains("read_file"), "{out:?}"); + assert_eq!( + fake.requests(), + vec![approvals_request(), approve_request()] + ); +} + +#[test] +fn the_turn_goes_on_after_the_answer() { + let fake = broker(); + let out = turn( + vec![ + TurnEvent::Reasoning { + text: "hm".to_string(), + }, + pending_event("shell"), + TurnEvent::Content { + text: "It ran.".to_string(), + }, + ], + &fake.socket, + OnPending::Ask, + false, + "41\n", + ); + assert!( + out.starts_with("\x1b[2mhm\x1b[0m\n\x1b[0m41 "), + "reasoning is ended before the block: {out:?}" + ); + assert!(out.ends_with("approved 41: runs\nIt ran."), "{out:?}"); +} + +#[test] +fn show_prints_the_block_and_answers_nothing() { + let fake = broker(); + let out = turn( + vec![pending_event("shell")], + &fake.socket, + OnPending::Show, + false, + "41\n", + ); + assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}"); + assert!(!out.contains("to approve"), "{out:?}"); + assert_eq!(fake.requests(), vec![approvals_request()]); +} + +#[test] +fn event_only_prints_the_json_line_and_never_asks_brokerd() { + let fake = broker(); + let out = turn( + vec![pending_event("shell")], + &fake.socket, + OnPending::EventOnly, + true, + "41\n", + ); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 1, "{out:?}"); + let event: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(event["event"], "approval_pending"); + assert_eq!(event["approval"], 41); + assert_eq!(fake.requests(), Vec::::new()); +} + +// ---- the binary ---- + +fn chat(loopd: &Path, brokerd: &Path, extra: &[&str], typed: &str) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl")) + .args(["chat", "--socket"]) + .arg(loopd) + .arg("--admin-socket") + .arg(brokerd) + .args(extra) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + { + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(typed.as_bytes()).unwrap(); + } + child.wait_with_output().unwrap() +} + +/// With a pipe, everything typed is in the reader's buffer before the first turn is sent. The +/// approval's answer must come from that same reader: a second reader on stdin would see the +/// end of the input, and refuse. +#[test] +fn interactive_mode_reads_the_answer_from_the_same_input_as_the_chat() { + let loopd = fake_loopd(vec![pending_event("shell")], "ok"); + let fake = broker(); + let output = chat(&loopd.socket, &fake.socket, &[], "go\n41\n/quit\n"); + assert!(output.status.success()); + assert_eq!( + fake.requests(), + vec![approvals_request(), approve_request()] + ); + assert_eq!( + *loopd.turns.lock().unwrap(), + vec!["go".to_string()], + "the answer was not sent to the model as a message" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(PROMPT), "{stderr}"); + assert!(stderr.contains("approved 41: runs"), "{stderr}"); +} + +#[test] +fn interactive_mode_refuses_on_a_stray_line() { + let loopd = fake_loopd(vec![pending_event("shell")], "ok"); + let fake = broker(); + let output = chat(&loopd.socket, &fake.socket, &[], "go\ny\n/quit\n"); + assert!(output.status.success()); + assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]); + assert_eq!(*loopd.turns.lock().unwrap(), vec!["go".to_string()]); +} + +#[test] +fn say_shows_the_block_and_answers_nothing() { + let loopd = fake_loopd(vec![pending_event("read_file")], "ok"); + let fake = broker(); + let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "41\n"); + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(" shell {\"command\":\"ls\"}\n"), + "{stderr}" + ); + assert!(!stderr.contains("to approve"), "{stderr}"); + assert_eq!(fake.requests(), vec![approvals_request()]); +} + +#[test] +fn json_prints_only_json_and_never_asks_brokerd() { + let loopd = fake_loopd(vec![pending_event("shell")], "ok"); + let fake = broker(); + let output = chat( + &loopd.socket, + &fake.socket, + &["--json", "--say", "go"], + "41\n", + ); + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stderr.lines() { + assert!( + serde_json::from_str::(line).is_ok(), + "not JSON: {line}" + ); + } + assert_eq!(stderr.lines().count(), 2, "the event and the done frame"); + assert_eq!(fake.requests(), Vec::::new()); +} + +/// The answer on stdout is the model's text too. +#[test] +fn the_answer_on_stdout_is_printed_as_data() { + let loopd = fake_loopd(Vec::new(), "a\x1b[8mb\n\tc"); + let fake = broker(); + let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], ""); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("a{}[8mb\n\tc\n", esc(0x1b)) + ); +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/chat_print.rs b/docs/plans/M3a/files/crates/bxctl/tests/chat_print.rs new file mode 100644 index 0000000..0dd34fb --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/chat_print.rs @@ -0,0 +1,224 @@ +//! Tests for how `bxctl chat` prints what the model wrote and what `brokerd` denied. Do not edit. + +use bxctl::chat::Printer; +use proto::{DataClass, DenyReason, Timestamp, TurnEvent}; + +const BACKSLASH: char = '\\'; + +/// The escape for one code point, built and never spelled out (see `tests/escape.rs`). +fn esc(code: u32) -> String { + format!("{BACKSLASH}u{code:04x}") +} + +fn print(printer: &mut Printer, events: &[TurnEvent]) -> String { + let mut out = Vec::new(); + for e in events { + printer.event(&mut out, e).unwrap(); + } + printer.end_reasoning(&mut out).unwrap(); + String::from_utf8(out).unwrap() +} + +fn denied(name: &str, reason: DenyReason) -> TurnEvent { + TurnEvent::ToolDenied { + name: name.to_string(), + reason, + } +} + +#[test] +fn a_denial_shows_its_reason_by_its_wire_name() { + let mut p = Printer::new(true, false); + assert_eq!( + print(&mut p, &[denied("read_file", DenyReason::NoGrant)]), + "[denied read_file: no_grant]\n" + ); +} + +/// Walks all ten reasons: the three that mean the harness is refusing to work carry their +/// runbook entry on the next line, and the other seven carry nothing. +#[test] +fn the_three_fail_closed_denials_point_to_the_runbook_and_no_other_does() { + let cases = [ + (DenyReason::NoGrant, "no_grant", None), + (DenyReason::GrantExpired, "grant_expired", None), + (DenyReason::TaintTooHigh, "taint_too_high", None), + (DenyReason::DeniedByGrant, "denied_by_grant", None), + (DenyReason::ApprovalRefused, "approval_refused", None), + (DenyReason::ApprovalExpired, "approval_expired", None), + (DenyReason::InvalidArguments, "invalid_arguments", None), + ( + DenyReason::GrantsInvalid, + "grants_invalid", + Some("see docs/runbook.md#grants-invalid"), + ), + ( + DenyReason::AuditUnavailable, + "audit_unavailable", + Some("see docs/runbook.md#audit-unavailable"), + ), + ( + DenyReason::StateUnreadable, + "state_unreadable", + Some("see docs/runbook.md#broker-state-damaged"), + ), + ]; + for (reason, name, pointer) in cases { + let mut p = Printer::new(true, false); + let got = print(&mut p, &[denied("shell", reason)]); + let want = match pointer { + Some(pointer) => format!("[denied shell: {name}]\n{pointer}\n"), + None => format!("[denied shell: {name}]\n"), + }; + assert_eq!(got, want); + } +} + +#[test] +fn a_denial_ends_an_open_reasoning_block_first() { + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[ + TurnEvent::Reasoning { + text: "hm".to_string(), + }, + denied("shell", DenyReason::NoGrant), + ], + ); + assert_eq!(got, "\x1b[2mhm\x1b[0m\n[denied shell: no_grant]\n"); +} + +#[test] +fn reasoning_and_content_are_printed_as_data() { + let hostile = "a\x1b[8mb\x07c\rd"; + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[ + TurnEvent::Reasoning { + text: hostile.to_string(), + }, + TurnEvent::Content { + text: hostile.to_string(), + }, + ], + ); + let shown = format!("a{}[8mb{}c{}d", esc(0x1b), esc(0x07), esc(0x0d)); + // The only escape sequences left are the printer's own: dim on, dim off. + assert_eq!(got, format!("\x1b[2m{shown}\x1b[0m\n{shown}")); +} + +#[test] +fn invisible_and_direction_changing_characters_are_shown() { + let rlo = char::from_u32(0x202e).unwrap(); + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[TurnEvent::Content { + text: format!("see {rlo}txt.exe"), + }], + ); + assert_eq!(got, format!("see {}txt.exe", esc(0x202e))); +} + +#[test] +fn newlines_and_tabs_in_model_text_pass_through() { + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[TurnEvent::Content { + text: "one\n\ttwo\n".to_string(), + }], + ); + assert_eq!(got, "one\n\ttwo\n"); +} + +/// The model chooses tool names too: every place a name is printed escapes it. +#[test] +fn tool_names_are_printed_as_data_everywhere() { + let name = "sh\x1b[2Jell"; + let shown = format!("sh{}[2Jell", esc(0x1b)); + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[ + TurnEvent::ToolCallStarted { + name: name.to_string(), + }, + TurnEvent::ToolResult { + name: name.to_string(), + class: DataClass::Public, + truncated: false, + }, + TurnEvent::ToolResult { + name: name.to_string(), + class: DataClass::Private, + truncated: true, + }, + denied(name, DenyReason::NoGrant), + ], + ); + assert_eq!( + got, + format!( + "[tool {shown}]\n[{shown}: Public]\n[{shown}: Private, truncated]\n\ + [denied {shown}: no_grant]\n" + ) + ); + assert!(!got.contains('\x1b')); +} + +/// The printer shows nothing for a pending approval: the block comes from `brokerd`, through +/// `handle_pending`. It still ends an open reasoning block, so the block is not dimmed. +#[test] +fn a_pending_approval_prints_nothing_itself_but_ends_the_reasoning_block() { + let pending = TurnEvent::ApprovalPending { + approval: 41, + tool: "read_file".to_string(), + expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(), + }; + let mut p = Printer::new(true, false); + assert_eq!(print(&mut p, std::slice::from_ref(&pending)), ""); + let mut p = Printer::new(true, false); + let got = print( + &mut p, + &[ + TurnEvent::Reasoning { + text: "hm".to_string(), + }, + pending, + ], + ); + assert_eq!(got, "\x1b[2mhm\x1b[0m\n"); +} + +#[test] +fn json_mode_prints_the_new_events_as_json_lines() { + let mut p = Printer::new(true, true); + let got = print( + &mut p, + &[ + TurnEvent::ApprovalPending { + approval: 41, + tool: "read_file".to_string(), + expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(), + }, + denied("shell", DenyReason::GrantsInvalid), + ], + ); + let lines: Vec = got + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!( + lines, + vec![ + serde_json::json!({"event": "approval_pending", "approval": 41, + "tool": "read_file", "expires": "2026-09-18T12:15:00.000Z"}), + serde_json::json!({"event": "tool_denied", "name": "shell", + "reason": "grants_invalid"}), + ] + ); + assert!(!got.contains("runbook"), "json mode adds no prose"); +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/cli.rs b/docs/plans/M3a/files/crates/bxctl/tests/cli.rs new file mode 100644 index 0000000..bd51793 --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/cli.rs @@ -0,0 +1,283 @@ +//! Tests for `bxctl`'s command line. Do not edit. + +use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse}; +use proto::SessionId; +use std::path::{Path, PathBuf}; +use std::process::Command as Process; + +const HOME: &str = "/srv/bx"; + +fn args(words: &[&str]) -> Vec { + words.iter().map(|w| w.to_string()).collect() +} + +fn ok(words: &[&str]) -> Command { + parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse")) +} + +fn bad(words: &[&str]) { + assert_eq!( + parse(&args(words), Path::new(HOME)), + Err(UsageError), + "{words:?} must be a usage error" + ); +} + +fn default_admin() -> PathBuf { + PathBuf::from("/srv/bx/run/owner-broker/admin.sock") +} + +#[test] +fn chat_defaults_come_from_home() { + assert_eq!( + ok(&["chat"]), + Command::Chat(ChatOptions { + socket: PathBuf::from("/srv/bx/run/loop/loop.sock"), + admin_socket: default_admin(), + session: None, + show_thinking: true, + say: None, + json: false, + }) + ); +} + +#[test] +fn chat_takes_every_flag_in_any_order() { + assert_eq!( + ok(&[ + "chat", + "--json", + "--admin-socket", + "/tmp/a.sock", + "--say", + "hello there", + "--no-thinking", + "--session", + "s-1", + "--socket", + "/tmp/l.sock", + ]), + Command::Chat(ChatOptions { + socket: PathBuf::from("/tmp/l.sock"), + admin_socket: PathBuf::from("/tmp/a.sock"), + session: Some(SessionId::new("s-1").unwrap()), + show_thinking: false, + say: Some("hello there".to_string()), + json: true, + }) + ); +} + +#[test] +fn chat_usage_errors() { + bad(&["chat", "--session", "Not Valid!"]); + bad(&["chat", "--session"]); + bad(&["chat", "--socket"]); + bad(&["chat", "--admin-socket"]); + bad(&["chat", "--say"]); + bad(&["chat", "--dance"]); + bad(&["chat", "stray"]); +} + +#[test] +fn approvals() { + assert_eq!( + ok(&["approvals"]), + Command::Approvals { + admin_socket: default_admin() + } + ); + assert_eq!( + ok(&["approvals", "--admin-socket", "/tmp/a.sock"]), + Command::Approvals { + admin_socket: PathBuf::from("/tmp/a.sock") + } + ); + bad(&["approvals", "41"]); + bad(&["approvals", "--admin-socket"]); + bad(&["approvals", "--reason", "x"]); +} + +#[test] +fn approve() { + assert_eq!( + ok(&["approve", "41"]), + Command::Approve { + admin_socket: default_admin(), + approval: 41 + } + ); + // The flag may come before or after the id. + for words in [ + ["approve", "--admin-socket", "/tmp/a.sock", "41"], + ["approve", "41", "--admin-socket", "/tmp/a.sock"], + ] { + assert_eq!( + ok(&words), + Command::Approve { + admin_socket: PathBuf::from("/tmp/a.sock"), + approval: 41 + } + ); + } + assert_eq!( + ok(&["approve", "18446744073709551615"]), + Command::Approve { + admin_socket: default_admin(), + approval: u64::MAX + } + ); +} + +/// An id is decimal digits and nothing else. `str::parse::` alone would accept `+41`. +#[test] +fn an_approval_id_is_only_digits() { + bad(&["approve"]); + bad(&["approve", "41", "42"]); + bad(&["approve", "+41"]); + bad(&["approve", "-1"]); + bad(&["approve", "4 1"]); + bad(&["approve", " 41"]); + bad(&["approve", "0x29"]); + bad(&["approve", "forty-one"]); + bad(&["approve", ""]); + bad(&["approve", "18446744073709551616"]); + bad(&["approve", "41", "--reason", "x"]); + bad(&["refuse"]); + bad(&["refuse", "+41"]); + bad(&["refuse", "41", "42"]); +} + +#[test] +fn refuse() { + assert_eq!( + ok(&["refuse", "41"]), + Command::Refuse { + admin_socket: default_admin(), + approval: 41, + reason: None + } + ); + assert_eq!( + ok(&[ + "refuse", + "41", + "--reason", + "not on a Friday", + "--admin-socket", + "/tmp/a.sock" + ]), + Command::Refuse { + admin_socket: PathBuf::from("/tmp/a.sock"), + approval: 41, + reason: Some("not on a Friday".to_string()) + } + ); + // A value is a value, even when it looks like a flag. + assert_eq!( + ok(&["refuse", "--reason", "--admin-socket", "41"]), + Command::Refuse { + admin_socket: default_admin(), + approval: 41, + reason: Some("--admin-socket".to_string()) + } + ); + bad(&["refuse", "41", "--reason"]); + bad(&["refuse", "41", "--reason", "a", "--reason", "b"]); +} + +#[test] +fn grants_check_and_audit_verify() { + assert_eq!( + ok(&["grants", "check"]), + Command::GrantsCheck { + admin_socket: default_admin() + } + ); + assert_eq!( + ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]), + Command::GrantsCheck { + admin_socket: PathBuf::from("/tmp/a.sock") + } + ); + assert_eq!( + ok(&["audit", "verify"]), + Command::AuditVerify { + home: PathBuf::from(HOME) + } + ); + assert_eq!( + ok(&["audit", "verify", "--home", "/tmp/h"]), + Command::AuditVerify { + home: PathBuf::from("/tmp/h") + } + ); + bad(&["grants"]); + bad(&["grants", "list"]); + bad(&["grants", "check", "extra"]); + bad(&["audit"]); + bad(&["audit", "verify", "--home"]); + bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]); +} + +#[test] +fn anything_else_is_a_usage_error() { + bad(&[]); + bad(&["dance"]); + bad(&["--admin-socket", "/tmp/a.sock", "approvals"]); +} + +#[test] +fn usage_names_every_command() { + for word in [ + "bxctl chat", + "bxctl approvals", + "bxctl approve ", + "bxctl refuse ", + "bxctl grants check", + "bxctl audit verify", + "--admin-socket", + "--reason", + "--home", + ] { + assert!(USAGE.contains(word), "usage lacks {word:?}"); + } +} + +#[test] +fn the_binary_prints_usage_and_exits_2() { + for words in [ + vec![], + vec!["dance"], + vec!["approve", "+41"], + vec!["refuse"], + ] { + let output = Process::new(env!("CARGO_BIN_EXE_bxctl")) + .args(&words) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{words:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("bxctl approve "), + "{words:?}" + ); + } +} + +/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails +/// to connect, and says where it tried. +#[test] +fn the_binary_finds_the_admin_socket_under_boxmaker_home() { + let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id())); + let output = Process::new(env!("CARGO_BIN_EXE_bxctl")) + .arg("approvals") + .env("BOXMAKER_HOME", &home) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + let want = home.join("run/owner-broker/admin.sock"); + assert!(stderr.contains(want.to_str().unwrap()), "{stderr}"); +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/escape.rs b/docs/plans/M3a/files/crates/bxctl/tests/escape.rs new file mode 100644 index 0000000..693cdf9 --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/escape.rs @@ -0,0 +1,150 @@ +//! Tests for `bxctl::escape`: text the model wrote is printed as data. Do not edit. +//! +//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this +//! file can turn one into the character it stands for. + +use bxctl::escape::{escape_json_text, escape_model_text}; + +const BACKSLASH: char = '\\'; + +/// The escape for one code point: a backslash, `u`, and four lowercase hex digits. +fn esc(code: u32) -> String { + format!("{BACKSLASH}u{code:04x}") +} + +fn ch(code: u32) -> char { + char::from_u32(code).unwrap() +} + +/// Every code point that must be escaped, as inclusive ranges. +const HIDDEN: [(u32, u32); 6] = [ + (0x0000, 0x001f), + (0x007f, 0x009f), + (0x200b, 0x200f), + (0x2028, 0x202e), + (0x2060, 0x2069), + (0xfeff, 0xfeff), +]; + +fn hidden(code: u32) -> bool { + HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code)) +} + +/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left +/// exactly as it is. +#[test] +fn every_listed_code_point_is_escaped_and_no_other() { + let mut escaped = 0; + for code in 0..0x11000u32 { + let Some(c) = char::from_u32(code) else { + continue; // the surrogates are not characters + }; + let text = format!("a{c}b"); + let got = escape_json_text(&text); + if hidden(code) { + escaped += 1; + assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}"); + } else { + assert_eq!(got, text, "U+{code:04X} must pass through"); + } + } + assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full"); +} + +#[test] +fn the_edges_of_each_range() { + for (lo, hi) in HIDDEN { + assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo)); + assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi)); + if lo > 0 { + let before = ch(lo - 1).to_string(); + assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1); + } + let after = ch(hi + 1).to_string(); + assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1); + } +} + +#[test] +fn hex_digits_are_lowercase_and_there_are_always_four() { + assert_eq!(escape_json_text("\x1b"), esc(0x1b)); + assert!(escape_json_text("\x1b").ends_with("001b")); + assert!(escape_json_text("\0").ends_with("0000")); + assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff")); + assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e")); +} + +#[test] +fn an_escape_sequence_cannot_reach_the_terminal() { + let text = "before\x1b[8mhidden\x1b[0m\x07after"; + let got = escape_json_text(text); + assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}"); + assert_eq!( + got, + format!( + "before{}[8mhidden{}[0m{}after", + esc(0x1b), + esc(0x1b), + esc(0x07) + ) + ); +} + +#[test] +fn a_path_cannot_be_shown_backwards() { + // U+202E makes a terminal draw what follows from right to left. + let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e)); + assert_eq!( + escape_json_text(&text), + format!("/home/kyle/notes/{}dm.terces", esc(0x202e)) + ); + let text = format!("a{}b{}c", ch(0x200b), ch(0x2066)); + assert_eq!( + escape_json_text(&text), + format!("a{}b{}c", esc(0x200b), esc(0x2066)) + ); +} + +#[test] +fn ordinary_text_is_unchanged() { + for text in [ + "", + "plain", + r#"{"command":"ls -l","cwd":"/home/kyle"}"#, + "naïve café 日本語 🙂", + "a backslash \\ and a quote \" stay as they are", + ] { + assert_eq!(escape_json_text(text), text); + assert_eq!(escape_model_text(text), text); + } +} + +#[test] +fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() { + let text = "one\n\ttwo\r\n"; + assert_eq!( + escape_json_text(text), + format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a)) + ); + assert_eq!( + escape_model_text(text), + format!("one\n\ttwo{}\n", esc(0x0d)), + "only newline and tab pass; a carriage return could overwrite the line" + ); +} + +#[test] +fn model_text_escapes_everything_else_the_same_way() { + for (lo, hi) in HIDDEN { + for code in lo..=hi { + if code == 0x0a || code == 0x09 { + continue; + } + assert_eq!( + escape_model_text(&ch(code).to_string()), + esc(code), + "U+{code:04X}" + ); + } + } +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/support/mod.rs b/docs/plans/M3a/files/crates/bxctl/tests/support/mod.rs new file mode 100644 index 0000000..fd1886b --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/support/mod.rs @@ -0,0 +1,186 @@ +//! Scripted stand-ins for `brokerd`'s admin socket and for `loopd`, for tests. Do not edit. + +#![allow(dead_code)] // each test file uses a different part of this module + +use proto::{ + ApprovalList, CallId, DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION, + PendingApproval, SessionId, Timestamp, TurnDone, TurnEvent, Usage, WireError, read_frame, + write_frame, +}; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +/// A socket path in a fresh temporary directory. +pub fn temp_socket(name: &str) -> PathBuf { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("bxctl-m3a-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +pub fn ts(text: &str) -> Timestamp { + Timestamp::parse(text).unwrap() +} + +pub struct FakeBrokerd { + pub socket: PathBuf, + /// Every request message received, in order. + pub requests: Arc>>, +} + +impl FakeBrokerd { + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +/// Serves `admin.sock`: one request per connection, answered with the frames `answer` returns, +/// written exactly as given (so a test can send a wrong id or a frame that is not final). An +/// empty list closes the connection without an answer. +pub fn fake_brokerd_frames( + answer: impl Fn(&Envelope) -> Vec + Send + 'static, +) -> FakeBrokerd { + let socket = temp_socket("admin.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let Ok(request) = read_frame(&mut stream) else { + continue; + }; + seen.lock().unwrap().push(request.msg.clone()); + for frame in answer(&request) { + if write_frame(&mut stream, &frame).is_err() { + break; // the client went away; the next connection is still served + } + } + } + }); + FakeBrokerd { socket, requests } +} + +/// The usual case: one final frame with the request's id. +pub fn fake_brokerd(answer: impl Fn(&Message) -> Message + Send + 'static) -> FakeBrokerd { + fake_brokerd_frames(move |request| { + vec![Envelope { + v: PROTOCOL_VERSION, + id: request.id, + r#final: true, + msg: answer(&request.msg), + }] + }) +} + +pub fn wire_error(code: ErrorCode, detail: &str) -> Message { + Message::Error(WireError { + code, + detail: detail.to_string(), + }) +} + +/// A pending approval created at 12:00:00 that expires at 12:15:00 on 2026-09-18. +pub fn pending(approval: u64, tool: &str, arguments: &str) -> PendingApproval { + PendingApproval { + approval, + session: SessionId::new("chat-1758196800-123456789").unwrap(), + call: CallId(7), + tool: tool.to_string(), + arguments: arguments.to_string(), + grant: "shell-scratch".to_string(), + taint: DataClass::Private, + created: ts("2026-09-18T12:00:00.000Z"), + expires: ts("2026-09-18T12:15:00.000Z"), + } +} + +/// A `brokerd` with these approvals pending. `approve` is answered with `outcome`, `refuse` with +/// `ok`; both answer `no_such_approval` for an id that is not in the list. +pub fn brokerd_with(items: Vec, outcome: proto::DecisionRecord) -> FakeBrokerd { + fake_brokerd(move |msg| { + let known = |id: u64| items.iter().any(|item| item.approval == id); + match msg { + Message::Approvals(_) => Message::ApprovalList(ApprovalList { + items: items.clone(), + }), + Message::Approve(a) if known(a.approval) => { + Message::ApproveResult(proto::ApproveResult { + outcome: outcome.clone(), + }) + } + Message::Refuse(r) if known(r.approval) => Message::Ok(proto::Empty {}), + Message::Approve(_) | Message::Refuse(_) => { + wire_error(ErrorCode::NoSuchApproval, "no such approval") + } + _ => wire_error(ErrorCode::BadMessage, "not an admin request"), + } + }) +} + +pub struct FakeLoopd { + pub socket: PathBuf, + /// The content of every turn received, in order. + pub turns: Arc>>, +} + +pub fn usage() -> Usage { + Usage { + cache_n: 10, + prompt_n: 5, + predicted_n: 7, + reasoning_tokens: 3, + thinking_capped: false, + } +} + +/// Serves `loop.sock`: every turn gets the same events, then `turn_done` with `answer`. It does +/// not wait for an approval to be answered: `bxctl` deals with each event before it reads the +/// next frame, so the order of what it does is fixed all the same. +pub fn fake_loopd(events: Vec, answer: &str) -> FakeLoopd { + let socket = temp_socket("loop.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let turns = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&turns); + let answer = answer.to_string(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let Ok(request) = read_frame(&mut stream) else { + continue; + }; + let Message::Turn(turn) = request.msg else { + continue; + }; + seen.lock().unwrap().push(turn.content); + let mut frames: Vec<(bool, Message)> = events + .iter() + .map(|e| (false, Message::TurnEvent(e.clone()))) + .collect(); + frames.push(( + true, + Message::TurnDone(TurnDone { + content: answer.clone(), + usage: usage(), + }), + )); + for (last, msg) in frames { + let frame = Envelope { + v: PROTOCOL_VERSION, + id: request.id, + r#final: last, + msg, + }; + if write_frame(&mut stream, &frame).is_err() { + break; + } + } + } + }); + FakeLoopd { socket, turns } +} diff --git a/docs/plans/M3a/files/crates/bxctl/tests/verify.rs b/docs/plans/M3a/files/crates/bxctl/tests/verify.rs new file mode 100644 index 0000000..b89e141 --- /dev/null +++ b/docs/plans/M3a/files/crates/bxctl/tests/verify.rs @@ -0,0 +1,179 @@ +//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`. +//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped. +struct Home { + path: PathBuf, +} + +impl Home { + fn with_case(case: &str) -> Home { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let name = format!("bxctl-verify-{}-{n}", std::process::id()); + let path = std::env::temp_dir().join(name); + let _ = std::fs::remove_dir_all(&path); + let audit = path.join("audit"); + std::fs::create_dir_all(&audit).unwrap(); + let from = format!( + "{}/../proto/tests/fixtures/audit/{case}", + env!("CARGO_MANIFEST_DIR") + ); + for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap(); + } + Home { path } + } +} + +impl Drop for Home { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn run(case: &str) -> (bool, String) { + let home = Home::with_case(case); + // What brokerd leaves beside the log must not be read as part of it. + std::fs::write(home.path.join("audit/.lock"), "").unwrap(); + let mut out = Vec::new(); + let ok = bxctl::verify::run(&home.path, &mut out).unwrap(); + (ok, String::from_utf8(out).unwrap()) +} + +/// The hex of the hash of the last line of `file` in `case`. +fn head_of(case: &str, file: &str) -> String { + let path = format!( + "{}/../proto/tests/fixtures/audit/{case}/{file}", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(path).unwrap(); + proto::sha256(text.lines().last().unwrap().as_bytes()) + .unwrap() + .to_hex() +} + +#[test] +fn a_good_log() { + let (ok, out) = run("good"); + assert!(ok); + let head = head_of("good", "2026-09-18.jsonl"); + assert_eq!( + out, + format!( + "audit: ok, 10 records, head {head}\n\ + pending or abandoned: approval 6\n\ + running or unfinished: decision 7\n" + ) + ); +} + +#[test] +fn everything_worth_knowing_is_listed_one_per_line() { + let (ok, out) = run("recovered-next-day"); + assert!(ok); + let lines: Vec<&str> = out.lines().collect(); + assert!( + lines[0].starts_with("audit: ok, 11 records, head "), + "{out}" + ); + assert_eq!( + lines[1..], + [ + "recovered line: 2026-09-17.jsonl:6", + "pending or abandoned: approval 7", + "running or unfinished: decision 8", + ] + ); + + let (ok, out) = run("accepted-break-older-file"); + assert!(ok); + assert!( + out.contains("\naccepted break: 2026-09-18.jsonl:6\n"), + "{out}" + ); + + let (ok, out) = run("clock-back"); + assert!(ok); + assert!( + out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"), + "{out}" + ); +} + +/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is +/// reported and is not a failure. +#[test] +fn a_torn_tail_is_reported_and_is_ok() { + let (ok, out) = run("torn-tail"); + assert!(ok); + assert!(out.starts_with("audit: ok, 10 records, head "), "{out}"); + assert!( + out.ends_with( + "\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n" + ), + "{out}" + ); +} + +#[test] +fn a_broken_chain_is_two_lines_and_false() { + let cases = [ + ( + "changed-byte", + "2026-09-17.jsonl:4: prev is not the hash of the line before", + ), + ("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"), + ( + "cut-short", + "2026-09-17.jsonl:3: does not parse as an audit record", + ), + ( + "file-not-chained", + "2026-09-18.jsonl:1: does not chain from the last line of the file before", + ), + ( + "break-wrong-line", + "2026-09-17.jsonl:4: prev is not the hash of the line before", + ), + ]; + for (case, first) in cases { + let (ok, out) = run(case); + assert!(!ok, "{case}"); + assert_eq!( + out, + format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"), + "{case}" + ); + } +} + +#[test] +fn an_empty_audit_directory_is_an_empty_log() { + let home = Home::with_case("good"); + for entry in std::fs::read_dir(home.path.join("audit")).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + let mut out = Vec::new(); + assert!(bxctl::verify::run(&home.path, &mut out).unwrap()); + assert_eq!( + String::from_utf8(out).unwrap(), + "audit: ok, 0 records, head none\n" + ); +} + +/// A home with no audit directory is a mistake in `--home`, not a clean log. +#[test] +fn a_missing_audit_directory_is_an_error() { + let home = Home::with_case("good"); + std::fs::remove_dir_all(home.path.join("audit")).unwrap(); + let mut out = Vec::new(); + let error = bxctl::verify::run(&home.path, &mut out).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + assert!(out.is_empty()); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/broker_port.rs b/docs/plans/M3a/files/crates/loopd/tests/broker_port.rs new file mode 100644 index 0000000..2ad285d --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/broker_port.rs @@ -0,0 +1,314 @@ +//! Tests for `BrokerPort` against a fake `brokerd` on a temporary socket: answers, approvals and +//! timeouts. Frames that break the protocol are in `broker_port_bad.rs`. Do not edit. + +#[path = "support/broker.rs"] +mod fake; + +use std::io::Write; +use std::os::unix::net::UnixListener; +use std::thread; +use std::time::Duration; + +use fake::{assert_unavailable, broker, call, frame, in_ms, request, result, send, socket_path}; +use loopd::broker_port::{BrokerPort, NoBroker, POINTER, TOO_LARGE}; +use loopd::tools::{Pending, ToolPort}; +use proto::{ + DenyReason, Message, PROTOCOL_VERSION, Timestamp, ToolResponse, read_frame, write_frame, +}; + +#[test] +fn a_result_comes_back_as_it_was_sent() { + let (socket, broker) = broker(|stream, request| { + send(stream, request.id, true, result("hello\n")); + }); + let got = call(socket, 2_000, &request()); + assert_eq!(got.response, result("hello\n")); + assert!(got.pending.is_empty()); + assert!(got.lines.is_empty(), "nothing to report: {:?}", got.lines); + + // What the broker received: one final frame holding exactly the request. + let sent = broker.join().unwrap(); + assert_eq!(sent.v, PROTOCOL_VERSION); + assert!(sent.r#final, "a request is a single final frame"); + assert_eq!(sent.msg, Message::ToolRequest(request())); +} + +#[test] +fn every_denial_and_a_failure_come_back_as_they_were_sent() { + let mut answers: Vec = [ + DenyReason::NoGrant, + DenyReason::GrantExpired, + DenyReason::TaintTooHigh, + DenyReason::DeniedByGrant, + DenyReason::ApprovalRefused, + DenyReason::ApprovalExpired, + DenyReason::GrantsInvalid, + DenyReason::AuditUnavailable, + DenyReason::InvalidArguments, + DenyReason::StateUnreadable, + ] + .into_iter() + .map(|reason| ToolResponse::Denied { reason }) + .collect(); + answers.push(ToolResponse::Failed { + message: "the runner arrives in M3b".to_string(), + }); + for answer in answers { + let reply = answer.clone(); + let (socket, broker) = broker(move |stream, request| { + send(stream, request.id, true, reply); + }); + let got = call(socket, 2_000, &request()); + assert_eq!(got.response, answer); + assert!( + got.lines.is_empty(), + "a denial is not an outage: {:?}", + got.lines + ); + broker.join().unwrap(); + } +} + +#[test] +fn a_pending_frame_reaches_the_callback_and_the_final_frame_is_the_answer() { + let expires = in_ms(60_000); + let (socket, broker) = broker(move |stream, request| { + let pending = ToolResponse::PendingApproval { + approval: 41, + expires, + }; + send(stream, request.id, false, pending); + thread::sleep(Duration::from_millis(150)); + send(stream, request.id, true, result("approved")); + }); + let got = call(socket, 2_000, &request()); + assert_eq!(got.response, result("approved")); + assert_eq!( + got.pending, + [Pending { + approval: 41, + expires + }], + "called once, with the frame's values" + ); + assert!(got.lines.is_empty(), "{:?}", got.lines); + broker.join().unwrap(); +} + +#[test] +fn an_answer_after_expires_but_within_the_timeout_of_it_is_delivered() { + // expires in 100 ms, timeout 1,500 ms: the port waits until 1,600 ms. The answer comes at + // about 400 ms, well after `expires`: an approval given at the last moment still gets the + // whole timeout to run. + let (socket, broker) = broker(|stream, request| { + let pending = ToolResponse::PendingApproval { + approval: 1, + expires: in_ms(100), + }; + send(stream, request.id, false, pending); + thread::sleep(Duration::from_millis(400)); + send(stream, request.id, true, result("late but good")); + }); + let got = call(socket, 1_500, &request()); + assert_eq!(got.response, result("late but good")); + assert!(got.lines.is_empty(), "{:?}", got.lines); + broker.join().unwrap(); +} + +#[test] +fn an_expiry_that_has_already_passed_still_leaves_the_timeout() { + let (socket, broker) = broker(|stream, request| { + let pending = ToolResponse::PendingApproval { + approval: 1, + expires: Timestamp::parse("2020-01-01T00:00:00.000Z").unwrap(), + }; + send(stream, request.id, false, pending); + thread::sleep(Duration::from_millis(200)); + send(stream, request.id, true, result("fine")); + }); + let got = call(socket, 1_500, &request()); + assert_eq!(got.response, result("fine")); + broker.join().unwrap(); +} + +#[test] +fn no_socket_is_unavailable() { + let socket = socket_path(); + let _ = std::fs::remove_file(&socket); + let got = call(socket.clone(), 2_000, &request()); + assert_unavailable(&got, "no socket file"); + assert!( + got.lines[0].contains(&socket.display().to_string()), + "the line names the socket: {}", + got.lines[0] + ); + assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took); +} + +#[test] +fn a_broker_that_closes_without_answering_is_unavailable() { + let (socket, broker) = broker(|_, _| {}); + let got = call(socket, 2_000, &request()); + assert_unavailable(&got, "closed before any frame"); + assert!( + got.took < Duration::from_millis(1_500), + "a close is seen at once: {:?}", + got.took + ); + broker.join().unwrap(); +} + +#[test] +fn a_broker_that_closes_while_pending_is_unavailable() { + let (socket, broker) = broker(|stream, request| { + let pending = ToolResponse::PendingApproval { + approval: 3, + expires: in_ms(60_000), + }; + send(stream, request.id, false, pending); + // brokerd was restarted: the connection just ends. + }); + let got = call(socket, 2_000, &request()); + assert_unavailable(&got, "closed while pending"); + assert_eq!(got.pending.len(), 1, "the pending frame was reported first"); + assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took); + broker.join().unwrap(); +} + +#[test] +fn a_broker_that_never_answers_is_unavailable_after_the_timeout() { + let (socket, broker) = broker(|_, _| thread::sleep(Duration::from_millis(1_200))); + let got = call(socket, 300, &request()); + assert_unavailable(&got, "silence"); + assert!( + got.took >= Duration::from_millis(250), + "gave up early: {:?}", + got.took + ); + assert!( + got.took < Duration::from_millis(1_100), + "gave up late: {:?}", + got.took + ); + broker.join().unwrap(); +} + +#[test] +fn a_pending_call_nobody_answers_ends_at_expires_plus_the_timeout() { + let (socket, broker) = broker(|stream, request| { + let pending = ToolResponse::PendingApproval { + approval: 3, + expires: in_ms(300), + }; + send(stream, request.id, false, pending); + thread::sleep(Duration::from_millis(1_800)); + }); + let got = call(socket, 300, &request()); + assert_unavailable(&got, "silence while pending"); + assert!( + got.took >= Duration::from_millis(550), + "it must wait for expires (300) plus the timeout (300): {:?}", + got.took + ); + assert!( + got.took < Duration::from_millis(1_700), + "gave up late: {:?}", + got.took + ); + broker.join().unwrap(); +} + +#[test] +fn the_timeout_is_a_deadline_for_the_frame_not_for_each_read() { + // The four header bytes arrive 250 ms apart, the last at 1,000 ms. A port that only sets a + // 600 ms read timeout sees every single read succeed and returns the result; a port with a + // deadline gives up at 600 ms. The answer tells them apart, so no timing is asserted. + let (socket, broker) = broker(|stream, request| { + let mut bytes = Vec::new(); + write_frame( + &mut bytes, + &frame(request.id, true, Message::ToolResponse(result("slow"))), + ) + .unwrap(); + for byte in bytes.iter().take(4) { + thread::sleep(Duration::from_millis(250)); + if stream.write_all(&[*byte]).is_err() { + return; + } + } + let _ = stream.write_all(&bytes[4..]); + }); + let got = call(socket, 600, &request()); + assert_unavailable(&got, "a trickled frame"); + broker.join().unwrap(); +} + +#[test] +fn a_zero_timeout_fails_closed_and_does_not_panic() { + let (socket, _broker) = broker(|stream, request| { + send(stream, request.id, true, result("too late")); + }); + let got = call(socket, 0, &request()); + assert_unavailable(&got, "timeout_ms = 0"); +} + +#[test] +fn a_request_too_large_for_a_frame_is_its_own_failure() { + // Nothing is sent, so the fake broker sees a connection that closes or none at all. + let path = socket_path(); + let _ = std::fs::remove_file(&path); + let _listener = UnixListener::bind(&path).unwrap(); + let mut big = request(); + big.arguments = format!(r#"{{"path":"/{}"}}"#, "a".repeat(proto::MAX_FRAME)); + let got = call(path, 1_000, &big); + assert_eq!( + got.response, + ToolResponse::Failed { + message: TOO_LARGE.to_string() + } + ); + assert!( + got.lines.is_empty(), + "the broker is fine; this is not an outage: {:?}", + got.lines + ); +} + +#[test] +fn every_call_is_its_own_connection() { + let path = socket_path(); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + let server = thread::spawn(move || { + for n in 0..3u64 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_frame(&mut stream).unwrap(); + send( + &mut stream, + request.id, + true, + result(&format!("answer {n}")), + ); + } + }); + let port = BrokerPort::new(path, Duration::from_millis(2_000)); + for n in 0..3 { + let got = port.call(&request(), &mut |_| {}); + assert_eq!(got, result(&format!("answer {n}"))); + } + server.join().unwrap(); +} + +#[test] +fn without_a_broker_every_call_fails_in_words_the_model_can_repeat() { + let mut seen = 0; + let got = NoBroker.call(&request(), &mut |_| seen += 1); + assert_eq!( + got, + ToolResponse::Failed { + message: "no tool broker is configured".to_string() + } + ); + assert_eq!(seen, 0); + assert_eq!(POINTER, "see docs/runbook.md#broker-unavailable"); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/broker_port_bad.rs b/docs/plans/M3a/files/crates/loopd/tests/broker_port_bad.rs new file mode 100644 index 0000000..f412c94 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/broker_port_bad.rs @@ -0,0 +1,127 @@ +//! Tests for `BrokerPort` when the broker's frames break the protocol. Every one ends in the same +//! plain failure and one printed line. Do not edit. + +#[path = "support/broker.rs"] +mod fake; + +use std::io::Write; +use std::os::unix::net::UnixStream; +use std::thread; +use std::time::Duration; + +use fake::{assert_unavailable, broker, call, frame, in_ms, request, result, send}; +use proto::{Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolResponse, WireError, write_frame}; + +#[test] +fn frames_that_break_the_protocol_are_unavailable() { + type Script = Box; + let pending = |approval| ToolResponse::PendingApproval { + approval, + expires: in_ms(60_000), + }; + let cases: Vec<(&str, Script)> = vec![ + ( + "an answer for another request id", + Box::new(|s, r| send(s, r.id + 1, true, result("x"))), + ), + ( + "a final frame that is pending", + Box::new(move |s, r| send(s, r.id, true, pending(1))), + ), + ( + "an answer that is not final", + Box::new(|s, r| send(s, r.id, false, result("x"))), + ), + ( + "a second pending frame", + Box::new(move |s, r| { + send(s, r.id, false, pending(1)); + send(s, r.id, false, pending(2)); + thread::sleep(Duration::from_millis(100)); + }), + ), + ( + "pending, then another id", + Box::new(move |s, r| { + send(s, r.id, false, pending(1)); + send(s, r.id + 1, true, result("x")); + }), + ), + ( + "an error message", + Box::new(|s, r| { + let error = Message::Error(WireError { + code: ErrorCode::Forbidden, + detail: "tool requests only".to_string(), + }); + let _ = write_frame(s, &frame(r.id, true, error)); + }), + ), + ( + "a message of another kind", + Box::new(|s, r| { + let echo = Message::ToolRequest(request()); + let _ = write_frame(s, &frame(r.id, true, echo)); + }), + ), + ( + "another protocol version", + Box::new(|s, r| { + let mut env = frame(r.id, true, Message::ToolResponse(result("x"))); + env.v = PROTOCOL_VERSION + 1; + let _ = write_frame(s, &env); + }), + ), + ( + "a zero length", + Box::new(|s, _| { + let _ = s.write_all(&[0, 0, 0, 0]); + }), + ), + ( + "a length over the maximum", + Box::new(|s, _| { + let _ = s.write_all(&[0xff, 0xff, 0xff, 0xff]); + }), + ), + ( + "a body that is not JSON", + Box::new(|s, _| { + let _ = s.write_all(&[0, 0, 0, 5]); + let _ = s.write_all(b"hello"); + }), + ), + ( + "a body cut short", + Box::new(|s, _| { + let _ = s.write_all(&[0, 0, 0, 50]); + let _ = s.write_all(b"{\"v\":1"); + }), + ), + ]; + for (why, script) in cases { + let (socket, broker) = broker(script); + let got = call(socket, 1_000, &request()); + assert_unavailable(&got, why); + broker.join().unwrap(); + } +} + +#[test] +fn the_error_message_the_broker_sent_is_in_the_line() { + let (socket, broker) = broker(|s, r| { + let error = Message::Error(WireError { + code: ErrorCode::Internal, + detail: "the ledger is gone".to_string(), + }); + let _ = write_frame(s, &frame(r.id, true, error)); + }); + let got = call(socket, 1_000, &request()); + assert_unavailable(&got, "an error message"); + assert!( + got.lines[0].contains("the ledger is gone"), + "{}", + got.lines[0] + ); + broker.join().unwrap(); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/config.rs b/docs/plans/M3a/files/crates/loopd/tests/config.rs new file mode 100644 index 0000000..78b37b9 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/config.rs @@ -0,0 +1,272 @@ +//! Tests for `loopd`'s configuration. Do not edit: these define the required behaviour. + +use loopd::config::{Config, ConfigError, Limits, Sampling}; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/config") + .join(name) +} + +#[test] +fn minimal_file_gets_the_documented_defaults() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!( + c.infer.socket, + PathBuf::from("/run/boxmaker/infer/infer.sock") + ); + assert_eq!(c.infer.model, "ornith-1.5-35b-a3b"); + assert_eq!((c.slots.main, c.slots.background), (0, 1)); + assert_eq!(c.expect.n_ctx, 131_072); + assert_eq!(c.expect.slots, 2); + assert_eq!( + c.expect.template_sha256.to_hex(), + "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" + ); + assert_eq!( + c.sampling, + Sampling { + temperature: 0.6, + top_p: 0.95, + top_k: 20 + } + ); + let want = Limits { + poll_ms: 5_000, + busy_wait_ms: 600_000, + load_wait_ms: 180_000, + idle_grace_ms: 30_000, + liveness_ms: 30_000, + thinking_cap: 4_096, + thinking_overrun: 256, + max_tokens: 8_192, + queue_len: 8, + retry_attempts: 4, + retry_backoff_ms: vec![2_000, 8_000, 30_000], + retry_window_ms: 300_000, + }; + assert_eq!(c.limits, want); + assert_eq!(Limits::default(), want); +} + +#[test] +fn full_file_overrides_every_default() { + let c = Config::load(&fixture("full.toml")).unwrap(); + assert_eq!( + c.sampling, + Sampling { + temperature: 0.2, + top_p: 0.9, + top_k: 40 + } + ); + let want = Limits { + poll_ms: 50, + busy_wait_ms: 200, + load_wait_ms: 300, + idle_grace_ms: 150, + liveness_ms: 100, + thinking_cap: 20, + thinking_overrun: 10, + max_tokens: 512, + queue_len: 1, + retry_attempts: 2, + retry_backoff_ms: vec![10], + retry_window_ms: 1_000, + }; + assert_eq!(c.limits, want); +} + +#[test] +fn a_partial_limits_table_keeps_the_other_defaults() { + let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap(); + let c = Config::parse(&format!("{text}\n[limits]\nliveness_ms = 1234\n")).unwrap(); + assert_eq!(c.limits.liveness_ms, 1234); + assert_eq!(c.limits.poll_ms, 5_000); +} + +/// Every table in the file must reject a key it does not know: a misspelt limit that silently +/// fell back to its default would be a limit the owner believes is set and is not. +#[test] +fn unknown_keys_are_errors_in_every_table() { + let text = std::fs::read_to_string(fixture("full.toml")).unwrap(); + assert!(Config::parse(&text).is_ok()); + for table in ["infer", "slots", "expect", "sampling", "limits"] { + let header = format!("[{table}]\n"); + assert!(text.contains(&header), "fixture has no [{table}] table"); + let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1); + assert!( + Config::parse(&bad).is_err(), + "[{table}] accepted an unknown key" + ); + } + assert!( + Config::parse(&format!("zz_unknown = 1\n{text}")).is_err(), + "top level" + ); + assert!( + Config::parse(&format!("{text}\n[zz_unknown]\nx = 1\n")).is_err(), + "unknown table" + ); +} + +#[test] +fn required_tables_and_values_are_checked() { + let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap(); + for table in ["infer", "slots", "expect"] { + let without: String = text + .split("\n\n") + .filter(|block| !block.trim_start().starts_with(&format!("[{table}]"))) + .collect::>() + .join("\n\n"); + assert!(Config::parse(&without).is_err(), "[{table}] is required"); + } + let bad_hash = text.replace("f55f5293", "F55F5293"); + assert!( + Config::parse(&bad_hash).is_err(), + "the hash must be lowercase hex" + ); + let negative = text.replace("main = 0", "main = -1"); + assert!(Config::parse(&negative).is_err()); +} + +#[test] +fn load_reports_which_file_failed() { + let missing = fixture("does-not-exist.toml"); + match Config::load(&missing) { + Err(ConfigError::Read(path, _)) => assert_eq!(path, missing), + other => panic!("expected a read error, got {other:?}"), + } + let e: Box = Box::new(Config::load(&missing).unwrap_err()); + assert!(e.to_string().contains("does-not-exist.toml")); +} + +// ---- M2b: paths, channel, loop and baseline ---- + +#[test] +fn the_m2b_tables_have_defaults() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!( + c.channel.socket, + PathBuf::new(), + "empty means: under the home" + ); + assert_eq!(c.channel_socket(), c.paths.home.join("run/loop/loop.sock")); + assert_eq!( + ( + c.r#loop.tool_iterations, + c.r#loop.repeat_detection, + c.r#loop.tool_result_cap + ), + (8, true, 16 * 1024) + ); + // A relative system prompt is taken from the config file's directory. + assert_eq!(c.baseline.system, fixture("system.md")); + // The home comes from BOXMAKER_HOME when set, else /var/lib/boxmaker. Either way it is absolute. + assert!(c.paths.home.is_absolute(), "{:?}", c.paths.home); +} + +#[test] +fn the_m2b_tables_can_be_set() { + let c = Config::load(&fixture("m2b.toml")).unwrap(); + assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker")); + assert_eq!( + c.channel.socket, + PathBuf::from("/run/boxmaker/loop/loop.sock") + ); + assert_eq!( + c.channel_socket(), + PathBuf::from("/run/boxmaker/loop/loop.sock") + ); + assert_eq!( + ( + c.r#loop.tool_iterations, + c.r#loop.repeat_detection, + c.r#loop.tool_result_cap + ), + (3, false, 1024) + ); + assert_eq!( + c.baseline.system, + fixture("prompts/agent.md"), + "relative to the config file" + ); + let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap(); + let absolute = text.replace("prompts/agent.md", "/etc/boxmaker/system.md"); + assert_eq!( + Config::parse(&absolute).unwrap().baseline.system, + PathBuf::from("/etc/boxmaker/system.md") + ); +} + +#[test] +fn unknown_keys_are_errors_in_the_m2b_tables_too() { + let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap(); + assert!(Config::parse(&text).is_ok()); + for table in ["paths", "channel", "loop", "baseline"] { + let header = format!("[{table}]\n"); + let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1); + assert!( + Config::parse(&bad).is_err(), + "[{table}] accepted an unknown key" + ); + } + let partial = text.replace("tool_iterations = 3\nrepeat_detection = false\n", ""); + let c = Config::parse(&partial).unwrap(); + assert_eq!( + ( + c.r#loop.tool_iterations, + c.r#loop.repeat_detection, + c.r#loop.tool_result_cap + ), + (8, true, 1024), + "a partial table keeps the other defaults" + ); +} + +#[test] +fn the_broker_table_is_optional_and_its_socket_has_no_default() { + let c = Config::load(&fixture("minimal.toml")).unwrap(); + assert_eq!( + c.broker.socket, None, + "no socket means no broker, never a guessed path" + ); + assert_eq!(c.broker.timeout_ms, 120_000); +} + +#[test] +fn the_broker_table_can_be_set_and_rejects_unknown_keys() { + let base = std::fs::read_to_string(fixture("m2b.toml")).unwrap(); + let text = format!( + "{base}\n[broker]\nsocket = \"/run/boxmaker/loop-broker/broker.sock\"\ntimeout_ms = 5000\n" + ); + let c = Config::parse(&text).unwrap(); + assert_eq!( + c.broker.socket, + Some(PathBuf::from("/run/boxmaker/loop-broker/broker.sock")) + ); + assert_eq!(c.broker.timeout_ms, 5000); + + let only_timeout = format!("{base}\n[broker]\ntimeout_ms = 5000\n"); + let c = Config::parse(&only_timeout).unwrap(); + assert_eq!((c.broker.socket, c.broker.timeout_ms), (None, 5000)); + + let only_socket = format!("{base}\n[broker]\nsocket = \"/b.sock\"\n"); + let c = Config::parse(&only_socket).unwrap(); + assert_eq!( + c.broker.timeout_ms, 120_000, + "a partial table keeps the default" + ); + + for bad in [ + "zz_unknown = 1", + "timeout = 5000", + "timeout_ms = -1", + "timeout_ms = \"5s\"", + "socket = 7", + ] { + let text = format!("{base}\n[broker]\n{bad}\n"); + assert!(Config::parse(&text).is_err(), "[broker] accepted `{bad}`"); + } +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/device.rs b/docs/plans/M3a/files/crates/loopd/tests/device.rs new file mode 100644 index 0000000..61793c6 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/device.rs @@ -0,0 +1,448 @@ +//! Checks against the real server. Ignored unless run by `make verify-device`. Do not edit. +//! +//! They need two environment variables: +//! BOXMAKER_INFERPROXY path to the built `inferproxy` binary +//! BOXMAKER_UPSTREAM host:port of the llama-server router, for example straylight:11434 +//! and, optionally, BOXMAKER_MODEL (default ornith-1.5-35b-a3b). They use slot 0. +//! +//! They never touch the real `llama-server` process. The "server dies" test kills and restarts +//! its own `inferproxy`. + +use loopd::config::Config; +use loopd::llama::info::{CacheOutcome, cache_outcome}; +use loopd::llama::{ChatEvent, ChatMessage, ChatRequest, Client}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +struct Proxy { + child: Child, + socket: PathBuf, +} + +impl Proxy { + fn start(socket: &Path) -> Proxy { + let binary = std::env::var("BOXMAKER_INFERPROXY").expect("BOXMAKER_INFERPROXY is not set"); + let upstream = std::env::var("BOXMAKER_UPSTREAM").expect("BOXMAKER_UPSTREAM is not set"); + let child = Command::new(binary) + .arg("--listen") + .arg(socket) + .arg("--upstream") + .arg(upstream) + .spawn() + .expect("cannot start inferproxy"); + let mut proxy = Proxy { + child, + socket: socket.to_path_buf(), + }; + for _ in 0..100 { + if socket.exists() { + return proxy; + } + thread::sleep(Duration::from_millis(20)); + } + proxy.kill(); + panic!("inferproxy did not create {}", socket.display()); + } + + fn kill(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + self.kill(); + } +} + +fn socket_path(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("loopd-device-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("infer.sock") +} + +fn config(socket: &Path) -> Config { + let model = + std::env::var("BOXMAKER_MODEL").unwrap_or_else(|_| "ornith-1.5-35b-a3b".to_string()); + let text = format!( + r#" +[infer] +socket = "{}" +model = "{model}" +[slots] +main = 0 +background = 1 +[expect] +template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" +n_ctx = 131072 +slots = 2 +"#, + socket.display() + ); + Config::parse(&text).unwrap() +} + +fn user(text: &str) -> ChatMessage { + // A different prompt each run, so that an earlier run's cache cannot make a check pass. + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + ChatMessage::User { + content: format!("{text} (run {nonce})"), + } +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn the_startup_self_test_passes() { + let socket = socket_path("selftest"); + let _proxy = Proxy::start(&socket); + let client = Client::new(config(&socket)); + loopd::selftest::run(&client, &mut |step| eprintln!("selftest: {step}")).unwrap(); +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn a_capped_thinking_block_ends_and_the_answer_arrives() { + let socket = socket_path("cap"); + let _proxy = Proxy::start(&socket); + let mut cfg = config(&socket); + cfg.limits.thinking_cap = 60; + let client = Client::new(cfg); + let ask = "Think at great length about how many ways 8 rooks fit on a chessboard without attacking \ + each other and with none on the main diagonal. Then answer in one sentence."; + let req = ChatRequest { + slot: 0, + messages: vec![user(ask)], + tools: vec![], + thinking: true, + }; + let mut capped = Vec::new(); + let done = client + .chat_with_retry(&req, &mut |e| { + if let ChatEvent::ThinkingCapped { tokens } = e { + capped.push(*tokens); + } + }) + .unwrap(); + assert!(done.thinking_capped); + assert_eq!(capped.len(), 1); + assert!(capped[0] >= 60 && capped[0] < 70, "capped at {capped:?}"); + assert!( + done.reasoning_tokens < 60 + 256, + "thinking went on to {}", + done.reasoning_tokens + ); + assert!( + done.content.is_some_and(|c| !c.trim().is_empty()), + "an answer followed the forced end" + ); +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn a_request_survives_its_proxy_being_killed_and_restarted() { + let socket = socket_path("restart"); + let mut proxy = Proxy::start(&socket); + let mut cfg = config(&socket); + cfg.limits.retry_backoff_ms = vec![1_500]; + let client = Client::new(cfg); + let ask = "Write about 300 words on the history of cork."; + let req = ChatRequest { + slot: 0, + messages: vec![user(ask)], + tools: vec![], + thinking: false, + }; + + let (tx, rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut retries = 0; + let mut told = false; + let result = client.chat_with_retry(&req, &mut |e| match e { + ChatEvent::Content(_) if !told => { + told = true; + let _ = tx.send(()); + } + ChatEvent::Retrying { .. } => retries += 1, + _ => {} + }); + (result, retries) + }); + + rx.recv_timeout(Duration::from_secs(120)) + .expect("no content arrived"); + proxy.kill(); // the stream dies in the middle of the answer + thread::sleep(Duration::from_millis(300)); + let _proxy = Proxy::start(&proxy.socket); + + let (result, retries) = worker.join().unwrap(); + let done = result.expect("the retry should have succeeded"); + assert!(retries >= 1, "the request was retried"); + assert!( + done.content + .is_some_and(|c| c.split_whitespace().count() > 100) + ); +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn a_second_turn_reuses_the_first_turns_cache() { + let socket = socket_path("cache"); + let _proxy = Proxy::start(&socket); + let client = Client::new(config(&socket)); + let mut messages = vec![user( + "What is 17 * 23? Think briefly, then answer in one short sentence.", + )]; + let req = ChatRequest { + slot: 0, + messages: messages.clone(), + tools: vec![], + thinking: true, + }; + let turn1 = client.chat_with_retry(&req, &mut |_| {}).unwrap(); + assert!( + turn1.reasoning_content.is_some(), + "this check is about replaying a thinking block" + ); + messages.push(ChatMessage::Assistant { + content: turn1.content.clone(), + reasoning_content: turn1.reasoning_content.clone(), + tool_calls: turn1.tool_calls.clone(), + }); + messages.push(user("And 17 * 24?")); + let req = ChatRequest { + slot: 0, + messages, + tools: vec![], + thinking: true, + }; + let turn2 = client.chat_with_retry(&req, &mut |_| {}).unwrap(); + assert_eq!( + cache_outcome(&turn1.timings, &turn2.timings), + CacheOutcome::Hit, + "{:?} then {:?}", + turn1.timings, + turn2.timings + ); +} + +// ---- M2b: the agent loop on the real server ---- + +/// A `loopd serve` on a private home, killed on drop. +struct Served { + child: Child, + home: PathBuf, + socket: PathBuf, +} + +fn config_text(infer: &Path, home: &Path) -> String { + let model = + std::env::var("BOXMAKER_MODEL").unwrap_or_else(|_| "ornith-1.5-35b-a3b".to_string()); + format!( + r#" +[infer] +socket = "{}" +model = "{model}" +[slots] +main = 0 +background = 1 +[expect] +template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" +n_ctx = 131072 +slots = 2 +[paths] +home = "{}" +"#, + infer.display(), + home.display() + ) +} + +impl Served { + /// Writes the config and the repository's `system.md` into `home`, and starts `loopd serve`. + fn start(infer: &Path, home: &Path) -> Served { + std::fs::create_dir_all(home).unwrap(); + let config = home.join("config.toml"); + std::fs::write(&config, config_text(infer, home)).unwrap(); + let prompt = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/system.md"); + std::fs::copy(&prompt, home.join("system.md")) + .expect("config/system.md exists in the repository"); + let socket = home.join("run").join("loop").join("loop.sock"); + let _ = std::fs::remove_file(&socket); + let child = Command::new(env!("CARGO_BIN_EXE_loopd")) + .arg("serve") + .arg("--config") + .arg(&config) + .spawn() + .expect("cannot start loopd"); + let mut served = Served { + child, + home: home.to_path_buf(), + socket, + }; + for _ in 0..600 { + if served.socket.exists() { + return served; + } + thread::sleep(Duration::from_millis(100)); + } + served.kill(); + panic!("loopd did not come up within 60 s"); + } + + fn kill(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + /// One `bxctl chat --say` turn. Returns the answer. + fn say(&self, session: &str, text: &str) -> String { + let bxctl = std::env::var("BOXMAKER_BXCTL").expect("BOXMAKER_BXCTL is not set"); + let output = Command::new(bxctl) + .arg("chat") + .arg("--socket") + .arg(&self.socket) + .args(["--session", session, "--no-thinking", "--say", text]) + .output() + .expect("cannot run bxctl"); + assert!( + output.status.success(), + "bxctl failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string() + } + + fn records(&self, session: &str) -> Vec { + let text = + std::fs::read_to_string(self.home.join("sessions").join(session).join("0.jsonl")) + .unwrap(); + text.lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } +} + +impl Drop for Served { + fn drop(&mut self) { + self.kill(); + } +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn the_baseline_fits_the_token_budget() { + let socket = socket_path("budget"); + let _proxy = Proxy::start(&socket); + let home = socket.parent().unwrap().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let mut cfg = config(&socket); + cfg.paths.home = home.clone(); + cfg.baseline.system = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/system.md"); + let baseline = + loopd::baseline::Baseline::assemble(&cfg, &loopd::tools::Registry::m3a()).unwrap(); + let client = Client::new(cfg); + // The system text plus every tool schema as the request carries it. + let mut text = baseline.system.clone(); + for tool in &baseline.tools { + text.push('\n'); + text.push_str(&serde_json::to_string(&tool).unwrap()); + } + let tokens = client.tokenize(&text).unwrap(); + eprintln!("baseline: {tokens} tokens"); + assert!( + tokens <= 3000, + "the baseline is {tokens} tokens; the brief allows 3000" + ); +} + +#[test] +#[ignore = "needs the real server; run with make verify-device"] +fn a_conversation_uses_tools_survives_a_restart_and_keeps_its_cache() { + let socket = socket_path("loop"); + let _proxy = Proxy::start(&socket); + let home = socket.parent().unwrap().join("home"); + let session = format!("device-{}", std::process::id()); + let mut served = Served::start(&socket, &home); + + let a1 = served.say(&session, "Reply with exactly: box made."); + assert!(a1.to_lowercase().contains("box made"), "{a1}"); + let a2 = served.say( + &session, + "What is the current time? Use your clock tool, then tell me the year.", + ); + assert!(a2.contains("2026") || a2.contains("202"), "{a2}"); + // No broker is configured here, so the call fails in plain words and the turn goes on. What + // is checked is the path: find_tool, then call_tool, then an answer. + let a3 = served.say( + &session, + "Find a tool that reads files, use it to read /etc/hostname, and tell me in one \ + sentence what happened.", + ); + assert!(!a3.trim().is_empty(), "the turn must end in an answer"); + + served.kill(); + served = Served::start(&socket, &home); + let a4 = served.say( + &session, + "What were the exact words I first asked you to reply with?", + ); + assert!( + a4.to_lowercase().contains("box made"), + "after a restart the session must still know: {a4}" + ); + + let records = served.records(&session); + let tool_names: Vec = records + .iter() + .filter_map(|r| match r { + proto::LogRecord::Assistant { tool_calls, .. } => Some( + tool_calls + .iter() + .map(|c| c.name.clone()) + .collect::>(), + ), + _ => None, + }) + .flatten() + .collect(); + assert!(tool_names.contains(&"clock".to_string()), "{tool_names:?}"); + assert!( + tool_names.contains(&"find_tool".to_string()) + && tool_names.contains(&"call_tool".to_string()), + "{tool_names:?}" + ); + let usages = records + .iter() + .filter(|r| matches!(r, proto::LogRecord::Usage { .. })) + .count(); + let assistants = records + .iter() + .filter(|r| matches!(r, proto::LogRecord::Assistant { .. })) + .count(); + assert_eq!(usages, assistants, "one usage record per completion"); + let losses: Vec<&proto::LogRecord> = records + .iter() + .filter(|r| matches!(r, proto::LogRecord::CacheLoss { .. })) + .collect(); + assert!( + losses.is_empty(), + "every request hit the cache, including the one after the restart: {losses:?}" + ); + let results = records + .iter() + .filter(|r| matches!(r, proto::LogRecord::ToolResult { .. })) + .count(); + assert!( + results >= 3, + "clock, find_tool and call_tool each left a result: {results}" + ); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/end_to_end.rs b/docs/plans/M3a/files/crates/loopd/tests/end_to_end.rs new file mode 100644 index 0000000..5647d34 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/end_to_end.rs @@ -0,0 +1,168 @@ +//! End to end in two processes: `loopd`'s turn loop, with a `BrokerPort`, against the real +//! `brokerd` binary on a temporary home, and the fake llama server. Do not edit. +//! +//! It cannot be one process: `loopd` may not depend on `brokerd`, even for tests. The path of +//! the `brokerd` binary comes from `BOXMAKER_BROKERD`; without it the test is ignored, and +//! `make gate` builds the workspace and runs it with the variable set. + +mod support; + +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use loopd::baseline::Baseline; +use loopd::broker_port::BrokerPort; +use loopd::llama::Client; +use loopd::session::Session; +use loopd::tools::Registry; +use loopd::turn::{Runtime, run_turn}; +use proto::{ + AuditEvent, AuditRecord, ChainVerifier, DecisionRecord, DenyReason, SessionId, TurnEvent, +}; +use support::{FakeServer, Home, Reply}; + +const CHAT: &str = "/v1/chat/completions"; + +/// `brokerd serve`, killed when dropped. +struct Brokerd(Child); + +impl Drop for Brokerd { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn start_brokerd(home: &Path) -> (Brokerd, PathBuf) { + let binary = std::env::var_os("BOXMAKER_BROKERD") + .expect("set BOXMAKER_BROKERD to the brokerd binary; `make gate` does"); + std::fs::create_dir_all(home.join("grants")).unwrap(); + let config = home.join("brokerd.toml"); + let text = format!( + "[paths]\nhome = \"{h}\"\ngrants = \"{h}/grants\"\n", + h = home.display() + ); + std::fs::write(&config, text).unwrap(); + let child = Command::new(binary) + .args(["serve", "--config"]) + .arg(&config) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let brokerd = Brokerd(child); + let socket = home.join("run/loop-broker/broker.sock"); + let until = Instant::now() + Duration::from_secs(10); + while UnixStream::connect(&socket).is_err() { + assert!( + Instant::now() < until, + "brokerd never listened on {}", + socket.display() + ); + std::thread::sleep(Duration::from_millis(20)); + } + (brokerd, socket) +} + +/// Every record under `audit/`, after checking that the chain verifies. +fn audit(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.ends_with(".jsonl")) + .collect(); + names.sort(); + let mut verifier = ChainVerifier::new(); + let mut records = Vec::new(); + for name in &names { + let bytes = std::fs::read(dir.join(name)).unwrap(); + verifier.feed(name, &bytes); + for line in String::from_utf8(bytes).unwrap().lines() { + records.push(serde_json::from_str(line).unwrap()); + } + } + let report = verifier.finish(); + assert!(report.failure.is_none(), "{:?}", report.failure); + assert!(report.torn_tail.is_none()); + assert_eq!(report.records, records.len() as u64); + records +} + +#[test] +#[ignore = "needs the brokerd binary in BOXMAKER_BROKERD; `make gate` runs it"] +fn a_call_no_grant_allows_reaches_the_model_as_a_denial_and_is_audited() { + let home = Home::new(); + let broker_home = home.dir.join("broker-home"); + let (_brokerd, socket) = start_brokerd(&broker_home); + + let server = FakeServer::start(); + // The recorded model calls `read_file` on /etc/hostname, then answers in plain text. + server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let cfg = home.config(&server.socket); + let client = Client::new(cfg.clone()); + let port = BrokerPort::new(socket, Duration::from_secs(10)); + let registry = Registry::m2b(); + let baseline = Baseline::assemble(&cfg, ®istry).unwrap(); + let id = SessionId::new("e2e").unwrap(); + let mut session = Session::create(&home.dir, id, baseline, cfg.slots.main).unwrap(); + let runtime = Runtime { + cfg: &cfg, + client: &client, + port: &port, + registry: ®istry, + }; + let mut events = Vec::new(); + let outcome = run_turn( + &mut session, + &runtime, + "what is this host called?", + &mut |e| events.push(e.clone()), + ); + assert!( + outcome.is_ok(), + "the turn goes on after a denial: {outcome:?}" + ); + assert!( + events.contains(&TurnEvent::ToolDenied { + name: "read_file".to_string(), + reason: DenyReason::NoGrant, + }), + "{events:?}" + ); + // The model reads the denial in its next request. + let second = server.requests_to(CHAT)[1].json(); + assert_eq!( + second["messages"][3]["content"], + "Denied: no grant allows this call." + ); + + let records = audit(&broker_home.join("audit")); + assert_eq!(records.len(), 1, "{records:?}"); + match &records[0].event { + AuditEvent::Decision { + session, + tool, + arguments, + outcome, + grant, + .. + } => { + assert_eq!(session.as_str(), "e2e"); + assert_eq!(tool, "read_file"); + assert_eq!(arguments, r#"{"path":"/etc/hostname"}"#); + assert_eq!( + *outcome, + DecisionRecord::Denied { + reason: DenyReason::NoGrant + } + ); + assert_eq!(*grant, None); + } + other => panic!("{other:?}"), + } +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/pointers.rs b/docs/plans/M3a/files/crates/loopd/tests/pointers.rs new file mode 100644 index 0000000..e83763d --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/pointers.rs @@ -0,0 +1,240 @@ +//! Every fail-closed message `loopd` produces ends with the runbook entry that explains it. +//! `scripts/check-runbook.sh` checks that the entries exist; these tests check that the messages +//! name them. Do not edit. + +mod support; + +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use loopd::baseline::Baseline; +use loopd::broker_port::{not_configured_line, unavailable_line}; +use loopd::session::{Session, SessionError}; +use loopd::tools::Registry; +use proto::SessionId; +use support::{FakeServer, Home, Reply}; + +// `want` is the whole pointer, written out: scripts/check-runbook.sh reads the anchors of every +// pointer in the source, and cannot read one built with `format!`. +fn ends_with_pointer(message: &str, want: &str) { + assert!( + message.ends_with(want), + "{message:?} must end with {want:?}" + ); + assert_eq!( + message.matches("runbook.md#").count(), + 1, + "one pointer, not two: {message:?}" + ); +} + +#[test] +fn a_damaged_session_log_names_its_entry() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + let id = SessionId::new("a").unwrap(); + let baseline = Baseline::assemble(&cfg, &Registry::m3a()).unwrap(); + drop(Session::create(&home.dir, id.clone(), baseline, 0).unwrap()); + let log = home.dir.join("sessions/a/0.jsonl"); + let mut text = std::fs::read_to_string(&log).unwrap(); + text.push_str("{\"type\":\"user\",\"time\":"); + std::fs::write(&log, text).unwrap(); + match Session::open(&home.dir, id) { + Err(e @ SessionError::Torn { .. }) => { + let message = e.to_string(); + assert!(message.contains("0.jsonl:2: "), "{message}"); + ends_with_pointer(&message, "see docs/runbook.md#session-log-damaged"); + } + Err(other) => panic!("{other:?}"), + Ok(_) => panic!("a torn log was opened"), + } +} + +#[test] +fn only_the_torn_error_points_at_the_damaged_log_entry() { + let home = Home::new(); + let missing = Session::open(&home.dir, SessionId::new("nobody").unwrap()); + let message = match missing { + Err(e) => e.to_string(), + Ok(_) => panic!("a session nobody created was opened"), + }; + assert!(!message.contains("runbook"), "{message}"); +} + +#[test] +fn an_unreadable_core_memory_names_its_entry_and_a_missing_system_prompt_does_not() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + home.write("memory/core.md", "memory\n"); + let core = home.dir.join("memory/core.md"); + if !running_as_root() { + std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap(); + let result = Baseline::assemble(&cfg, &Registry::m3a()); + std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap(); + let message = result.expect_err("unreadable core.md").to_string(); + assert!(message.contains("core.md"), "{message}"); + ends_with_pointer(&message, "see docs/runbook.md#core-memory-unreadable"); + } + // The system prompt is a different file with a different remedy: no pointer to this entry. + std::fs::remove_file(home.dir.join("system.md")).unwrap(); + let message = Baseline::assemble(&cfg, &Registry::m3a()) + .expect_err("no system.md") + .to_string(); + assert!(!message.contains("core-memory-unreadable"), "{message}"); +} + +fn running_as_root() -> bool { + std::fs::read_to_string("/proc/self/status") + .map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t"))) + .unwrap_or(false) +} + +#[test] +fn the_broker_lines_name_their_entry() { + let line = unavailable_line("cannot connect to /run/x.sock: No such file"); + assert_eq!( + line, + "loopd: the tool broker is unavailable: cannot connect to /run/x.sock: No such file; \ + see docs/runbook.md#broker-unavailable" + ); + ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable"); + let line = not_configured_line(); + assert!( + line.starts_with("loopd: no tool broker is configured"), + "{line}" + ); + ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable"); +} + +fn config_file( + home: &Home, + server: &FakeServer, + expect_slots: u32, + extra: &str, +) -> std::path::PathBuf { + let text = format!( + r#" +[infer] +socket = "{}" +model = "test-model" +[slots] +main = 0 +background = 1 +[expect] +template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" +n_ctx = 131072 +slots = {expect_slots} +[limits] +poll_ms = 40 +liveness_ms = 500 +retry_backoff_ms = [10] +[paths] +home = "{}" +{extra} +"#, + server.socket.display(), + home.dir.display() + ); + let path = home.dir.join("config.toml"); + std::fs::write(&path, text).unwrap(); + path +} + +fn healthy_routes(server: &FakeServer) { + server.route("/props", vec![Reply::fixture("props")]); + server.route( + "/v1/chat/completions", + vec![ + Reply::fixture("tool_call"), + Reply::fixture("turn1"), + Reply::fixture("turn2"), + Reply::fixture("plain"), + ], + ); +} + +/// Waits for `loopd serve` to bind its socket, stops it, and returns what it printed. +fn stderr_once_serving(mut child: Child, socket: &Path) -> String { + let mut up = false; + for _ in 0..200 { + if socket.exists() { + up = true; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + assert!(up, "the socket never appeared; stderr: {stderr}"); + stderr +} + +fn serve(config: &Path) -> Child { + Command::new(env!("CARGO_BIN_EXE_loopd")) + .args(["serve", "--config"]) + .arg(config) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap() +} + +#[test] +fn a_failed_self_test_names_its_entry() { + let home = Home::new(); + let server = FakeServer::start(); + healthy_routes(&server); + // The server has two slots; the config expects three. + let config = config_file(&home, &server, 3, ""); + for command in ["selftest", "serve"] { + let output = Command::new(env!("CARGO_BIN_EXE_loopd")) + .args([command, "--config"]) + .arg(&config) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(1), "{command}: {stderr}"); + let line = stderr + .lines() + .find(|l| l.starts_with("selftest: FAILED: ")) + .unwrap_or_else(|| panic!("{command}: no FAILED line: {stderr}")); + ends_with_pointer(line, "see docs/runbook.md#loopd-selftest-failed"); + } +} + +#[test] +fn serve_without_a_broker_says_so_once_with_the_entry() { + let home = Home::new(); + let server = FakeServer::start(); + healthy_routes(&server); + let config = config_file(&home, &server, 2, ""); + let socket = home.dir.join("run/loop/loop.sock"); + let stderr = stderr_once_serving(serve(&config), &socket); + let lines: Vec<&str> = stderr + .lines() + .filter(|l| l.contains("no tool broker is configured")) + .collect(); + assert_eq!(lines.len(), 1, "once, at startup: {stderr}"); + ends_with_pointer(lines[0], "see docs/runbook.md#broker-unavailable"); +} + +#[test] +fn serve_with_a_broker_socket_does_not_say_it() { + let home = Home::new(); + let server = FakeServer::start(); + healthy_routes(&server); + // The socket need not exist: loopd connects per call, and a missing broker is not a reason + // to refuse to start. + let extra = format!( + "[broker]\nsocket = \"{}\"\n", + home.dir.join("run/loop-broker/broker.sock").display() + ); + let config = config_file(&home, &server, 2, &extra); + let socket = home.dir.join("run/loop/loop.sock"); + let stderr = stderr_once_serving(serve(&config), &socket); + assert!(!stderr.contains("no tool broker"), "{stderr}"); + assert!(stderr.contains("serving on"), "{stderr}"); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/support/broker.rs b/docs/plans/M3a/files/crates/loopd/tests/support/broker.rs new file mode 100644 index 0000000..c48ec00 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/support/broker.rs @@ -0,0 +1,136 @@ +//! A fake `brokerd` for the `BrokerPort` tests. Included with `#[path]`. Do not edit. +//! +//! The fake behaves as the real one will: it reads one request frame, answers on the same +//! connection, never half-closes, and closes after the final frame. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use loopd::broker_port::{BrokerPort, UNAVAILABLE}; +use loopd::tools::{Pending, ToolPort}; +use proto::{ + CallId, DataClass, Envelope, Message, PROTOCOL_VERSION, SessionId, Timestamp, ToolRequest, + ToolResponse, read_frame, write_frame, +}; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub fn socket_path() -> PathBuf { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!("loopd-broker-{}-{n}.sock", std::process::id())) +} + +/// Accepts one connection, reads the request frame and hands both to `script`. The connection +/// closes when `script` returns. +pub fn broker(script: F) -> (PathBuf, JoinHandle) +where + F: FnOnce(&mut UnixStream, &Envelope) + Send + 'static, +{ + let path = socket_path(); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_frame(&mut stream).unwrap(); + script(&mut stream, &request); + request + }); + (path, handle) +} + +pub fn frame(id: u64, r#final: bool, msg: Message) -> Envelope { + Envelope { + v: PROTOCOL_VERSION, + id, + r#final, + msg, + } +} + +pub fn send(stream: &mut UnixStream, id: u64, r#final: bool, response: ToolResponse) { + // The port may already have given up and gone; the fake does not care. + let _ = write_frame(stream, &frame(id, r#final, Message::ToolResponse(response))); +} + +pub fn result(content: &str) -> ToolResponse { + ToolResponse::Result { + content: content.to_string(), + class: DataClass::Secret, + untrusted: true, + truncated: false, + } +} + +pub fn request() -> ToolRequest { + ToolRequest { + session: SessionId::new("chat-1").unwrap(), + call: CallId(7), + tool: "read_file".to_string(), + arguments: r#"{"path":"/home/kyle/notes/a.md"}"#.to_string(), + } +} + +pub fn in_ms(ms: u64) -> Timestamp { + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + ms).unwrap() +} + +pub struct Call { + pub response: ToolResponse, + pub pending: Vec, + pub lines: Vec, + pub took: Duration, +} + +pub fn call(socket: PathBuf, timeout_ms: u64, request: &ToolRequest) -> Call { + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = lines.clone(); + let port = BrokerPort::with_log( + socket, + Duration::from_millis(timeout_ms), + Box::new(move |line| sink.lock().unwrap().push(line.to_string())), + ); + let mut pending = Vec::new(); + let started = Instant::now(); + let response = port.call(request, &mut |p| pending.push(*p)); + let took = started.elapsed(); + let lines = lines.lock().unwrap().clone(); + Call { + response, + pending, + lines, + took, + } +} + +/// The answer is the fixed failure, and `loopd` printed exactly one line, with the pointer. +pub fn assert_unavailable(call: &Call, why: &str) { + assert_eq!( + call.response, + ToolResponse::Failed { + message: UNAVAILABLE.to_string() + }, + "{why}" + ); + assert_eq!( + call.lines.len(), + 1, + "{why}: one line per failed call: {:?}", + call.lines + ); + let line = &call.lines[0]; + assert!( + line.starts_with("loopd: the tool broker is unavailable: "), + "{why}: {line}" + ); + assert!( + line.ends_with("; see docs/runbook.md#broker-unavailable"), + "{why}: {line}" + ); + assert!(call.pending.is_empty() || why.contains("pending"), "{why}"); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/support/mod.rs b/docs/plans/M3a/files/crates/loopd/tests/support/mod.rs new file mode 100644 index 0000000..5a62324 --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/support/mod.rs @@ -0,0 +1,403 @@ +//! A scripted stand-in for `llama-server`, for tests. Do not edit. +//! +//! It listens on a Unix socket in a temporary directory. Each path has a list of replies that are +//! served in order; the last one repeats. A reply is raw bytes, normally a response recorded from +//! the real server (`tests/fixtures/http/*.http`), and can be delayed, sent in small pieces, cut +//! short, or left hanging. Every request is recorded. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::collections::VecDeque; +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub fn fixture_path(kind: &str, name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(kind) + .join(name) +} + +pub fn fixture_bytes(kind: &str, name: &str) -> Vec { + let path = fixture_path(kind, name); + std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +/// A config that points at `socket`, with every limit short enough for a test. +pub fn test_config(socket: &Path) -> loopd::config::Config { + let text = format!( + r#" +[infer] +socket = "{}" +model = "test-model" +[slots] +main = 0 +background = 1 +[expect] +template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" +n_ctx = 131072 +slots = 2 +[limits] +poll_ms = 40 +busy_wait_ms = 400 +load_wait_ms = 300 +idle_grace_ms = 200 +liveness_ms = 150 +retry_backoff_ms = [10, 20, 30] +retry_window_ms = 5000 +"#, + socket.display() + ); + loopd::config::Config::parse(&text).unwrap() +} + +/// A temporary `BOXMAKER_HOME` with a `system.md` beside a `config.toml`, for session tests. +pub struct Home { + pub dir: PathBuf, +} + +impl Home { + pub fn new() -> Home { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("loopd-home-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("system.md"), "You are Boxmaker, a test agent.\n").unwrap(); + Home { dir } + } + + /// A config for `socket` whose home, system prompt and channel socket are all under this + /// directory, with the fast test limits. + pub fn config(&self, socket: &Path) -> loopd::config::Config { + let mut cfg = test_config(socket); + cfg.paths.home = self.dir.clone(); + cfg.baseline.system = self.dir.join("system.md"); + cfg.channel.socket = self.dir.join("loop.sock"); + cfg + } + + pub fn write(&self, relative: &str, text: &str) { + let path = self.dir.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + pub fn read(&self, relative: &str) -> String { + std::fs::read_to_string(self.dir.join(relative)).unwrap() + } + + /// The records of a session's log, epoch 0. + pub fn records(&self, session: &str) -> Vec { + let text = self.read(&format!("sessions/{session}/0.jsonl")); + text.lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } +} + +/// A tool port that answers from a script and records what it was asked. +pub struct ScriptedPort { + replies: Mutex>, + calls: Mutex>, +} + +impl ScriptedPort { + /// Replies are given in order; when they run out, every call gets `fallback`. + pub fn new(replies: Vec) -> ScriptedPort { + ScriptedPort { + replies: Mutex::new(replies.into()), + calls: Mutex::new(Vec::new()), + } + } + + pub fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +pub fn ok_result(content: &str) -> proto::ToolResponse { + proto::ToolResponse::Result { + content: content.to_string(), + class: proto::DataClass::Private, + untrusted: true, + truncated: false, + } +} + +pub fn pending(approval: u64, expires: &str) -> proto::ToolResponse { + proto::ToolResponse::PendingApproval { + approval, + expires: proto::Timestamp::parse(expires).unwrap(), + } +} + +impl loopd::tools::ToolPort for ScriptedPort { + /// A scripted `PendingApproval` is reported through `on_pending`, as the real port does with + /// the broker's pending frame, and the reply after it is the answer. Use + /// `ReturnsPendingPort` for a port that breaks the rule and returns one. + fn call( + &self, + request: &proto::ToolRequest, + on_pending: &mut dyn FnMut(&loopd::tools::Pending), + ) -> proto::ToolResponse { + self.calls.lock().unwrap().push(request.clone()); + let mut replies = self.replies.lock().unwrap(); + let mut reply = replies.pop_front(); + if let Some(proto::ToolResponse::PendingApproval { approval, expires }) = reply { + on_pending(&loopd::tools::Pending { approval, expires }); + reply = replies.pop_front(); + } + reply.unwrap_or_else(|| ok_result("scripted")) + } +} + +/// A port that breaks the rule: it returns a pending frame as its final answer. +pub struct ReturnsPendingPort; + +impl loopd::tools::ToolPort for ReturnsPendingPort { + fn call( + &self, + _request: &proto::ToolRequest, + _on_pending: &mut dyn FnMut(&loopd::tools::Pending), + ) -> proto::ToolResponse { + pending(1, "2026-09-18T12:00:00.000Z") + } +} + +/// What an independent script derived from a recorded stream: `tests/fixtures/expected/`. +pub fn expected(name: &str) -> serde_json::Value { + serde_json::from_slice(&fixture_bytes("expected", &format!("{name}.json"))).unwrap() +} + +#[derive(Clone)] +pub struct Reply { + bytes: Vec, + head_delay_ms: u64, + piece: usize, + piece_delay_ms: u64, + stop_after: Option, + hang_ms: u64, +} + +impl Reply { + /// Exactly these bytes, then close. + pub fn raw(bytes: impl Into>) -> Reply { + Reply { + bytes: bytes.into(), + head_delay_ms: 0, + piece: usize::MAX, + piece_delay_ms: 0, + stop_after: None, + hang_ms: 0, + } + } + + /// A response recorded from the real server: `tests/fixtures/http/.http`. + pub fn fixture(name: &str) -> Reply { + Reply::raw(fixture_bytes("http", &format!("{name}.http"))) + } + + /// A small JSON response with a content length. + pub fn json(status: u16, body: &str) -> Reply { + Reply::raw(format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + )) + } + + /// Wait this long before sending the first byte, as a queued request does. + pub fn head_delay(mut self, ms: u64) -> Reply { + self.head_delay_ms = ms; + self + } + + /// Send `piece` bytes at a time, waiting `delay_ms` before each piece after the first. + pub fn trickle(mut self, piece: usize, delay_ms: u64) -> Reply { + self.piece = piece.max(1); + self.piece_delay_ms = delay_ms; + self + } + + /// Send only the first `bytes` bytes, then close, as a server that dies does. + pub fn cut_after(mut self, bytes: usize) -> Reply { + self.stop_after = Some(bytes); + self + } + + /// Send only the first `bytes` bytes, then stay silent for `ms` before closing. + pub fn hang_after(mut self, bytes: usize, ms: u64) -> Reply { + self.stop_after = Some(bytes); + self.hang_ms = ms; + self + } + + /// The offset just after the `n`th `data:` line of the body, for use with `cut_after`. + pub fn offset_after_events(&self, n: usize) -> usize { + let mut seen = 0; + let mut at = 0; + while let Some(found) = find(&self.bytes[at..], b"\n\n") { + at += found + 2; + seen += 1; + if seen == n { + return at; + } + } + panic!("the reply has only {seen} events"); + } +} + +#[derive(Debug, Clone)] +pub struct Recorded { + pub method: String, + /// Path and query as sent. + pub target: String, + /// Header lines as sent, without the request line. + pub headers: Vec, + pub body: Vec, +} + +impl Recorded { + pub fn path(&self) -> &str { + self.target.split('?').next().unwrap_or("") + } + + pub fn json(&self) -> serde_json::Value { + serde_json::from_slice(&self.body).expect("request body is JSON") + } +} + +struct State { + routes: Mutex)>>, + requests: Mutex>, +} + +pub struct FakeServer { + pub socket: PathBuf, + state: Arc, +} + +impl FakeServer { + pub fn start() -> FakeServer { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("loopd-fake-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("infer.sock"); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let state = Arc::new(State { + routes: Mutex::new(Vec::new()), + requests: Mutex::new(Vec::new()), + }); + let accept_state = Arc::clone(&state); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let state = Arc::clone(&accept_state); + thread::spawn(move || serve(stream, &state)); + } + }); + FakeServer { socket, state } + } + + /// Replies for `path` (the query is ignored), served in order. The last one repeats. + pub fn route(&self, path: &str, replies: Vec) { + assert!(!replies.is_empty()); + let mut routes = self.state.routes.lock().unwrap(); + routes.retain(|(p, _)| p != path); + routes.push((path.to_string(), replies.into())); + } + + pub fn requests(&self) -> Vec { + self.state.requests.lock().unwrap().clone() + } + + pub fn requests_to(&self, path: &str) -> Vec { + self.requests() + .into_iter() + .filter(|r| r.path() == path) + .collect() + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn read_request(stream: &mut UnixStream) -> Option { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let head_end = loop { + if let Some(end) = find(&buf, b"\r\n\r\n") { + break end; + } + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return None, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let mut lines = head.split("\r\n"); + let mut request_line = lines.next()?.split(' '); + let method = request_line.next()?.to_string(); + let target = request_line.next()?.to_string(); + let headers: Vec = lines.map(str::to_string).collect(); + let length = headers + .iter() + .filter_map(|h| h.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.trim().parse::().ok()) + .unwrap_or(0); + let mut body = buf[head_end + 4..].to_vec(); + while body.len() < length { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return None, + Ok(n) => body.extend_from_slice(&chunk[..n]), + } + } + Some(Recorded { + method, + target, + headers, + body, + }) +} + +fn serve(mut stream: UnixStream, state: &State) { + let Some(request) = read_request(&mut stream) else { + return; + }; + let path = request.path().to_string(); + state.requests.lock().unwrap().push(request); + let reply = { + let mut routes = state.routes.lock().unwrap(); + match routes.iter_mut().find(|(p, _)| *p == path) { + Some((_, replies)) if replies.len() > 1 => replies.pop_front(), + Some((_, replies)) => replies.front().cloned(), + None => None, + } + }; + let reply = + reply.unwrap_or_else(|| Reply::json(404, r#"{"error":"no route in the fake server"}"#)); + thread::sleep(Duration::from_millis(reply.head_delay_ms)); + let end = reply + .stop_after + .unwrap_or(reply.bytes.len()) + .min(reply.bytes.len()); + for (i, piece) in reply.bytes[..end].chunks(reply.piece).enumerate() { + if i > 0 { + thread::sleep(Duration::from_millis(reply.piece_delay_ms)); + } + if stream.write_all(piece).is_err() { + return; // the client went away, which some tests do on purpose + } + let _ = stream.flush(); + } + thread::sleep(Duration::from_millis(reply.hang_ms)); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/tools.rs b/docs/plans/M3a/files/crates/loopd/tests/tools.rs new file mode 100644 index 0000000..1e450ac --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/tools.rs @@ -0,0 +1,307 @@ +//! Tests for the registry, dispatch, the denial sentences, the result cap and the fake tools. +//! Do not edit. + +mod support; + +use loopd::tools::{ + Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch, +}; +use proto::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse}; + +fn req(tool: &str, arguments: &str) -> ToolRequest { + ToolRequest { + session: SessionId::new("s").unwrap(), + call: CallId(1), + tool: tool.to_string(), + arguments: arguments.to_string(), + } +} + +#[test] +fn the_core_schemas_are_the_fixed_tools_array() { + let names: Vec = Registry::m2b() + .core_schemas() + .into_iter() + .map(|s| s.name) + .collect(); + assert_eq!(names, ["clock", "find_tool", "call_tool"]); + let find = &Registry::m2b().core_schemas()[1]; + assert_eq!(find.parameters["required"], serde_json::json!(["query"])); + let call = &Registry::m2b().core_schemas()[2]; + assert_eq!( + call.parameters["required"], + serde_json::json!(["name", "arguments"]) + ); +} + +#[test] +fn find_matches_name_or_description_case_insensitively() { + let r = Registry::m2b(); + let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::>(); + assert_eq!(names("echo"), ["echo"]); + assert_eq!(names("ECHO"), ["echo"]); + assert_eq!(names("unchanged"), ["echo"], "matches the description too"); + assert_eq!(names("time"), ["clock"]); + assert!(names("weather").is_empty()); + assert!( + names("").is_empty(), + "an empty query matches nothing, not everything" + ); + assert!(names(" ").is_empty()); +} + +#[test] +fn find_tool_is_answered_locally() { + let r = Registry::m2b(); + match dispatch(&r, "find_tool", r#"{"query":"echo"}"#) { + Dispatch::Local(text) => { + assert!(text.starts_with("1 tool(s) match:\n"), "{text}"); + assert!(text.contains("\"name\":\"echo\""), "{text}"); + assert!( + text.contains("\"parameters\""), + "the schema is included: {text}" + ); + assert!(text.ends_with("Call it with call_tool."), "{text}"); + } + other => panic!("{other:?}"), + } + assert_eq!( + dispatch(&r, "find_tool", r#"{"query":"weather"}"#), + Dispatch::Local("No tool matches.".to_string()) + ); + assert!( + matches!(dispatch(&r, "find_tool", "not json"), Dispatch::Local(t) if t == "No tool matches.") + ); +} + +#[test] +fn call_tool_unwraps_a_known_non_core_tool_and_nothing_else() { + let r = Registry::m2b(); + let args = r#"{"name":"echo","arguments":{"text": "box"}}"#; + match dispatch(&r, "call_tool", args) { + Dispatch::Port { tool, arguments } => { + assert_eq!(tool, "echo"); + assert_eq!( + serde_json::from_str::(&arguments).unwrap(), + serde_json::json!({"text": "box"}) + ); + } + other => panic!("{other:?}"), + } + let cases = [ + ( + r#"{"name":"weather","arguments":{}}"#, + "No tool named \"weather\"", + ), + (r#"{"name":"clock","arguments":{}}"#, "core tool"), + (r#"{"arguments":{}}"#, "No tool named \"\""), + ("not json", "needs a JSON object"), + ]; + for (args, want) in cases { + match dispatch(&r, "call_tool", args) { + Dispatch::Local(text) => assert!(text.contains(want), "{args}: {text}"), + other => panic!("{args}: {other:?}, must never reach the port"), + } + } +} + +#[test] +fn any_other_tool_goes_to_the_port_as_it_is() { + let r = Registry::m2b(); + // Even one the registry does not know: the port (brokerd) decides, not loopd. + let want = Dispatch::Port { + tool: "read_file".to_string(), + arguments: r#"{"path":"/x"}"#.to_string(), + }; + assert_eq!(dispatch(&r, "read_file", r#"{"path":"/x"}"#), want); + let want = Dispatch::Port { + tool: "weather".to_string(), + arguments: "not json".to_string(), + }; + assert_eq!(dispatch(&r, "weather", "not json"), want); +} + +#[test] +fn the_clock_is_answered_locally_whatever_its_arguments() { + for registry in [Registry::m2b(), Registry::m3a(), Registry::new(vec![])] { + for arguments in ["{}", r#"{"zone":"UTC"}"#, "not json", ""] { + let before = proto::Timestamp::now(); + match dispatch(®istry, "clock", arguments) { + Dispatch::Local(text) => { + let time = proto::Timestamp::parse(&text) + .unwrap_or_else(|e| panic!("an RFC 3339 time, got {text:?}: {e:?}")); + assert!(time >= before, "{text}"); + assert!(text.ends_with('Z'), "UTC: {text}"); + } + other => panic!("{arguments:?}: {other:?}, the clock must not reach the port"), + } + } + } +} + +#[test] +fn the_m3a_registry_has_the_same_core_and_the_four_broker_tools() { + let r = Registry::m3a(); + assert_eq!( + r.core_schemas(), + Registry::m2b().core_schemas(), + "the tools array is part of the baseline: it must not change" + ); + let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::>(); + assert_eq!(names("file"), ["read_file", "write_file"]); + assert_eq!(names("shell"), ["shell"]); + assert_eq!(names("fetch"), ["http_fetch"]); + assert_eq!(names("https"), ["http_fetch"]); + assert!(names("echo").is_empty(), "echo is a test tool only"); + + // The argument schemas are section 3's table: exactly these properties, all strings. + let table: [(&str, &[&str], &[&str]); 4] = [ + ("read_file", &["path"], &["path"]), + ("write_file", &["content", "path"], &["path", "content"]), + ("shell", &["command", "cwd"], &["command"]), + ("http_fetch", &["url"], &["url"]), + ]; + for (name, properties, required) in table { + let entry = r.get(name).unwrap_or_else(|| panic!("{name} is missing")); + assert!(!entry.core, "{name} is found with find_tool, not declared"); + let p = &entry.schema.parameters; + assert_eq!(p["type"], "object", "{name}"); + let mut got: Vec<&str> = p["properties"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + got.sort_unstable(); + assert_eq!(got, properties, "{name}: properties"); + for property in properties { + assert_eq!( + p["properties"][property]["type"], "string", + "{name}.{property}" + ); + assert!( + p["properties"][property]["description"].is_string(), + "{name}.{property} needs a description" + ); + } + assert_eq!( + p["required"], + serde_json::json!(required), + "{name}: required" + ); + } + + // call_tool lets the four through to the port and nothing else. + match dispatch( + &r, + "call_tool", + r#"{"name":"shell","arguments":{"command":"ls"}}"#, + ) { + Dispatch::Port { tool, arguments } => { + assert_eq!(tool, "shell"); + assert_eq!(arguments, r#"{"command":"ls"}"#); + } + other => panic!("{other:?}"), + } + assert!(matches!( + dispatch(&r, "call_tool", r#"{"name":"echo","arguments":{}}"#), + Dispatch::Local(t) if t.contains("No tool named \"echo\"") + )); +} + +#[test] +fn every_deny_reason_has_its_sentence() { + let table = [ + (DenyReason::NoGrant, "Denied: no grant allows this call."), + ( + DenyReason::GrantExpired, + "Denied: the grant for this call has expired.", + ), + ( + DenyReason::TaintTooHigh, + "Denied: this session has seen data too sensitive for this call.", + ), + ( + DenyReason::DeniedByGrant, + "Denied: a grant forbids this call.", + ), + ( + DenyReason::ApprovalRefused, + "Denied: the owner refused this call.", + ), + ( + DenyReason::ApprovalExpired, + "Denied: the approval request expired without an answer.", + ), + ( + DenyReason::InvalidArguments, + "Denied: the arguments are not valid for this tool.", + ), + ( + DenyReason::GrantsInvalid, + "Denied: the grant files have an error; the owner has been told.", + ), + ( + DenyReason::AuditUnavailable, + "Denied: the audit log cannot be written; the owner has been told.", + ), + ( + DenyReason::StateUnreadable, + "Denied: this session's broker state is damaged; the owner has been told.", + ), + ]; + for (reason, want) in table { + assert_eq!(denial_text(reason), want, "{reason:?}"); + } +} + +#[test] +fn results_are_cut_on_a_character_boundary_and_marked() { + assert_eq!(cap_result("short", 100), ("short".to_string(), false)); + assert_eq!(cap_result("exactly", 7), ("exactly".to_string(), false)); + let (text, truncated) = cap_result("abcdefghij", 4); + assert_eq!(text, "abcd\n[truncated]"); + assert!(truncated); + // 3 ASCII bytes, then 2-byte characters: byte 4 is inside a character. + let (text, truncated) = cap_result("abc\u{e9}\u{e9}\u{e9}", 4); + assert_eq!(text, "abc\n[truncated]"); + assert!(truncated); + assert_eq!(cap_result("", 0), (String::new(), false)); + assert_eq!(cap_result("x", 0), ("\n[truncated]".to_string(), true)); +} + +#[test] +fn fake_tools_answer_echo_deny_the_rest_and_record_calls() { + let fake = FakeTools::new(); + let mut seen = 0; + let mut on_pending = |_: &Pending| seen += 1; + match fake.call(&req("echo", r#"{"text":"box"}"#), &mut on_pending) { + ToolResponse::Result { + content, + class, + untrusted, + truncated, + } => { + assert_eq!(content, "box"); + assert_eq!(class, proto::DataClass::Public); + assert!(!untrusted && !truncated); + } + other => panic!("{other:?}"), + } + assert!(matches!( + fake.call(&req("echo", r#"{"tex":"box"}"#), &mut on_pending), + ToolResponse::Failed { .. } + )); + for tool in ["weather", "read_file", "clock"] { + assert_eq!( + fake.call(&req(tool, "{}"), &mut on_pending), + ToolResponse::Denied { + reason: DenyReason::NoGrant + }, + "{tool}: the clock is loopd's own now, not the port's" + ); + } + assert_eq!(seen, 0, "the fake never asks for approval"); + assert_eq!(fake.calls().len(), 5); + assert_eq!(fake.calls()[0].tool, "echo"); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/turn.rs b/docs/plans/M3a/files/crates/loopd/tests/turn.rs new file mode 100644 index 0000000..7433ebe --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/turn.rs @@ -0,0 +1,297 @@ +//! Tests for one turn: record sequences and tool dispatch. Do not edit. +//! The limits and the append-only property are in `limits.rs`; denials and approvals are in +//! `turn_broker.rs`. + +mod support; +#[path = "support/turn.rs"] +mod turn_support; + +use loopd::tools::Registry; +use proto::{DataClass, LogRecord, SessionId, ToolResponse, TurnEvent}; +use support::{Reply, ok_result}; +use turn_support::{setup, types}; + +const CHAT: &str = "/v1/chat/completions"; + +#[test] +fn a_plain_turn() { + let s = setup(vec![]); + s.server.route(CHAT, vec![Reply::fixture("plain")]); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "hello"); + let outcome = result.unwrap(); + assert_eq!( + outcome.content, + support::expected("plain")["content"].as_str().unwrap() + ); + assert_eq!( + (outcome.usage.prompt_n, outcome.usage.predicted_n), + (46, 16) + ); + assert_eq!( + types(session.records()), + ["start", "user", "assistant", "usage"] + ); + assert_eq!( + types(&s.home.records("a")), + ["start", "user", "assistant", "usage"], + "on disk too" + ); + assert!( + events + .iter() + .any(|e| matches!(e, TurnEvent::Content { .. })) + ); + assert!( + !events + .iter() + .any(|e| matches!(e, TurnEvent::ToolCallStarted { .. })) + ); + assert!(s.port.calls().is_empty()); + + let sent = s.server.requests_to(CHAT); + assert_eq!(sent.len(), 1); + let body = sent[0].json(); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!( + body["messages"][0]["content"], + "You are Boxmaker, a test agent." + ); + assert_eq!( + body["messages"][1], + serde_json::json!({"role": "user", "content": "hello"}) + ); + let names: Vec<&str> = body["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["function"]["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["clock", "find_tool", "call_tool"]); + assert_eq!(body["id_slot"], 0); +} + +#[test] +fn a_tool_turn_goes_through_the_port_and_records_everything() { + let s = setup(vec![ok_result("straylight\n")]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "read the hostname"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + types(session.records()), + [ + "start", + "user", + "assistant", + "usage", + "tool_result", + "assistant", + "usage" + ] + ); + + let calls = s.port.calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].tool, "read_file"); + assert_eq!( + calls[0].arguments, r#"{"path":"/etc/hostname"}"#, + "arguments are passed on unparsed" + ); + assert_eq!(calls[0].session, SessionId::new("a").unwrap()); + assert_eq!(calls[0].call, proto::CallId(1)); + + match &session.records()[4] { + LogRecord::ToolResult { + call, + tool_call_id, + content, + class, + untrusted, + truncated, + .. + } => { + assert_eq!(*call, proto::CallId(1)); + assert_eq!( + tool_call_id, "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", + "the server's id, so the template can pair it" + ); + assert_eq!(content, "straylight\n"); + assert_eq!( + (*class, *untrusted, *truncated), + (DataClass::Private, true, false) + ); + } + other => panic!("{other:?}"), + } + let kinds: Vec<&str> = events + .iter() + .filter_map(|e| match e { + TurnEvent::ToolCallStarted { name } => Some(name.as_str()), + TurnEvent::ToolResult { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(kinds, ["read_file", "read_file"]); + + // The second request extends the first: the tool result sits after the assistant turn. + let sent = s.server.requests_to(CHAT); + let m2 = sent[1].json()["messages"].as_array().unwrap().clone(); + assert_eq!(m2[2]["role"], "assistant"); + assert_eq!( + m2[2]["tool_calls"][0]["id"], + "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F" + ); + assert_eq!( + m2[3], + serde_json::json!({"role": "tool", "tool_call_id": "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", "content": "straylight\n"}) + ); +} + +#[test] +fn find_tool_and_call_tool_reach_the_port_only_for_the_target() { + let s = setup(vec![ok_result("box")]); + s.server.route( + CHAT, + vec![ + Reply::fixture("find_tool"), + Reply::fixture("call_tool"), + Reply::fixture("plain"), + ], + ); + let mut session = s.session("a"); + let (result, _) = s.turn(&mut session, "echo box"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + types(session.records()), + [ + "start", + "user", + "assistant", + "usage", + "tool_result", + "assistant", + "usage", + "tool_result", + "assistant", + "usage" + ] + ); + let calls = s.port.calls(); + assert_eq!( + calls.len(), + 1, + "find_tool is answered by loopd; only echo reaches the port" + ); + assert_eq!(calls[0].tool, "echo"); + assert_eq!( + serde_json::from_str::(&calls[0].arguments).unwrap(), + serde_json::json!({"text": "box"}) + ); + match &session.records()[4] { + LogRecord::ToolResult { + content, + class, + untrusted, + .. + } => { + assert!( + content.contains("\"name\":\"echo\"") + && content.ends_with("Call it with call_tool."), + "{content}" + ); + assert_eq!((*class, *untrusted), (DataClass::Public, false)); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_call_tool_for_an_unknown_tool_never_reaches_the_port() { + // The recorded call_tool asks for "echo"; with echo removed from the registry it is unknown. + let mut s = setup(vec![]); + s.registry = Registry::new(vec![loopd::tools::Entry { + schema: loopd::tools::clock_schema(), + core: true, + }]); + s.server.route( + CHAT, + vec![Reply::fixture("call_tool"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + assert!(s.turn(&mut session, "x").0.is_ok()); + assert!(s.port.calls().is_empty()); + assert!( + matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content.contains("No tool named \"echo\"")) + ); +} + +#[test] +fn the_result_cap_applies_when_appended() { + let mut s = setup(vec![ok_result(&"x".repeat(100))]); + s.cfg.r#loop.tool_result_cap = 20; + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "x"); + assert!(result.is_ok()); + match &session.records()[4] { + LogRecord::ToolResult { + content, truncated, .. + } => { + assert_eq!(content, &format!("{}\n[truncated]", "x".repeat(20))); + assert!(truncated); + } + other => panic!("{other:?}"), + } + assert!(events.iter().any(|e| matches!( + e, + TurnEvent::ToolResult { + truncated: true, + .. + } + ))); + let m2 = s.server.requests_to(CHAT)[1].json(); + assert_eq!( + m2["messages"][3]["content"].as_str().unwrap().len(), + 20 + "\n[truncated]".len(), + "the model sees the capped text" + ); +} + +#[test] +fn a_tool_failure_becomes_a_result_the_model_can_read() { + let s = setup(vec![ToolResponse::Failed { + message: "disk on fire".to_string(), + }]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "x"); + assert!(result.is_ok(), "{result:?}"); + match &session.records()[4] { + LogRecord::ToolResult { + content, + class, + untrusted, + .. + } => { + assert_eq!(content, "The tool failed: disk on fire"); + assert_eq!((*class, *untrusted), (DataClass::Public, false)); + } + other => panic!("{other:?}"), + } + assert!( + !events + .iter() + .any(|e| matches!(e, TurnEvent::ToolDenied { .. })), + "a failure is not a denial" + ); +} diff --git a/docs/plans/M3a/files/crates/loopd/tests/turn_broker.rs b/docs/plans/M3a/files/crates/loopd/tests/turn_broker.rs new file mode 100644 index 0000000..ba43fce --- /dev/null +++ b/docs/plans/M3a/files/crates/loopd/tests/turn_broker.rs @@ -0,0 +1,222 @@ +//! Tests for what the turn loop does with the broker's answers: denials, pending approvals, and +//! a port that misbehaves. Do not edit. + +mod support; +#[path = "support/turn.rs"] +mod turn_support; + +use proto::{DataClass, DenyReason, LogRecord, Timestamp, ToolResponse, TurnEvent}; +use support::{Reply, ReturnsPendingPort, ok_result, pending}; +use turn_support::{setup, types}; + +const CHAT: &str = "/v1/chat/completions"; + +/// The tool events of a turn, in order, as short strings. +fn tool_events(events: &[TurnEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + TurnEvent::ToolCallStarted { name } => Some(format!("started {name}")), + TurnEvent::ApprovalPending { approval, tool, .. } => { + Some(format!("pending {approval} {tool}")) + } + TurnEvent::ToolDenied { name, reason } => Some(format!("denied {name} {reason:?}")), + TurnEvent::ToolResult { name, .. } => Some(format!("result {name}")), + _ => None, + }) + .collect() +} + +#[test] +fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() { + let s = setup(vec![ToolResponse::Denied { + reason: DenyReason::TaintTooHigh, + }]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "x"); + assert!( + result.is_ok(), + "the turn goes on after a denial: {result:?}" + ); + match &session.records()[4] { + LogRecord::ToolResult { + content, + class, + untrusted, + truncated, + .. + } => { + assert_eq!( + content, + "Denied: this session has seen data too sensitive for this call." + ); + assert_eq!( + (*class, *untrusted, *truncated), + (DataClass::Public, false, false) + ); + } + other => panic!("{other:?}"), + } + assert_eq!( + tool_events(&events), + [ + "started read_file", + "denied read_file TaintTooHigh", + "result read_file" + ], + "the denial comes before the result" + ); + // The model reads the sentence in the next request. + let m2 = s.server.requests_to(CHAT)[1].json(); + assert_eq!( + m2["messages"][3]["content"], + "Denied: this session has seen data too sensitive for this call." + ); +} + +#[test] +fn a_denied_call_tool_names_the_target_tool_in_the_denial() { + let s = setup(vec![ToolResponse::Denied { + reason: DenyReason::NoGrant, + }]); + s.server.route( + CHAT, + vec![Reply::fixture("call_tool"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "echo box"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + tool_events(&events), + [ + "started call_tool", + "denied echo NoGrant", + "result call_tool" + ], + "the owner writes grants for `echo`, not for `call_tool`" + ); + assert!( + matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "Denied: no grant allows this call.") + ); +} + +#[test] +fn a_pending_approval_is_an_event_and_the_answer_after_it_is_the_result() { + let s = setup(vec![ + pending(41, "2026-09-18T12:15:00.000Z"), + ok_result("straylight\n"), + ]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "x"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + tool_events(&events), + [ + "started read_file", + "pending 41 read_file", + "result read_file" + ] + ); + let expires = events.iter().find_map(|e| match e { + TurnEvent::ApprovalPending { expires, .. } => Some(*expires), + _ => None, + }); + assert_eq!( + expires, + Some(Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap()) + ); + assert_eq!(s.port.calls().len(), 1, "one call, however long it waited"); + assert_eq!( + types(session.records()), + [ + "start", + "user", + "assistant", + "usage", + "tool_result", + "assistant", + "usage" + ], + "waiting writes nothing to the log" + ); + assert!( + matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "straylight\n") + ); +} + +#[test] +fn a_pending_approval_that_ends_in_a_refusal() { + let s = setup(vec![ + pending(7, "2026-09-18T12:15:00.000Z"), + ToolResponse::Denied { + reason: DenyReason::ApprovalRefused, + }, + ]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let (result, events) = s.turn(&mut session, "x"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + tool_events(&events), + [ + "started read_file", + "pending 7 read_file", + "denied read_file ApprovalRefused", + "result read_file" + ] + ); + assert!( + matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "Denied: the owner refused this call.") + ); +} + +#[test] +fn a_port_that_returns_a_pending_frame_as_its_answer_is_a_failure_not_a_decision() { + let s = setup(vec![]); + s.server.route( + CHAT, + vec![Reply::fixture("tool_call"), Reply::fixture("plain")], + ); + let mut session = s.session("a"); + let runtime = loopd::turn::Runtime { + cfg: &s.cfg, + client: &s.client, + port: &ReturnsPendingPort, + registry: &s.registry, + }; + let mut events = Vec::new(); + let result = + loopd::turn::run_turn(&mut session, &runtime, "x", &mut |e| events.push(e.clone())); + assert!(result.is_ok(), "{result:?}"); + match &session.records()[4] { + LogRecord::ToolResult { + content, + class, + untrusted, + .. + } => { + assert_eq!( + content, + "The tool failed: the tool broker gave no final answer" + ); + assert_eq!((*class, *untrusted), (DataClass::Public, false)); + } + other => panic!("{other:?}"), + } + assert_eq!( + tool_events(&events), + ["started read_file", "result read_file"], + "neither pending nor denied: the port said neither" + ); +} diff --git a/docs/plans/M3a/files/crates/proto/tests/admin_wire.rs b/docs/plans/M3a/files/crates/proto/tests/admin_wire.rs new file mode 100644 index 0000000..d58ebdf --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/admin_wire.rs @@ -0,0 +1,257 @@ +//! Tests for the admin messages of `admin.sock` and the two new error codes, against byte-exact +//! fixtures. Do not edit these or the fixtures. + +use proto::{ + ApprovalList, Approve, ApproveResult, CallId, DataClass, DecisionRecord, DenyReason, Empty, + Envelope, ErrorCode, GrantProblem, GrantsReport, Message, PendingApproval, Refuse, SessionId, + Timestamp, WireError, +}; + +fn fixture(name: &str) -> String { + let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + text.trim_end_matches('\n').to_string() +} + +/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes. +fn check(name: &str, id: u64, msg: Message) { + let want = Envelope { + v: 1, + id, + r#final: true, + msg, + }; + let text = fixture(name); + let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(got, want, "{name}: decoded value"); + assert_eq!( + serde_json::to_string(&want).unwrap(), + text, + "{name}: encoded bytes" + ); +} + +fn pending() -> PendingApproval { + PendingApproval { + approval: 41, + session: SessionId::new("chat-1789700000-42").unwrap(), + call: CallId(3), + tool: "shell".to_string(), + arguments: r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"# + .to_string(), + grant: "shell-scratch".to_string(), + taint: DataClass::Private, + created: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(), + expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(), + } +} + +#[test] +fn the_three_requests_with_an_empty_body() { + check("approvals.json", 5, Message::Approvals(Empty {})); + check("check_grants.json", 8, Message::CheckGrants(Empty {})); + check("ok.json", 7, Message::Ok(Empty {})); +} + +#[test] +fn approval_list() { + let list = ApprovalList { + items: vec![pending()], + }; + check("approval_list.json", 5, Message::ApprovalList(list)); + let empty = ApprovalList { items: Vec::new() }; + check("approval_list_empty.json", 5, Message::ApprovalList(empty)); +} + +#[test] +fn approve_and_its_result() { + check( + "approve.json", + 6, + Message::Approve(Approve { approval: 41 }), + ); + check( + "approve_result_allowed.json", + 6, + Message::ApproveResult(ApproveResult { + outcome: DecisionRecord::Allowed {}, + }), + ); + check( + "approve_result_denied.json", + 6, + Message::ApproveResult(ApproveResult { + outcome: DecisionRecord::Denied { + reason: DenyReason::NoGrant, + }, + }), + ); +} + +#[test] +fn refuse_with_and_without_a_reason() { + check( + "refuse.json", + 7, + Message::Refuse(Refuse { + approval: 41, + reason: Some("not while I am away".to_string()), + }), + ); + check( + "refuse_no_reason.json", + 7, + Message::Refuse(Refuse { + approval: 41, + reason: None, + }), + ); +} + +#[test] +fn grants_report() { + let report = GrantsReport { + problems: vec![ + GrantProblem { + file: "notes-read.toml".to_string(), + line: Some(3), + problem: "unknown field `mdoe`".to_string(), + }, + GrantProblem { + file: "Bad_Name.toml".to_string(), + line: None, + problem: "the file name is not a valid grant id".to_string(), + }, + ], + }; + check("grants_report.json", 8, Message::GrantsReport(report)); + let ok = GrantsReport { + problems: Vec::new(), + }; + check("grants_report_ok.json", 8, Message::GrantsReport(ok)); +} + +#[test] +fn the_two_new_error_codes() { + check( + "error_forbidden.json", + 9, + Message::Error(WireError { + code: ErrorCode::Forbidden, + detail: "approve is not accepted on broker.sock".to_string(), + }), + ); + check( + "error_no_such_approval.json", + 6, + Message::Error(WireError { + code: ErrorCode::NoSuchApproval, + detail: "41".to_string(), + }), + ); +} + +/// An empty body is an object with no keys: not `null`, not a missing body, not an object with a +/// key in it. +#[test] +fn an_empty_body_must_be_an_empty_object() { + let good = fixture("approvals.json"); + assert!(serde_json::from_str::(&good).is_ok()); + let bad = [ + good.replacen("\"body\":{}", "\"body\":null", 1), + good.replacen(",\"body\":{}", "", 1), + good.replacen("\"body\":{}", "\"body\":{\"all\":true}", 1), + ]; + for text in bad { + assert_ne!(text, good); + assert!( + serde_json::from_str::(&text).is_err(), + "accepted {text}" + ); + } +} + +/// `reason` and `line` may be null but may not be left out: every field is always written, so a +/// reader never has to guess what a missing one means. +#[test] +fn optional_fields_are_null_not_absent() { + let refuse = fixture("refuse_no_reason.json"); + let cut = refuse.replacen(",\"reason\":null", "", 1); + assert_ne!(cut, refuse); + assert!( + serde_json::from_str::(&cut).is_ok(), + "serde reads a missing Option as None; this documents it" + ); + assert!( + serde_json::to_string(&Refuse { + approval: 1, + reason: None + }) + .unwrap() + .contains("\"reason\":null") + ); + assert!( + serde_json::to_string(&GrantProblem { + file: "a.toml".to_string(), + line: None, + problem: "x".to_string() + }) + .unwrap() + .contains("\"line\":null") + ); +} + +/// An outcome is strict too. serde does not apply `deny_unknown_fields` to unit variants such as +/// `allowed`, so `DecisionRecord` must not rely on the derive for it. +#[test] +fn an_outcome_rejects_unknown_and_misplaced_fields() { + for good in [ + r#"{"outcome":"allowed"}"#, + r#"{"outcome":"ask"}"#, + r#"{"outcome":"denied","reason":"no_grant"}"#, + ] { + let value: DecisionRecord = serde_json::from_str(good).unwrap(); + assert_eq!(serde_json::to_string(&value).unwrap(), good); + } + for bad in [ + r#"{"outcome":"allowed","zz":1}"#, + r#"{"outcome":"ask","zz":1}"#, + r#"{"outcome":"denied","reason":"no_grant","zz":1}"#, + r#"{"outcome":"allowed","reason":"no_grant"}"#, + r#"{"outcome":"ask","reason":null,"zz":1}"#, + r#"{"outcome":"allowed","reason":null}"#, + r#"{"outcome":"denied"}"#, + r#"{"outcome":"approved"}"#, + r#"{"reason":"no_grant"}"#, + ] { + assert!( + serde_json::from_str::(bad).is_err(), + "accepted {bad}" + ); + } +} + +#[test] +fn pending_approvals_reject_bad_values() { + let good = fixture("approval_list.json"); + assert!(serde_json::from_str::(&good).is_ok()); + let bad = [ + // an approval id is a number + good.replacen("\"approval\":41", "\"approval\":\"41\"", 1), + // the session id is validated + good.replacen("chat-1789700000-42", "../etc", 1), + // the taint is one of the three classes + good.replacen("\"taint\":\"private\"", "\"taint\":\"internal\"", 1), + // a field is missing + good.replacen("\"grant\":\"shell-scratch\",", "", 1), + // a field nobody defined + good.replacen("\"grant\":", "\"note\":1,\"grant\":", 1), + ]; + for text in bad { + assert_ne!(text, good); + assert!( + serde_json::from_str::(&text).is_err(), + "accepted {text}" + ); + } +} diff --git a/docs/plans/M3a/files/crates/proto/tests/chain.rs b/docs/plans/M3a/files/crates/proto/tests/chain.rs new file mode 100644 index 0000000..85ef90f --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/chain.rs @@ -0,0 +1,389 @@ +//! The audit chain verifier against the fixture logs in `tests/fixtures/audit/`. Do not edit +//! this file or the fixtures: their hashes are real, and one changed byte changes the verdict. +//! +//! Every fixture is a small audit directory. `good` is an undamaged two-day log; the others are +//! `good` with one thing done to it, named by the directory. + +use proto::{AuditRecord, ChainReport, ChainVerifier, Hash32, Location, sha256}; + +const D1: &str = "2026-09-17.jsonl"; +const D2: &str = "2026-09-18.jsonl"; + +fn dir(case: &str) -> String { + format!("{}/tests/fixtures/audit/{case}", env!("CARGO_MANIFEST_DIR")) +} + +/// The `.jsonl` files of a case, in name order, with their bytes. +fn files(case: &str) -> Vec<(String, Vec)> { + let dir = dir(case); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("{dir}: {e}")) + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .filter(|name| name.ends_with(".jsonl")) + .collect(); + names.sort(); + assert!(!names.is_empty(), "{dir}: no files"); + names + .into_iter() + .map(|name| { + let bytes = std::fs::read(format!("{dir}/{name}")).unwrap(); + (name, bytes) + }) + .collect() +} + +fn verify(case: &str) -> ChainReport { + let mut verifier = ChainVerifier::new(); + for (name, bytes) in files(case) { + verifier.feed(&name, &bytes); + } + verifier.finish() +} + +/// Line `line` (1-based) of a file of a case, without its newline. +fn line_of(case: &str, file: &str, line: usize) -> Vec { + let (_, bytes) = files(case) + .into_iter() + .find(|(name, _)| name == file) + .unwrap(); + bytes.split(|b| *b == b'\n').nth(line - 1).unwrap().to_vec() +} + +fn hash_of(case: &str, file: &str, line: usize) -> Hash32 { + sha256(&line_of(case, file, line)).unwrap() +} + +fn at(file: &str, line: u64) -> Location { + Location { + file: file.to_string(), + line, + } +} + +#[test] +fn good_log_verifies() { + let report = verify("good"); + assert_eq!(report.failure, None); + assert_eq!(report.records, 10); + assert_eq!(report.next_seq, 10); + assert_eq!(report.head, Some(hash_of("good", D2, 5))); + assert_eq!( + report.abandoned, + vec![6], + "the ask at seq 6 has no approval" + ); + assert_eq!( + report.unfinished, + vec![7], + "the allowed call at seq 7 has no result" + ); + assert!(report.recoveries.is_empty()); + assert!(report.accepted_breaks.is_empty()); + assert!(report.clock_warnings.is_empty()); + assert_eq!(report.torn_tail, None); +} + +/// The tampering suite: each case fails, at this file and line, with this text. +#[test] +fn tampering_is_found_at_the_right_line() { + let parse = "does not parse as an audit record"; + let cases = [ + // The changed line still parses and chains; the line after it no longer chains from it. + ( + "changed-byte", + D1, + 4, + "prev is not the hash of the line before", + ), + ("deleted-line", D1, 3, "seq is 3, expected 2"), + ("swapped-lines", D1, 2, "seq is 2, expected 1"), + ("seq-gap", D1, 3, "seq is 3, expected 2"), + ( + "file-not-chained", + D2, + 1, + "does not chain from the last line of the file before", + ), + ("cut-short", D1, 3, parse), + ( + "break-wrong-line", + D1, + 4, + "prev is not the hash of the line before", + ), + ( + "break-wrong-last-good", + D1, + 4, + "prev is not the hash of the line before", + ), + ( + "break-wrong-prev", + D1, + 4, + "prev is not the hash of the line before", + ), + ( + "break-wrong-seq", + D1, + 4, + "prev is not the hash of the line before", + ), + ( + "break-without-failure", + D2, + 6, + "an accepted break with no failure before it", + ), + ("recovery-wrong-hash", D2, 6, parse), + ("recovery-wrong-length", D2, 6, parse), + ( + "recovery-describes-nothing", + D2, + 6, + "a recovery record that does not describe the line before it", + ), + ("torn-recovery", D2, 6, parse), + ]; + for (case, file, line, what) in cases { + let failure = verify(case) + .failure + .unwrap_or_else(|| panic!("{case}: verified, but it is damaged")); + assert_eq!( + (failure.file.as_str(), failure.line, failure.what.as_str()), + (file, line, what), + "{case}" + ); + } +} + +#[test] +fn a_failure_says_what_a_break_record_must_carry() { + let failure = verify("changed-byte").failure.unwrap(); + assert_eq!(failure.last_good, hash_of("changed-byte", D1, 3)); + assert_eq!(failure.break_prev, hash_of("changed-byte", D2, 5)); + // The failing line should have had seq 3; seven lines run from it to the end of the log. + assert_eq!(failure.break_seq, 10); + assert!(!failure.tail_torn); + + // A failure at the very first line: nothing verified, so last_good is all zeros. + let mut verifier = ChainVerifier::new(); + verifier.file(D1); + verifier.line(b"not json", true); + verifier.line(b"nor this", true); + let failure = verifier.finish().failure.unwrap(); + assert_eq!( + (failure.line, failure.last_good, failure.break_seq), + (1, Hash32::ZERO, 2) + ); + assert_eq!(failure.break_prev, sha256(b"nor this").unwrap()); + + let failure = verify("torn-recovery").failure.unwrap(); + assert!(failure.tail_torn, "the last line has no newline"); + assert_eq!( + failure.break_seq, 12, + "seq 10 for line 6, and two lines to the end" + ); +} + +#[test] +fn verification_stops_counting_at_a_failure() { + let report = verify("changed-byte"); + assert_eq!(report.records, 3); + assert_eq!(report.head, Some(hash_of("changed-byte", D1, 3))); + assert_eq!(report.next_seq, 3); + assert_eq!(report.torn_tail, None); +} + +#[test] +fn a_torn_tail_is_not_a_failure() { + // (case, file, line, has_newline, records, recovery_seq, file and line of the record before) + let cases = [ + ("torn-tail", D2, 6, false, 10, 10, (D2, 5)), + // Complete JSON that lacks only its newline is torn all the same. + ("torn-tail-complete-json", D2, 6, false, 10, 10, (D2, 5)), + // A crash between ending a torn line and writing its Recovery. + ("torn-unparseable-newline", D2, 6, true, 10, 10, (D2, 5)), + ("torn-first-line", D2, 1, false, 5, 5, (D1, 5)), + ]; + for (case, file, line, has_newline, records, seq, before) in cases { + let report = verify(case); + assert_eq!(report.failure, None, "{case}"); + assert_eq!(report.records, records, "{case}"); + let torn = report + .torn_tail + .unwrap_or_else(|| panic!("{case}: no torn tail")); + let bytes = line_of(case, file, line as usize); + assert_eq!(torn.at, at(file, line), "{case}"); + assert_eq!(torn.has_newline, has_newline, "{case}"); + assert_eq!(torn.bytes, bytes.len() as u64, "{case}"); + assert_eq!(torn.sha256, sha256(&bytes).unwrap(), "{case}"); + assert_eq!(torn.recovery_seq, seq, "{case}"); + assert_eq!( + torn.recovery_prev, + hash_of(case, before.0, before.1), + "{case}" + ); + assert_eq!( + report.next_seq, seq, + "{case}: the torn line is not a record" + ); + } + let whole = line_of("torn-tail-complete-json", D2, 6); + assert!( + serde_json::from_slice::(&whole).is_ok(), + "this case must be a line that parses" + ); +} + +#[test] +fn an_empty_latest_file_is_fine() { + let report = verify("empty-latest"); + assert_eq!((report.failure, report.torn_tail), (None, None)); + assert_eq!((report.records, report.next_seq), (5, 5)); +} + +#[test] +fn a_recovered_line_is_not_a_record_and_not_a_failure() { + // (case, where the recovered line is, records, abandoned, unfinished) + let cases = [ + ("recovered", at(D2, 6), 12, vec![6], vec![]), + // The recovered line is complete JSON with seq 10; the Recovery takes seq 10 again. + ("recovered-complete-json", at(D2, 6), 12, vec![6], vec![]), + // Torn on one day, recovered on the next: the Recovery is in the torn line's file. + ("recovered-next-day", at(D1, 6), 11, vec![7], vec![8]), + ("recovered-first-line", at(D2, 1), 6, vec![], vec![]), + ]; + for (case, recovered, records, abandoned, unfinished) in cases { + let report = verify(case); + assert_eq!(report.failure, None, "{case}"); + assert_eq!(report.torn_tail, None, "{case}"); + assert_eq!(report.recoveries, vec![recovered], "{case}"); + assert_eq!(report.records, records, "{case}"); + assert_eq!(report.abandoned, abandoned, "{case}"); + assert_eq!(report.unfinished, unfinished, "{case}"); + assert!(report.clock_warnings.is_empty(), "{case}"); + } +} + +#[test] +fn a_clock_stepped_back_is_a_warning() { + let report = verify("clock-back"); + assert_eq!(report.failure, None); + assert_eq!(report.records, 11); + assert_eq!(report.clock_warnings, vec![at(D2, 6)]); +} + +#[test] +fn an_accepted_break_clears_the_failure_before_it() { + // (case, where the break record is, records, next_seq) + let cases = [ + ("accepted-break", at(D1, 6), 5, 7), + ("accepted-break-older-file", at(D2, 6), 5, 12), + // A deleted line in day 2 as well: one break covers every failure before it. + ("accepted-break-two-failures", at(D2, 5), 4, 10), + // A line in the region claims seq 18446744073709551615. The break's seq is counted + // from lines, so it is 10 all the same. + ("accepted-break-max-seq", at(D2, 6), 4, 11), + ]; + for (case, break_at, records, next_seq) in cases { + let report = verify(case); + assert_eq!(report.failure, None, "{case}"); + assert_eq!(report.accepted_breaks, vec![break_at], "{case}"); + assert_eq!(report.records, records, "{case}"); + assert_eq!(report.next_seq, next_seq, "{case}"); + } + // Records inside the region are not vouched for: the approval of seq 2 is in it. + let report = verify("accepted-break"); + assert_eq!(report.abandoned, vec![2]); + assert_eq!(report.unfinished, vec![6]); +} + +/// A verifier that starts at the latest file cannot judge a break that names an older one. It +/// checks the break's `prev` and goes on; the full verification judges the rest. +#[test] +fn a_resumed_verifier_accepts_a_break_naming_an_earlier_file() { + let case = "accepted-break-older-file"; + let last: AuditRecord = serde_json::from_slice(&line_of(case, D1, 5)).unwrap(); + let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5)); + let (_, day2) = files(case).into_iter().nth(1).unwrap(); + verifier.feed(D2, &day2); + let report = verifier.finish(); + assert_eq!(report.failure, None); + assert_eq!(report.accepted_breaks, vec![at(D2, 6)]); + assert_eq!((report.records, report.next_seq), (7, 12)); + + // The same break with a wrong prev is not accepted, resumed or not. + let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5)); + let (_, day2) = files("break-wrong-prev").into_iter().nth(1).unwrap(); + verifier.feed(D2, &day2); + assert!(verifier.finish().failure.is_some()); +} + +#[test] +fn resume_continues_from_the_file_before() { + let last: AuditRecord = serde_json::from_slice(&line_of("good", D1, 5)).unwrap(); + let (_, day2) = files("good").into_iter().nth(1).unwrap(); + + let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of("good", D1, 5)); + verifier.feed(D2, &day2); + let report = verifier.finish(); + assert_eq!(report.failure, None); + assert_eq!((report.records, report.next_seq), (5, 10)); + + // Resumed from the wrong hash, the first line of the file does not chain. + let mut verifier = ChainVerifier::resume(last.seq + 1, Hash32::ZERO); + verifier.feed(D2, &day2); + let failure = verifier.finish().failure.unwrap(); + assert_eq!((failure.file.as_str(), failure.line), (D2, 1)); + assert_eq!( + failure.what, + "does not chain from the last line of the file before" + ); + assert_eq!( + failure.last_good, + Hash32::ZERO, + "the hash it was resumed with" + ); +} + +/// `feed` is `file` and then `line` for each line; both ways must give the same report. +#[test] +fn feed_is_file_then_lines() { + for case in [ + "good", + "torn-tail", + "recovered", + "changed-byte", + "empty-latest", + ] { + let mut verifier = ChainVerifier::new(); + for (name, bytes) in files(case) { + verifier.file(&name); + let mut rest: &[u8] = &bytes; + while !rest.is_empty() { + match rest.iter().position(|b| *b == b'\n') { + Some(end) => { + verifier.line(&rest[..end], true); + rest = &rest[end + 1..]; + } + None => { + verifier.line(rest, false); + rest = &[]; + } + } + } + } + assert_eq!(verifier.finish(), verify(case), "{case}"); + } +} + +#[test] +fn an_empty_log_is_fine() { + let report = ChainVerifier::new().finish(); + assert_eq!( + (report.failure, report.torn_tail, report.head), + (None, None, None) + ); + assert_eq!((report.records, report.next_seq), (0, 0)); +} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-18.jsonl new file mode 100644 index 0000000..cdbcd65 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-max-seq/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":18446744073709551615,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-18.jsonl new file mode 100644 index 0000000..3f44b40 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-older-file/2026-09-18.jsonl @@ -0,0 +1,7 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} +{"seq":11,"time":"2026-09-18T09:30:01.000Z","prev":"17662d4b56809a03a432c30d037904fcab478ed8b3d9fa8a1f515b7dbe0a7837","event":{"type":"decision","session":"chat-1","call":9,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-18.jsonl new file mode 100644 index 0000000..11b45c4 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break-two-failures/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break/2026-09-17.jsonl new file mode 100644 index 0000000..1a4a004 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/accepted-break/2026-09-17.jsonl @@ -0,0 +1,7 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":5,"time":"2026-09-17T08:30:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} +{"seq":6,"time":"2026-09-17T08:30:01.000Z","prev":"1d166141aa286ccb2b76e4c5b640a397a8af3c7fef0623a145182c812bacc114","event":{"type":"decision","session":"chat-1","call":9,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-18.jsonl new file mode 100644 index 0000000..6d22510 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-without-failure/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-18.jsonl","line":5,"last_good":"0000000000000000000000000000000000000000000000000000000000000000"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-18.jsonl new file mode 100644 index 0000000..f4f6084 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-last-good/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"0000000000000000000000000000000000000000000000000000000000000000"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-18.jsonl new file mode 100644 index 0000000..e0a603f --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-line/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":3,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-18.jsonl new file mode 100644 index 0000000..48d80bc --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-prev/2026-09-18.jsonl @@ -0,0 +1,7 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{} +{"seq":11,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-18.jsonl new file mode 100644 index 0000000..7d57ec3 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/break-wrong-seq/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":11,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-17.jsonl new file mode 100644 index 0000000..9e987a5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-18.jsonl new file mode 100644 index 0000000..30d3cdd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/changed-byte/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-18.jsonl new file mode 100644 index 0000000..85c31b5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/clock-back/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-17T23:59:58.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-17.jsonl new file mode 100644 index 0000000..a579a11 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6 +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-18.jsonl new file mode 100644 index 0000000..30d3cdd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/cut-short/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-17.jsonl new file mode 100644 index 0000000..2b57d2f --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-17.jsonl @@ -0,0 +1,4 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-18.jsonl new file mode 100644 index 0000000..30d3cdd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/deleted-line/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/empty-latest/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/empty-latest/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/empty-latest/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/empty-latest/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/empty-latest/2026-09-18.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-18.jsonl new file mode 100644 index 0000000..cab5ae0 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/file-not-chained/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"d5bb0822b4a83babe54392edcdc0b895e9919e118ca0d9c42da179be8a7719a8","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"ee18e126c0bb8dfa719c7c2b0a94d26019017a4c7e983b61668ed81cc135cb94","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"cfccb0f03849b11c17a2a71a0281583052cbfb96cd5b9eb184145142e35db056","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"56ca7170d34f57b158991a3f4798c795fa5d147f8ca44a858197cd94c9c6f14c","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-18.jsonl new file mode 100644 index 0000000..30d3cdd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/good/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-18.jsonl new file mode 100644 index 0000000..3d4f3ce --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-complete-json/2026-09-18.jsonl @@ -0,0 +1,8 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":368,"torn_sha256":"6627168a838f2beffce7ca0be64c0be17ecaec1184d9915c3c9ae046eeb9fe8c"}} +{"seq":11,"time":"2026-09-18T09:10:01.000Z","prev":"20a555911cfdfa0c514ec07c4162733e64b5ebc26e8bd660804fd1c9a9638c6c","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-18.jsonl new file mode 100644 index 0000000..0be87fa --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-first-line/2026-09-18.jsonl @@ -0,0 +1,2 @@ +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a +{"seq":5,"time":"2026-09-18T09:10:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"recovery","torn_bytes":70,"torn_sha256":"bec17a53cfdde80790a9101d04a5423848b61250cccb4bb02efdbc2f30197d26"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-17.jsonl new file mode 100644 index 0000000..1365ccc --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-17.jsonl @@ -0,0 +1,7 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":5,"time":"2026-09-17T23:59:59.000Z","prev":"29ef9d1d28442c8615bf8db10598d +{"seq":5,"time":"2026-09-18T00:00:30.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"recovery","torn_bytes":80,"torn_sha256":"d6aa54d6db80b2944686ee4317dea6b2519a98c994ea383ac4fc415fc5472aa8"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-18.jsonl new file mode 100644 index 0000000..0a164da --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered-next-day/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":6,"time":"2026-09-18T09:00:00.000Z","prev":"be4b24b8b8b219dc38c3b73ce43e9a431d5c5e51d740a8937ec155d71cc8c212","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:01.000Z","prev":"3da9d8ea7c71c912ec7d0b2faa6709df1148d49290268264d1da72d06ae56b2f","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:02.000Z","prev":"d9b8896a97cac08380f305c24e5ee7ad25268c7a338d05711dcba98a2fd41343","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:03.000Z","prev":"bf6a9a326fcde5060568de8497806fe22239715877ff432e8d87a80f55d30a54","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:04.000Z","prev":"e7bb179edf34ebff28d7f9efb6d11ae481975119273c6664ea71f82197812590","event":{"type":"approval","session":"chat-1","call":6,"decision":9,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-18.jsonl new file mode 100644 index 0000000..b1ccbf7 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovered/2026-09-18.jsonl @@ -0,0 +1,8 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":70,"torn_sha256":"bec17a53cfdde80790a9101d04a5423848b61250cccb4bb02efdbc2f30197d26"}} +{"seq":11,"time":"2026-09-18T09:10:01.000Z","prev":"68aa294e6a5a2f3925e4c2f0d4094ef047591295d17185ea2c8ec6a709530414","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-18.jsonl new file mode 100644 index 0000000..695156a --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-describes-nothing/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":70,"torn_sha256":"bec17a53cfdde80790a9101d04a5423848b61250cccb4bb02efdbc2f30197d26"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-18.jsonl new file mode 100644 index 0000000..79ae38e --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-hash/2026-09-18.jsonl @@ -0,0 +1,7 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":14,"torn_sha256":"f41f3fa625ff120ddca7ef456bf66371ecea23c129f4e4c32367101edb516cf8"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-18.jsonl new file mode 100644 index 0000000..22aa191 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/recovery-wrong-length/2026-09-18.jsonl @@ -0,0 +1,7 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":71,"torn_sha256":"bec17a53cfdde80790a9101d04a5423848b61250cccb4bb02efdbc2f30197d26"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/seq-gap/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/seq-gap/2026-09-17.jsonl new file mode 100644 index 0000000..2c43117 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/seq-gap/2026-09-17.jsonl @@ -0,0 +1,3 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":3,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-17.jsonl new file mode 100644 index 0000000..0f91b34 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-18.jsonl new file mode 100644 index 0000000..30d3cdd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/swapped-lines/2026-09-18.jsonl @@ -0,0 +1,5 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-18.jsonl new file mode 100644 index 0000000..89dabe5 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-first-line/2026-09-18.jsonl @@ -0,0 +1 @@ +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a \ No newline at end of file diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-18.jsonl new file mode 100644 index 0000000..aa777f6 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-recovery/2026-09-18.jsonl @@ -0,0 +1,7 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a +{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev" \ No newline at end of file diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-18.jsonl new file mode 100644 index 0000000..7b6eac2 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail-complete-json/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} \ No newline at end of file diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-18.jsonl new file mode 100644 index 0000000..4ab0a3c --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-tail/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a \ No newline at end of file diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-17.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-17.jsonl new file mode 100644 index 0000000..a9dc337 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-17.jsonl @@ -0,0 +1,5 @@ +{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} +{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-18.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-18.jsonl new file mode 100644 index 0000000..c903a01 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/audit/torn-unparseable-newline/2026-09-18.jsonl @@ -0,0 +1,6 @@ +{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}} +{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/records/audit.jsonl b/docs/plans/M3a/files/crates/proto/tests/fixtures/records/audit.jsonl new file mode 100644 index 0000000..2bfb105 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/records/audit.jsonl @@ -0,0 +1,10 @@ +{"seq":0,"time":"2026-09-17T08:05:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"mm-thread-42","call":1,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}","outcome":{"outcome":"allowed"},"grant":"read-etc","grant_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint":"private","untrusted":false}} +{"seq":1,"time":"2026-09-17T08:05:00.500Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"result","session":"mm-thread-42","call":1,"decision":0,"status":"result","class":"secret","untrusted":true,"truncated":true,"bytes":65536,"sha256":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","taint_after":"secret"}} +{"seq":2,"time":"2026-09-17T08:05:01.250Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"decision","session":"mm-thread-42","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-ask","grant_sha256":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","taint":"secret","untrusted":true}} +{"seq":3,"time":"2026-09-17T08:06:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"approval","session":"mm-thread-42","call":2,"decision":2,"answer":"approved","by":"u8f3k2","post":"p9x7","reason":null,"outcome":{"outcome":"allowed"},"grant":"shell-auto","grant_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint":"secret","untrusted":true}} +{"seq":4,"time":"2026-09-17T08:06:00.100Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"result","session":"mm-thread-42","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":27,"sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint_after":"secret"}} +{"seq":5,"time":"2026-09-17T08:07:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"decision","session":"cron-morning","call":1,"tool":"rm_rf","arguments":"{}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":6,"time":"2026-09-17T08:08:00.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"approval","session":"cron-morning","call":3,"decision":4,"answer":"refused","by":"bxctl","post":null,"reason":"not \"now\"","outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}} +{"seq":7,"time":"2026-09-17T08:23:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"approval","session":"cron-morning","call":4,"decision":5,"answer":"expired","by":null,"post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_expired"},"grant":null,"grant_sha256":null,"taint":"public","untrusted":false}} +{"seq":8,"time":"2026-09-18T00:00:00.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"recovery","torn_bytes":117,"torn_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}} +{"seq":9,"time":"2026-09-18T00:00:01.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":7,"last_good":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list.json new file mode 100644 index 0000000..cd43c81 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list.json @@ -0,0 +1 @@ +{"v":1,"id":5,"final":true,"msg":{"kind":"approval_list","body":{"items":[{"approval":41,"session":"chat-1789700000-42","call":3,"tool":"shell","arguments":"{\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}","grant":"shell-scratch","taint":"private","created":"2026-09-18T08:05:00.000Z","expires":"2026-09-18T08:20:00.000Z"}]}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list_empty.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list_empty.json new file mode 100644 index 0000000..81da4a9 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approval_list_empty.json @@ -0,0 +1 @@ +{"v":1,"id":5,"final":true,"msg":{"kind":"approval_list","body":{"items":[]}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approvals.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approvals.json new file mode 100644 index 0000000..012a021 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approvals.json @@ -0,0 +1 @@ +{"v":1,"id":5,"final":true,"msg":{"kind":"approvals","body":{}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve.json new file mode 100644 index 0000000..2010b9f --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve.json @@ -0,0 +1 @@ +{"v":1,"id":6,"final":true,"msg":{"kind":"approve","body":{"approval":41}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_allowed.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_allowed.json new file mode 100644 index 0000000..467b6e4 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_allowed.json @@ -0,0 +1 @@ +{"v":1,"id":6,"final":true,"msg":{"kind":"approve_result","body":{"outcome":{"outcome":"allowed"}}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_denied.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_denied.json new file mode 100644 index 0000000..5497161 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/approve_result_denied.json @@ -0,0 +1 @@ +{"v":1,"id":6,"final":true,"msg":{"kind":"approve_result","body":{"outcome":{"outcome":"denied","reason":"no_grant"}}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/check_grants.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/check_grants.json new file mode 100644 index 0000000..6471af8 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/check_grants.json @@ -0,0 +1 @@ +{"v":1,"id":8,"final":true,"msg":{"kind":"check_grants","body":{}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_forbidden.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_forbidden.json new file mode 100644 index 0000000..601f644 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_forbidden.json @@ -0,0 +1 @@ +{"v":1,"id":9,"final":true,"msg":{"kind":"error","body":{"code":"forbidden","detail":"approve is not accepted on broker.sock"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_no_such_approval.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_no_such_approval.json new file mode 100644 index 0000000..9bbeca8 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/error_no_such_approval.json @@ -0,0 +1 @@ +{"v":1,"id":6,"final":true,"msg":{"kind":"error","body":{"code":"no_such_approval","detail":"41"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report.json new file mode 100644 index 0000000..a46303f --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report.json @@ -0,0 +1 @@ +{"v":1,"id":8,"final":true,"msg":{"kind":"grants_report","body":{"problems":[{"file":"notes-read.toml","line":3,"problem":"unknown field `mdoe`"},{"file":"Bad_Name.toml","line":null,"problem":"the file name is not a valid grant id"}]}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report_ok.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report_ok.json new file mode 100644 index 0000000..af1dcc2 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/grants_report_ok.json @@ -0,0 +1 @@ +{"v":1,"id":8,"final":true,"msg":{"kind":"grants_report","body":{"problems":[]}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/ok.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/ok.json new file mode 100644 index 0000000..da17c30 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/ok.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":true,"msg":{"kind":"ok","body":{}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse.json new file mode 100644 index 0000000..99cedfd --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":true,"msg":{"kind":"refuse","body":{"approval":41,"reason":"not while I am away"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse_no_reason.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse_no_reason.json new file mode 100644 index 0000000..4c63789 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/refuse_no_reason.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":true,"msg":{"kind":"refuse","body":{"approval":41,"reason":null}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/tool_response_pending.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/tool_response_pending.json new file mode 100644 index 0000000..67cb898 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/tool_response_pending.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":false,"msg":{"kind":"tool_response","body":{"status":"pending_approval","approval":41,"expires":"2026-09-17T08:35:00.000Z"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_approval_pending.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_approval_pending.json new file mode 100644 index 0000000..3fe22e6 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_approval_pending.json @@ -0,0 +1 @@ +{"v":1,"id":3,"final":false,"msg":{"kind":"turn_event","body":{"event":"approval_pending","approval":41,"tool":"shell","expires":"2026-09-18T08:20:00.000Z"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_tool_denied.json b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_tool_denied.json new file mode 100644 index 0000000..1622ad6 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/fixtures/wire/turn_event_tool_denied.json @@ -0,0 +1 @@ +{"v":1,"id":3,"final":false,"msg":{"kind":"turn_event","body":{"event":"tool_denied","name":"read_file","reason":"no_grant"}}} diff --git a/docs/plans/M3a/files/crates/proto/tests/records.rs b/docs/plans/M3a/files/crates/proto/tests/records.rs new file mode 100644 index 0000000..dd17fc9 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/records.rs @@ -0,0 +1,302 @@ +//! Tests for audit and session log records against JSONL fixtures. Do not edit these or the fixtures. + +use proto::{ + ApprovalAnswer, AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, DenyReason, Epoch, + Hash32, LogRecord, ResultStatus, SessionId, Timestamp, ToolCall, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +const SEQ_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +const REV_HEX: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; + +fn ts(s: &str) -> Timestamp { + Timestamp::parse(s).unwrap() +} + +/// Line `i` of the fixture must decode to `want[i]`, and `want[i]` must encode to exactly that line. +fn check(name: &str, want: &[T]) { + let path = format!( + "{}/tests/fixtures/records/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), want.len(), "{name}: number of lines"); + for (i, (line, want)) in lines.iter().zip(want).enumerate() { + let got: T = serde_json::from_str(line).unwrap_or_else(|e| panic!("{name}:{}: {e}", i + 1)); + assert_eq!(&got, want, "{name}:{}: decoded value", i + 1); + assert_eq!( + &serde_json::to_string(want).unwrap(), + line, + "{name}:{}: encoded bytes", + i + 1 + ); + } +} + +fn sid(s: &str) -> SessionId { + SessionId::new(s).unwrap() +} + +fn record(seq: u64, time: &str, prev: Hash32, event: AuditEvent) -> AuditRecord { + AuditRecord { + seq, + time: ts(time), + prev, + event, + } +} + +/// One line per event variant, every `DecisionRecord` variant, and every option both set and unset. +#[test] +fn audit_records() { + let seq_hash = Hash32::from_hex(SEQ_HEX).unwrap(); + let rev_hash = Hash32::from_hex(REV_HEX).unwrap(); + let want = [ + record( + 0, + "2026-09-17T08:05:00.000Z", + Hash32::ZERO, + AuditEvent::Decision { + session: sid("mm-thread-42"), + call: CallId(1), + tool: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + outcome: DecisionRecord::Allowed {}, + grant: Some("read-etc".to_string()), + grant_sha256: Some(seq_hash), + taint: DataClass::Private, + untrusted: false, + }, + ), + record( + 1, + "2026-09-17T08:05:00.500Z", + seq_hash, + AuditEvent::Result { + session: sid("mm-thread-42"), + call: CallId(1), + decision: 0, + status: ResultStatus::Result, + class: DataClass::Secret, + untrusted: true, + truncated: true, + bytes: 65536, + sha256: rev_hash, + taint_after: DataClass::Secret, + }, + ), + record( + 2, + "2026-09-17T08:05:01.250Z", + rev_hash, + AuditEvent::Decision { + session: sid("mm-thread-42"), + call: CallId(2), + tool: "shell".to_string(), + arguments: r#"{"command":"df -h"}"#.to_string(), + outcome: DecisionRecord::Ask {}, + grant: Some("shell-ask".to_string()), + grant_sha256: Some(rev_hash), + taint: DataClass::Secret, + untrusted: true, + }, + ), + record( + 3, + "2026-09-17T08:06:00.000Z", + seq_hash, + AuditEvent::Approval { + session: sid("mm-thread-42"), + call: CallId(2), + decision: 2, + answer: ApprovalAnswer::Approved, + by: Some("u8f3k2".to_string()), + post: Some("p9x7".to_string()), + reason: None, + outcome: DecisionRecord::Allowed {}, + grant: Some("shell-auto".to_string()), + grant_sha256: Some(seq_hash), + taint: DataClass::Secret, + untrusted: true, + }, + ), + record( + 4, + "2026-09-17T08:06:00.100Z", + rev_hash, + AuditEvent::Result { + session: sid("mm-thread-42"), + call: CallId(2), + decision: 2, + status: ResultStatus::Failed, + class: DataClass::Private, + untrusted: false, + truncated: false, + bytes: 27, + sha256: seq_hash, + taint_after: DataClass::Secret, + }, + ), + record( + 5, + "2026-09-17T08:07:00.000Z", + seq_hash, + AuditEvent::Decision { + session: sid("cron-morning"), + call: CallId(1), + tool: "rm_rf".to_string(), + arguments: "{}".to_string(), + outcome: DecisionRecord::Denied { + reason: DenyReason::NoGrant, + }, + grant: None, + grant_sha256: None, + taint: DataClass::Private, + untrusted: false, + }, + ), + record( + 6, + "2026-09-17T08:08:00.000Z", + rev_hash, + AuditEvent::Approval { + session: sid("cron-morning"), + call: CallId(3), + decision: 4, + answer: ApprovalAnswer::Refused, + by: Some("bxctl".to_string()), + post: None, + reason: Some("not \"now\"".to_string()), + outcome: DecisionRecord::Denied { + reason: DenyReason::ApprovalRefused, + }, + grant: None, + grant_sha256: None, + taint: DataClass::Private, + untrusted: false, + }, + ), + record( + 7, + "2026-09-17T08:23:00.000Z", + seq_hash, + AuditEvent::Approval { + session: sid("cron-morning"), + call: CallId(4), + decision: 5, + answer: ApprovalAnswer::Expired, + by: None, + post: None, + reason: None, + outcome: DecisionRecord::Denied { + reason: DenyReason::ApprovalExpired, + }, + grant: None, + grant_sha256: None, + taint: DataClass::Public, + untrusted: false, + }, + ), + record( + 8, + "2026-09-18T00:00:00.000Z", + rev_hash, + AuditEvent::Recovery { + torn_bytes: 117, + torn_sha256: seq_hash, + }, + ), + record( + 9, + "2026-09-18T00:00:01.000Z", + seq_hash, + AuditEvent::AcceptedBreak { + file: "2026-09-17.jsonl".to_string(), + line: 7, + last_good: rev_hash, + }, + ), + ]; + check("audit.jsonl", &want); +} + +#[test] +fn session_log_records() { + let want = [ + LogRecord::SessionStart { + time: ts("2026-09-17T08:05:00.000Z"), + session: SessionId::new("mm-thread-42").unwrap(), + epoch: Epoch(0), + slot: 0, + baseline: Hash32::from_hex(SEQ_HEX).unwrap(), + }, + LogRecord::User { + time: ts("2026-09-17T08:05:01.000Z"), + content: "What is in /etc/hosts?".to_string(), + }, + LogRecord::Assistant { + time: ts("2026-09-17T08:05:03.000Z"), + content: None, + reasoning_content: Some("The user wants a file.\nI will read it.".to_string()), + tool_calls: vec![ToolCall { + id: "call_a1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + }], + }, + LogRecord::ToolResult { + time: ts("2026-09-17T08:05:04.000Z"), + call: CallId(1), + tool_call_id: "call_a1".to_string(), + content: "127.0.0.1 localhost\n".to_string(), + class: DataClass::Private, + untrusted: true, + truncated: false, + }, + LogRecord::Assistant { + time: ts("2026-09-17T08:05:06.000Z"), + content: Some("It maps localhost to 127.0.0.1.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + LogRecord::CacheLoss { + time: ts("2026-09-17T09:00:00.000Z"), + expected: 30695, + got: 0, + }, + LogRecord::EpochEnd { + time: ts("2026-09-17T12:00:00.000Z"), + next: Epoch(1), + summary: "Looked at /etc/hosts.".to_string(), + }, + ]; + check("session.jsonl", &want); +} + +#[test] +fn unknown_fields_and_types_are_rejected() { + let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#; + assert!(serde_json::from_str::(user).is_ok()); + let extra = user.replacen("\"content\"", "\"role\":\"user\",\"content\"", 1); + assert!(serde_json::from_str::(&extra).is_err()); + let unknown_type = user.replacen("\"user\"", "\"system\"", 1); + assert!(serde_json::from_str::(&unknown_type).is_err()); + + // A variant with no fields must reject an unknown key too. A serde unit variant would not. + for decision in [r#"{"outcome":"allowed"}"#, r#"{"outcome":"ask"}"#] { + assert!(serde_json::from_str::(decision).is_ok()); + let extra = decision.replacen('}', ",\"why\":\"\"}", 1); + assert!( + serde_json::from_str::(&extra).is_err(), + "{extra}" + ); + } + let denied = r#"{"outcome":"denied","reason":"no_grant"}"#; + assert!(serde_json::from_str::(denied).is_ok()); + let extra = denied.replacen("\"reason\"", "\"grant\":null,\"reason\"", 1); + assert!(serde_json::from_str::(&extra).is_err()); + let approved = r#"{"outcome":"approved"}"#; + assert!(serde_json::from_str::(approved).is_err()); +} diff --git a/docs/plans/M3a/files/crates/proto/tests/strict.rs b/docs/plans/M3a/files/crates/proto/tests/strict.rs new file mode 100644 index 0000000..09178b9 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/strict.rs @@ -0,0 +1,150 @@ +//! Every JSON object in every fixture must reject an unknown key. Do not edit. +//! +//! The other test files check unknown fields in a few hand-picked places. This one checks all of +//! them: it walks each fixture, adds one unknown key to one object at a time, at every depth, and +//! requires that the result no longer decodes. + +use proto::{AuditRecord, Envelope, Grant, LogRecord}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +/// Every copy of `value` that has exactly one extra key in exactly one object. +fn with_one_unknown_key(value: &Value) -> Vec { + let mut out = Vec::new(); + match value { + Value::Object(map) => { + let mut extended = map.clone(); + extended.insert("zz_unknown".to_string(), Value::Bool(true)); + out.push(Value::Object(extended)); + for (key, child) in map { + for changed in with_one_unknown_key(child) { + let mut copy = map.clone(); + copy.insert(key.clone(), changed); + out.push(Value::Object(copy)); + } + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + for changed in with_one_unknown_key(child) { + let mut copy = items.clone(); + copy[i] = changed; + out.push(Value::Array(copy)); + } + } + } + _ => {} + } + out +} + +/// Returns how many variations were tried, so callers can check the walk reached nested objects. +fn check(what: &str, text: &str) -> usize { + let value: Value = serde_json::from_str(text).unwrap_or_else(|e| panic!("{what}: {e}")); + assert!( + serde_json::from_value::(value.clone()).is_ok(), + "{what}: fixture must decode" + ); + let variations = with_one_unknown_key(&value); + for changed in &variations { + assert!( + serde_json::from_value::(changed.clone()).is_err(), + "{what}: accepted an unknown key: {changed}" + ); + } + variations.len() +} + +fn fixture(path: &str) -> String { + let full = format!("{}/tests/fixtures/{path}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{full}: {e}")) +} + +#[test] +fn envelopes_reject_unknown_keys_at_every_depth() { + for name in [ + "tool_request.json", + "tool_response_pending.json", + "tool_response_result.json", + "tool_response_failed.json", + "tool_response_denied.json", + "error.json", + "turn.json", + "turn_event_tool_result.json", + "turn_event_content.json", + "turn_event_retrying.json", + "turn_done.json", + "error_session_full.json", + "approvals.json", + "approval_list.json", + "approval_list_empty.json", + "approve.json", + "approve_result_allowed.json", + "approve_result_denied.json", + "refuse.json", + "refuse_no_reason.json", + "ok.json", + "check_grants.json", + "grants_report.json", + "grants_report_ok.json", + "error_forbidden.json", + "error_no_such_approval.json", + "turn_event_approval_pending.json", + "turn_event_tool_denied.json", + ] { + // Envelope, msg and body: three objects. Some bodies hold more: turn_done a usage + // object, approval_list one item, approve_result an outcome, grants_report two problems. + let want = match name { + "turn_done.json" | "approval_list.json" => 4, + "approve_result_allowed.json" | "approve_result_denied.json" => 4, + "grants_report.json" => 5, + _ => 3, + }; + assert_eq!( + check::(name, &fixture(&format!("wire/{name}"))), + want, + "{name}" + ); + } +} + +#[test] +fn audit_records_reject_unknown_keys_at_every_depth() { + for (i, line) in fixture("records/audit.jsonl").lines().enumerate() { + // The record and its event: two objects. Decision and approval events also hold an + // outcome object. + let want = if line.contains("\"outcome\"") { 3 } else { 2 }; + assert_eq!( + check::(&format!("audit.jsonl:{}", i + 1), line), + want + ); + } +} + +#[test] +fn log_records_reject_unknown_keys_at_every_depth() { + let mut tried = 0; + for (i, line) in fixture("records/session.jsonl").lines().enumerate() { + tried += check::(&format!("session.jsonl:{}", i + 1), line); + } + // Seven records, plus the one tool call inside the first assistant record. + assert_eq!(tried, 8); +} + +#[test] +fn usage_log_records_reject_unknown_keys_at_every_depth() { + let mut tried = 0; + for (i, line) in fixture("records/session_usage.jsonl").lines().enumerate() { + tried += check::(&format!("session_usage.jsonl:{}", i + 1), line); + } + // Seven records, plus the one tool call inside the first assistant record. + assert_eq!(tried, 8); +} + +#[test] +fn grants_reject_unknown_keys_at_every_depth() { + let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap(); + let text = serde_json::to_string(&grant).unwrap(); + // The grant and its constraints: two objects. + assert_eq!(check::("full.toml as JSON", &text), 2); +} diff --git a/docs/plans/M3a/files/crates/proto/tests/turn_wire.rs b/docs/plans/M3a/files/crates/proto/tests/turn_wire.rs new file mode 100644 index 0000000..835fbf9 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/turn_wire.rs @@ -0,0 +1,283 @@ +//! Tests for the channel messages and the usage record, against byte-exact fixtures. Do not edit. + +use proto::{ + CallId, DataClass, DenyReason, Envelope, Epoch, ErrorCode, Hash32, LogRecord, Message, + SessionId, Timestamp, ToolCall, Turn, TurnDone, TurnEvent, Usage, WireError, +}; + +fn fixture(kind: &str, name: &str) -> String { + let path = format!( + "{}/tests/fixtures/{kind}/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{path}: {e}")) + .trim_end_matches('\n') + .to_string() +} + +fn check(name: &str, want: Envelope) { + let text = fixture("wire", name); + let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(got, want, "{name}: decoded value"); + assert_eq!( + serde_json::to_string(&want).unwrap(), + text, + "{name}: encoded bytes" + ); +} + +fn env(id: u64, r#final: bool, msg: Message) -> Envelope { + Envelope { + v: 1, + id, + r#final, + msg, + } +} + +fn usage() -> Usage { + Usage { + cache_n: 539, + prompt_n: 27, + predicted_n: 24, + reasoning_tokens: 8, + thinking_capped: false, + } +} + +#[test] +fn turn() { + let body = Turn { + session: SessionId::new("chat-1789700000-42").unwrap(), + content: "What time is it?".to_string(), + resume: false, + }; + check("turn.json", env(3, true, Message::Turn(body))); +} + +#[test] +fn turn_events() { + check( + "turn_event_tool_result.json", + env( + 3, + false, + Message::TurnEvent(TurnEvent::ToolResult { + name: "clock".to_string(), + class: DataClass::Public, + truncated: false, + }), + ), + ); + check( + "turn_event_content.json", + env( + 3, + false, + Message::TurnEvent(TurnEvent::Content { + text: "It is ".to_string(), + }), + ), + ); + check( + "turn_event_retrying.json", + env( + 3, + false, + Message::TurnEvent(TurnEvent::Retrying { + attempt: 2, + after_ms: 1500, + error: "the server went silent".to_string(), + }), + ), + ); + check( + "turn_event_approval_pending.json", + env( + 3, + false, + Message::TurnEvent(TurnEvent::ApprovalPending { + approval: 41, + tool: "shell".to_string(), + expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(), + }), + ), + ); + check( + "turn_event_tool_denied.json", + env( + 3, + false, + Message::TurnEvent(TurnEvent::ToolDenied { + name: "read_file".to_string(), + reason: DenyReason::NoGrant, + }), + ), + ); +} + +#[test] +fn turn_done_and_the_new_error_codes() { + check( + "turn_done.json", + env( + 3, + true, + Message::TurnDone(TurnDone { + content: "It is noon.".to_string(), + usage: usage(), + }), + ), + ); + let body = WireError { + code: ErrorCode::SessionFull, + detail: "this conversation is full; start a new one".to_string(), + }; + check( + "error_session_full.json", + env(3, true, Message::Error(body)), + ); + let codes = [ + (ErrorCode::SessionFull, "session_full"), + (ErrorCode::TurnLimit, "turn_limit"), + (ErrorCode::SessionBusy, "session_busy"), + (ErrorCode::NoSuchSession, "no_such_session"), + (ErrorCode::SessionExists, "session_exists"), + (ErrorCode::Inference, "inference"), + ]; + for (value, text) in codes { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{text}\"") + ); + } +} + +#[test] +fn every_turn_event_kind_round_trips() { + let all = vec![ + TurnEvent::Queued { ahead: 1 }, + TurnEvent::Waiting { slot_busy: true }, + TurnEvent::Progress { + total: 100, + cache: 50, + processed: 75, + }, + TurnEvent::Reasoning { + text: "hm".to_string(), + }, + TurnEvent::Content { + text: "hi".to_string(), + }, + TurnEvent::ToolCallStarted { + name: "clock".to_string(), + }, + TurnEvent::ToolResult { + name: "clock".to_string(), + class: DataClass::Secret, + truncated: true, + }, + TurnEvent::ThinkingCapped { tokens: 4096 }, + TurnEvent::Retrying { + attempt: 2, + after_ms: 10, + error: "x".to_string(), + }, + TurnEvent::CacheLoss { + expected: 500, + got: 20, + }, + TurnEvent::ApprovalPending { + approval: u64::MAX, + tool: "http_fetch".to_string(), + expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(), + }, + TurnEvent::ToolDenied { + name: "shell".to_string(), + reason: DenyReason::StateUnreadable, + }, + ]; + for event in all { + let text = serde_json::to_string(&event).unwrap(); + assert!(text.starts_with("{\"event\":\""), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), event); + } + assert!(serde_json::from_str::(r#"{"event":"content","text":"x","zz":1}"#).is_err()); + assert!(serde_json::from_str::(r#"{"event":"dance"}"#).is_err()); +} + +#[test] +fn usage_records() { + let text = fixture("records", "session_usage.jsonl"); + let lines: Vec<&str> = text.lines().collect(); + let want = [ + LogRecord::SessionStart { + time: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(), + session: SessionId::new("chat-1789700000-42").unwrap(), + epoch: Epoch(0), + slot: 0, + baseline: Hash32::from_hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + ) + .unwrap(), + }, + LogRecord::User { + time: Timestamp::parse("2026-09-18T08:05:01.000Z").unwrap(), + content: "What time is it?".to_string(), + }, + LogRecord::Assistant { + time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(), + content: None, + reasoning_content: None, + tool_calls: vec![ToolCall { + id: "call_a1".to_string(), + name: "clock".to_string(), + arguments: "{}".to_string(), + }], + }, + LogRecord::Usage { + time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(), + cache_n: 539, + prompt_n: 27, + predicted_n: 24, + reasoning_tokens: 8, + thinking_capped: false, + }, + LogRecord::ToolResult { + time: Timestamp::parse("2026-09-18T08:05:03.100Z").unwrap(), + call: CallId(1), + tool_call_id: "call_a1".to_string(), + content: "2026-09-18T08:05:03.000Z".to_string(), + class: DataClass::Public, + untrusted: false, + truncated: false, + }, + LogRecord::Assistant { + time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(), + content: Some("It is five past eight.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + LogRecord::Usage { + time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(), + cache_n: 589, + prompt_n: 40, + predicted_n: 64, + reasoning_tokens: 27, + thinking_capped: true, + }, + ]; + assert_eq!(lines.len(), want.len()); + for (i, (line, want)) in lines.iter().zip(&want).enumerate() { + let got: LogRecord = + serde_json::from_str(line).unwrap_or_else(|e| panic!("line {}: {e}", i + 1)); + assert_eq!(&got, want, "line {}", i + 1); + assert_eq!( + &serde_json::to_string(want).unwrap(), + line, + "line {}: bytes", + i + 1 + ); + } +} diff --git a/docs/plans/M3a/files/crates/proto/tests/wire.rs b/docs/plans/M3a/files/crates/proto/tests/wire.rs new file mode 100644 index 0000000..b254fb9 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/wire.rs @@ -0,0 +1,194 @@ +//! Tests for IPC messages against byte-exact fixtures. Do not edit these or the fixtures. + +use proto::{ + CallId, DataClass, DenyReason, Envelope, ErrorCode, Message, SessionId, Timestamp, ToolRequest, + ToolResponse, WireError, +}; + +fn fixture(name: &str) -> String { + let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + text.trim_end_matches('\n').to_string() +} + +/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes. +fn check(name: &str, want: Envelope) { + let text = fixture(name); + let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(got, want, "{name}: decoded value"); + assert_eq!( + serde_json::to_string(&want).unwrap(), + text, + "{name}: encoded bytes" + ); +} + +fn response(id: u64, r#final: bool, body: ToolResponse) -> Envelope { + Envelope { + v: 1, + id, + r#final, + msg: Message::ToolResponse(body), + } +} + +#[test] +fn tool_request() { + let body = ToolRequest { + session: SessionId::new("mm-thread-42").unwrap(), + call: CallId(3), + tool: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + }; + check( + "tool_request.json", + Envelope { + v: 1, + id: 7, + r#final: true, + msg: Message::ToolRequest(body), + }, + ); +} + +#[test] +fn tool_response_pending() { + let body = ToolResponse::PendingApproval { + approval: 41, + expires: Timestamp::parse("2026-09-17T08:35:00.000Z").unwrap(), + }; + check("tool_response_pending.json", response(7, false, body)); +} + +#[test] +fn an_approval_id_is_a_number_not_a_string() { + let good = fixture("tool_response_pending.json"); + let bad = good.replacen("\"approval\":41", "\"approval\":\"41\"", 1); + assert_ne!(good, bad); + assert!(serde_json::from_str::(&good).is_ok()); + assert!(serde_json::from_str::(&bad).is_err()); + let negative = good.replacen("\"approval\":41", "\"approval\":-1", 1); + assert!(serde_json::from_str::(&negative).is_err()); +} + +#[test] +fn tool_response_result() { + let body = ToolResponse::Result { + content: "127.0.0.1 localhost\n".to_string(), + class: DataClass::Private, + untrusted: true, + truncated: false, + }; + check("tool_response_result.json", response(7, true, body)); +} + +#[test] +fn tool_response_failed() { + let body = ToolResponse::Failed { + message: "exit status 2".to_string(), + }; + check("tool_response_failed.json", response(8, true, body)); +} + +#[test] +fn tool_response_denied() { + let body = ToolResponse::Denied { + reason: DenyReason::TaintTooHigh, + }; + check("tool_response_denied.json", response(9, true, body)); +} + +#[test] +fn error_message() { + let body = WireError { + code: ErrorCode::BadVersion, + detail: "expected 1".to_string(), + }; + check( + "error.json", + Envelope { + v: 1, + id: 0, + r#final: true, + msg: Message::Error(body), + }, + ); +} + +#[test] +fn deny_reasons_and_error_codes_are_snake_case() { + let reasons = [ + (DenyReason::NoGrant, "no_grant"), + (DenyReason::GrantExpired, "grant_expired"), + (DenyReason::TaintTooHigh, "taint_too_high"), + (DenyReason::DeniedByGrant, "denied_by_grant"), + (DenyReason::ApprovalRefused, "approval_refused"), + (DenyReason::ApprovalExpired, "approval_expired"), + (DenyReason::GrantsInvalid, "grants_invalid"), + (DenyReason::AuditUnavailable, "audit_unavailable"), + (DenyReason::InvalidArguments, "invalid_arguments"), + (DenyReason::StateUnreadable, "state_unreadable"), + ]; + for (value, text) in reasons { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{text}\"") + ); + } + let codes = [ + (ErrorCode::BadFrame, "bad_frame"), + (ErrorCode::BadVersion, "bad_version"), + (ErrorCode::BadMessage, "bad_message"), + (ErrorCode::Internal, "internal"), + (ErrorCode::Forbidden, "forbidden"), + (ErrorCode::NoSuchApproval, "no_such_approval"), + ]; + for (value, text) in codes { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{text}\"") + ); + } +} + +#[test] +fn unknown_and_missing_fields_are_rejected() { + let good = fixture("tool_request.json"); + assert!(serde_json::from_str::(&good).is_ok()); + let bad = [ + // extra field in the envelope + good.replacen("{\"v\":1,", "{\"v\":1,\"extra\":0,", 1), + // extra field beside kind and body + good.replacen( + "\"kind\":\"tool_request\",", + "\"kind\":\"tool_request\",\"x\":1,", + 1, + ), + // extra field in the body + good.replacen("\"call\":3,", "\"call\":3,\"priority\":9,", 1), + // missing field in the body + good.replacen("\"call\":3,", "", 1), + // missing `final` + good.replacen("\"final\":true,", "", 1), + // unknown kind + good.replacen("tool_request", "tool_demand", 1), + // invalid session id inside a message + good.replacen("mm-thread-42", "../../etc", 1), + ]; + for text in bad { + assert!( + serde_json::from_str::(&text).is_err(), + "accepted {text}" + ); + } +} + +#[test] +fn unknown_field_in_a_response_variant_is_rejected() { + let good = fixture("tool_response_denied.json"); + let bad = good.replacen("\"reason\":", "\"note\":\"x\",\"reason\":", 1); + assert!(serde_json::from_str::(&good).is_ok()); + assert!(serde_json::from_str::(&bad).is_err()); + let bad_status = good.replacen("denied", "refused", 1); + assert!(serde_json::from_str::(&bad_status).is_err()); +} diff --git a/docs/plans/M3a/files/scripts/test-gate-scripts.sh b/docs/plans/M3a/files/scripts/test-gate-scripts.sh new file mode 100644 index 0000000..e674508 --- /dev/null +++ b/docs/plans/M3a/files/scripts/test-gate-scripts.sh @@ -0,0 +1,167 @@ +#!/bin/sh +# Self-test for the gate scripts. It builds small fake trees in a temporary +# directory and checks that each script passes the good tree and fails the bad ones. +# Do not edit: this file defines the required behaviour of the scripts. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +fails=0 + +expect() { # expect pass|fail NAME SCRIPT ROOT + want="$1"; name="$2"; script="$3"; root="$4" + if sh "$here/$script" "$root" >/dev/null 2>&1; then got=pass; else got=fail; fi + if [ "$got" != "$want" ]; then + echo "test-gate-scripts: $name: expected $want, got $got" >&2 + fails=$((fails + 1)) + fi +} + +manifest() { # manifest DIR NAME [DEPENDENCY-LINES...] + dir="$1"; name="$2"; shift 2 + mkdir -p "$dir/src" + { + printf '[package]\nname = "%s"\nversion = "0.1.0"\n\n[dependencies]\n' "$name" + for line in "$@"; do printf '%s\n' "$line"; done + } > "$dir/Cargo.toml" +} + +tree() { # tree ROOT: a good workspace with proto, loopd and brokerd + root="$1" + mkdir -p "$root/docs" + printf '[workspace]\nmembers = ["crates/*"]\n\n[workspace.dependencies]\nproto = { path = "crates/proto" }\nserde = { version = "1", features = ["derive"] }\n' > "$root/Cargo.toml" + printf '# Dependencies\n\n| Crate | Why |\n|---|---|\n| `serde` | types |\n' > "$root/docs/dependencies.md" + manifest "$root/crates/proto" proto 'serde.workspace = true' + manifest "$root/crates/loopd" loopd 'proto.workspace = true' + manifest "$root/crates/brokerd" brokerd 'proto = { workspace = true }' 'serde.workspace = true' +} + +lines() { # lines N FILE + mkdir -p "$(dirname "$2")" + i=0; : > "$2" + while [ "$i" -lt "$1" ]; do echo "// line" >> "$2"; i=$((i + 1)); done +} + +# check-lines.sh +tree "$tmp/l-ok"; lines 500 "$tmp/l-ok/crates/loopd/src/lib.rs" +expect pass "500 lines is allowed" check-lines.sh "$tmp/l-ok" +tree "$tmp/l-bad"; lines 501 "$tmp/l-bad/crates/loopd/src/deep/mod.rs" +expect fail "501 lines in a nested file" check-lines.sh "$tmp/l-bad" +tree "$tmp/l-test"; lines 501 "$tmp/l-test/crates/proto/tests/big.rs" +expect fail "501 lines in a test file" check-lines.sh "$tmp/l-test" +tree "$tmp/l-tgt"; lines 501 "$tmp/l-tgt/crates/proto/target/debug/gen.rs" +expect pass "files under target/ are ignored" check-lines.sh "$tmp/l-tgt" + +# check-crate-deps.sh +tree "$tmp/c-ok" +expect pass "roles depend only on proto" check-crate-deps.sh "$tmp/c-ok" +tree "$tmp/c-role"; manifest "$tmp/c-role/crates/loopd" loopd 'proto.workspace = true' 'brokerd.workspace = true' +expect fail "role depends on another role" check-crate-deps.sh "$tmp/c-role" +tree "$tmp/c-tbl"; manifest "$tmp/c-tbl/crates/loopd" loopd 'brokerd = { path = "../brokerd" }' +expect fail "role depends on another role by path" check-crate-deps.sh "$tmp/c-tbl" +tree "$tmp/c-dev"; printf '\n[dev-dependencies]\nbrokerd.workspace = true\n' >> "$tmp/c-dev/crates/loopd/Cargo.toml" +expect fail "role dev-depends on another role" check-crate-deps.sh "$tmp/c-dev" +tree "$tmp/c-proto"; manifest "$tmp/c-proto/crates/proto" proto 'loopd.workspace = true' +expect fail "proto depends on a role" check-crate-deps.sh "$tmp/c-proto" +# A dependency can also be written as its own table: [dependencies.NAME] +tree "$tmp/c-sect"; printf '\n[dependencies.brokerd]\npath = "../brokerd"\n' >> "$tmp/c-sect/crates/loopd/Cargo.toml" +expect fail "role depends on another role, written as a table" check-crate-deps.sh "$tmp/c-sect" +tree "$tmp/c-sectw"; printf '\n[dev-dependencies.brokerd]\nworkspace = true\n' >> "$tmp/c-sectw/crates/loopd/Cargo.toml" +expect fail "role dev-depends on another role, written as a table" check-crate-deps.sh "$tmp/c-sectw" +tree "$tmp/c-dot"; manifest "$tmp/c-dot/crates/loopd" loopd 'proto.workspace = true' 'brokerd.path = "../brokerd"' +expect fail "role depends on another role, written with a dotted key" check-crate-deps.sh "$tmp/c-dot" +tree "$tmp/c-sectok"; manifest "$tmp/c-sectok/crates/loopd" loopd; printf '\n[dependencies.proto]\nworkspace = true\n' >> "$tmp/c-sectok/crates/loopd/Cargo.toml" +expect pass "role depends on proto, written as a table" check-crate-deps.sh "$tmp/c-sectok" + +# check-dep-docs.sh +tree "$tmp/d-ok" +expect pass "every dependency is documented" check-dep-docs.sh "$tmp/d-ok" +tree "$tmp/d-miss"; printf 'rand = "0.9"\n' >> "$tmp/d-miss/Cargo.toml" +expect fail "workspace dependency without a docs row" check-dep-docs.sh "$tmp/d-miss" +tree "$tmp/d-prose"; printf 'rand = "0.9"\n' >> "$tmp/d-prose/Cargo.toml"; printf '\nWe do not use rand.\n' >> "$tmp/d-prose/docs/dependencies.md" +expect fail "a mention in prose is not a table row" check-dep-docs.sh "$tmp/d-prose" +tree "$tmp/d-loose"; manifest "$tmp/d-loose/crates/loopd" loopd 'proto.workspace = true' 'rand = "0.9"' +expect fail "crate declares a dependency outside the workspace table" check-dep-docs.sh "$tmp/d-loose" +tree "$tmp/d-sect"; printf '\n[dependencies.rand]\nversion = "0.9"\n' >> "$tmp/d-sect/crates/loopd/Cargo.toml" +expect fail "table-form dependency without workspace = true" check-dep-docs.sh "$tmp/d-sect" +tree "$tmp/d-sectok"; printf '\n[dependencies.serde]\nworkspace = true\nfeatures = ["derive"]\n' >> "$tmp/d-sectok/crates/loopd/Cargo.toml" +expect pass "table-form dependency with workspace = true" check-dep-docs.sh "$tmp/d-sectok" + +# check-runbook.sh +book() { # book ROOT [HEADING-LINES...]: a tree whose runbook has these lines + root="$1"; shift + tree "$root" + { printf '# Runbook\n\nProse that mentions grants-invalid is not an entry.\n\n' + for line in "$@"; do printf '%s\n\nText.\n\n' "$line"; done + } > "$root/docs/runbook.md" +} +src() { # src FILE TEXT: a source file holding TEXT + mkdir -p "$(dirname "$1")" + printf '%s\n' "$2" > "$1" +} +says() { # says NAME SCRIPT ROOT WORD...: the script's output must contain every WORD + name="$1"; script="$2"; root="$3"; shift 3 + output=$(sh "$here/$script" "$root" 2>&1) || true + for word in "$@"; do + case "$output" in + *"$word"*) ;; + *) echo "test-gate-scripts: $name: output lacks $word" >&2; fails=$((fails + 1)) ;; + esac + done +} + +book "$tmp/r-ok" '## grants-invalid' '## audit-unavailable' +src "$tmp/r-ok/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +src "$tmp/r-ok/crates/brokerd/src/b.rs" '// see docs/runbook.md#audit-unavailable.' +expect pass "every pointer has an entry" check-runbook.sh "$tmp/r-ok" +book "$tmp/r-miss" '## audit-unavailable' +src "$tmp/r-miss/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +expect fail "a pointer without an entry; a mention in prose is not one" check-runbook.sh "$tmp/r-miss" +book "$tmp/r-h3" '### grants-invalid' '## grants-invalid and more' ' ## grants-invalid' +src "$tmp/r-h3/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +expect fail "the entry is the whole line, at level two" check-runbook.sh "$tmp/r-h3" +book "$tmp/r-test" '## grants-invalid' +src "$tmp/r-test/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +src "$tmp/r-test/crates/brokerd/tests/t.rs" 'assert!(m.ends_with("see docs/runbook.md#no-such-entry"));' +expect fail "a pointer in a test file counts" check-runbook.sh "$tmp/r-test" +book "$tmp/r-line" '## grants-invalid' +src "$tmp/r-line/crates/brokerd/src/a.rs" 'f("docs/runbook.md#grants-invalid", "docs/runbook.md#second-on-the-line");' +expect fail "the second pointer on a line counts" check-runbook.sh "$tmp/r-line" +book "$tmp/r-tgt" '## grants-invalid' +src "$tmp/r-tgt/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +src "$tmp/r-tgt/crates/brokerd/target/debug/gen.rs" 'eprintln!("see docs/runbook.md#no-such-entry");' +expect pass "files under target/ are ignored" check-runbook.sh "$tmp/r-tgt" +book "$tmp/r-case" '## grants-invalid' +src "$tmp/r-case/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#Grants-Invalid");' +expect fail "anchors are compared exactly" check-runbook.sh "$tmp/r-case" +book "$tmp/r-fmt" '## grants-invalid' +src "$tmp/r-fmt/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +src "$tmp/r-fmt/crates/brokerd/src/b.rs" 'eprintln!("see docs/runbook.md#{anchor}");' +expect fail "a pointer whose anchor is not written out" check-runbook.sh "$tmp/r-fmt" +# Report every problem, not only the first. +book "$tmp/r-all" '## grants-invalid' +src "$tmp/r-all/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#first-missing");' +src "$tmp/r-all/crates/loopd/src/b.rs" 'eprintln!("see docs/runbook.md#second-missing");' +expect fail "two pointers without entries" check-runbook.sh "$tmp/r-all" +says "both missing entries and their files are reported" check-runbook.sh "$tmp/r-all" \ + first-missing second-missing crates/brokerd/src/a.rs crates/loopd/src/b.rs + +# A check that cannot find what it checks must fail, not pass. +mkdir -p "$tmp/empty" +expect fail "check-lines without a crates directory" check-lines.sh "$tmp/empty" +expect fail "check-crate-deps without a crates directory" check-crate-deps.sh "$tmp/empty" +expect fail "check-dep-docs without a crates directory" check-dep-docs.sh "$tmp/empty" +tree "$tmp/d-nodoc"; rm "$tmp/d-nodoc/docs/dependencies.md" +expect fail "check-dep-docs without docs/dependencies.md" check-dep-docs.sh "$tmp/d-nodoc" +expect fail "check-runbook without a crates directory" check-runbook.sh "$tmp/empty" +book "$tmp/r-nobook" '## grants-invalid'; rm "$tmp/r-nobook/docs/runbook.md" +src "$tmp/r-nobook/crates/brokerd/src/a.rs" 'eprintln!("see docs/runbook.md#grants-invalid");' +expect fail "check-runbook without docs/runbook.md" check-runbook.sh "$tmp/r-nobook" +book "$tmp/r-none" '## grants-invalid' +expect fail "check-runbook when no source file has a pointer" check-runbook.sh "$tmp/r-none" + +if [ "$fails" -ne 0 ]; then + echo "test-gate-scripts: $fails failure(s)" >&2 + exit 1 +fi +echo "test-gate-scripts: ok" diff --git a/docs/plans/M3a/files/tools/check-m3a-device.sh b/docs/plans/M3a/files/tools/check-m3a-device.sh new file mode 100755 index 0000000..e5cd69c --- /dev/null +++ b/docs/plans/M3a/files/tools/check-m3a-device.sh @@ -0,0 +1,151 @@ +#!/bin/sh +# The M3a check on straylight, run by the owner (not part of `make gate` or `verify-device`). +# +# A private home with one `ask` grant for `read_file` on a directory; `brokerd serve` and +# `loopd serve` on it, `loopd` talking to the real server through a private `inferproxy`. A +# `bxctl chat --say` asks Ornith to read a file in that directory; the approval appears in +# `bxctl approvals`; approving it gives the M3a runner's failure, which the model reports. The +# audit log must verify and hold a Decision, an Approval and a Result. +# +# It uses slot 0 only, and first checks that slot 0 is idle: the server is shared. +# +# sh tools/check-m3a-device.sh [host:port] (default straylight:11434) +# +# Needs curl and jq. On success the home is removed; on failure its path is printed. If the +# model is not loaded, `/slots` may fail: load it first (the check does not load models). +set -u + +UPSTREAM="${1:-straylight:11434}" +MODEL="${BOXMAKER_MODEL:-ornith-1.5-35b-a3b}" +ROOT=$(cd "$(dirname "$0")/.." && pwd) || exit 1 +BIN="$ROOT/target/debug" + +fail() { + echo "check-m3a-device: FAILED: $*" >&2 + [ -n "${HOME_DIR:-}" ] && echo "check-m3a-device: the home is kept at $HOME_DIR" >&2 + exit 1 +} + +for tool in curl jq cargo; do + command -v "$tool" > /dev/null || fail "$tool is not installed" +done + +# 1. Slot 0 must be idle. Anything but a clear "not processing" stops the check. +slots=$(curl -sf "http://$UPSTREAM/slots?model=$MODEL") || fail "cannot read /slots from $UPSTREAM" +# Not `jq -e`: it exits 1 when the value is `false`, which is the answer we want. +busy=$(printf '%s' "$slots" | jq '.[] | select(.id == 0) | .is_processing') \ + || fail "the /slots answer is not a list of slots" +[ -n "$busy" ] || fail "slot 0 is not in the /slots answer" +[ "$busy" = "false" ] || fail "slot 0 is busy ($busy); try again later" + +# 2. Build. +cargo build --workspace --locked --manifest-path "$ROOT/Cargo.toml" || fail "cargo build" + +HOME_DIR=$(mktemp -d) || fail "mktemp" +PIDS="" +cleanup() { + for pid in $PIDS; do kill "$pid" 2> /dev/null; done +} +trap cleanup EXIT + +wait_for() { # path, seconds + n=0 + while [ ! -S "$1" ]; do + n=$((n + 1)) + [ "$n" -gt $(($2 * 10)) ] && fail "$1 did not appear within $2 s" + sleep 0.1 + done +} + +# 3. The home: a file to read, one ask grant, the configs, the system prompt. +mkdir -p "$HOME_DIR/files" "$HOME_DIR/grants" "$HOME_DIR/run/infer" || fail "mkdir" +echo "The launch code is BANANA-42." > "$HOME_DIR/files/note.txt" +cat > "$HOME_DIR/grants/files-read.toml" < "$HOME_DIR/brokerd.toml" < "$HOME_DIR/config.toml" < "$HOME_DIR/inferproxy.err" & +PIDS="$PIDS $!" +wait_for "$HOME_DIR/run/infer/infer.sock" 5 +"$BIN/brokerd" serve --config "$HOME_DIR/brokerd.toml" 2> "$HOME_DIR/brokerd.err" & +PIDS="$PIDS $!" +wait_for "$HOME_DIR/run/owner-broker/admin.sock" 10 +"$BIN/loopd" serve --config "$HOME_DIR/config.toml" 2> "$HOME_DIR/loopd.err" & +PIDS="$PIDS $!" +wait_for "$HOME_DIR/run/loop/loop.sock" 60 + +ADMIN="$HOME_DIR/run/owner-broker/admin.sock" + +# 5. The turn, in the background: it waits for the approval. +"$BIN/bxctl" chat --socket "$HOME_DIR/run/loop/loop.sock" --admin-socket "$ADMIN" \ + --session m3a-device --no-thinking \ + --say "Read the file $HOME_DIR/files/note.txt with the read_file tool and tell me exactly what happened." \ + > "$HOME_DIR/chat.out" 2> "$HOME_DIR/chat.err" & +CHAT=$! + +# 6. Wait for the approval, check what it shows, approve it. +n=0 +while :; do + list=$("$BIN/bxctl" approvals --admin-socket "$ADMIN") || fail "bxctl approvals" + [ "$list" != "no pending approvals" ] && break + kill -0 "$CHAT" 2> /dev/null || fail "the turn ended without asking; see $HOME_DIR/chat.out" + n=$((n + 1)) + [ "$n" -gt 300 ] && fail "no approval within 300 s" + sleep 1 +done +echo "$list" +printf '%s\n' "$list" | grep -q "grant files-read" || fail "the block does not name the grant" +printf '%s\n' "$list" | grep -q "read_file {\"path\":\"$HOME_DIR/files/note.txt\"}" \ + || fail "the block does not show the call" +id=$(printf '%s\n' "$list" | head -n 1 | cut -d ' ' -f 1) +"$BIN/bxctl" approve "$id" --admin-socket "$ADMIN" | tee "$HOME_DIR/approve.out" +grep -qx "approved $id: runs" "$HOME_DIR/approve.out" || fail "approve did not say it runs" + +wait "$CHAT" || fail "bxctl chat failed; see $HOME_DIR/chat.err" +echo "--- the model's answer:" +cat "$HOME_DIR/chat.out" +echo "---" + +# 7. The audit log verifies and holds the three records. +"$BIN/bxctl" audit verify --home "$HOME_DIR" || fail "the audit log does not verify" +for type in decision approval result; do + cat "$HOME_DIR"/audit/*.jsonl | grep -q "\"type\":\"$type\"" || fail "no $type record" +done +grep -q "M3b" "$HOME_DIR/chat.out" \ + || echo "check-m3a-device: note: the model's answer does not quote the runner's sentence; read it above" + +cleanup +trap - EXIT +rm -rf "$HOME_DIR" +echo "check-m3a-device: ok"