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:
@@ -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<Build>) -> 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user