//! 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. //! //! 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: "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!() }; //! ``` //! //! 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: "shell".to_string(), //! arguments: r#"{"command":"ls"}"#.to_string(), //! }; //! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap(); //! let _ = brokerd::policy::Ask { request, args, matched: todo!() }; //! ``` //! //! ```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: "shell".to_string(), //! arguments: r#"{"command":"ls"}"#.to_string(), //! }; //! 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 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, 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 { 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 } } 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 } } /// The result of deciding a call: run it, ask, or deny it. #[derive(Debug)] pub enum Outcome { Allowed(Decision), Ask(Ask), Denied(Denial), } 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, }); } } 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(target) => { if grant .constraints .hosts .iter() .any(|pattern| host_matches(pattern, target.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 } }