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)
This commit is contained in:
2026-09-19 03:05:01 -07:00
parent e2ab29aa15
commit e1e6c7a338
8 changed files with 1923 additions and 48 deletions
+451 -48
View File
@@ -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<T: Clone>() {}
//! needs_clone::<brokerd::policy::Decision>();
//! ```
//!
//! Nor one that needs to be decoded:
//!
//! ```compile_fail
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_decoding::<brokerd::policy::Decision>();
//! ```
//!
//! 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<T: Clone>() {}
//! needs_clone::<brokerd::policy::Ask>();
//! ```
//!
//! ```compile_fail
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_decoding::<brokerd::policy::Ask>();
//! ```
//!
//! 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<T: Clone>() {}
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_clone::<proto::ToolRequest>();
//! needs_decoding::<proto::ToolRequest>();
//! 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<String>,
/// `Some` only for `DeniedByGrant`: the deny grant's file hash.
pub grant_sha256: Option<Hash32>,
}
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<String>,
paths: Vec<String>,
hosts: Vec<String>,
expires: Option<Timestamp>,
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<Timestamp> {
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<Decision, DenyReason> {
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<Timestamp> {
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<Decision, Denial> {
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<Candidate>,
expired: bool,
tainted: bool,
}
#[derive(Clone)]
struct Candidate {
id: String,
grant_sha256: Hash32,
mode: Mode,
matched_path: Option<String>,
paths: Vec<String>,
hosts: Vec<String>,
expires: Option<Timestamp>,
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<Candidate> = 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<Option<String>> {
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<Option<String>> {
let mut best: Option<String> = 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<Candidate> {
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 }
}