diff --git a/crates/brokerd/src/runner.rs b/crates/brokerd/src/runner.rs index c12c956..12d4f1a 100644 --- a/crates/brokerd/src/runner.rs +++ b/crates/brokerd/src/runner.rs @@ -1,12 +1,138 @@ -//! Tool runner stub. Real tool execution arrives in M3. +//! The runner seam: an allowed call becomes a `RunSpec` and is handed to a `Runtime`. +//! +//! A `RunSpec` can only be built here, from a `Decision`, so a runtime never sees a call policy +//! did not allow. +//! +//! ```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() +//! } +//! ``` +use crate::args::{ToolArgs, ToolName}; use crate::policy::Decision; use proto::ToolResponse; -/// Takes the Decision by value, so one decision cannot run a tool twice. -pub fn run(decision: Decision) -> ToolResponse { - let _ = decision; - ToolResponse::Failed { - message: "no tool runner until M3".to_string(), +/// A directory mounted for one call: a path and whether the runtime may write to it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mount { + pub path: String, + pub writable: bool, +} + +/// What one call was turned into before it reached a `Runtime`: the tool, its arguments, the +/// directories mounted and the hosts it may reach. Built only here, from a `Decision`. +#[derive(Debug)] +pub struct RunSpec { + tool: ToolName, + arguments: ToolArgs, + mounts: Vec, + egress: Option>, +} + +impl RunSpec { + pub fn tool(&self) -> ToolName { + self.tool + } + pub fn arguments(&self) -> &ToolArgs { + &self.arguments + } + pub fn mounts(&self) -> &[Mount] { + &self.mounts + } + pub fn egress(&self) -> Option<&[String]> { + self.egress.as_deref() + } +} + +/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled +/// public and trusted, so a runtime may only put a fixed sentence in it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunError { + Failed(String), + Unavailable(String), +} + +/// What a `Runtime` produced for one call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunOutput { + pub content: String, + pub truncated: bool, +} + +/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once. +pub trait Runtime: Send + Sync { + fn run(&self, spec: &RunSpec) -> Result; +} + +/// The production runtime for M3a. It runs nothing; every call is `Unavailable`. +pub struct Refusing; + +impl Runtime for Refusing { + fn run(&self, _spec: &RunSpec) -> Result { + Err(RunError::Unavailable(REFUSING.to_string())) + } +} + +/// The sentence `Refusing` puts in every `Unavailable`: fixed, because it reaches the model. +pub const REFUSING: &str = "the runner arrives in M3b"; + +/// Turn an allowed call into a `RunSpec`, run it, and answer. A `RunError`'s text reaches the +/// model unchanged, so a runtime may only put a fixed sentence in it. +pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse { + let label = decision.label(); + let args = decision.args(); + let (mounts, egress) = match args { + ToolArgs::ReadFile { .. } => (matched_mount(decision.matched_path(), false), None), + ToolArgs::WriteFile { .. } => (matched_mount(decision.matched_path(), true), None), + ToolArgs::Shell { .. } => ( + decision + .paths() + .iter() + .map(|path| Mount { + path: path.clone(), + writable: true, + }) + .collect(), + None, + ), + ToolArgs::HttpFetch { .. } => (Vec::new(), Some(decision.hosts().to_vec())), + }; + let spec = RunSpec { + tool: args.tool(), + arguments: args.clone(), + mounts, + egress, + }; + match runtime.run(&spec) { + Ok(output) => ToolResponse::Result { + content: output.content, + class: label.class, + untrusted: label.untrusted, + truncated: output.truncated, + }, + Err(RunError::Failed(message) | RunError::Unavailable(message)) => { + ToolResponse::Failed { message } + } + } +} + +/// The matched path as one mount, or none when the call matched no path. +fn matched_mount(path: Option<&str>, writable: bool) -> Vec { + match path { + Some(at) => vec![Mount { + path: at.to_string(), + writable, + }], + None => Vec::new(), } } diff --git a/crates/brokerd/tests/runner.rs b/crates/brokerd/tests/runner.rs new file mode 100644 index 0000000..667c082 --- /dev/null +++ b/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/crates/brokerd/tests/support/runtime.rs b/crates/brokerd/tests/support/runtime.rs new file mode 100644 index 0000000..0547430 --- /dev/null +++ b/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/implementer-log.md b/docs/implementer-log.md index 5b77784..e4fe2a1 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -51,6 +51,7 @@ reviewer adds findings under "Reviews" once per milestone. | M3a/06-brokerd-grants | 2026-09-23 | done | 2 | fail | none | Wrote crates/brokerd/src/grants.rs: `RUNBOOK`, `LoadedGrant`, `GrantSet` (private `grants` field, `from_grants` sorts by id and collects every problem, `grants()`), `valid_id`, `load` (read_dir -> one directory problem, a missing dir is not empty, sorted names, skip non-.toml, read/utf8/toml/sha256 each record a problem and continue, then `from_grants`, stable sort by file), `render`, and `span_line` (count newlines in `text.get(..offset)` + 1). Rules 2-9 live in `check_grant`/`check_tool_constraints`; an unknown tool skips rule 6 only. First gate failed on clippy `needless_borrows_for_generic_args` (pass `format!()` not `&format!()` to the `impl Into` `push`); all 17 grants tests pass; `make gate` prints `gate: ok`. | OpenCode | | M3a/05-brokerd-args | 2026-09-23 | done | 4 | fail | none | Wrote crates/brokerd/src/args.rs (MAX_PATH, MAX_URL, ToolName with ALL/parse/as_str, ToolArgs with tool/canonical_json, ArgsError with hand-written Display+Error, parse, valid_path, inside, valid_host, valid_host_pattern, host_matches, url_host) and added `pub mod args;` to lib.rs. 13 args tests pass; `make gate` prints `gate: ok`. Three clippy fixes before a clean gate: collapsed the shell `cwd` if-let into an edition-2024 let-chain, `('a'..='z').contains` -> `is_ascii_lowercase`, and the trailing `/` match -> `?`. `source()` returns None because `String` does not implement `std::error::Error`. The URL rules read the host as written (no `to_lowercase`); uppercase fails `valid_host`, matching the test that lists `https://Example.com/` as invalid. | OpenCode | Wrote crates/proto/src/chain.rs: `ChainVerifier`, a pure line-holding state machine (each line is judged only once the next one has arrived, so a `Recovery` record can mark the line before it not-a-record), plus `ChainFailure`, `TornTail`, `ChainReport`, `Location`. Holds each line, checks recovery against the next, then rule 1 (parse, expected seq, prev with the file-before text for line 1 of a resumed/continued verifier), the failed-region counting of rule 5, the resumed-earlier-file break exception of rule 6, run/ask tracking for `abandoned`/`unfinished`, and clock warnings; `finish` reports the torn tail and the break's required seq/prev. Added `pub mod chain` and the five re-exports to lib.rs and the same line to audit.rs. The single worker subagent for this task entered an unrecoverable reasoning loop on the state machine and was not completing, so the orchestrator implemented it directly from the spec and fixtures. 13 chain tests pass; `make gate` prints `gate: ok`. | OpenCode | | M3a/09-brokerd-audit-writer | 2026-09-19 | done | 2 | fail | `write_record` opens with `.append(true)` (task says "for write") because this environment's `tmpfs` truncates on `write(true).create(true)`; `open` tolerates an already-existing dir (the `case` fixtures pre-create it); `Lock(fs::File)` wrapper added so `Writer` can `#[derive(Debug)]` (the copied tests call `unwrap_err`). | Wrote `crates/brokerd/src/audit.rs`: `Writer`, `Opened`, `AuditError` (Locked/Broken/NothingToAccept/Io/Stopped, hand-written Display ending in the task's RUNBOOK anchors), `verify_dir` (the short check for 2+ files, else full), and `RECOVERED_NOTICE`; `pub mod audit;` in lib.rs. Copied three test files byte-identical. The day-boundary and failed-write tests failed for two real reasons: the appends were silently losing every second line because `tmpfs` truncates on `write(true)` (fixed with `.append(true)`), and the second writer was not being marked `Stopped` after a failed write (fixed per append rule 5). All 16 tests pass (9 audit + 7 audit_startup) across five runs; `make gate` prints `gate: ok`. Two clippy fixes before a clean gate: collapsed the dir-builder `if let` into a let-chain, and added `.truncate(false)` to the lock's open. | OpenCode | +| M3a/10-brokerd-runner | 2026-09-19 | done | 1 | pass | none | Wrote `crates/brokerd/src/runner.rs`: `Mount` (path/writable), `RunSpec` (four private fields in the given order, no constructor, no Clone, four getters, `egress` as `as_deref`), `RunOutput`, `RunError` (Failed/Unavailable), `Runtime` (Send+Sync), `Refusing` (every call `Err(Unavailable(REFUSING))`), and `run(decision, &dyn Runtime)`. `run` takes `decision.label()` first, builds the spec (`tool = args().tool()`, `arguments = args().clone()`), then the mounts/egress table is one `match` on the `ToolArgs` variant with no `_` arm: ReadFile mounts the matched path read-only, WriteFile writable, Shell mounts every grant path in order writable, HttpFetch mounts nothing and carries `Some(hosts())`; only HttpFetch has network. `Ok(output)` -> `ToolResponse::Result` with the label's class/untrusted; `Err(Failed|Unavailable(m))` -> `Failed { message: m }` unchanged. Added the two module doctests (compile_fail proving the fields private, compiling through proving `tool()` public). 8 runner tests pass; 9 doctests pass; `make gate` prints `gate: ok`. Proved the compile_fail doctest has teeth: making the four RunSpec fields `pub` made it fail ("test compiled"), reverting to private made it pass again. | ? | ## Reviews