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:
@@ -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.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
secret = "api-token"
|
||||
|
||||
[constraints]
|
||||
hosts = ["api.example.com"]
|
||||
patterns = ["^GET "]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
|
||||
[constraints]
|
||||
paths = ["notes", "/home/kyle/../etc", "/"]
|
||||
hosts = ["example.com"]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
tool = "shell"
|
||||
mode = "auto"
|
||||
max_taint =
|
||||
+10
@@ -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"]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
result_class = "public"
|
||||
|
||||
[constraints]
|
||||
hosts = ["example.com", "*.example.com"]
|
||||
+7
@@ -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"]
|
||||
+10
@@ -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.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
result_class = "public"
|
||||
|
||||
[constraints]
|
||||
hosts = ["example.com", "*.example.com"]
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
tool = "http_fetch"
|
||||
mode = "deny"
|
||||
max_taint = "secret"
|
||||
|
||||
[constraints]
|
||||
hosts = ["internal.example.com"]
|
||||
+10
@@ -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"]
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
tool = "write_file"
|
||||
mode = "ask"
|
||||
max_taint = "private"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"]
|
||||
+4
@@ -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, ¬es(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"), ¬es(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"), ¬es(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"), ¬es(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"), ¬es(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"), ¬es(Mode::Auto), now())
|
||||
}));
|
||||
assert!(panicked.is_err());
|
||||
let later = ledger.decide(read("/n/a"), ¬es(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
|
||||
}
|
||||
Reference in New Issue
Block a user