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
+388
View File
@@ -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<T: Copy>(&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<LoadedGrant> {
let count = rng.below(8);
let mut ids: Vec<usize> = (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<String>,
class: DataClass,
untrusted: bool,
},
Ask {
grant: String,
path: Option<String>,
class: DataClass,
untrusted: bool,
},
Denied {
reason: DenyReason,
grant: Option<String>,
},
}
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::<Vec<_>>() == 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<String>),
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<Option<String>> {
let holding = |path: &str, itself_counts: bool| -> Option<Option<String>> {
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<String>,
class: DataClass,
untrusted: bool,
}
let mut left: Vec<Left> = 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")
}