Hand over the M3a plan: 22 tasks, their files, and the check record

Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:45:43 -07:00
co-authored by Claude Opus 5
parent 69f0a0a218
commit e3f37da232
180 changed files with 17219 additions and 292 deletions
@@ -0,0 +1,385 @@
//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which
//! answers an approval the same way. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use brokerd::admin;
use brokerd::broker::{self, Broker};
use brokerd::policy::Label;
use client::{Serve, next, open};
use proto::{
ApprovalAnswer, Approve, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, ErrorCode,
Message, Refuse, SessionId, Timestamp, ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig) {
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
}
/// Sends a call that waits for approval; returns its connection once the pending frame is read.
fn pending(broker: &Arc<Broker>, call: u64, arguments: &str) -> (UnixStream, u64) {
let req = request("s1", call, "read_file", arguments);
let mut stream = open(broker, broker::handle, call, Message::ToolRequest(req));
match next(&mut stream).msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval),
other => panic!("{other:?}"),
}
}
/// One admin request; returns the answer's message after checking its id and `final`.
fn admin(broker: &Arc<Broker>, msg: Message) -> Message {
let mut stream = open(broker, admin::handle, 5, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (5, true), "{env:?}");
env.msg
}
fn approve(broker: &Arc<Broker>, approval: u64) -> Message {
admin(broker, Message::Approve(Approve { approval }))
}
fn refuse(broker: &Arc<Broker>, approval: u64, reason: Option<&str>) -> Message {
let reason = reason.map(str::to_string);
admin(broker, Message::Refuse(Refuse { approval, reason }))
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
match next(stream).msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn outcome(msg: Message) -> DecisionRecord {
match msg {
Message::ApproveResult(r) => r.outcome,
other => panic!("{other:?}"),
}
}
fn error_code(msg: &Message) -> Option<ErrorCode> {
match msg {
Message::Error(e) => Some(e.code),
_ => None,
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
#[test]
fn approvals_lists_the_arguments_as_policy_parsed_them() {
let rig = Rig::new("admin-list");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert!(matches!(
admin(&broker, Message::Approvals(Empty {})),
Message::ApprovalList(list) if list.items.is_empty()
));
// A backslash-u escape of `/` is `/` in JSON; the owner sees the one spelling policy matched.
let escaped = format!(r#"{{"path":"{}n{}a"}}"#, "\\u002f", "\\u002f");
assert!(
escaped.contains("u002f"),
"the escape must survive: {escaped}"
);
let (_stream, approval) = pending(&broker, 1, &escaped);
match admin(&broker, Message::Approvals(Empty {})) {
Message::ApprovalList(list) => {
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].approval, approval);
assert_eq!(list.items[0].arguments, r#"{"path":"/n/a"}"#);
assert_eq!(list.items[0].grant, "notes");
}
other => panic!("{other:?}"),
}
// The audit log keeps the raw string.
match &rig.events()[0] {
AuditEvent::Decision { arguments, .. } => assert_eq!(arguments, &escaped),
other => panic!("{other:?}"),
}
}
#[test]
fn approve_runs_the_call_and_answers_with_the_re_decision() {
let rig = Rig::new("admin-approve");
ask_notes(&rig);
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert_eq!(outcome(approve(&broker, approval)), DecisionRecord::Ask {});
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!(*answer, ApprovalAnswer::Approved);
assert_eq!(by.as_deref(), Some("bxctl"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn refuse_denies_the_call_and_records_the_reason() {
let rig = Rig::new("admin-refuse");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert!(matches!(
refuse(&broker, approval, Some("not today")),
Message::Ok(Empty {})
));
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalRefused)
);
assert_eq!(rt.count(), 0);
match &rig.events()[1] {
AuditEvent::Approval { answer, reason, .. } => {
assert_eq!(*answer, ApprovalAnswer::Refused);
assert_eq!(reason.as_deref(), Some("not today"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_unknown_or_answered_id_is_no_such_approval() {
let rig = Rig::new("admin-unknown");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert_eq!(
error_code(&approve(&broker, 41)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(
error_code(&refuse(&broker, 41, None)),
Some(ErrorCode::NoSuchApproval)
);
let (_stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
refuse(&broker, approval, None);
assert_eq!(
error_code(&approve(&broker, approval)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(rig.events().len(), 2, "one decision, one approval");
}
#[test]
fn approve_and_refuse_at_once_give_exactly_one_answer() {
let rig = Rig::new("admin-race");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
for round in 0..100u64 {
let (mut stream, approval) = pending(&broker, round, r#"{"path":"/n/a"}"#);
let start = Arc::new(Barrier::new(2));
let racers: Vec<_> = [true, false]
.into_iter()
.map(|approving| {
let (broker, start) = (Arc::clone(&broker), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
if approving {
approve(&broker, approval)
} else {
refuse(&broker, approval, None)
}
})
})
.collect();
let answers: Vec<Message> = racers.into_iter().map(|r| r.join().unwrap()).collect();
let losers = answers
.iter()
.filter(|m| error_code(m) == Some(ErrorCode::NoSuchApproval))
.count();
assert_eq!(losers, 1, "round {round}: {answers:?}");
final_answer(&mut stream);
let approvals = rig
.events()
.iter()
.filter(|e| matches!(e, AuditEvent::Approval { decision, .. } if *decision == approval))
.count();
assert_eq!(approvals, 1, "round {round}");
}
}
#[test]
fn approve_after_the_grant_file_is_removed_is_denied() {
let rig = Rig::new("admin-removed");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.remove_grant("notes");
let reason = DenyReason::NoGrant;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn approve_after_the_taint_rose_past_the_grant_is_denied() {
let rig = Rig::new("admin-taint");
let text = grant_text("read_file", "ask", "", "paths = [\"/n\"]")
.replace("max_taint = \"secret\"", "max_taint = \"private\"");
rig.grant("notes", &text);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let s1 = SessionId::new("s1").unwrap();
let secret = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), secret)
.unwrap();
let reason = DenyReason::TaintTooHigh;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn an_approval_that_cannot_be_recorded_is_denied_on_both_sides() {
let rig = Rig::new("admin-norecord");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
let reason = DenyReason::AuditUnavailable;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn a_refusal_that_cannot_be_recorded_is_an_error_for_bxctl() {
let rig = Rig::new("admin-norefuse");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
match refuse(&broker, approval, None) {
Message::Error(e) => {
assert_eq!(e.code, ErrorCode::Internal);
assert!(
e.detail.ends_with("see docs/runbook.md#audit-unavailable"),
"{}",
e.detail
);
}
other => panic!("{other:?}"),
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::AuditUnavailable)
);
}
#[test]
fn expiry_denies_an_approval_nobody_answered() {
let rig = Rig::with_ttl("admin-expire", 100);
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let until = Instant::now() + Duration::from_secs(10);
while admin::expire_due(&broker, Timestamp::now()) == 0 {
assert!(Instant::now() < until, "never expired");
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalExpired)
);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!((*answer, by.as_deref()), (ApprovalAnswer::Expired, None));
}
other => panic!("{other:?}"),
}
assert_eq!(rt.count(), 0);
}
#[test]
fn check_grants_reports_every_problem_or_none() {
let rig = Rig::new("admin-grants");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => assert!(r.problems.is_empty(), "{r:?}"),
other => panic!("{other:?}"),
}
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
rig.grant(
"Worse",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => {
let files: Vec<&str> = r.problems.iter().map(|p| p.file.as_str()).collect();
assert!(files.contains(&"bad.toml"), "{r:?}");
assert!(files.contains(&"Worse.toml"), "{r:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn every_other_kind_on_admin_sock_is_forbidden() {
let rig = Rig::new("admin-forbidden");
let broker = rig.broker(&Recording::answering("x"));
let req = request("s1", 1, "read_file", r#"{"path":"/n/a"}"#);
for msg in [
Message::ToolRequest(req),
Message::Ok(Empty {}),
Message::ToolResponse(denied(DenyReason::NoGrant)),
] {
assert_eq!(error_code(&admin(&broker, msg)), Some(ErrorCode::Forbidden));
}
let lines = rig.lines.with("on admin.sock");
assert_eq!(lines.len(), 3, "{:?}", rig.lines.all());
assert!(lines[0].contains("tool_request"), "{}", lines[0]);
assert!(
lines
.iter()
.all(|l| l.ends_with("see docs/runbook.md#socket-forbidden"))
);
assert!(
rig.events().is_empty(),
"a refused tool request decides nothing"
);
}
@@ -0,0 +1,159 @@
//! The pending-approval table: whoever takes an entry answers it, and everyone else finds it
//! gone. Do not edit.
#[path = "support/build.rs"]
mod build;
use std::sync::{Arc, Barrier};
use brokerd::approvals::{Table, Verdict};
use brokerd::policy::{Ask, Outcome, SessionState, decide};
use build::{grant, now, read, set, ts};
use proto::{CallId, DataClass, DenyReason, Mode, PendingApproval, SessionId, Timestamp};
fn ask() -> Ask {
let grants = set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]);
match decide(read("/n/a"), &grants, SessionState::default(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("the test's call does not ask: {other:?}"),
}
}
fn info(approval: u64, expires: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("s1").unwrap(),
call: CallId(approval + 100),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
grant: "n".to_string(),
taint: DataClass::Private,
created: now(),
expires: ts(expires),
}
}
const LATER: &str = "2026-09-18T12:15:00.000Z";
#[test]
fn a_new_table_is_empty_and_lists_in_id_order() {
let table = Table::new();
assert_eq!(table.list(), []);
let _a = table.insert(info(7, LATER), ask());
let _b = table.insert(info(3, LATER), ask());
assert_eq!(table.list(), [info(3, LATER), info(7, LATER)]);
}
#[test]
fn an_entry_can_be_taken_once() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).expect("the entry is there");
assert_eq!(entry.info, info(5, LATER));
assert_eq!(entry.ask.grant(), "n");
assert!(table.take(5).is_none(), "taken twice");
assert_eq!(table.list(), []);
}
#[test]
fn an_id_never_added_is_not_there() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
assert!(table.take(6).is_none());
assert_eq!(table.list().len(), 1);
}
#[test]
fn the_verdict_goes_to_the_waiting_side() {
let table = Table::new();
let rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).unwrap();
entry
.reply
.send(Verdict::Denied(DenyReason::ApprovalRefused))
.unwrap();
match rx.recv().unwrap() {
Verdict::Denied(reason) => assert_eq!(reason, DenyReason::ApprovalRefused),
Verdict::Run(_) => panic!("the verdict changed on the way"),
}
}
#[test]
fn an_entry_expires_at_its_expiry_and_not_before() {
let table = Table::new();
let _a = table.insert(info(9, "2026-09-18T12:00:01.000Z"), ask());
let _b = table.insert(info(2, "2026-09-18T12:00:00.500Z"), ask());
let _c = table.insert(info(4, LATER), ask());
let before = ts("2026-09-18T12:00:00.499Z");
assert!(table.take_expired(before).is_empty());
// Exactly at `expires` is expired.
let at = ts("2026-09-18T12:00:00.500Z");
let due: Vec<u64> = table
.take_expired(at)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [2]);
let after = ts("2026-09-18T13:00:00.000Z");
let due: Vec<u64> = table
.take_expired(after)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [4, 9], "in id order");
assert_eq!(table.list(), []);
assert!(table.take_expired(Timestamp::MAX).is_empty());
}
#[test]
fn two_takers_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, LATER), ask());
let start = Arc::new(Barrier::new(2));
let takers: Vec<_> = (0..2)
.map(|_| {
let table = Arc::clone(&table);
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
table.take(1).is_some()
})
})
.collect();
let got: Vec<bool> = takers.into_iter().map(|t| t.join().unwrap()).collect();
assert_eq!(
got.iter().filter(|g| **g).count(),
1,
"round {round}: {got:?}"
);
}
}
#[test]
fn a_taker_and_the_expiry_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, "2026-09-18T12:00:00.000Z"), ask());
let start = Arc::new(Barrier::new(2));
let t = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
usize::from(table.take(1).is_some())
})
};
let e = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
table.take_expired(now()).len()
})
};
let total = t.join().unwrap() + e.join().unwrap();
assert_eq!(total, 1, "round {round}");
}
}
@@ -0,0 +1,428 @@
//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit.
//!
//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here.
use brokerd::args::{
ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host,
valid_host, valid_host_pattern, valid_path,
};
#[test]
fn the_four_tool_names() {
let names = ["read_file", "write_file", "shell", "http_fetch"];
for (tool, name) in ToolName::ALL.into_iter().zip(names) {
assert_eq!(tool.as_str(), name);
assert_eq!(ToolName::parse(name), Some(tool));
}
for other in [
"",
"echo",
"clock",
"call_tool",
"Read_File",
"read_file ",
"readfile",
] {
assert_eq!(ToolName::parse(other), None, "{other:?}");
}
}
#[test]
fn valid_paths() {
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
assert_eq!(longest.len(), MAX_PATH);
for path in [
"/",
"/etc",
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/with space/and\ttab",
"/dots.in.names/..hidden/...",
"/unicode/\u{e9}t\u{e9}",
longest.as_str(),
] {
assert!(valid_path(path), "{path:?} should be valid");
}
}
#[test]
fn invalid_paths() {
let too_long = format!("/{}", "a".repeat(MAX_PATH));
assert_eq!(too_long.len(), MAX_PATH + 1);
for path in [
"",
"notes/a.md",
"./notes",
"~/notes",
"/home/kyle/notes/../.ssh/id",
"/home/kyle//notes/./a.md",
"/home//kyle",
"/home/./kyle",
"/home/kyle/",
"/home/kyle/..",
"/..",
"/.",
"//",
"/nul\0byte",
too_long.as_str(),
] {
assert!(!valid_path(path), "{path:?} should be invalid");
}
}
/// The table in the spec, row by row, for the rows about form and containment.
#[test]
fn inside_is_by_whole_components() {
let grant = "/home/kyle/notes";
assert!(inside(grant, "/home/kyle/notes/a.md"));
assert!(inside(grant, "/home/kyle/notes"));
assert!(inside(grant, "/home/kyle/notes/deep/er/b.md"));
assert!(!inside(grant, "/home/kyle/notes2/a.md"));
assert!(!inside(grant, "/home/kyle/note"));
assert!(!inside(grant, "/home/kyle"));
assert!(!inside(grant, "/"));
assert!(!inside(grant, "/other/home/kyle/notes/a.md"));
// A grant of the root is refused when grants are loaded, but the function is still right.
assert!(inside("/", "/etc/passwd"));
assert!(inside("/", "/"));
}
#[test]
fn valid_hosts_and_patterns() {
let label63 = "a".repeat(63);
let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57));
assert_eq!(long.len(), 253);
for host in [
"example.com",
"www.example.com",
"a.b.example.com",
"xn--bcher-kva.example",
"1password.com",
"3.example.org",
"a-b.c-d.io",
long.as_str(),
] {
assert!(valid_host(host), "{host:?} should be a valid host");
assert!(
valid_host_pattern(host),
"{host:?} should be a valid pattern"
);
let wild = format!("*.{host}");
assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host");
}
assert!(valid_host_pattern("*.example.com"));
assert!(valid_host_pattern("*.a.b.example.com"));
}
#[test]
fn invalid_hosts_and_patterns() {
let label64 = format!("{}.com", "a".repeat(64));
let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join("."));
assert!(too_long.len() > 253);
for host in [
"",
"localhost",
"com",
"Example.com",
"example.COM",
"example.com.",
".example.com",
"example..com",
"-example.com",
"example-.com",
"exa_mple.com",
"example.com:443",
"example.com/path",
"user@example.com",
"exa mple.com",
"[::1]",
"::1",
// Every spelling of an IPv4 address: the last label does not start with a letter.
"127.0.0.1",
"127.1",
"10.0.0.0x1",
"1.2.3.4",
"example.123",
"b\u{fc}cher.example",
label64.as_str(),
too_long.as_str(),
] {
assert!(!valid_host(host), "{host:?} should not be a valid host");
assert!(
!valid_host_pattern(host),
"{host:?} should not be a valid pattern"
);
}
for pattern in [
"*",
"*.",
"*.com",
"*example.com",
"www.*.com",
"*.*.example.com",
"**.example.com",
"*.Example.com",
"*.127.0.0.1",
] {
assert!(!valid_host_pattern(pattern), "{pattern:?}");
}
}
/// The host table in the spec, row by row.
#[test]
fn host_matching() {
assert!(host_matches("example.com", "example.com"));
assert!(!host_matches("example.com", "www.example.com"));
assert!(host_matches("*.example.com", "www.example.com"));
assert!(host_matches("*.example.com", "a.b.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
// A suffix is not enough: the match is by whole labels.
assert!(!host_matches("*.example.com", "badexample.com"));
assert!(!host_matches("*.example.com", "www.example.com.evil.org"));
assert!(!host_matches("example.com", "example.com.evil.org"));
assert!(!host_matches("*.example.com", ".example.com"));
}
#[test]
fn valid_urls_and_their_hosts() {
let base = "https://example.com/";
let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len()));
assert_eq!(longest.len(), MAX_URL);
for (url, host) in [
("https://example.com", "example.com"),
("https://example.com/", "example.com"),
("https://example.com:443", "example.com"),
("https://example.com:443/", "example.com"),
("https://www.example.com/a/b.html", "www.example.com"),
("https://example.com/search?q=a+b&x=%20#frag", "example.com"),
("https://example.com/@user", "example.com"),
("https://example.com/a:8080/b", "example.com"),
("https://example.com/https://other.org/", "example.com"),
("https://example.com/back\\slash", "example.com"),
(longest.as_str(), "example.com"),
] {
assert_eq!(url_host(url), Some(host), "{url}");
}
}
#[test]
fn invalid_urls() {
let base = "https://example.com/";
let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1));
assert_eq!(too_long.len(), MAX_URL + 1);
for url in [
"",
"example.com",
"http://example.com/",
"HTTPS://example.com/",
"https:/example.com/",
"https://",
"https:///path",
"ftp://example.com/",
"file:///etc/passwd",
// userinfo
"https://user@example.com/",
"https://user:pw@example.com/",
"https://example.com@evil.org/",
// ports
"https://example.com:8443/",
"https://example.com:80/",
"https://example.com:/",
"https://example.com:443x/",
"https://example.com:4433/",
"https://example.com:443:443/",
// what follows the host must be the end, `:443` or `/`
"https://example.com?q=1",
"https://example.com#frag",
"https://example.com\\@evil.org/",
// hosts that are not host names
"https://localhost/",
"https://127.0.0.1/",
"https://127.1/",
"https://[::1]/",
"https://Example.com/",
"https://example.com./",
"https://b\u{fc}cher.example/",
// the rest must be printable ASCII with no space
"https://example.com/a b",
"https://example.com/a\tb",
"https://example.com/a\nb",
"https://example.com/caf\u{e9}",
"https://example.com/\u{7f}",
" https://example.com/",
"https://example.com/ ",
too_long.as_str(),
] {
assert_eq!(url_host(url), None, "{url:?} should be invalid");
}
}
#[test]
fn each_tool_parses_its_own_arguments() {
assert_eq!(
parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#),
Ok(ToolArgs::ReadFile {
path: "/home/kyle/notes/a.md".to_string()
})
);
assert_eq!(
parse(
ToolName::WriteFile,
r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"#
),
Ok(ToolArgs::WriteFile {
path: "/home/kyle/notes/a.md".to_string(),
content: "line\n".to_string()
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls -l"}"#),
Ok(ToolArgs::Shell {
command: "ls -l".to_string(),
cwd: None
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: Some("/home/kyle".to_string())
})
);
assert_eq!(
parse(
ToolName::HttpFetch,
r#"{"url":"https://www.example.com/a"}"#
),
Ok(ToolArgs::HttpFetch {
url: "https://www.example.com/a".to_string(),
host: "www.example.com".to_string()
})
);
// Field order and white space in the request do not matter.
assert_eq!(
parse(
ToolName::WriteFile,
" { \"content\" : \"x\" , \"path\" : \"/a/b\" } "
),
Ok(ToolArgs::WriteFile {
path: "/a/b".to_string(),
content: "x".to_string()
})
);
// `command` and `content` are not inspected.
assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok());
assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok());
assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok());
}
#[test]
fn arguments_of_the_wrong_shape_are_refused() {
let cases: [(ToolName, &str); 17] = [
(ToolName::ReadFile, ""),
(ToolName::ReadFile, "null"),
(ToolName::ReadFile, "[]"),
(ToolName::ReadFile, r#""/etc/hosts""#),
(ToolName::ReadFile, "{}"),
(ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#),
(ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#),
(ToolName::ReadFile, r#"{"path":7}"#),
(ToolName::ReadFile, r#"{"path":null}"#),
(ToolName::ReadFile, r#"{"path":"/a"} trailing"#),
(ToolName::WriteFile, r#"{"path":"/a/b"}"#),
(ToolName::WriteFile, r#"{"content":"x"}"#),
(
ToolName::WriteFile,
r#"{"path":"/a/b","content":"x","append":true}"#,
),
(ToolName::Shell, r#"{"cwd":"/a"}"#),
(ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#),
(ToolName::Shell, r#"{"command":["ls"]}"#),
(
ToolName::HttpFetch,
r#"{"url":"https://example.com/","method":"POST"}"#,
),
];
for (tool, text) in cases {
match parse(tool, text) {
Err(ArgsError::Shape(_)) => {}
other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"),
}
}
// One tool's arguments do not fit another tool.
assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err());
assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err());
}
#[test]
fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() {
for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] {
let quoted = serde_json::to_string(bad).unwrap();
let read = format!(r#"{{"path":{quoted}}}"#);
let write = format!(r#"{{"path":{quoted},"content":"x"}}"#);
let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#);
assert_eq!(
parse(ToolName::ReadFile, &read),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::WriteFile, &write),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::Shell, &shell),
Err(ArgsError::Path(bad.to_string()))
);
}
assert_eq!(
parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#),
Err(ArgsError::Url("http://example.com/".to_string()))
);
// A NUL can only arrive as a JSON escape; it is refused once decoded.
let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\');
assert!(matches!(
parse(ToolName::ReadFile, &nul),
Err(ArgsError::Path(_))
));
// `cwd: null` is the same as no `cwd`.
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: None
})
);
}
/// What the owner is shown is the parsed value written out again, so two spellings of one path
/// look the same. The escape is built from pieces so that no tool rewrites it on the way here.
#[test]
fn canonical_json_shows_what_was_parsed() {
let escaped_slash = format!("{}u002f", '\\');
let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}");
assert!(sneaky.contains("u002fetc"));
let args = parse(ToolName::ReadFile, &sneaky).unwrap();
assert_eq!(
args,
ToolArgs::ReadFile {
path: "/etc/hosts".to_string()
}
);
assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#);
// Fields come out in the spec's order whatever order they came in.
let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap();
assert_eq!(
write.canonical_json(),
r#"{"path":"/a/b","content":"x\ny"}"#
);
let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap();
assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#);
// An absent cwd is left out, and the host is never written: it is not an argument.
let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap();
assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#);
let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap();
assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#);
assert_eq!(fetch.tool(), ToolName::HttpFetch);
assert_eq!(write.tool(), ToolName::WriteFile);
}
@@ -0,0 +1,258 @@
//! The audit writer: the chain it writes, the lock, rollover, and stopping after a failed
//! write. Do not edit. Startup checks, recovery and accepted breaks are in `audit_startup.rs`.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use std::os::unix::fs::PermissionsExt;
use audit_dir::{D1, D2, TempDir, denied, lines, ts};
use brokerd::audit::{AuditError, RECOVERED_NOTICE, Writer, verify_dir};
use proto::{AuditRecord, Hash32, sha256};
fn mode(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn the_first_record_starts_the_chain() {
let dir = TempDir::unmade("first");
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
assert!(opened.accepted.is_none());
let mut writer = opened.writer;
assert_eq!(writer.next_seq(), 0);
let seq = writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
assert_eq!(seq, 0);
assert_eq!(writer.next_seq(), 1);
let text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
assert!(text.ends_with('\n'), "a record is one line and its newline");
assert_eq!(text.lines().count(), 1);
let record: AuditRecord = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!((record.seq, record.prev), (0, Hash32::ZERO));
assert_eq!(record.time, ts("2026-09-17T08:00:00.000Z"));
assert_eq!(record.event, denied(1));
assert_eq!(mode(&dir.path), 0o700, "the directory open() made");
assert_eq!(mode(&dir.path.join(D1)), 0o600);
}
#[test]
fn the_chain_runs_across_a_day_boundary() {
let dir = TempDir::unmade("days");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
let times = [
"2026-09-17T23:59:58.000Z",
"2026-09-17T23:59:59.999Z",
"2026-09-18T00:00:00.000Z",
"2026-09-18T00:00:01.000Z",
];
for (i, time) in times.iter().enumerate() {
assert_eq!(writer.append(ts(time), denied(i as u64)).unwrap(), i as u64);
}
let (day1, day2) = (lines(&dir.path, D1), lines(&dir.path, D2));
assert_eq!((day1.len(), day2.len()), (2, 2));
// seq goes on across files, and the new file chains from the last line of the old one.
let first: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(first.seq, 2);
assert_eq!(first.prev, sha256(day1[1].as_bytes()).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!((report.records, report.next_seq), (4, 4));
assert!(report.clock_warnings.is_empty());
}
#[test]
fn reopening_continues_the_chain() {
let dir = TempDir::unmade("reopen");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
writer
.append(ts("2026-09-17T08:00:01.000Z"), denied(2))
.unwrap();
drop(writer);
// One file: the whole of it is checked.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 2);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(3))
.unwrap(),
2
);
drop(writer);
// Two files: the latest is checked, resumed from the last line of the one before.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 3);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:01.000Z"), denied(4))
.unwrap(),
3
);
drop(writer);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.records, 4);
}
#[test]
fn an_empty_latest_file_gets_the_next_record_as_its_first_line() {
let dir = TempDir::case("empty-latest", None);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
let mut writer = opened.writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(9))
.unwrap(),
5
);
let day2 = lines(&dir.path, D2);
assert_eq!(day2.len(), 1);
let record: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(
record.prev,
sha256(lines(&dir.path, D1)[4].as_bytes()).unwrap()
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_second_writer_is_refused() {
let dir = TempDir::unmade("lock");
let first = Writer::open(&dir.path, false).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
assert!(matches!(error, AuditError::Locked), "{error}");
let text = error.to_string();
assert!(text.starts_with("brokerd is already running"), "{text}");
assert!(
text.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The lock goes when the writer goes, however that happens.
drop(first);
assert!(Writer::open(&dir.path, false).is_ok());
}
#[test]
fn the_writer_never_goes_back_to_an_earlier_file() {
let dir = TempDir::unmade("clock");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-18T00:00:05.000Z"), denied(1))
.unwrap();
// The clock is stepped back over midnight.
writer
.append(ts("2026-09-17T23:59:50.000Z"), denied(2))
.unwrap();
assert!(
!dir.path.join(D1).exists(),
"a record went into an earlier file"
);
assert_eq!(lines(&dir.path, D2).len(), 2);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.clock_warnings.len(), 1);
// It holds across a restart too.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T23:59:55.000Z"), denied(3))
.unwrap();
assert!(!dir.path.join(D1).exists());
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn files_that_are_not_log_files_are_ignored() {
let dir = TempDir::case("good", None);
std::fs::write(dir.path.join("notes.txt"), "not a log\n").unwrap();
std::fs::write(dir.path.join("2026-09-19.jsonl.bak"), "not a log\n").unwrap();
std::fs::write(dir.path.join("latest.jsonl"), "not a log\n").unwrap();
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 10);
writer
.append(ts("2026-09-18T10:00:00.000Z"), denied(9))
.unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().records, 11);
}
/// After one failed write the writer writes nothing more, even when the cause has gone: part of
/// a line may be on disk, and only the next start deals with that.
#[test]
fn a_failed_write_stops_the_writer() {
let dir = TempDir::unmade("sticky");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
// A new day needs a new file, and the directory no longer allows one.
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(dir.path.join("probe"), "").is_ok() {
eprintln!("skipped: this user can write to a read-only directory (root?)");
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
return;
}
let error = writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(2))
.unwrap_err();
assert!(matches!(error, AuditError::Io { .. }), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
for time in ["2026-09-18T08:00:01.000Z", "2026-09-17T08:00:02.000Z"] {
let error = writer.append(ts(time), denied(3)).unwrap_err();
assert!(matches!(error, AuditError::Stopped), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
}
assert_eq!(
lines(&dir.path, D1).len(),
1,
"a stopped writer wrote something"
);
assert!(!dir.path.join(D2).exists());
// A restart puts it right.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:03.000Z"), denied(4))
.unwrap(),
1
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn the_recovered_notice_names_its_runbook_entry() {
assert!(RECOVERED_NOTICE.starts_with("audit: recovered a torn final line"));
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
@@ -0,0 +1,300 @@
//! What `Writer::open` does with the log it finds: refuse a broken chain, recover a torn tail,
//! accept a break when told to. Do not edit. The fixture logs are in
//! `crates/proto/tests/fixtures/audit/`; each test works on a copy.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use audit_dir::{D1, D2, TempDir, denied, lines, snapshot};
use brokerd::audit::{AuditError, Writer, verify_dir};
use proto::{AuditEvent, AuditRecord, Location, Timestamp};
type Case = (
&'static str,
Option<&'static [&'static str]>,
&'static str,
u64,
&'static str,
);
fn at(file: &str, line: u64) -> Location {
Location {
file: file.to_string(),
line,
}
}
/// An ordinary start checks the latest file only, so each damaged file is copied alone: it is
/// then the latest. Nothing may be written to a log that does not verify.
#[test]
fn a_broken_chain_refuses_to_start_and_writes_nothing() {
let parse = "does not parse as an audit record";
// (case, the files to copy, then the failure's file, line and text)
let cases: [Case; 9] = [
(
"changed-byte",
Some(&[D1]),
D1,
4,
"prev is not the hash of the line before",
),
("deleted-line", Some(&[D1]), D1, 3, "seq is 3, expected 2"),
("swapped-lines", Some(&[D1]), D1, 2, "seq is 2, expected 1"),
("seq-gap", None, D1, 3, "seq is 3, expected 2"),
("cut-short", Some(&[D1]), D1, 3, parse),
// Both files: the latest does not chain from the last line of the one before.
(
"file-not-chained",
None,
D2,
1,
"does not chain from the last line of the file before",
),
(
"break-without-failure",
None,
D2,
6,
"an accepted break with no failure before it",
),
("recovery-wrong-hash", None, D2, 6, parse),
("torn-recovery", None, D2, 6, parse),
];
for (case, only, file, line, what) in cases {
let dir = TempDir::case(case, only);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, false)
.err()
.unwrap_or_else(|| panic!("{case}: started"));
let AuditError::Broken(failure) = &error else {
panic!("{case}: {error}");
};
assert_eq!(
(failure.file.as_str(), failure.line, failure.what.as_str()),
(file, line, what),
"{case}"
);
let text = error.to_string();
assert!(
text.starts_with(&format!("{file}:{line}: {what}")),
"{case}: {text}"
);
assert!(
text.ends_with("see docs/runbook.md#audit-chain-broken"),
"{case}: {text}"
);
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A torn tail is recovered: the torn bytes stay, a newline ends them if one is missing, and a
/// `Recovery` record follows in the same file, whatever today's date is.
#[test]
fn a_torn_tail_is_recovered() {
// (case, the torn line's file and number, newline already there)
let cases = [
("torn-tail", D2, 6, false),
("torn-tail-complete-json", D2, 6, false),
("torn-unparseable-newline", D2, 6, true),
("torn-first-line", D2, 1, false),
];
for (case, file, line, has_newline) in cases {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, false).unwrap_or_else(|e| panic!("{case}: {e}"));
assert!(opened.recovered, "{case}");
assert!(opened.accepted.is_none(), "{case}");
let after = snapshot(&dir.path);
assert_eq!(
after.len(),
before.len(),
"{case}: the Recovery went into a new file"
);
let (old, new) = (&before[file], &after[file]);
assert!(
new.starts_with(old),
"{case}: bytes already on disk were changed"
);
let added = &new[old.len()..];
// One newline to end the torn line if it had none, then one line.
let added = if has_newline {
added
} else {
added.strip_prefix(b"\n").expect(case)
};
assert_eq!(added.iter().filter(|b| **b == b'\n').count(), 1, "{case}");
let record: AuditRecord =
serde_json::from_slice(added.strip_suffix(b"\n").expect(case)).expect(case);
assert!(
matches!(record.event, AuditEvent::Recovery { .. }),
"{case}"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.torn_tail, None, "{case}");
assert_eq!(report.recoveries, vec![at(file, line)], "{case}");
// The chain goes on from the Recovery, and the next start finds nothing to recover.
let mut writer = opened.writer;
assert_eq!(
writer.append(Timestamp::now(), denied(9)).unwrap(),
record.seq + 1,
"{case}"
);
drop(writer);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered, "{case}");
assert_eq!(verify_dir(&dir.path).unwrap().failure, None, "{case}");
}
}
/// Damage in an older file is not seen by an ordinary start. `bxctl audit verify` sees it, and
/// `--accept-break` must too: it verifies the whole log.
#[test]
fn a_break_in_an_older_file_can_be_accepted() {
let dir = TempDir::case("changed-byte", None);
let before = snapshot(&dir.path);
drop(Writer::open(&dir.path, false).expect("the latest file verifies"));
assert_eq!(snapshot(&dir.path), before);
let failure = verify_dir(&dir.path).unwrap().failure.unwrap();
assert_eq!((failure.file.as_str(), failure.line), (D1, 4));
let opened = Writer::open(&dir.path, true).unwrap();
assert!(!opened.recovered);
assert_eq!(
opened.accepted.as_ref().map(|f| (f.file.as_str(), f.line)),
Some((D1, 4))
);
let after = snapshot(&dir.path);
assert_eq!(after[D1], before[D1], "nothing is repaired");
assert!(after[D2].starts_with(&before[D2]));
let last: AuditRecord = serde_json::from_str(lines(&dir.path, D2).last().unwrap()).unwrap();
assert_eq!(
last.event,
AuditEvent::AcceptedBreak {
file: D1.to_string(),
line: 4,
last_good: failure.last_good,
}
);
assert_eq!(
last.seq, 10,
"seq 3 for the failing line, and seven lines to the break"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D2, 6)]);
let mut writer = opened.writer;
assert_eq!(writer.append(Timestamp::now(), denied(9)).unwrap(), 11);
drop(writer);
// The next ordinary start resumes at the latest file and meets a break that names a file
// it has not read.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.append(Timestamp::now(), denied(10)).unwrap(), 12);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_break_in_the_latest_file_can_be_accepted() {
// (case, failing line, seq of the break record)
for (case, line, seq) in [("recovery-wrong-hash", 6, 12), ("torn-recovery", 6, 12)] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, true).unwrap_or_else(|e| panic!("{case}: {e}"));
assert_eq!(
opened.accepted.as_ref().map(|f| f.line),
Some(line),
"{case}"
);
let after = snapshot(&dir.path);
assert!(after[D2].starts_with(&before[D2]), "{case}");
// torn-recovery ends without a newline: the break record must start on its own line.
let all = lines(&dir.path, D2);
let last: AuditRecord = serde_json::from_str(all.last().unwrap()).expect(case);
assert!(
matches!(last.event, AuditEvent::AcceptedBreak { .. }),
"{case}"
);
assert_eq!(last.seq, seq, "{case}");
assert_eq!(all.len(), 8, "{case}");
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.accepted_breaks, vec![at(D2, 8)], "{case}");
drop(opened);
assert!(Writer::open(&dir.path, false).is_ok(), "{case}");
}
}
/// The short check is a shortcut and never the last word: when it cannot be made, or fails, the
/// whole log is verified and that verdict stands. Here the last line of the older file is the
/// damage, so there is nothing to resume from.
#[test]
fn an_accepted_break_at_the_end_of_an_older_file_does_not_stop_later_starts() {
let dir = TempDir::case("good", None);
let day1 = std::fs::read_to_string(dir.path.join(D1)).unwrap();
let cut = format!("{}\n", &day1[..day1.len() - 40]);
std::fs::write(dir.path.join(D1), cut).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 5));
drop(Writer::open(&dir.path, true).unwrap());
let mut writer = Writer::open(&dir.path, false)
.expect("the break was accepted")
.writer;
writer.append(Timestamp::now(), denied(9)).unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn accept_break_with_nothing_to_accept_is_an_error() {
for case in ["good", "torn-tail"] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, true).unwrap_err();
assert!(
matches!(error, AuditError::NothingToAccept),
"{case}: {error}"
);
assert!(error.to_string().starts_with("nothing to accept"), "{case}");
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A second failure after an accepted break needs its own break.
#[test]
fn damage_after_a_break_is_a_new_failure() {
let dir = TempDir::case("accepted-break", None);
drop(Writer::open(&dir.path, false).expect("the fixture verifies"));
let mut text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
text.push_str("{}\n{}\n");
std::fs::write(dir.path.join(D1), text).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 8));
drop(Writer::open(&dir.path, true).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D1, 6), at(D1, 10)]);
}
@@ -0,0 +1,324 @@
//! One `broker.sock` connection: decision, record, run, answer; and every way it fails closed.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use brokerd::broker;
use brokerd::ledger::NOT_RECORDED;
use brokerd::runner::{REFUSING, RunError};
use client::{Serve, call, next, open};
use proto::{
ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty,
Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse,
Turn, TurnDone, TurnEvent, Usage, WireError,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
const NOTES: &str = r#"{"path":"/n/a"}"#;
fn read_notes(call_id: u64) -> proto::ToolRequest {
request("s1", call_id, "read_file", NOTES)
}
fn only(frames: &[Envelope]) -> &ToolResponse {
assert_eq!(frames.len(), 1, "{frames:?}");
assert!(frames[0].r#final);
match &frames[0].msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
fn auto_notes(rig: &Rig, class: &str) {
let text = grant_text("read_file", "auto", "", "paths = [\"/n\"]").replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
);
rig.grant("notes", &text);
}
#[test]
fn a_call_no_grant_allows_is_denied_and_recorded() {
let rig = Rig::new("broker-nogrant");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(41));
assert_eq!(frames[0].id, 41, "the answer carries the request's id");
assert_eq!(only(&frames), &denied(DenyReason::NoGrant));
assert_eq!(rt.count(), 0);
match rig.events().as_slice() {
[AuditEvent::Decision { outcome, .. }] => assert_eq!(
*outcome,
DecisionRecord::Denied {
reason: DenyReason::NoGrant
}
),
other => panic!("{other:?}"),
}
}
#[test]
fn an_allowed_call_runs_and_its_result_raises_the_state() {
let rig = Rig::new("broker-allowed");
auto_notes(&rig, "secret");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(7));
assert_eq!(
only(&frames),
&ToolResponse::Result {
content: "the notes".to_string(),
class: DataClass::Secret,
untrusted: false,
truncated: false,
}
);
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
decision: 0,
status: ResultStatus::Result,
taint_after: DataClass::Secret,
..
}
]
));
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
#[test]
fn a_runtime_failure_is_passed_on_and_recorded_as_failed() {
let rig = Rig::new("broker-refusing");
auto_notes(&rig, "secret");
let rt = Recording::with(Err(RunError::Unavailable(REFUSING.to_string())));
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
match &rig.events()[1] {
AuditEvent::Result {
status,
taint_after,
..
} => assert_eq!(
(*status, *taint_after),
(ResultStatus::Failed, DataClass::Private)
),
other => panic!("{other:?}"),
}
assert!(!rig.state_file("s1").exists(), "a failure changes no state");
}
fn every_kind_but_tool_request() -> Vec<Message> {
let usage = Usage {
cache_n: 0,
prompt_n: 0,
predicted_n: 0,
reasoning_tokens: 0,
thinking_capped: false,
};
vec![
Message::ToolResponse(denied(DenyReason::NoGrant)),
Message::Error(WireError {
code: ErrorCode::Internal,
detail: String::new(),
}),
Message::Turn(Turn {
session: SessionId::new("s1").unwrap(),
content: String::new(),
resume: false,
}),
Message::TurnEvent(TurnEvent::Content {
text: String::new(),
}),
Message::TurnDone(TurnDone {
content: String::new(),
usage,
}),
Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }),
Message::Approve(Approve { approval: 0 }),
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Allowed {},
}),
Message::Refuse(Refuse {
approval: 0,
reason: None,
}),
Message::Ok(Empty {}),
Message::CheckGrants(Empty {}),
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
}),
]
}
#[test]
fn every_other_kind_on_broker_sock_is_forbidden() {
let rig = Rig::new("broker-forbidden");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let kinds = every_kind_but_tool_request();
assert_eq!(kinds.len(), 13, "every Message variant but tool_request");
for (i, msg) in kinds.into_iter().enumerate() {
let id = 100 + i as u64;
let mut stream = open(&broker, broker::handle, id, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (id, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden),
other => panic!("{other:?}"),
}
}
let lines = rig.lines.with("broker.sock");
assert_eq!(lines.len(), 13, "{:?}", rig.lines.all());
for line in &lines {
assert!(
line.ends_with("\nsee docs/runbook.md#socket-forbidden"),
"{line}"
);
}
assert_eq!(rig.lines.with("kind approve on broker.sock").len(), 1);
assert!(
rig.events().is_empty(),
"nothing is recorded for a refused kind"
);
}
#[test]
fn a_frame_that_is_not_json_is_answered_with_bad_message() {
let rig = Rig::new("broker-badframe");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut client, server) = UnixStream::pair().unwrap();
let b = Arc::clone(&broker);
std::thread::spawn(move || broker::handle(server, &b));
client.write_all(&3u32.to_be_bytes()).unwrap();
client.write_all(b"{{{").unwrap();
let env = next(&mut client);
assert_eq!((env.id, env.r#final), (0, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::BadMessage),
other => panic!("{other:?}"),
}
assert!(rig.events().is_empty());
}
#[test]
fn one_invalid_grant_file_denies_a_call_a_valid_file_would_allow() {
let rig = Rig::new("broker-invalid");
auto_notes(&rig, "private");
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
for id in [1, 2] {
let frames = call(&broker, read_notes(id));
assert_eq!(only(&frames), &denied(DenyReason::GrantsInvalid));
}
assert_eq!(rt.count(), 0);
// Printed once for the two calls, with the pointer.
let printed = rig.lines.with("bad.toml");
assert_eq!(printed.len(), 1, "{:?}", rig.lines.all());
assert!(
printed[0].ends_with("see docs/runbook.md#grants-invalid"),
"{}",
printed[0]
);
rig.remove_grant("bad");
let frames = call(&broker, read_notes(3));
assert!(matches!(only(&frames), ToolResponse::Result { .. }));
assert_eq!(rt.count(), 1);
}
#[test]
fn a_damaged_session_state_is_state_unreadable() {
let rig = Rig::new("broker-state");
auto_notes(&rig, "private");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "{\"taint\":\"loud\"}").unwrap();
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::StateUnreadable));
assert_eq!(rt.count(), 0);
let lines = rig.lines.with("s1.json");
assert!(
lines
.iter()
.any(|l| l.ends_with("see docs/runbook.md#broker-state-damaged")),
"{lines:?}"
);
}
#[test]
fn a_failed_audit_write_runs_nothing_now_or_later() {
let rig = Rig::new("broker-audit");
auto_notes(&rig, "private");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
rig.switch.fail(true);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
rig.switch.fail(false);
let frames = call(&broker, read_notes(2));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
assert_eq!(rt.count(), 0);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("broker-ro");
auto_notes(&rig, "secret");
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let rt = Recording::answering("the secret");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rt.count(), 1, "it ran; its content is what is withheld");
let text = format!("{frames:?}");
assert!(!text.contains("the secret"), "{text}");
}
@@ -0,0 +1,230 @@
//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester
//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::broker::{self, Broker, GONE};
use brokerd::ledger::Answer;
use client::{Serve, next, open};
use proto::{
AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig, extra: &str) {
rig.grant(
"notes",
&grant_text("read_file", "ask", extra, "paths = [\"/n\"]"),
);
}
/// Sends a call and reads its pending frame. Returns the connection and the frame's values.
fn start(broker: &Arc<Broker>, arguments: &str) -> (UnixStream, u64, Timestamp) {
let req = request("s1", 9, "read_file", arguments);
let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req));
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (9, false), "{env:?}");
match env.msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => {
(stream, approval, expires)
}
other => panic!("{other:?}"),
}
}
/// What `admin` does for an entry it took: record the answer, send the verdict.
fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord {
let entry = broker.table().take(id).expect("the entry is pending");
answer_entry(broker, entry, answer)
}
fn answer_entry(
broker: &Broker,
entry: brokerd::approvals::Entry,
answer: Answer,
) -> DecisionRecord {
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
entry.info.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
done.outcome
}
fn approved() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
let env = next(stream);
assert!(env.r#final, "{env:?}");
match env.msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn eventually(what: &str, mut done: impl FnMut() -> bool) {
let until = Instant::now() + Duration::from_secs(10);
while !done() {
assert!(Instant::now() < until, "never happened: {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn an_ask_call_waits_is_listed_and_runs_when_approved() {
let rig = Rig::new("pending-approve");
ask_notes(&rig, "");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let before = Timestamp::now();
// Spaced JSON: the table shows the arguments as policy parsed them, not as sent.
let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#);
assert_eq!(approval, 0, "the approval id is the decision's seq");
let items = broker.table().list();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.approval, 0);
assert_eq!((item.session.as_str(), item.call.0), ("s1", 9));
assert_eq!(item.tool, "read_file");
assert_eq!(item.arguments, r#"{"path":"/n/a"}"#);
assert_eq!(
(item.grant.as_str(), item.taint),
("notes", DataClass::Private)
);
assert!(item.created >= before);
assert_eq!(item.expires, expires);
assert_eq!(
expires.unix_millis() - item.created.unix_millis(),
900_000,
"now plus ttl_ms"
);
assert_eq!(rt.count(), 0, "nothing runs while it waits");
assert_eq!(
answer_by_hand(&broker, 0, approved()),
DecisionRecord::Ask {}
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(
matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Approval { decision: 0, .. },
AuditEvent::Result { decision: 0, .. }
]
),
"{events:?}"
);
}
#[test]
fn the_expiry_is_the_grants_when_that_is_earlier() {
let rig = Rig::new("pending-grant-expiry");
let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap();
ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339()));
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#);
assert_eq!(expires, soon);
}
#[test]
fn a_denied_verdict_reaches_the_requester_and_nothing_runs() {
let rig = Rig::new("pending-refuse");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: None,
};
answer_by_hand(&broker, approval, refused);
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused
}
);
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() {
let rig = Rig::new("pending-leave");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#);
drop(stream);
// The waiting thread looks at its connection every second.
eventually("the entry is removed", || broker.table().list().is_empty());
std::thread::sleep(Duration::from_millis(200));
assert_eq!(rig.events().len(), 1, "only the decision");
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert_eq!(report.abandoned, [0]);
assert!(report.failure.is_none());
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() {
let rig = Rig::new("pending-lastlook");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
// Take the entry first, so the waiting thread cannot take it back when it sees the
// connection gone; then approve.
let entry = broker.table().take(approval).unwrap();
drop(stream);
answer_entry(&broker, entry, approved());
eventually("the call is closed", || rig.events().len() == 3);
match &rig.events()[2] {
AuditEvent::Result {
status,
decision,
sha256,
bytes,
..
} => {
assert_eq!((*status, *decision), (ResultStatus::Failed, 0));
assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap());
assert_eq!(*bytes, GONE.len() as u64);
}
other => panic!("{other:?}"),
}
assert_eq!(GONE, "the requester went away");
assert_eq!(rt.count(), 0);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.unfinished.is_empty() && report.abandoned.is_empty());
}
@@ -0,0 +1,238 @@
//! Properties over sequences of calls, through `broker::handle` with many threads at once:
//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the
//! record that let its call run; the runtime sees a call only after `allowed` or an approval.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use brokerd::ledger::Answer;
use client::{Serve, call};
use proto::{
AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn grants(rig: &Rig) {
let with = |class: &str, max: &str| {
grant_text("read_file", "auto", "", "")
.replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
)
.replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\""))
};
rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n"));
rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n"));
// Stops applying once a session has read a secret.
rig.grant(
"public",
&(with("public", "private") + "paths = [\"/p\"]\n"),
);
rig.grant(
"never",
&grant_text("read_file", "deny", "", "paths = [\"/d\"]"),
);
rig.grant(
"asked",
&grant_text("read_file", "ask", "", "paths = [\"/a\"]"),
);
}
/// Which call ran on what authority: (session, call) of every Decision that allowed and every
/// Approval whose re-decision let the call run, keyed by the decision's seq.
fn check_sequence(records: &[AuditRecord]) -> usize {
let mut may_run: BTreeMap<u64, (SessionId, CallId)> = BTreeMap::new();
let mut taint: BTreeMap<String, DataClass> = BTreeMap::new();
let mut results = 0;
for (i, r) in records.iter().enumerate() {
assert_eq!(r.seq, i as u64, "no seq repeats or skips");
match &r.event {
AuditEvent::Decision {
session,
call,
outcome: DecisionRecord::Allowed {},
..
} => {
may_run.insert(r.seq, (session.clone(), *call));
}
AuditEvent::Approval {
session,
call,
decision,
outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {},
..
} => {
may_run.insert(*decision, (session.clone(), *call));
}
AuditEvent::Result {
session,
call,
decision,
taint_after,
..
} => {
results += 1;
let allowed = may_run.remove(decision);
assert_eq!(
allowed,
Some((session.clone(), *call)),
"seq {}: a Result with no record letting its call run",
r.seq
);
let before = taint.insert(session.as_str().to_string(), *taint_after);
assert!(
before.is_none_or(|b| b <= *taint_after),
"seq {}: taint went down",
r.seq
);
}
_ => {}
}
}
assert!(
may_run.is_empty(),
"calls allowed but never finished: {may_run:?}"
);
results
}
#[test]
fn eight_threads_two_sessions_fifty_calls_each() {
let rig = Rig::new("sequence-many");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"];
let threads: Vec<_> = (0..8u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
let session = if t % 2 == 0 { "s-even" } else { "s-odd" };
for i in 0..50u64 {
let path = paths[((t + i) % 5) as usize];
let args = format!(r#"{{"path":"{path}"}}"#);
let req = request(session, t * 1000 + i, "read_file", &args);
let frames = call(&broker, req);
assert_eq!(frames.len(), 1);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none(), "{:?}", report.failure);
assert!(report.unfinished.is_empty());
let records = rig.records();
let decisions = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Decision { .. }))
.count();
assert_eq!(decisions, 400);
let results = check_sequence(&records);
assert_eq!(
rt.count(),
results,
"the runtime saw exactly the allowed calls"
);
assert!(results > 0 && results < 400);
// Both sessions read a secret.
for s in ["s-even", "s-odd"] {
let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
}
#[test]
fn approved_and_refused_calls_run_only_after_an_approval() {
let rig = Rig::new("sequence-ask");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
// An owner who approves even ids and refuses odd ones, as fast as they appear.
let stop = Arc::new(AtomicBool::new(false));
let owner = {
let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop));
std::thread::spawn(move || {
while !stop.load(Ordering::SeqCst) {
for item in broker.table().list() {
let Some(entry) = broker.table().take(item.approval) else {
continue;
};
let answer = if item.approval % 2 == 0 {
Answer::Approved { by: None }
} else {
Answer::Refused {
by: None,
reason: None,
}
};
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
item.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
}
std::thread::sleep(Duration::from_millis(2));
}
})
};
let threads: Vec<_> = (0..4u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
for i in 0..10u64 {
let path = if i % 2 == 0 { "/a/x" } else { "/n/x" };
let args = format!(r#"{{"path":"{path}"}}"#);
let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args));
let last = frames.last().unwrap();
assert!(
matches!(
&last.msg,
Message::ToolResponse(
ToolResponse::Result { .. } | ToolResponse::Denied { .. }
)
),
"{last:?}"
);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
stop.store(true, Ordering::SeqCst);
owner.join().unwrap();
let records = rig.records();
let approvals = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Approval { .. }))
.count();
assert_eq!(approvals, 20, "one Approval for each of the twenty asks");
let results = check_sequence(&records);
assert_eq!(rt.count(), results);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none() && report.abandoned.is_empty());
}
@@ -0,0 +1,118 @@
//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour.
use brokerd::config::{Approvals, Config, ConfigError, Sockets};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
/// What `home` must default to in this process. The test does not set the variable: changing the
/// environment of a running test binary would race with the other tests.
fn default_home() -> PathBuf {
std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"))
}
#[test]
fn an_empty_file_gets_every_default() {
let c = Config::load(&fixture("empty.toml")).unwrap();
assert_eq!(c.paths.home, default_home());
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
assert_eq!(c.sockets, Sockets::default());
assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 });
assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 });
assert_eq!(c, Config::parse("").unwrap());
assert_eq!(c, Config::default());
}
#[test]
fn sockets_and_directories_default_to_places_under_home() {
let c = Config::load(&fixture("home_only.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock")
);
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
// The grants are not under home: the owner writes them, brokerd only reads them.
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
}
#[test]
fn every_key_can_be_set() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants"));
assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock"));
assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock"));
assert_eq!(c.approvals.ttl_ms, 60_000);
// The two directories always follow home.
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
}
#[test]
fn one_socket_set_leaves_the_other_at_its_default() {
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap();
assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
}
#[test]
fn unknown_keys_and_tables_are_errors() {
for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] {
match Config::load(&fixture(name)) {
Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)),
other => panic!("{name}: expected a parse error, got {other:?}"),
}
}
// In every table, not only the one the fixture shows.
for text in [
"[paths]\nhome = \"/h\"\nhouse = \"/h\"\n",
"[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n",
"[approvals]\nttl_ms = 1\nttl_s = 1\n",
"top = 1\n",
"[approvals]\nttl_ms = -5\n",
] {
assert!(Config::parse(text).is_err(), "accepted: {text}");
}
}
#[test]
fn a_missing_file_is_a_read_error_that_names_the_file() {
let path = fixture("does-not-exist.toml");
match Config::load(&path) {
Err(ConfigError::Read(p, _)) => assert_eq!(p, path),
other => panic!("expected a read error, got {other:?}"),
}
let text = Config::load(&path).unwrap_err().to_string();
assert!(text.contains("does-not-exist.toml"), "{text}");
}
#[test]
fn a_parse_error_names_the_file_and_the_key() {
let text = Config::load(&fixture("unknown_key.toml"))
.unwrap_err()
.to_string();
assert!(text.contains("unknown_key.toml"), "{text}");
assert!(text.contains("ttl"), "{text}");
}
@@ -0,0 +1 @@
# Nothing set: every value is a default.
@@ -0,0 +1,11 @@
# Every key set.
[paths]
home = "/srv/boxmaker"
grants = "/srv/boxmaker-grants"
[sockets]
broker = "/run/bx/broker.sock"
admin = "/run/bx/admin.sock"
[approvals]
ttl_ms = 60000
@@ -0,0 +1,2 @@
[paths]
home = "/srv/boxmaker"
@@ -0,0 +1,3 @@
[approvals]
ttl_ms = 60000
ttl = 5
@@ -0,0 +1,2 @@
[secrets]
store = "/etc/boxmaker/secrets"
@@ -0,0 +1,2 @@
[approvals]
ttl_ms = "15 min"
@@ -0,0 +1 @@
An empty set of grants is valid: every call is denied with no_grant.
@@ -0,0 +1,8 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
secret = "api-token"
[constraints]
hosts = ["api.example.com"]
patterns = ["^GET "]
@@ -0,0 +1,7 @@
tool = "read_file"
mode = "auto"
max_taint = "private"
[constraints]
paths = ["notes", "/home/kyle/../etc", "/"]
hosts = ["example.com"]
@@ -0,0 +1,3 @@
tool = "shell"
mode = "auto"
max_taint =
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,7 @@
# The owner meant `mode`. If this file were skipped, fetch-example would allow what it forbids.
tool = "http_fetch"
mdoe = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1 @@
Grants for the tests. This file is not a grant and is ignored.
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,6 @@
tool = "http_fetch"
mode = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,6 @@
tool = "write_file"
mode = "ask"
max_taint = "private"
[constraints]
paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"]
@@ -0,0 +1,4 @@
# A shell with nothing mounted.
tool = "shell"
mode = "ask"
max_taint = "secret"
@@ -0,0 +1,482 @@
//! Tests for loading grant files. Do not edit these or the fixtures.
//!
//! One case per loading rule in the M3a spec, each with words its problem text must contain, and
//! the rule that matters most: one invalid file makes the whole set invalid.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::grants::{GrantSet, LoadedGrant, RUNBOOK, load, render, valid_id};
use proto::{Constraints, DataClass, Grant, GrantProblem, Hash32, Mode};
use std::path::{Path, PathBuf};
use tmp::TempDir;
fn fixture(case: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/grants")
.join(case)
}
const GOOD: &str = "tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n\n\
[constraints]\npaths = [\"/home/kyle/notes\"]\n";
/// A directory holding one good grant and the given files; returns the problems of loading it.
fn problems_of(files: &[(&str, &str)]) -> Vec<GrantProblem> {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
for (name, text) in files {
dir.write(name, text);
}
load(dir.path()).expect_err("the set should be invalid")
}
/// Exactly one problem, in `file`, whose text contains every one of `words`.
fn one_problem(files: &[(&str, &str)], file: &str, words: &[&str]) -> GrantProblem {
let problems = problems_of(files);
assert_eq!(problems.len(), 1, "{problems:?}");
let p = problems.into_iter().next().unwrap();
assert_eq!(p.file, file);
for word in words {
assert!(p.problem.contains(word), "{:?} lacks {word:?}", p.problem);
}
p
}
fn body(tool: &str, mode: &str, rest: &str) -> String {
format!("tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n{rest}")
}
#[test]
fn the_valid_fixture_loads_in_id_order_with_file_hashes() {
let set = load(&fixture("valid")).unwrap();
let ids: Vec<&str> = set.grants().iter().map(|g| g.id.as_str()).collect();
assert_eq!(
ids,
[
"fetch-example",
"no-fetch-internal",
"notes-read",
"scratch-write",
"shell-bare"
]
);
let notes = &set.grants()[2];
assert_eq!(notes.grant.tool, "read_file");
assert_eq!(notes.grant.mode, Mode::Auto);
assert!(!notes.grant.untrusted);
assert_eq!(notes.grant.constraints.paths, ["/home/kyle/notes"]);
let bytes = std::fs::read(fixture("valid").join("notes-read.toml")).unwrap();
assert_eq!(notes.sha256, proto::sha256(&bytes).unwrap());
// Defaults from `proto::Grant`.
let scratch = &set.grants()[3];
assert_eq!(scratch.grant.result_class, DataClass::Private);
assert!(scratch.grant.untrusted);
assert_eq!(scratch.grant.expires, None);
}
#[test]
fn files_that_do_not_end_in_toml_are_ignored() {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
dir.write("good.toml~", "not toml at all {{{");
dir.write("good.toml.bak", "not toml at all {{{");
dir.write("README.md", "# notes");
dir.write("toml", "x");
std::fs::create_dir(dir.path().join("archive")).unwrap();
let set = load(dir.path()).unwrap();
assert_eq!(set.grants().len(), 1);
assert_eq!(set.grants()[0].id, "good");
}
#[test]
fn an_empty_directory_is_a_valid_empty_set() {
let set = load(&fixture("empty")).unwrap();
assert!(set.grants().is_empty());
assert_eq!(set, GrantSet::default());
}
#[test]
fn a_missing_directory_is_a_problem_not_an_empty_set() {
let missing = fixture("does-not-exist");
let problems = load(&missing).unwrap_err();
assert_eq!(problems.len(), 1);
assert!(problems[0].file.contains("does-not-exist"), "{problems:?}");
assert!(problems[0].problem.contains("cannot be read"));
// A file where the directory should be is the same.
let dir = TempDir::new("grants");
let file = dir.write("grants", "x");
assert!(load(&file).is_err());
}
/// The rule the whole design leans on. `fetch-example` alone would allow a fetch that the
/// mistyped `no-fetch-internal` was written to forbid, so nothing loads at all.
#[test]
fn one_invalid_file_makes_the_whole_set_invalid() {
let problems = load(&fixture("one-bad")).unwrap_err();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].file, "no-fetch-internal.toml");
assert_eq!(problems[0].line, Some(3));
assert!(problems[0].problem.contains("mdoe"), "{problems:?}");
// The same directory without the bad file is fine.
let dir = TempDir::new("grants");
for name in ["notes-read.toml", "fetch-example.toml"] {
let text = std::fs::read_to_string(fixture("one-bad").join(name)).unwrap();
dir.write(name, &text);
}
assert_eq!(load(dir.path()).unwrap().grants().len(), 2);
}
#[test]
fn every_problem_in_every_file_is_reported() {
let problems = load(&fixture("many-bad")).unwrap_err();
let got: Vec<(&str, Option<u64>)> =
problems.iter().map(|p| (p.file.as_str(), p.line)).collect();
assert_eq!(
got,
[
("a-secret.toml", None),
("a-secret.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("c-syntax.toml", Some(3)),
],
"{problems:?}"
);
let all: String = problems
.iter()
.map(|p| format!("{}\n", p.problem))
.collect();
for words in [
"secrets are not supported until M4",
"patterns are not supported",
"read_file does not take hosts",
"\"notes\" is not a valid absolute path",
"\"/home/kyle/../etc\" is not a valid absolute path",
"a grant of the whole file system is not supported",
] {
assert!(all.contains(words), "missing {words:?} in:\n{all}");
}
}
#[test]
fn rule_1_unreadable_not_utf8_or_not_a_grant() {
one_problem(&[("bad.toml", "tool = ")], "bad.toml", &[]);
let p = one_problem(
&[("bad.toml", &body("shell", "auto", "colour = \"red\"\n"))],
"bad.toml",
&["colour"],
);
assert_eq!(p.line, Some(4));
one_problem(
&[("bad.toml", "tool = \"shell\"\nmode = \"auto\"\n")],
"bad.toml",
&["max_taint"],
);
one_problem(
&[("bad.toml", &body("shell", "sometimes", ""))],
"bad.toml",
&["sometimes"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "auto", "[constraints]\ncwd = [\"/a\"]\n"),
)],
"bad.toml",
&["cwd"],
);
// Not UTF-8.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::write(dir.path().join("latin1.toml"), b"tool = \"caf\xe9\"\n").unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "latin1.toml");
assert!(problems[0].problem.contains("UTF-8"), "{problems:?}");
// Exists but cannot be read: a directory with a grant's name.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::create_dir(dir.path().join("folder.toml")).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "folder.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_1_a_file_without_read_permission_is_a_problem() {
use std::os::unix::fs::PermissionsExt;
if tmp::running_as_root("rule_1_a_file_without_read_permission_is_a_problem") {
return;
}
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
let locked = dir.write("locked.toml", GOOD);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "locked.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_2_the_file_stem_is_the_id() {
for id in ["a", "notes-read", "0", "a-1-b", &"x".repeat(64)] {
assert!(valid_id(id), "{id:?}");
}
for id in [
"",
"Notes",
"notes_read",
"notes.read",
"notes read",
".hidden",
&"x".repeat(65),
] {
assert!(!valid_id(id), "{id:?}");
}
one_problem(
&[("Bad_Name.toml", GOOD)],
"Bad_Name.toml",
&["not a valid grant id"],
);
one_problem(&[(".toml", GOOD)], ".toml", &["not a valid grant id"]);
one_problem(&[("a.b.toml", GOOD)], "a.b.toml", &["not a valid grant id"]);
}
#[test]
fn rule_3_the_tool_is_one_of_the_four() {
for tool in ["echo", "clock", "Read_File", ""] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[
"unknown tool",
"read_file, write_file, shell and http_fetch",
],
);
}
}
#[test]
fn rules_4_and_5_secrets_and_patterns_are_not_supported() {
one_problem(
&[("bad.toml", &body("shell", "ask", "secret = \"token\"\n"))],
"bad.toml",
&["secrets are not supported until M4"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "ask", "[constraints]\npatterns = [\"^ls\"]\n"),
)],
"bad.toml",
&["patterns are not supported"],
);
// An empty list is the same as no list.
let dir = TempDir::new("grants");
dir.write(
"ok.toml",
&body("shell", "ask", "[constraints]\npatterns = []\nhosts = []\n"),
);
assert!(load(dir.path()).is_ok());
}
/// The table of rule 6, cell by cell.
#[test]
fn rule_6_each_tool_takes_its_own_constraints() {
let paths = "[constraints]\npaths = [\"/a\"]\n";
let hosts = "[constraints]\nhosts = [\"example.com\"]\n";
let both = "[constraints]\npaths = [\"/a\"]\nhosts = [\"example.com\"]\n";
for tool in ["read_file", "write_file"] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[tool, "needs at least one path"],
);
one_problem(
&[("bad.toml", &body(tool, "auto", both))],
"bad.toml",
&[tool, "does not take hosts"],
);
}
one_problem(
&[("bad.toml", &body("shell", "auto", both))],
"bad.toml",
&["shell does not take hosts"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", ""))],
"bad.toml",
&["http_fetch needs at least one host"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", both))],
"bad.toml",
&["http_fetch does not take paths"],
);
// The allowed cells.
let dir = TempDir::new("grants");
dir.write("r.toml", &body("read_file", "auto", paths));
dir.write("w.toml", &body("write_file", "auto", paths));
dir.write("s1.toml", &body("shell", "auto", paths));
dir.write("s2.toml", &body("shell", "auto", ""));
dir.write("h.toml", &body("http_fetch", "auto", hosts));
assert_eq!(load(dir.path()).unwrap().grants().len(), 5);
// Two wrong cells in one file are two problems.
let wrong = body("http_fetch", "auto", paths);
assert_eq!(problems_of(&[("bad.toml", &wrong)]).len(), 2);
}
#[test]
fn rule_7_paths_are_valid_absolute_paths_and_never_the_root() {
for bad in ["notes", "/a/../b", "/a//b", "/a/./b", "/a/", ""] {
let text = body(
"shell",
"auto",
&format!("[constraints]\npaths = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["is not a valid absolute path"],
);
}
let root = body(
"shell",
"auto",
"[constraints]\npaths = [\"/home/kyle\", \"/\"]\n",
);
one_problem(
&[("bad.toml", &root)],
"bad.toml",
&["a grant of the whole file system is not supported"],
);
}
#[test]
fn rule_8_hosts_are_valid_host_patterns() {
for bad in [
"Example.com",
"example.com:443",
"127.0.0.1",
"localhost",
"*.com",
"https://example.com",
] {
let text = body(
"http_fetch",
"auto",
&format!("[constraints]\nhosts = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&[bad, "is not a valid host pattern"],
);
}
}
#[test]
fn rule_9_a_deny_grant_applies_at_every_taint() {
for taint in ["public", "private"] {
let text = format!("tool = \"shell\"\nmode = \"deny\"\nmax_taint = \"{taint}\"\n");
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["a deny grant must apply at every taint"],
);
}
let dir = TempDir::new("grants");
dir.write("no-shell.toml", &body("shell", "deny", ""));
assert!(load(dir.path()).is_ok());
// The rule is about deny only.
dir.write(
"ask.toml",
"tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"public\"\n",
);
assert!(load(dir.path()).is_ok());
}
fn loaded(id: &str, tool: &str, mode: Mode) -> LoadedGrant {
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(),
},
sha256: Hash32::ZERO,
}
}
/// `from_grants` is how tests and the property test build a set without files. It applies the
/// same value rules, sorts by id, and refuses two grants with one id.
#[test]
fn from_grants_applies_the_value_rules() {
let set = GrantSet::from_grants(vec![
loaded("zz", "shell", Mode::Ask),
loaded("aa", "shell", Mode::Deny),
])
.unwrap();
assert_eq!(set.grants()[0].id, "aa");
assert_eq!(set.grants()[1].id, "zz");
let problems = GrantSet::from_grants(vec![
loaded("ok", "shell", Mode::Auto),
loaded("no-paths", "read_file", Mode::Auto),
loaded("Bad", "shell", Mode::Auto),
])
.unwrap_err();
let files: Vec<&str> = problems.iter().map(|p| p.file.as_str()).collect();
assert_eq!(files, ["Bad.toml", "no-paths.toml"]);
let twice = GrantSet::from_grants(vec![
loaded("same", "shell", Mode::Auto),
loaded("same", "shell", Mode::Ask),
])
.unwrap_err();
assert!(
twice[0].problem.contains("two grants have this id"),
"{twice:?}"
);
}
#[test]
fn render_prints_every_problem_and_then_the_runbook_pointer() {
let problems = [
GrantProblem {
file: "a.toml".to_string(),
line: Some(3),
problem: "unknown field `mdoe`".to_string(),
},
GrantProblem {
file: "Bad_Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
];
assert_eq!(
render(&problems),
"a.toml:3: unknown field `mdoe`\n\
Bad_Name.toml: the file name is not a valid grant id\n\
see docs/runbook.md#grants-invalid\n"
);
assert_eq!(RUNBOOK, "see docs/runbook.md#grants-invalid");
assert!(render(&problems).trim_end().ends_with(RUNBOOK));
}
@@ -0,0 +1,383 @@
//! The ledger's first and third steps: decide and record; raise the state and record the result.
//! And what happens after a failed append or a panic. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::panic::AssertUnwindSafe;
use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED};
use brokerd::policy::{Label, SessionState};
use build::{grant, now, read, set};
use proto::{
AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus,
SessionId, ToolResponse,
};
use rig::Rig;
fn one(grants: Vec<build::Build>) -> Grants {
Ok(set(grants))
}
fn notes(mode: Mode) -> Grants {
one(vec![grant("n", "read_file", mode).paths(&["/n"])])
}
fn call(label: Label) -> Call {
Call {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
label,
}
}
fn secret() -> Label {
Label {
class: DataClass::Secret,
untrusted: true,
}
}
fn result(content: &str, label: Label) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: label.class,
untrusted: label.untrusted,
truncated: false,
}
}
#[test]
fn an_allowed_call_is_recorded_in_full() {
let rig = Rig::new("ledger-allowed");
let ledger = rig.ledger();
let request = read("/n/a");
let arguments = request.arguments.clone();
match ledger.decide(request, &notes(Mode::Auto), now()) {
Decided::Allowed { decision, seq } => {
assert_eq!(seq, 0);
assert_eq!(decision.grant(), "n");
}
other => panic!("{other:?}"),
}
let records = rig.records();
assert_eq!(records.len(), 1);
assert_eq!(records[0].time, now());
assert_eq!(
records[0].event,
AuditEvent::Decision {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments,
outcome: DecisionRecord::Allowed {},
grant: Some("n".to_string()),
grant_sha256: Some(proto::sha256(b"n").unwrap()),
taint: DataClass::Private,
untrusted: false,
}
);
}
#[test]
fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() {
let rig = Rig::new("ledger-ask");
let ledger = rig.ledger();
match ledger.decide(read("/n/a"), &notes(Mode::Ask), now()) {
Decided::Ask { ask, seq, state } => {
assert_eq!((seq, ask.grant()), (0, "n"));
assert_eq!(state, SessionState::default());
}
other => panic!("{other:?}"),
}
match &rig.events()[0] {
AuditEvent::Decision { outcome, grant, .. } => {
assert_eq!(*outcome, DecisionRecord::Ask {});
assert_eq!(grant.as_deref(), Some("n"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_denial_names_a_grant_only_when_a_grant_denied() {
let rig = Rig::new("ledger-denied");
let ledger = rig.ledger();
let none = ledger.decide(read("/n/a"), &one(Vec::new()), now());
assert!(
matches!(none, Decided::Denied(DenyReason::NoGrant)),
"{none:?}"
);
let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]);
let by = ledger.decide(read("/n/a"), &deny, now());
assert!(
matches!(by, Decided::Denied(DenyReason::DeniedByGrant)),
"{by:?}"
);
let events = rig.events();
let named: Vec<(DecisionRecord, Option<String>)> = events
.iter()
.map(|e| match e {
AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
named,
[
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant
},
None
),
(
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant
},
Some("d".to_string())
),
]
);
}
#[test]
fn invalid_grants_come_first_then_an_unreadable_state() {
let rig = Rig::new("ledger-order");
let ledger = rig.ledger();
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "not json").unwrap();
let invalid: Grants = Err(vec![GrantProblem {
file: "x.toml".to_string(),
line: None,
problem: "bad".to_string(),
}]);
let first = ledger.decide(read("/n/a"), &invalid, now());
assert!(
matches!(first, Decided::Denied(DenyReason::GrantsInvalid)),
"{first:?}"
);
let second = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(second, Decided::Denied(DenyReason::StateUnreadable)),
"{second:?}"
);
// A state brokerd cannot read is recorded as the most sensitive one.
for event in rig.events() {
match event {
AuditEvent::Decision {
taint, untrusted, ..
} => assert_eq!((taint, untrusted), (DataClass::Secret, true)),
other => panic!("{other:?}"),
}
}
let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged");
assert!(!damaged.is_empty(), "{:?}", rig.lines.all());
}
#[test]
fn a_failed_append_denies_this_call_and_every_later_one() {
let rig = Rig::new("ledger-stop");
let ledger = rig.ledger();
rig.switch.fail(true);
let first = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(first, Decided::Denied(DenyReason::AuditUnavailable)),
"{first:?}"
);
assert_eq!(rig.switch.attempts(), 1);
// The sink would work again; the ledger does not try it.
rig.switch.fail(false);
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
let finished = ledger.finish(&call(secret()), result("x", secret()), now());
assert_eq!(
finished,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rig.switch.attempts(), 1, "nothing more was written");
assert!(rig.records().is_empty());
assert!(!rig.state_file("s1").exists(), "the state was not raised");
assert!(!rig.lines.with(STOPPED).is_empty());
assert!(
rig.lines
.all()
.iter()
.all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")),
"{:?}",
rig.lines.all()
);
}
#[test]
fn a_panic_while_holding_the_ledger_denies_every_later_call() {
let rig = Rig::new("ledger-poison");
let ledger = rig.ledger();
rig.switch.panic_next();
let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| {
ledger.decide(read("/n/a"), &notes(Mode::Auto), now())
}));
assert!(panicked.is_err());
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
assert_eq!(rig.switch.attempts(), 1);
assert!(
!rig.lines.with(POISONED).is_empty(),
"{:?}",
rig.lines.all()
);
assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable"));
}
#[test]
fn a_result_raises_the_state_then_is_recorded() {
let rig = Rig::new("ledger-result");
let ledger = rig.ledger();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(answer, result("key", secret()), "passed on unchanged");
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(
state,
SessionState {
taint: DataClass::Secret,
untrusted: true
}
);
assert_eq!(
rig.events(),
[AuditEvent::Result {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
status: ResultStatus::Result,
class: DataClass::Secret,
untrusted: true,
truncated: false,
bytes: 3,
sha256: proto::sha256(b"key").unwrap(),
taint_after: DataClass::Secret,
}]
);
}
#[test]
fn taint_never_goes_down() {
let rig = Rig::new("ledger-down");
let ledger = rig.ledger();
let public = Label {
class: DataClass::Public,
untrusted: false,
};
ledger.finish(&call(secret()), result("a", secret()), now());
ledger.finish(&call(public), result("b", public), now());
let after: Vec<DataClass> = rig
.events()
.iter()
.map(|e| match e {
AuditEvent::Result { taint_after, .. } => *taint_after,
other => panic!("{other:?}"),
})
.collect();
assert_eq!(after, [DataClass::Secret, DataClass::Secret]);
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true));
}
#[test]
fn a_failure_changes_no_state_and_is_recorded_by_its_message() {
let rig = Rig::new("ledger-failed");
let ledger = rig.ledger();
let failed = ToolResponse::Failed {
message: "the tool timed out".to_string(),
};
assert_eq!(
ledger.finish(&call(secret()), failed.clone(), now()),
failed
);
assert!(!rig.state_file("s1").exists());
match &rig.events()[0] {
AuditEvent::Result {
status,
class,
bytes,
sha256,
taint_after,
..
} => {
assert_eq!(*status, ResultStatus::Failed);
assert_eq!(
*class,
DataClass::Secret,
"the label the result would have had"
);
assert_eq!(*bytes, 18);
assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap());
assert_eq!(*taint_after, DataClass::Private, "the taint before");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("ledger-ro");
let ledger = rig.ledger();
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
rig.records().is_empty(),
"no Result for a state not on disk"
);
assert!(
!rig.lines
.with("see docs/runbook.md#broker-state-damaged")
.is_empty()
);
}
#[test]
fn a_result_record_that_cannot_be_written_withholds_the_content() {
let rig = Rig::new("ledger-norecord");
let ledger = rig.ledger();
rig.switch.fail(true);
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
@@ -0,0 +1,262 @@
//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry
//! are recorded as denials. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::approvals::Verdict;
use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger};
use brokerd::policy::{Ask, Label};
use build::{grant, now, read, set};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId,
};
use rig::Rig;
fn asking() -> Grants {
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
}
fn pending(ledger: &Ledger) -> Ask {
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
Decided::Ask { ask, seq: 0, .. } => ask,
other => panic!("{other:?}"),
}
}
fn bxctl() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn denied(reason: DenyReason) -> DecisionRecord {
DecisionRecord::Denied { reason }
}
fn verdict_reason(answered: &Answered) -> Option<DenyReason> {
match &answered.verdict {
Verdict::Run(_) => None,
Verdict::Denied(reason) => Some(*reason),
}
}
/// The one `Approval` record, as (answer, by, reason, outcome, grant).
type Row = (
ApprovalAnswer,
Option<String>,
Option<String>,
DecisionRecord,
Option<String>,
);
fn approval(rig: &Rig) -> Row {
let events = rig.events();
assert_eq!(events.len(), 2, "a decision and one approval: {events:?}");
match &events[1] {
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome,
grant,
..
} => {
assert_eq!(session, &SessionId::new("s1").unwrap());
assert_eq!((*call, *decision, post), (CallId(1), 0, &None));
(
*answer,
by.clone(),
reason.clone(),
outcome.clone(),
grant.clone(),
)
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_that_still_asks_lets_the_call_run() {
let rig = Rig::new("answer-ask");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "n"),
other => panic!("{other:?}"),
}
assert_eq!(answered.outcome, DecisionRecord::Ask {});
let row = approval(&rig);
assert_eq!(
row,
(
ApprovalAnswer::Approved,
Some("bxctl".to_string()),
None,
DecisionRecord::Ask {},
Some("n".to_string())
)
);
}
#[test]
fn an_approval_under_a_grant_that_is_now_auto_records_allowed() {
let rig = Rig::new("answer-auto");
let ledger = rig.ledger();
let ask = pending(&ledger);
let now_auto = Ok(set(vec![
grant("n", "read_file", Mode::Auto).paths(&["/n"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now());
assert!(matches!(answered.verdict, Verdict::Run(_)));
assert_eq!(answered.outcome, DecisionRecord::Allowed {});
}
#[test]
fn the_approval_names_the_grant_matched_now() {
let rig = Rig::new("answer-grant");
let ledger = rig.ledger();
let ask = pending(&ledger);
let wider = Ok(set(vec![
grant("n", "read_file", Mode::Ask).paths(&["/n"]),
grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &wider, now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"),
other => panic!("{other:?}"),
}
assert_eq!(approval(&rig).4.as_deref(), Some("z-deep"));
}
#[test]
fn an_approval_after_the_grant_is_gone_is_denied() {
let rig = Rig::new("answer-gone");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant));
assert_eq!(answered.outcome, denied(DenyReason::NoGrant));
let row = approval(&rig);
assert_eq!(
(row.0, row.3, row.4),
(ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None)
);
}
#[test]
fn an_approval_after_the_taint_rose_is_denied() {
let rig = Rig::new("answer-taint");
let ledger = rig.ledger();
let low = || {
Ok(set(vec![
grant("n", "read_file", Mode::Ask)
.paths(&["/n"])
.max_taint(DataClass::Private),
]))
};
let ask = match ledger.decide(read("/n/a"), &low(), now()) {
Decided::Ask { ask, .. } => ask,
other => panic!("{other:?}"),
};
// Another call of the session read a secret while this one waited.
let s1 = SessionId::new("s1").unwrap();
let label = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), label)
.unwrap();
let answered = ledger.answer(ask, 0, bxctl(), &low(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh));
match &rig.events()[1] {
AuditEvent::Approval { taint, outcome, .. } => {
assert_eq!(*taint, DataClass::Secret, "the state at the re-decision");
assert_eq!(*outcome, denied(DenyReason::TaintTooHigh));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_with_invalid_grants_is_denied() {
let rig = Rig::new("answer-invalid");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid));
}
#[test]
fn a_refusal_is_recorded_with_the_owners_reason() {
let rig = Rig::new("answer-refuse");
let ledger = rig.ledger();
let ask = pending(&ledger);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: Some("not now".to_string()),
};
let answered = ledger.answer(ask, 0, refused, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Refused,
Some("bxctl".to_string()),
Some("not now".to_string()),
denied(DenyReason::ApprovalRefused),
None
)
);
}
#[test]
fn an_expiry_is_recorded_by_nobody() {
let rig = Rig::new("answer-expire");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Expired,
None,
None,
denied(DenyReason::ApprovalExpired),
None
)
);
}
#[test]
fn an_approval_that_cannot_be_recorded_does_not_run() {
let rig = Rig::new("answer-norecord");
let ledger = rig.ledger();
let ask = pending(&ledger);
rig.switch.fail(true);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
assert_eq!(
verdict_reason(&answered),
Some(DenyReason::AuditUnavailable)
);
assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable));
assert_eq!(rig.events().len(), 1, "only the decision");
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
@@ -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,175 @@
//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not
//! edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/runtime.rs"]
mod runtime;
use brokerd::args::{ToolArgs, ToolName};
use brokerd::policy::{Decision, Outcome, SessionState, decide};
use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run};
use build::{grant, now, read, request, set};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
use runtime::Recording;
fn allowed(grants: Vec<build::Build>, request: ToolRequest) -> Decision {
match decide(request, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(decision) => decision,
other => panic!("the test's call is not allowed: {other:?}"),
}
}
fn mount(path: &str, writable: bool) -> Mount {
Mount {
path: path.to_string(),
writable,
}
}
#[test]
fn read_file_mounts_the_matched_path_read_only_and_has_no_network() {
let d = allowed(
vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])],
read("/h/notes/deep/a.md"),
);
let rt = Recording::answering("text");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].tool, ToolName::ReadFile);
assert_eq!(
seen[0].arguments,
ToolArgs::ReadFile {
path: "/h/notes/deep/a.md".to_string()
}
);
// The longest path that holds the argument, and only that one.
assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn write_file_mounts_the_matched_path_writable() {
// A grant path equal to the argument does not count, so `/s/out` is written through `/s`.
let d = allowed(
vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])],
request("write_file", r#"{"path":"/s/out","content":"x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::WriteFile);
assert_eq!(seen[0].mounts, [mount("/s", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_mounts_every_path_of_the_grant_writable() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])],
request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::Shell);
assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_without_paths_mounts_nothing() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto)],
request("shell", r#"{"command":"date"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].mounts, []);
assert_eq!(seen[0].egress, None);
}
#[test]
fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() {
let d = allowed(
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])],
request("http_fetch", r#"{"url":"https://www.example.org/x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::HttpFetch);
assert_eq!(seen[0].mounts, []);
assert_eq!(
seen[0].egress,
Some(vec!["example.com".to_string(), "*.example.org".to_string()])
);
}
#[test]
fn a_result_carries_the_label_combined_over_every_matching_grant() {
// `b-keys` has the longer path and wins the mount; the label is the highest class of both
// grants, and untrusted because `a-home` says so.
let d = allowed(
vec![
grant("a-home", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Private),
grant("b-keys", "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
],
read("/home/kyle/keys/id"),
);
let rt = Recording::with(Ok(RunOutput {
content: "key".to_string(),
truncated: true,
}));
let answer = run(d, rt.as_ref());
assert_eq!(
answer,
ToolResponse::Result {
content: "key".to_string(),
class: DataClass::Secret,
untrusted: true,
truncated: true,
}
);
assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]);
}
#[test]
fn a_run_error_is_a_failure_with_the_runtimes_sentence() {
for error in [
RunError::Failed("the tool timed out".to_string()),
RunError::Unavailable("the container could not start".to_string()),
] {
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
let text = match &error {
RunError::Failed(t) | RunError::Unavailable(t) => t.clone(),
};
let rt = Recording::with(Err(error));
assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text });
}
}
#[test]
fn the_production_runtime_refuses_every_call() {
assert_eq!(REFUSING, "the runner arrives in M3b");
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
assert_eq!(
run(d, &Refusing),
ToolResponse::Failed {
message: REFUSING.to_string()
}
);
}
@@ -0,0 +1,405 @@
//! `brokerd serve` as a process: its startup, both sockets, and the expiry thread. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use brokerd::audit::RECOVERED_NOTICE;
use brokerd::runner::REFUSING;
use proto::{
AuditEvent, CallId, DenyReason, Empty, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
ResultStatus, SessionId, ToolRequest, ToolResponse,
};
use tmp::TempDir;
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
fn new(tag: &str, ttl_ms: u64) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let text = format!(
"[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[approvals]\nttl_ms = {ttl_ms}\n",
home = dir.path().display()
);
let config = dir.write("brokerd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn tools(&self) -> PathBuf {
self.path("run/loop-broker/broker.sock")
}
fn admin(&self) -> PathBuf {
self.path("run/owner-broker/admin.sock")
}
fn grant(&self, id: &str, mode: &str) {
let text = format!(
"tool = \"read_file\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n\
result_class = \"private\"\nuntrusted = false\n[constraints]\npaths = [\"/n\"]\n"
);
std::fs::write(self.path(&format!("grants/{id}.toml")), text).unwrap();
}
fn command(&self, extra: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_brokerd"));
command
.args(["serve", "--config"])
.arg(&self.config)
.args(extra);
command
}
/// Starts `brokerd serve` and waits until both sockets answer.
fn serve(&self) -> Running {
let child = self
.command(&[])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(Some(child));
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(self.tools()).is_err()
|| UnixStream::connect(self.admin()).is_err()
{
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
running
}
/// Runs `brokerd serve` expecting it to exit by itself.
fn run(&self, extra: &[&str]) -> Output {
self.command(extra).output().unwrap()
}
fn events(&self) -> Vec<AuditEvent> {
let dir = self.path("audit");
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
names
.iter()
.flat_map(|n| {
let text = std::fs::read_to_string(dir.join(n)).unwrap();
text.lines()
.map(|l| serde_json::from_str::<proto::AuditRecord>(l).unwrap().event)
.collect::<Vec<_>>()
})
.collect()
}
}
/// Kills the daemon when dropped; `stop` returns what it printed.
struct Running(Option<Child>);
impl Running {
fn stop(mut self) -> String {
let mut child = self.0.take().unwrap();
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
String::from_utf8_lossy(&output.stderr).to_string()
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn exchange(socket: &Path, id: u64, msg: Message) -> Vec<Envelope> {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut stream, &env).unwrap();
let mut frames = Vec::new();
loop {
let env = proto::read_frame(&mut stream).unwrap();
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
fn read_notes(call: u64) -> Message {
Message::ToolRequest(ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
})
}
fn last_response(frames: &[Envelope]) -> &ToolResponse {
match &frames.last().unwrap().msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
#[test]
fn it_makes_its_directories_0700_and_its_sockets_0600() {
let home = Home::new("serve-modes", 900_000);
// One directory found too open, one made.
std::fs::create_dir_all(home.path("run/owner-broker")).unwrap();
std::fs::set_permissions(
home.path("run/owner-broker"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let running = home.serve();
assert_eq!(mode(&home.path("run/loop-broker")), 0o700);
assert_eq!(mode(&home.path("run/owner-broker")), 0o700);
assert_eq!(mode(&home.tools()), 0o600);
assert_eq!(mode(&home.admin()), 0o600);
assert_eq!(mode(&home.path("audit")), 0o700);
let printed = running.stop();
assert!(printed.contains("serving"), "{printed}");
}
#[test]
fn a_stale_socket_is_replaced() {
let home = Home::new("serve-stale", 900_000);
std::fs::create_dir_all(home.path("run/loop-broker")).unwrap();
drop(UnixListener::bind(home.tools()).unwrap());
assert!(home.tools().exists(), "the stale socket file is there");
let _running = home.serve();
let frames = exchange(&home.tools(), 3, read_notes(3));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::NoGrant
}
);
}
#[test]
fn a_second_brokerd_on_the_same_home_refuses_to_start() {
let home = Home::new("serve-twice", 900_000);
let _running = home.serve();
let second = home.run(&[]);
assert_eq!(second.status.code(), Some(1));
let text = stderr(&second);
assert!(text.contains("brokerd is already running"), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The first still has its sockets.
let frames = exchange(&home.admin(), 1, Message::Approvals(Empty {}));
assert!(matches!(frames[0].msg, Message::ApprovalList(_)));
}
#[test]
fn it_answers_each_socket_and_refuses_the_other_kinds() {
let home = Home::new("serve-kinds", 900_000);
home.grant("notes", "auto");
let running = home.serve();
let frames = exchange(&home.tools(), 7, read_notes(7));
assert_eq!(frames[0].id, 7);
// The production runtime runs nothing.
assert_eq!(
last_response(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
let wrong = exchange(&home.tools(), 8, Message::Approvals(Empty {}));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let wrong = exchange(&home.admin(), 9, read_notes(9));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let printed = running.stop();
assert!(
printed.contains("approvals on broker.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(
printed.contains("tool_request on admin.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(matches!(
home.events().as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
status: ResultStatus::Failed,
..
}
]
));
}
#[test]
fn an_approval_nobody_answers_expires() {
let home = Home::new("serve-expire", 100);
home.grant("notes", "ask");
let _running = home.serve();
let started = Instant::now();
let frames = exchange(&home.tools(), 2, read_notes(2));
assert_eq!(frames.len(), 2, "{frames:?}");
assert!(matches!(
&frames[0].msg,
Message::ToolResponse(ToolResponse::PendingApproval { approval: 0, .. })
));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::ApprovalExpired
}
);
// The expiry thread looks every second.
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
fn copy_case(home: &Home, case: &str, only: &[&str]) {
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::create_dir_all(home.path("audit")).unwrap();
for name in only {
std::fs::copy(
format!("{from}/{name}"),
home.path(&format!("audit/{name}")),
)
.unwrap();
}
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut all: Vec<(String, Vec<u8>)> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|e| {
(
e.file_name().into_string().unwrap(),
std::fs::read(e.path()).unwrap(),
)
})
.collect();
all.sort();
all
}
#[test]
fn a_broken_chain_stops_it_before_any_socket_and_nothing_is_written() {
let home = Home::new("serve-broken", 900_000);
copy_case(&home, "changed-byte", &["2026-09-17.jsonl"]);
let before = snapshot(&home.path("audit"));
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
let text = stderr(&output);
assert!(text.contains("2026-09-17.jsonl:4: "), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#audit-chain-broken"),
"{text}"
);
assert_eq!(snapshot(&home.path("audit")), before);
assert!(!home.tools().exists() && !home.admin().exists());
}
#[test]
fn a_torn_tail_is_recovered_and_it_serves() {
let home = Home::new("serve-torn", 900_000);
copy_case(
&home,
"torn-tail",
&["2026-09-17.jsonl", "2026-09-18.jsonl"],
);
let running = home.serve();
let printed = running.stop();
assert!(printed.contains(RECOVERED_NOTICE), "{printed}");
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
#[test]
fn accept_break_with_nothing_to_accept_exits_2() {
let home = Home::new("serve-nothing", 900_000);
let output = home.run(&["--accept-break"]);
assert_eq!(output.status.code(), Some(2));
assert!(
stderr(&output).contains("nothing to accept"),
"{}",
stderr(&output)
);
}
#[test]
fn bad_arguments_and_bad_configs_do_not_start() {
let brokerd = env!("CARGO_BIN_EXE_brokerd");
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["serve", "--config", "a", "--config", "b"][..],
&["serve", "--config", "a", "--loud"][..],
&["run", "--config", "a"][..],
] {
let output = Command::new(brokerd).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}");
assert!(
stderr(&output).starts_with("usage: brokerd serve"),
"{args:?}"
);
}
let home = Home::new("serve-config", 900_000);
std::fs::write(&home.config, "[paths]\nhoem = \"/x\"\n").unwrap();
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("brokerd.toml"),
"{}",
stderr(&output)
);
std::fs::remove_file(&home.config).unwrap();
assert_eq!(home.run(&[]).status.code(), Some(1));
assert!(
!home.path("audit").exists(),
"nothing made before the config is read"
);
}
@@ -0,0 +1,281 @@
//! Tests for the session state files. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::policy::{Label, SessionState};
use brokerd::state::{RUNBOOK, StateError, StateStore};
use proto::{DataClass, SessionId};
use std::os::unix::fs::PermissionsExt;
use tmp::TempDir;
fn id(text: &str) -> SessionId {
SessionId::new(text).unwrap()
}
fn label(class: DataClass, untrusted: bool) -> Label {
Label { class, untrusted }
}
fn state(taint: DataClass, untrusted: bool) -> SessionState {
SessionState { taint, untrusted }
}
/// The store's directory is two levels below the temporary one and does not exist yet, as on a
/// fresh install.
fn store(home: &TempDir) -> StateStore {
StateStore::new(&home.path().join("broker/sessions"))
}
#[test]
fn a_session_with_no_file_is_private_and_trusted() {
let home = TempDir::new("state");
let store = store(&home);
let fresh = store.read(&id("chat-1")).unwrap();
assert_eq!(fresh, state(DataClass::Private, false));
assert_eq!(fresh, SessionState::default());
// Reading creates nothing.
assert!(!home.path().join("broker").exists());
}
#[test]
fn the_first_result_creates_the_file_and_its_directory() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("chat-1");
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, false),
)
.unwrap();
assert_eq!(next, state(DataClass::Private, false));
let path = home.path().join("broker/sessions/chat-1.json");
assert_eq!(store.path(&session), path);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\"taint\":\"private\",\"untrusted\":false}\n"
);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(&home.path().join("broker/sessions")), 0o700);
assert_eq!(mode(&home.path().join("broker")), 0o700);
// No temporary file is left behind.
assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists());
assert_eq!(store.read(&session).unwrap(), next);
}
#[test]
fn taint_and_the_untrusted_flag_only_go_up() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let steps = [
(
label(DataClass::Public, false),
state(DataClass::Private, false),
),
(
label(DataClass::Private, true),
state(DataClass::Private, true),
),
(
label(DataClass::Secret, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Public, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Private, false),
state(DataClass::Secret, true),
),
];
let mut current = store.read(&session).unwrap();
for (result, want) in steps {
current = store.raise(&session, current, result).unwrap();
assert_eq!(current, want);
assert_eq!(store.read(&session).unwrap(), want, "what is on disk");
}
assert_eq!(
std::fs::read_to_string(store.path(&session)).unwrap(),
"{\"taint\":\"secret\",\"untrusted\":true}\n"
);
}
#[test]
fn sessions_do_not_share_state() {
let home = TempDir::new("state");
let store = store(&home);
store
.raise(
&id("a"),
SessionState::default(),
label(DataClass::Secret, true),
)
.unwrap();
assert_eq!(store.read(&id("b")).unwrap(), SessionState::default());
assert_eq!(
store.read(&id("a")).unwrap(),
state(DataClass::Secret, true)
);
}
/// A file that exists but does not hold a valid state is an error, never "no file".
#[test]
fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() {
let home = TempDir::new("state");
let store = store(&home);
std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap();
let session = id("hurt");
for text in [
"",
"{",
"null",
"[]",
"{\"taint\":\"secret\"}",
"{\"untrusted\":false}",
"{\"taint\":\"internal\",\"untrusted\":false}",
"{\"taint\":\"secret\",\"untrusted\":\"no\"}",
"{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}",
"{\"taint\":\"secret\",\"untrusted\":false} trailing",
// A session is never below private, so this file was not written by brokerd.
"{\"taint\":\"public\",\"untrusted\":false}",
] {
std::fs::write(store.path(&session), text).unwrap();
let err = store.read(&session).expect_err(text);
assert!(
matches!(err, StateError::Unreadable(..)),
"{text:?}: {err:?}"
);
let shown = err.to_string();
assert!(shown.contains("hurt.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
}
assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged");
// A good file with or without its final newline reads fine.
for text in [
"{\"taint\":\"secret\",\"untrusted\":true}\n",
"{\"taint\":\"secret\",\"untrusted\":true}",
] {
std::fs::write(store.path(&session), text).unwrap();
assert_eq!(
store.read(&session).unwrap(),
state(DataClass::Secret, true)
);
}
// Not valid UTF-8, and a directory where the file should be.
std::fs::write(store.path(&session), b"\xff\xfe").unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::remove_file(store.path(&session)).unwrap();
std::fs::create_dir(store.path(&session)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
}
#[test]
fn a_file_without_read_permission_is_an_error_not_a_fresh_session() {
if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("locked");
store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
let path = store.path(&session);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn a_failed_write_is_an_error_and_leaves_the_old_state() {
if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let before = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, true),
)
.unwrap();
let dir = home.path().join("broker/sessions");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let err = store
.raise(&session, before, label(DataClass::Secret, false))
.expect_err("the directory is read-only");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(err, StateError::Write(..)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("s.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
assert_eq!(store.read(&session).unwrap(), before);
}
/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does
/// not stop the next write, and the next write replaces it.
#[test]
fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let dir = home.path().join("broker/sessions");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap();
assert_eq!(store.read(&session).unwrap(), SessionState::default());
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
assert_eq!(store.read(&session).unwrap(), next);
assert!(!dir.join("s.json.tmp").exists());
}
#[test]
fn raise_trusts_the_state_it_is_given_not_the_file() {
// The caller read the state under the ledger lock a moment ago; `raise` does not read again.
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let given = state(DataClass::Secret, true);
let next = store
.raise(&session, given, label(DataClass::Public, false))
.unwrap();
assert_eq!(next, given);
// Even a state below private is lifted to private on the way to disk.
let low = store
.raise(
&id("low"),
state(DataClass::Public, false),
label(DataClass::Public, false),
)
.unwrap();
assert_eq!(low, state(DataClass::Private, false));
}
@@ -0,0 +1,98 @@
//! Temporary audit directories for the audit tests. Do not edit.
#![allow(dead_code)] // each test file uses its own part of this
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use proto::{AuditEvent, CallId, DataClass, DecisionRecord, SessionId, Timestamp};
pub const D1: &str = "2026-09-17.jsonl";
pub const D2: &str = "2026-09-18.jsonl";
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A directory under the system's temporary directory, removed when dropped.
pub struct TempDir {
pub path: PathBuf,
}
impl TempDir {
/// A path that does not exist yet.
pub fn unmade(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("brokerd-{tag}-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
TempDir { path }
}
/// A copy of the fixture log `case` from `crates/proto/tests/fixtures/audit/`. With `only`,
/// just those files: damage in an older file is not seen by an ordinary start, so tests of
/// the startup check copy the damaged file alone.
pub fn case(case: &str, only: Option<&[&str]>) -> TempDir {
let dir = TempDir::unmade(case);
std::fs::create_dir_all(&dir.path).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
let mut copied = 0;
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
let name = entry.file_name().into_string().unwrap();
if only.is_none_or(|names| names.contains(&name.as_str())) {
std::fs::copy(entry.path(), dir.path.join(&name)).unwrap();
copied += 1;
}
}
assert!(copied > 0, "{from}: nothing copied");
dir
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// Every log file in `dir` with its bytes.
pub fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap())
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|entry| {
let name = entry.file_name().into_string().unwrap();
(name, std::fs::read(entry.path()).unwrap())
})
.collect()
}
pub fn lines(dir: &Path, file: &str) -> Vec<String> {
let text = std::fs::read_to_string(dir.join(file)).unwrap();
text.lines().map(str::to_string).collect()
}
pub fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
/// A denied decision for call `call`: an event that leaves nothing open in the report.
pub fn denied(call: u64) -> AuditEvent {
AuditEvent::Decision {
session: SessionId::new("chat-1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
outcome: DecisionRecord::Denied {
reason: proto::DenyReason::NoGrant,
},
grant: None,
grant_sha256: None,
taint: DataClass::Private,
untrusted: false,
}
}
@@ -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,77 @@
//! A client for `broker::handle` and `admin::handle` over a socket pair, and a `Broker` built on
//! a `Rig`. Do not edit.
//!
//! Included with `#[path = "support/client.rs"] mod client;`, beside `rig`, `runtime`, `sink`
//! and `tmp`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use brokerd::broker::{self, Broker};
use proto::{Envelope, Message, PROTOCOL_VERSION, ToolRequest};
use crate::rig::Rig;
use crate::runtime::{Recording, Shared};
pub trait Serve {
/// A `Broker` on this rig's home, with `runtime` and the rig's flaky sink and log.
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker>;
}
impl Serve for Rig {
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker> {
let runtime = Box::new(Shared(Arc::clone(runtime)));
Arc::new(Broker::new(
self.cfg.clone(),
self.ledger(),
runtime,
self.lines.sink(),
))
}
}
/// A connection to `handler` running on its own thread, with `msg` already sent under `id`.
pub fn open(
broker: &Arc<Broker>,
handler: fn(UnixStream, &Broker),
id: u64,
msg: Message,
) -> UnixStream {
let (mut client, server) = UnixStream::pair().unwrap();
let broker = Arc::clone(broker);
std::thread::spawn(move || handler(server, &broker));
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut client, &env).unwrap();
client
}
/// The next frame, waiting at most ten seconds.
pub fn next(stream: &mut UnixStream) -> Envelope {
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
proto::read_frame(stream).unwrap()
}
/// Sends a tool request to `broker::handle` and reads every frame up to the final one.
pub fn call(broker: &Arc<Broker>, req: ToolRequest) -> Vec<Envelope> {
let id = req.call.0;
let mut stream = open(broker, broker::handle, id, Message::ToolRequest(req));
let mut frames = Vec::new();
loop {
let env = next(&mut stream);
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
@@ -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")
}
@@ -0,0 +1,118 @@
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
//! a log to read. Do not edit.
//!
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
//! tests add `client`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use brokerd::audit::Writer;
use brokerd::config::{Approvals, Config, Paths, Sockets};
use brokerd::ledger::Ledger;
use brokerd::state::StateStore;
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
use crate::sink::{Flaky, Lines, Switch};
use crate::tmp::TempDir;
pub struct Rig {
pub dir: TempDir,
pub cfg: Config,
pub switch: Switch,
pub lines: Lines,
}
impl Rig {
pub fn new(tag: &str) -> Rig {
Rig::with_ttl(tag, 900_000)
}
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
let dir = TempDir::new(tag);
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
let cfg = Config {
paths: Paths {
home: dir.path().to_path_buf(),
grants,
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
};
Rig {
dir,
cfg,
switch: Switch::default(),
lines: Lines::default(),
}
}
pub fn state(&self) -> StateStore {
StateStore::new(&self.cfg.state_dir())
}
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
pub fn ledger(&self) -> Ledger {
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
let sink = Flaky {
writer: opened.writer,
switch: self.switch.clone(),
};
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
}
/// Writes `grants/<id>.toml`.
pub fn grant(&self, id: &str, text: &str) {
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
}
pub fn remove_grant(&self, id: &str) {
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
}
pub fn state_file(&self, session: &str) -> PathBuf {
self.cfg.state_dir().join(format!("{session}.json"))
}
/// Every record in the audit log, in order.
pub fn records(&self) -> Vec<AuditRecord> {
let dir = self.cfg.audit_dir();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let text = std::fs::read_to_string(dir.join(name)).unwrap();
for line in text.lines() {
out.push(serde_json::from_str(line).unwrap());
}
}
out
}
pub fn events(&self) -> Vec<AuditEvent> {
self.records().into_iter().map(|r| r.event).collect()
}
}
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
format!(
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
)
}
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new(session).unwrap(),
call: CallId(call),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
@@ -0,0 +1,70 @@
//! A runtime that records what it is asked to run, for the runner and broker tests. Do not edit.
//!
//! Included with `#[path = "support/runtime.rs"] mod runtime;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::{Arc, Mutex};
use brokerd::args::{ToolArgs, ToolName};
use brokerd::runner::{Mount, RunError, RunOutput, RunSpec, Runtime};
/// What one `run` was given, copied out of the `RunSpec`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seen {
pub tool: ToolName,
pub arguments: ToolArgs,
pub mounts: Vec<Mount>,
pub egress: Option<Vec<String>>,
}
pub struct Recording {
seen: Mutex<Vec<Seen>>,
answer: Result<RunOutput, RunError>,
}
impl Recording {
/// Answers every call with `content`, not truncated.
pub fn answering(content: &str) -> Arc<Recording> {
Recording::with(Ok(RunOutput {
content: content.to_string(),
truncated: false,
}))
}
pub fn with(answer: Result<RunOutput, RunError>) -> Arc<Recording> {
Arc::new(Recording {
seen: Mutex::new(Vec::new()),
answer,
})
}
pub fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
pub fn count(&self) -> usize {
self.seen.lock().unwrap().len()
}
}
impl Runtime for Recording {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.seen.lock().unwrap().push(Seen {
tool: spec.tool(),
arguments: spec.arguments().clone(),
mounts: spec.mounts().to_vec(),
egress: spec.egress().map(<[String]>::to_vec),
});
self.answer.clone()
}
}
/// Lets a test keep its `Arc<Recording>` while the broker owns a `Box<dyn Runtime>`.
pub struct Shared(pub Arc<Recording>);
impl Runtime for Shared {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.0.run(spec)
}
}
@@ -0,0 +1,78 @@
//! An audit sink that fails on demand, and a log that tests can read. Do not edit.
//!
//! Included with `#[path = "support/sink.rs"] mod sink;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use brokerd::audit::{AuditError, Writer};
use brokerd::ledger::AuditSink;
use proto::{AuditEvent, Timestamp};
/// Switches shared between a test and its `Flaky` sink.
#[derive(Clone, Default)]
pub struct Switch {
fail: Arc<AtomicBool>,
panic: Arc<AtomicBool>,
attempts: Arc<AtomicUsize>,
}
impl Switch {
/// Every append from now on fails, without writing anything.
pub fn fail(&self, on: bool) {
self.fail.store(on, Ordering::SeqCst);
}
/// The next append panics, as a bug part-way through a write would.
pub fn panic_next(&self) {
self.panic.store(true, Ordering::SeqCst);
}
/// How many appends the ledger has asked for.
pub fn attempts(&self) -> usize {
self.attempts.load(Ordering::SeqCst)
}
}
/// A real `Writer` behind a switch.
pub struct Flaky {
pub writer: Writer,
pub switch: Switch,
}
impl AuditSink for Flaky {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
self.switch.attempts.fetch_add(1, Ordering::SeqCst);
if self.switch.panic.swap(false, Ordering::SeqCst) {
panic!("a bug part-way through a write");
}
if self.switch.fail.load(Ordering::SeqCst) {
return Err(AuditError::Io {
what: "cannot write to the test log".to_string(),
source: std::io::Error::other("the disk is full"),
});
}
self.writer.append(time, event)
}
}
/// Collects every line a ledger or broker prints.
#[derive(Clone, Default)]
pub struct Lines(Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Box<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Box::new(move |line| lines.lock().unwrap().push(line.to_string()))
}
pub fn all(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
/// The lines that hold `text`.
pub fn with(&self, text: &str) -> Vec<String> {
self.all()
.into_iter()
.filter(|l| l.contains(text))
.collect()
}
}
@@ -0,0 +1,57 @@
//! Temporary directories for tests. Do not edit.
//!
//! Included with `#[path = "support/tmp.rs"] mod tmp;`. No crate is used: the name is made from
//! the process id and a counter, and the directory is removed when the value is dropped.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// Writes `text` to `name` inside the directory and returns the full path.
pub fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
// Put back the permissions a test may have taken away, or the removal fails.
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// True when the tests run as root, for whom file permissions do not apply. Tests that depend on
/// a permission error print why they are skipped and return.
pub fn running_as_root(test: &str) -> bool {
let probe = TempDir::new("rootprobe");
let file = probe.write("probe", "x");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
let root = std::fs::read(&file).is_ok();
if root {
eprintln!("{test}: skipped, because this user can read a mode 000 file (root?)");
}
root
}