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:
+451
-48
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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=<n>` runs one more seed, and `BOXMAKER_POLICY_CASES=<n>`
|
||||
//! 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<u64> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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