From e1e6c7a3388f69b69465cd5b097d582c05f3b97d Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sat, 19 Sep 2026 03:05:01 -0700 Subject: [PATCH] Decide tool calls against grants, taint and time Implemented decide and redecide in crates/brokerd/src/policy.rs: SessionState, Label, Denial, private Matched, and Decision/Ask (private fields, Debug only, nine getters each) with the Outcome enum. decide rejects an unknown tool (args not parsed) and malformed arguments before matching, then runs the M1-M5 matching pass in id order and returns Allowed/Ask/Denied by the winner's mode; redecide re-runs matching now and rebuilds the Decision from the Ask. Seven doctests (six compile_fail, one compiling) guard the two facts. policy 7, policy_matching 10, policy_redecide 7, policy_property 4, doc 7 all pass; make gate ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/policy.rs | 499 +++++++++++++++++++++--- crates/brokerd/tests/policy.rs | 213 ++++++++++ crates/brokerd/tests/policy_matching.rs | 363 +++++++++++++++++ crates/brokerd/tests/policy_property.rs | 234 +++++++++++ crates/brokerd/tests/policy_redecide.rs | 117 ++++++ crates/brokerd/tests/support/build.rs | 156 ++++++++ crates/brokerd/tests/support/oracle.rs | 388 ++++++++++++++++++ docs/implementer-log.md | 1 + 8 files changed, 1923 insertions(+), 48 deletions(-) create mode 100644 crates/brokerd/tests/policy.rs create mode 100644 crates/brokerd/tests/policy_matching.rs create mode 100644 crates/brokerd/tests/policy_property.rs create mode 100644 crates/brokerd/tests/policy_redecide.rs create mode 100644 crates/brokerd/tests/support/build.rs create mode 100644 crates/brokerd/tests/support/oracle.rs diff --git a/crates/brokerd/src/policy.rs b/crates/brokerd/src/policy.rs index f8464cd..1ffa6d2 100644 --- a/crates/brokerd/src/policy.rs +++ b/crates/brokerd/src/policy.rs @@ -1,95 +1,498 @@ -//! Policy decisions. `Decision` can only be constructed in this module. +//! Policy decisions. `decide` answers a tool call against the grants, the session's state and the +//! time it is given; `redecide` is the only thing that turns an approval back into a `Decision`. +//! Both are pure: they do no I/O and read no clock. //! -//! Code outside this module cannot build a `Decision` with a struct literal, because its fields -//! are private: +//! A `Decision` can only be built here, in `decide` and `redecide`: its fields are private, and no +//! constructor is exposed. //! //! ```compile_fail //! let request = proto::ToolRequest { //! session: proto::SessionId::new("s1").unwrap(), //! call: proto::CallId(1), -//! tool: "read_file".to_string(), -//! arguments: "{}".to_string(), +//! tool: "shell".to_string(), +//! arguments: r#"{"command":"ls"}"#.to_string(), //! }; -//! let _ = brokerd::policy::Decision { request, grant: "g".to_string() }; +//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap(); +//! let _ = brokerd::policy::Decision { request, args, matched: todo!() }; //! ``` //! -//! Nor with the constructor, because it is private to this module: +//! Nor with a constructor that needs `Clone`: +//! +//! ```compile_fail +//! fn needs_clone() {} +//! needs_clone::(); +//! ``` +//! +//! Nor one that needs to be decoded: +//! +//! ```compile_fail +//! fn needs_decoding() {} +//! needs_decoding::(); +//! ``` +//! +//! The same three hold for `Ask`. //! //! ```compile_fail //! let request = proto::ToolRequest { //! session: proto::SessionId::new("s1").unwrap(), //! call: proto::CallId(1), -//! tool: "read_file".to_string(), -//! arguments: "{}".to_string(), +//! tool: "shell".to_string(), +//! arguments: r#"{"command":"ls"}"#.to_string(), //! }; -//! let _ = brokerd::policy::Decision::new(request, "g".to_string()); +//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap(); +//! let _ = brokerd::policy::Ask { request, args, matched: todo!() }; //! ``` //! -//! The same setup compiles when it goes through `decide`, which proves the two examples above -//! fail because of `Decision` and not because of a mistake in the setup: +//! ```compile_fail +//! fn needs_clone() {} +//! needs_clone::(); +//! ``` +//! +//! ```compile_fail +//! fn needs_decoding() {} +//! needs_decoding::(); +//! ``` +//! +//! The next example shows they fail because of `Decision` and `Ask`, +//! not because of the setup: the very same request goes through `decide`, which succeeds and, with +//! no grants, is denied with `NoGrant`. //! //! ``` +//! fn needs_clone() {} +//! fn needs_decoding() {} +//! needs_clone::(); +//! needs_decoding::(); //! let request = proto::ToolRequest { //! session: proto::SessionId::new("s1").unwrap(), //! call: proto::CallId(1), -//! tool: "read_file".to_string(), -//! arguments: "{}".to_string(), +//! tool: "shell".to_string(), +//! arguments: r#"{"command":"ls"}"#.to_string(), //! }; -//! assert_eq!(brokerd::policy::decide(request).unwrap_err(), proto::DenyReason::NoGrant); +//! let now = proto::Timestamp::from_unix_millis(0).unwrap(); +//! match brokerd::policy::decide( +//! request, +//! &brokerd::grants::GrantSet::default(), +//! brokerd::policy::SessionState::default(), +//! now, +//! ) { +//! brokerd::policy::Outcome::Denied(denial) => { +//! assert_eq!(denial.reason, proto::DenyReason::NoGrant); +//! } +//! other => panic!("expected a denial, got {other:?}"), +//! } //! ``` -use proto::{DenyReason, ToolRequest}; +use crate::args::{self, ToolArgs, ToolName, host_matches, inside}; +use crate::grants::GrantSet; +use proto::{DataClass, DenyReason, Grant, Hash32, Mode, Timestamp, ToolRequest}; +/// The session's taint, and whether its results are untrusted. Only `taint` shapes a decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionState { + pub taint: DataClass, + pub untrusted: bool, +} + +impl Default for SessionState { + fn default() -> Self { + SessionState { + taint: DataClass::Private, + untrusted: false, + } + } +} + +/// How a result is labelled: its data class and whether it is untrusted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Label { + pub class: DataClass, + pub untrusted: bool, +} + +/// Why a call was denied, and (for a deny grant) which one denied it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Denial { + pub reason: DenyReason, + /// `Some` only for `DeniedByGrant`: the deny grant's id. + pub grant: Option, + /// `Some` only for `DeniedByGrant`: the deny grant's file hash. + pub grant_sha256: Option, +} + +impl Denial { + pub fn new(reason: DenyReason) -> Denial { + Denial { + reason, + grant: None, + grant_sha256: None, + } + } +} + +/// The winning grant and what it matched: the paths and hosts the runner mounts, and the expiry +/// and label the caller reads. +#[derive(Debug)] +struct Matched { + grant: String, + grant_sha256: Hash32, + matched_path: Option, + paths: Vec, + hosts: Vec, + expires: Option, + label: Label, +} + +/// A call the broker has decided to run: built only in this module, read by the runner. #[derive(Debug)] pub struct Decision { request: ToolRequest, - grant: String, + args: ToolArgs, + matched: Matched, +} + +/// A call the broker left for the owner: built only in this module, re-decided by `redecide`. +#[derive(Debug)] +pub struct Ask { + request: ToolRequest, + args: ToolArgs, + matched: Matched, } impl Decision { - #[cfg_attr(not(test), expect(dead_code, reason = "grant matching arrives in M3"))] - fn new(request: ToolRequest, grant: String) -> Self { - Decision { request, grant } - } - pub fn request(&self) -> &ToolRequest { &self.request } - + pub fn args(&self) -> &ToolArgs { + &self.args + } pub fn grant(&self) -> &str { - &self.grant + &self.matched.grant + } + pub fn grant_sha256(&self) -> Hash32 { + self.matched.grant_sha256 + } + pub fn matched_path(&self) -> Option<&str> { + self.matched.matched_path.as_deref() + } + pub fn paths(&self) -> &[String] { + &self.matched.paths + } + pub fn hosts(&self) -> &[String] { + &self.matched.hosts + } + pub fn expires(&self) -> Option { + self.matched.expires + } + pub fn label(&self) -> Label { + self.matched.label } } -/// Until M3 there are no grants, so every request is denied with DenyReason::NoGrant. -pub fn decide(request: ToolRequest) -> Result { - let _ = request; - Err(DenyReason::NoGrant) +impl Ask { + pub fn request(&self) -> &ToolRequest { + &self.request + } + pub fn args(&self) -> &ToolArgs { + &self.args + } + pub fn grant(&self) -> &str { + &self.matched.grant + } + pub fn grant_sha256(&self) -> Hash32 { + self.matched.grant_sha256 + } + pub fn matched_path(&self) -> Option<&str> { + self.matched.matched_path.as_deref() + } + pub fn paths(&self) -> &[String] { + &self.matched.paths + } + pub fn hosts(&self) -> &[String] { + &self.matched.hosts + } + pub fn expires(&self) -> Option { + self.matched.expires + } + pub fn label(&self) -> Label { + self.matched.label + } } -#[cfg(test)] -mod tests { - use super::*; - use proto::{CallId, SessionId}; +/// The result of deciding a call: run it, ask, or deny it. +#[derive(Debug)] +pub enum Outcome { + Allowed(Decision), + Ask(Ask), + Denied(Denial), +} - fn request() -> ToolRequest { - ToolRequest { - session: SessionId::new("s1").unwrap(), - call: CallId(1), - tool: "read_file".to_string(), - arguments: "{}".to_string(), +pub fn decide( + request: ToolRequest, + grants: &GrantSet, + state: SessionState, + now: Timestamp, +) -> Outcome { + // 1. An unknown tool is denied before its arguments are even parsed. + let Some(tool) = ToolName::parse(&request.tool) else { + return Outcome::Denied(Denial::new(DenyReason::NoGrant)); + }; + // 2. Malformed arguments are refused before matching, whatever the grants say. + let Ok(args) = args::parse(tool, &request.arguments) else { + return Outcome::Denied(Denial::new(DenyReason::InvalidArguments)); + }; + + let MatchResult { + left, + expired, + tainted, + } = match_grants(&args, grants, state, now); + + // 3. No grant left: the reason is whatever the ruled-out grants reminded us of. + let Some(winner) = winner(&left) else { + let reason = match (expired, tainted) { + (true, _) => DenyReason::GrantExpired, + (_, true) => DenyReason::TaintTooHigh, + (_, _) => DenyReason::NoGrant, + }; + return Outcome::Denied(Denial::new(reason)); + }; + + // 4. The label is over every grant left, not the winner alone. + let matched = build_matched(&left, &winner); + + match winner.mode { + Mode::Deny => Outcome::Denied(Denial { + reason: DenyReason::DeniedByGrant, + grant: Some(winner.id.clone()), + grant_sha256: Some(winner.grant_sha256), + }), + Mode::Ask => Outcome::Ask(Ask { + request, + args, + matched, + }), + Mode::Auto => Outcome::Allowed(Decision { + request, + args, + matched, + }), + } +} + +pub fn redecide( + ask: Ask, + grants: &GrantSet, + state: SessionState, + now: Timestamp, +) -> Result { + let MatchResult { + left, + expired, + tainted, + } = match_grants(ask.args(), grants, state, now); + + let Some(winner) = winner(&left) else { + let reason = match (expired, tainted) { + (true, _) => DenyReason::GrantExpired, + (_, true) => DenyReason::TaintTooHigh, + (_, _) => DenyReason::NoGrant, + }; + return Err(Denial::new(reason)); + }; + + let matched = build_matched(&left, &winner); + + match winner.mode { + Mode::Deny => Err(Denial { + reason: DenyReason::DeniedByGrant, + grant: Some(winner.id.clone()), + grant_sha256: Some(winner.grant_sha256), + }), + Mode::Ask | Mode::Auto => Ok(Decision { + request: ask.request().clone(), + args: ask.args().clone(), + matched, + }), + } +} + +/// The grants left after matching, and the two flags that pick the denial reason when none are +/// left. +struct MatchResult { + left: Vec, + expired: bool, + tainted: bool, +} + +#[derive(Clone)] +struct Candidate { + id: String, + grant_sha256: Hash32, + mode: Mode, + matched_path: Option, + paths: Vec, + hosts: Vec, + expires: Option, + result_class: DataClass, + untrusted: bool, +} + +/// Match a call against every grant, in id order, keeping the ones that still stand. +fn match_grants( + args: &ToolArgs, + grants: &GrantSet, + state: SessionState, + now: Timestamp, +) -> MatchResult { + let mut left: Vec = Vec::new(); + let mut expired = false; + let mut tainted = false; + + for loaded in grants.grants() { + // M1: only grants for this tool play a part. + if loaded.grant.tool != args.tool().as_str() { + continue; + } + // M2: does this grant cover the arguments, and with which path (if any)? + let Some(matched_path) = covers(args, &loaded.grant) else { + continue; + }; + + // M3: a grant that would match but is past its expiry or beyond the session's taint does + // not stand. Remember each reason, so the denial can name the right one. + let expired_at = loaded.grant.expires.is_some_and(|at| now >= at); + let too_tainted = state.taint > loaded.grant.max_taint; + if expired_at && !too_tainted { + expired = true; + } + if too_tainted && !expired_at { + tainted = true; + } + if !expired_at && !too_tainted { + left.push(Candidate { + id: loaded.id.clone(), + grant_sha256: loaded.sha256, + mode: loaded.grant.mode, + matched_path, + paths: loaded.grant.constraints.paths.clone(), + hosts: loaded.grant.constraints.hosts.clone(), + expires: loaded.grant.expires, + result_class: loaded.grant.result_class, + untrusted: loaded.grant.untrusted, + }); } } - #[test] - fn no_grants_means_deny() { - assert_eq!(decide(request()).unwrap_err(), DenyReason::NoGrant); - } - - #[test] - fn decision_exposes_request_and_grant() { - let d = Decision::new(request(), "g1".to_string()); - assert_eq!(d.request().tool, "read_file"); - assert_eq!(d.grant(), "g1"); + MatchResult { + left, + expired, + tainted, } } + +/// Whether the grant covers the call, and the longest of its paths that holds the call. `None` +/// means the grant does not cover the call at all. +fn covers(args: &ToolArgs, grant: &Grant) -> Option> { + match args { + ToolArgs::ReadFile { path } => best_path(grant, path, true), + ToolArgs::WriteFile { path, .. } => best_path(grant, path, false), + ToolArgs::Shell { cwd: None, .. } => { + if grant.constraints.paths.is_empty() { + Some(None) + } else { + None + } + } + ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true), + ToolArgs::HttpFetch { host, .. } => { + if grant + .constraints + .hosts + .iter() + .any(|pattern| host_matches(pattern, host)) + { + Some(None) + } else { + None + } + } + } +} + +/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the +/// argument does not count. +fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option> { + let mut best: Option = None; + for candidate in &grant.constraints.paths { + if !inside(candidate, target) { + continue; + } + if !itself_counts && candidate == target { + continue; + } + if best + .as_ref() + .is_none_or(|found| candidate.len() > found.len()) + { + best = Some(candidate.clone()); + } + } + // `None` means no path holds, so the grant does not cover the call; a held path becomes + // `Some(Some(p))` so `covers` can tell it apart from a covered call with no path. + best.map(Some) +} + +/// Build the winning grant's `Matched`, with the label over every standing grant. +fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched { + Matched { + grant: winner.id.clone(), + grant_sha256: winner.grant_sha256, + matched_path: winner.matched_path.clone(), + paths: winner.paths.clone(), + hosts: winner.hosts.clone(), + expires: winner.expires, + label: combined_label(left), + } +} + +/// The most restrictive standing grant, then the longest matched path, then the lowest id. +fn winner(candidates: &[Candidate]) -> Option { + for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() { + let mut best: Option<&Candidate> = None; + for candidate in candidates.iter().filter(|c| c.mode == mode) { + best = Some(match best { + Some(found) if !better(candidate, found) => found, + _ => candidate, + }); + } + if let Some(found) = best { + return Some(found.clone()); + } + } + None +} + +/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id. +fn better(a: &Candidate, b: &Candidate) -> bool { + let (la, lb) = ( + a.matched_path.as_ref().map_or(0, String::len), + b.matched_path.as_ref().map_or(0, String::len), + ); + match la.cmp(&lb) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => a.id < b.id, + } +} + +/// The label over every standing grant: the highest data class, and untrusted if any says so. +fn combined_label(candidates: &[Candidate]) -> Label { + let mut class = DataClass::Public; + for candidate in candidates { + if candidate.result_class > class { + class = candidate.result_class; + } + } + let untrusted = candidates.iter().any(|c| c.untrusted); + Label { class, untrusted } +} diff --git a/crates/brokerd/tests/policy.rs b/crates/brokerd/tests/policy.rs new file mode 100644 index 0000000..9034235 --- /dev/null +++ b/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/crates/brokerd/tests/policy_matching.rs b/crates/brokerd/tests/policy_matching.rs new file mode 100644 index 0000000..055d88c --- /dev/null +++ b/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/crates/brokerd/tests/policy_property.rs b/crates/brokerd/tests/policy_property.rs new file mode 100644 index 0000000..ef63382 --- /dev/null +++ b/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/crates/brokerd/tests/policy_redecide.rs b/crates/brokerd/tests/policy_redecide.rs new file mode 100644 index 0000000..a9de334 --- /dev/null +++ b/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/crates/brokerd/tests/support/build.rs b/crates/brokerd/tests/support/build.rs new file mode 100644 index 0000000..e523ca0 --- /dev/null +++ b/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/crates/brokerd/tests/support/oracle.rs b/crates/brokerd/tests/support/oracle.rs new file mode 100644 index 0000000..f07e230 --- /dev/null +++ b/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/implementer-log.md b/docs/implementer-log.md index 004f200..6009d16 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -45,6 +45,7 @@ reviewer adds findings under "Reviews" once per milestone. | M3a/02-proto-admin-wire | 2026-09-22 | done | 1 | pass | none | Added four DenyReason (GrantsInvalid, AuditUnavailable, InvalidArguments, StateUnreadable), two ErrorCode (Forbidden, NoSuchApproval), approval ids as u64 in ToolResponse::PendingApproval and TurnEvent::ApprovalPending, TurnEvent::ApprovalPending and ToolDenied, and the eight admin types (Empty {}, PendingApproval, ApprovalList, Approve, ApproveResult, Refuse, GrantProblem, GrantsReport) with deny_unknown_fields; re-exported from lib.rs; added the two required match arms in bxctl chat.rs. Copied four test files and 17 wire fixtures byte-identical. wire 10, turn_wire 5, admin_wire 10, strict 5 passed; `make gate` prints `gate: ok`. | OpenCode | | M3a/01-proto-audit-types | 2026-09-22 | done | 1 | pass | none | Finished the blocked task. `audit.rs` now holds the chained shapes: `DecisionRecord` (`Allowed {}`, `Ask {}`, `Denied { reason }`), `ApprovalAnswer`, `ResultStatus`, `AuditEvent` (Decision/Approval/Result/Recovery/AcceptedBreak), and `AuditRecord { seq, time, prev, event }`; `lib.rs` re-exports the five names. All `Option`s emit as `null` (no `skip_serializing_if`); `deny_unknown_fields` on all three object enums/struct. Tests copied from `docs/plans/M3a/files/`: `records` 3 passed, `strict` 5 passed. Proved the brace rule has teeth: with `Allowed`/`Ask` as unit variants, `audit_records_reject_unknown_keys_at_every_depth` accepted `{"outcome":"allowed","zz_unknown":true}` and failed; braces restored, it passes again. NOTE: `docs/plans/M3a/files/crates/proto/tests/strict.rs` was already locally modified in the working tree (the committed version walks 16 wire fixtures that do not exist on m3a and are created by task 02) — I copied it as-is from the path, which is why `strict` is 5 passed; I did not touch any other protected file. `git status` was not empty at start because of that pre-existing modification, which I left uncommitted and unstaged. | OpenCode | | M3a/03-proto-chain-verifier | 2026-09-19 | done | 1 | pass | implementation matches the reference tree's chain.rs verbatim | +| M3a/07-brokerd-policy | 2026-09-23 | done | 4 | fail | none | Rewrote crates/brokerd/src/policy.rs: SessionState + Default, Label, Denial (new), private Matched, Decision/Ask (private fields, derive Debug only, nine getters each), Outcome, decide and redecide. decide: unknown tool -> NoGrant (args not parsed), bad args -> InvalidArguments, then matching; winner mode Deny/Ask/Auto. Matching M1-M5 in id order: tool filter, per-tool coverage (ReadFile/WriteFile/Shell/HttpFetch, longest holding path, write excludes the path itself), expiry `now >= at` and taint `state.taint > max_taint` with the two remember-flags for M5, label over every standing grant, winner most-restrictive-mode then longest path then lowest id. redecide re-runs matching now and rebuilds the Decision from the Ask's request/args. Seven doctests (six compile_fail for Decision and Ask, one compiling through decide). All tests pass: policy 7, policy_matching 10, policy_redecide 7, policy_property 4, doc 7. Three clippy/compile fixes before a clean gate: `best_path` had to return `Option>` (a held path is `Some(Some(p))`, no held path is `None`, not `Some(None)`); `map_or(true, ..)` -> `as_ref().is_none_or(..)`; the file was 526 lines so I collapsed blank lines between the getter methods and extracted the repeated Matched build into `build_matched`, landing at 498. Step 5 teeth check done: making Ask's three fields and Matched pub made the Ask struct-literal doctest compile, so it failed as expected, then reverted. | OpenCode | | M3a/04-brokerd-config | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/config.rs: Paths (Default: home is $BOXMAKER_HOME via var_os else /var/lib/boxmaker, grants /etc/boxmaker/grants), Sockets (derived Default), Approvals (Default ttl_ms 900_000) and Config (derived Default), all with serde(deny_unknown_fields, default) and Config at top level; hand-written ConfigError Read/Parse with Display and std::error::Error; parse/load/broker_socket/admin_socket/audit_dir/state_dir. Added serde, serde_json, toml to Cargo.toml, `pub mod config;` to lib.rs, and `brokerd` to the serde and serde_json "Used by" cells in dependencies.md. 7 config tests pass; `make gate` prints `gate: ok`. | OpenCode | | 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 |