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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:45:43 -07:00
co-authored by Claude Opus 5
parent 69f0a0a218
commit e3f37da232
180 changed files with 17219 additions and 292 deletions
@@ -0,0 +1,385 @@
//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which
//! answers an approval the same way. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use brokerd::admin;
use brokerd::broker::{self, Broker};
use brokerd::policy::Label;
use client::{Serve, next, open};
use proto::{
ApprovalAnswer, Approve, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, ErrorCode,
Message, Refuse, SessionId, Timestamp, ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig) {
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
}
/// Sends a call that waits for approval; returns its connection once the pending frame is read.
fn pending(broker: &Arc<Broker>, call: u64, arguments: &str) -> (UnixStream, u64) {
let req = request("s1", call, "read_file", arguments);
let mut stream = open(broker, broker::handle, call, Message::ToolRequest(req));
match next(&mut stream).msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval),
other => panic!("{other:?}"),
}
}
/// One admin request; returns the answer's message after checking its id and `final`.
fn admin(broker: &Arc<Broker>, msg: Message) -> Message {
let mut stream = open(broker, admin::handle, 5, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (5, true), "{env:?}");
env.msg
}
fn approve(broker: &Arc<Broker>, approval: u64) -> Message {
admin(broker, Message::Approve(Approve { approval }))
}
fn refuse(broker: &Arc<Broker>, approval: u64, reason: Option<&str>) -> Message {
let reason = reason.map(str::to_string);
admin(broker, Message::Refuse(Refuse { approval, reason }))
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
match next(stream).msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn outcome(msg: Message) -> DecisionRecord {
match msg {
Message::ApproveResult(r) => r.outcome,
other => panic!("{other:?}"),
}
}
fn error_code(msg: &Message) -> Option<ErrorCode> {
match msg {
Message::Error(e) => Some(e.code),
_ => None,
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
#[test]
fn approvals_lists_the_arguments_as_policy_parsed_them() {
let rig = Rig::new("admin-list");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert!(matches!(
admin(&broker, Message::Approvals(Empty {})),
Message::ApprovalList(list) if list.items.is_empty()
));
// A backslash-u escape of `/` is `/` in JSON; the owner sees the one spelling policy matched.
let escaped = format!(r#"{{"path":"{}n{}a"}}"#, "\\u002f", "\\u002f");
assert!(
escaped.contains("u002f"),
"the escape must survive: {escaped}"
);
let (_stream, approval) = pending(&broker, 1, &escaped);
match admin(&broker, Message::Approvals(Empty {})) {
Message::ApprovalList(list) => {
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].approval, approval);
assert_eq!(list.items[0].arguments, r#"{"path":"/n/a"}"#);
assert_eq!(list.items[0].grant, "notes");
}
other => panic!("{other:?}"),
}
// The audit log keeps the raw string.
match &rig.events()[0] {
AuditEvent::Decision { arguments, .. } => assert_eq!(arguments, &escaped),
other => panic!("{other:?}"),
}
}
#[test]
fn approve_runs_the_call_and_answers_with_the_re_decision() {
let rig = Rig::new("admin-approve");
ask_notes(&rig);
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert_eq!(outcome(approve(&broker, approval)), DecisionRecord::Ask {});
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!(*answer, ApprovalAnswer::Approved);
assert_eq!(by.as_deref(), Some("bxctl"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn refuse_denies_the_call_and_records_the_reason() {
let rig = Rig::new("admin-refuse");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert!(matches!(
refuse(&broker, approval, Some("not today")),
Message::Ok(Empty {})
));
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalRefused)
);
assert_eq!(rt.count(), 0);
match &rig.events()[1] {
AuditEvent::Approval { answer, reason, .. } => {
assert_eq!(*answer, ApprovalAnswer::Refused);
assert_eq!(reason.as_deref(), Some("not today"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_unknown_or_answered_id_is_no_such_approval() {
let rig = Rig::new("admin-unknown");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert_eq!(
error_code(&approve(&broker, 41)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(
error_code(&refuse(&broker, 41, None)),
Some(ErrorCode::NoSuchApproval)
);
let (_stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
refuse(&broker, approval, None);
assert_eq!(
error_code(&approve(&broker, approval)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(rig.events().len(), 2, "one decision, one approval");
}
#[test]
fn approve_and_refuse_at_once_give_exactly_one_answer() {
let rig = Rig::new("admin-race");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
for round in 0..100u64 {
let (mut stream, approval) = pending(&broker, round, r#"{"path":"/n/a"}"#);
let start = Arc::new(Barrier::new(2));
let racers: Vec<_> = [true, false]
.into_iter()
.map(|approving| {
let (broker, start) = (Arc::clone(&broker), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
if approving {
approve(&broker, approval)
} else {
refuse(&broker, approval, None)
}
})
})
.collect();
let answers: Vec<Message> = racers.into_iter().map(|r| r.join().unwrap()).collect();
let losers = answers
.iter()
.filter(|m| error_code(m) == Some(ErrorCode::NoSuchApproval))
.count();
assert_eq!(losers, 1, "round {round}: {answers:?}");
final_answer(&mut stream);
let approvals = rig
.events()
.iter()
.filter(|e| matches!(e, AuditEvent::Approval { decision, .. } if *decision == approval))
.count();
assert_eq!(approvals, 1, "round {round}");
}
}
#[test]
fn approve_after_the_grant_file_is_removed_is_denied() {
let rig = Rig::new("admin-removed");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.remove_grant("notes");
let reason = DenyReason::NoGrant;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn approve_after_the_taint_rose_past_the_grant_is_denied() {
let rig = Rig::new("admin-taint");
let text = grant_text("read_file", "ask", "", "paths = [\"/n\"]")
.replace("max_taint = \"secret\"", "max_taint = \"private\"");
rig.grant("notes", &text);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let s1 = SessionId::new("s1").unwrap();
let secret = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), secret)
.unwrap();
let reason = DenyReason::TaintTooHigh;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn an_approval_that_cannot_be_recorded_is_denied_on_both_sides() {
let rig = Rig::new("admin-norecord");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
let reason = DenyReason::AuditUnavailable;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn a_refusal_that_cannot_be_recorded_is_an_error_for_bxctl() {
let rig = Rig::new("admin-norefuse");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
match refuse(&broker, approval, None) {
Message::Error(e) => {
assert_eq!(e.code, ErrorCode::Internal);
assert!(
e.detail.ends_with("see docs/runbook.md#audit-unavailable"),
"{}",
e.detail
);
}
other => panic!("{other:?}"),
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::AuditUnavailable)
);
}
#[test]
fn expiry_denies_an_approval_nobody_answered() {
let rig = Rig::with_ttl("admin-expire", 100);
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let until = Instant::now() + Duration::from_secs(10);
while admin::expire_due(&broker, Timestamp::now()) == 0 {
assert!(Instant::now() < until, "never expired");
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalExpired)
);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!((*answer, by.as_deref()), (ApprovalAnswer::Expired, None));
}
other => panic!("{other:?}"),
}
assert_eq!(rt.count(), 0);
}
#[test]
fn check_grants_reports_every_problem_or_none() {
let rig = Rig::new("admin-grants");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => assert!(r.problems.is_empty(), "{r:?}"),
other => panic!("{other:?}"),
}
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
rig.grant(
"Worse",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => {
let files: Vec<&str> = r.problems.iter().map(|p| p.file.as_str()).collect();
assert!(files.contains(&"bad.toml"), "{r:?}");
assert!(files.contains(&"Worse.toml"), "{r:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn every_other_kind_on_admin_sock_is_forbidden() {
let rig = Rig::new("admin-forbidden");
let broker = rig.broker(&Recording::answering("x"));
let req = request("s1", 1, "read_file", r#"{"path":"/n/a"}"#);
for msg in [
Message::ToolRequest(req),
Message::Ok(Empty {}),
Message::ToolResponse(denied(DenyReason::NoGrant)),
] {
assert_eq!(error_code(&admin(&broker, msg)), Some(ErrorCode::Forbidden));
}
let lines = rig.lines.with("on admin.sock");
assert_eq!(lines.len(), 3, "{:?}", rig.lines.all());
assert!(lines[0].contains("tool_request"), "{}", lines[0]);
assert!(
lines
.iter()
.all(|l| l.ends_with("see docs/runbook.md#socket-forbidden"))
);
assert!(
rig.events().is_empty(),
"a refused tool request decides nothing"
);
}
@@ -0,0 +1,159 @@
//! The pending-approval table: whoever takes an entry answers it, and everyone else finds it
//! gone. Do not edit.
#[path = "support/build.rs"]
mod build;
use std::sync::{Arc, Barrier};
use brokerd::approvals::{Table, Verdict};
use brokerd::policy::{Ask, Outcome, SessionState, decide};
use build::{grant, now, read, set, ts};
use proto::{CallId, DataClass, DenyReason, Mode, PendingApproval, SessionId, Timestamp};
fn ask() -> Ask {
let grants = set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]);
match decide(read("/n/a"), &grants, SessionState::default(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("the test's call does not ask: {other:?}"),
}
}
fn info(approval: u64, expires: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("s1").unwrap(),
call: CallId(approval + 100),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
grant: "n".to_string(),
taint: DataClass::Private,
created: now(),
expires: ts(expires),
}
}
const LATER: &str = "2026-09-18T12:15:00.000Z";
#[test]
fn a_new_table_is_empty_and_lists_in_id_order() {
let table = Table::new();
assert_eq!(table.list(), []);
let _a = table.insert(info(7, LATER), ask());
let _b = table.insert(info(3, LATER), ask());
assert_eq!(table.list(), [info(3, LATER), info(7, LATER)]);
}
#[test]
fn an_entry_can_be_taken_once() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).expect("the entry is there");
assert_eq!(entry.info, info(5, LATER));
assert_eq!(entry.ask.grant(), "n");
assert!(table.take(5).is_none(), "taken twice");
assert_eq!(table.list(), []);
}
#[test]
fn an_id_never_added_is_not_there() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
assert!(table.take(6).is_none());
assert_eq!(table.list().len(), 1);
}
#[test]
fn the_verdict_goes_to_the_waiting_side() {
let table = Table::new();
let rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).unwrap();
entry
.reply
.send(Verdict::Denied(DenyReason::ApprovalRefused))
.unwrap();
match rx.recv().unwrap() {
Verdict::Denied(reason) => assert_eq!(reason, DenyReason::ApprovalRefused),
Verdict::Run(_) => panic!("the verdict changed on the way"),
}
}
#[test]
fn an_entry_expires_at_its_expiry_and_not_before() {
let table = Table::new();
let _a = table.insert(info(9, "2026-09-18T12:00:01.000Z"), ask());
let _b = table.insert(info(2, "2026-09-18T12:00:00.500Z"), ask());
let _c = table.insert(info(4, LATER), ask());
let before = ts("2026-09-18T12:00:00.499Z");
assert!(table.take_expired(before).is_empty());
// Exactly at `expires` is expired.
let at = ts("2026-09-18T12:00:00.500Z");
let due: Vec<u64> = table
.take_expired(at)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [2]);
let after = ts("2026-09-18T13:00:00.000Z");
let due: Vec<u64> = table
.take_expired(after)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [4, 9], "in id order");
assert_eq!(table.list(), []);
assert!(table.take_expired(Timestamp::MAX).is_empty());
}
#[test]
fn two_takers_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, LATER), ask());
let start = Arc::new(Barrier::new(2));
let takers: Vec<_> = (0..2)
.map(|_| {
let table = Arc::clone(&table);
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
table.take(1).is_some()
})
})
.collect();
let got: Vec<bool> = takers.into_iter().map(|t| t.join().unwrap()).collect();
assert_eq!(
got.iter().filter(|g| **g).count(),
1,
"round {round}: {got:?}"
);
}
}
#[test]
fn a_taker_and_the_expiry_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, "2026-09-18T12:00:00.000Z"), ask());
let start = Arc::new(Barrier::new(2));
let t = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
usize::from(table.take(1).is_some())
})
};
let e = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
table.take_expired(now()).len()
})
};
let total = t.join().unwrap() + e.join().unwrap();
assert_eq!(total, 1, "round {round}");
}
}
@@ -0,0 +1,428 @@
//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit.
//!
//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here.
use brokerd::args::{
ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host,
valid_host, valid_host_pattern, valid_path,
};
#[test]
fn the_four_tool_names() {
let names = ["read_file", "write_file", "shell", "http_fetch"];
for (tool, name) in ToolName::ALL.into_iter().zip(names) {
assert_eq!(tool.as_str(), name);
assert_eq!(ToolName::parse(name), Some(tool));
}
for other in [
"",
"echo",
"clock",
"call_tool",
"Read_File",
"read_file ",
"readfile",
] {
assert_eq!(ToolName::parse(other), None, "{other:?}");
}
}
#[test]
fn valid_paths() {
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
assert_eq!(longest.len(), MAX_PATH);
for path in [
"/",
"/etc",
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/with space/and\ttab",
"/dots.in.names/..hidden/...",
"/unicode/\u{e9}t\u{e9}",
longest.as_str(),
] {
assert!(valid_path(path), "{path:?} should be valid");
}
}
#[test]
fn invalid_paths() {
let too_long = format!("/{}", "a".repeat(MAX_PATH));
assert_eq!(too_long.len(), MAX_PATH + 1);
for path in [
"",
"notes/a.md",
"./notes",
"~/notes",
"/home/kyle/notes/../.ssh/id",
"/home/kyle//notes/./a.md",
"/home//kyle",
"/home/./kyle",
"/home/kyle/",
"/home/kyle/..",
"/..",
"/.",
"//",
"/nul\0byte",
too_long.as_str(),
] {
assert!(!valid_path(path), "{path:?} should be invalid");
}
}
/// The table in the spec, row by row, for the rows about form and containment.
#[test]
fn inside_is_by_whole_components() {
let grant = "/home/kyle/notes";
assert!(inside(grant, "/home/kyle/notes/a.md"));
assert!(inside(grant, "/home/kyle/notes"));
assert!(inside(grant, "/home/kyle/notes/deep/er/b.md"));
assert!(!inside(grant, "/home/kyle/notes2/a.md"));
assert!(!inside(grant, "/home/kyle/note"));
assert!(!inside(grant, "/home/kyle"));
assert!(!inside(grant, "/"));
assert!(!inside(grant, "/other/home/kyle/notes/a.md"));
// A grant of the root is refused when grants are loaded, but the function is still right.
assert!(inside("/", "/etc/passwd"));
assert!(inside("/", "/"));
}
#[test]
fn valid_hosts_and_patterns() {
let label63 = "a".repeat(63);
let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57));
assert_eq!(long.len(), 253);
for host in [
"example.com",
"www.example.com",
"a.b.example.com",
"xn--bcher-kva.example",
"1password.com",
"3.example.org",
"a-b.c-d.io",
long.as_str(),
] {
assert!(valid_host(host), "{host:?} should be a valid host");
assert!(
valid_host_pattern(host),
"{host:?} should be a valid pattern"
);
let wild = format!("*.{host}");
assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host");
}
assert!(valid_host_pattern("*.example.com"));
assert!(valid_host_pattern("*.a.b.example.com"));
}
#[test]
fn invalid_hosts_and_patterns() {
let label64 = format!("{}.com", "a".repeat(64));
let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join("."));
assert!(too_long.len() > 253);
for host in [
"",
"localhost",
"com",
"Example.com",
"example.COM",
"example.com.",
".example.com",
"example..com",
"-example.com",
"example-.com",
"exa_mple.com",
"example.com:443",
"example.com/path",
"user@example.com",
"exa mple.com",
"[::1]",
"::1",
// Every spelling of an IPv4 address: the last label does not start with a letter.
"127.0.0.1",
"127.1",
"10.0.0.0x1",
"1.2.3.4",
"example.123",
"b\u{fc}cher.example",
label64.as_str(),
too_long.as_str(),
] {
assert!(!valid_host(host), "{host:?} should not be a valid host");
assert!(
!valid_host_pattern(host),
"{host:?} should not be a valid pattern"
);
}
for pattern in [
"*",
"*.",
"*.com",
"*example.com",
"www.*.com",
"*.*.example.com",
"**.example.com",
"*.Example.com",
"*.127.0.0.1",
] {
assert!(!valid_host_pattern(pattern), "{pattern:?}");
}
}
/// The host table in the spec, row by row.
#[test]
fn host_matching() {
assert!(host_matches("example.com", "example.com"));
assert!(!host_matches("example.com", "www.example.com"));
assert!(host_matches("*.example.com", "www.example.com"));
assert!(host_matches("*.example.com", "a.b.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
// A suffix is not enough: the match is by whole labels.
assert!(!host_matches("*.example.com", "badexample.com"));
assert!(!host_matches("*.example.com", "www.example.com.evil.org"));
assert!(!host_matches("example.com", "example.com.evil.org"));
assert!(!host_matches("*.example.com", ".example.com"));
}
#[test]
fn valid_urls_and_their_hosts() {
let base = "https://example.com/";
let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len()));
assert_eq!(longest.len(), MAX_URL);
for (url, host) in [
("https://example.com", "example.com"),
("https://example.com/", "example.com"),
("https://example.com:443", "example.com"),
("https://example.com:443/", "example.com"),
("https://www.example.com/a/b.html", "www.example.com"),
("https://example.com/search?q=a+b&x=%20#frag", "example.com"),
("https://example.com/@user", "example.com"),
("https://example.com/a:8080/b", "example.com"),
("https://example.com/https://other.org/", "example.com"),
("https://example.com/back\\slash", "example.com"),
(longest.as_str(), "example.com"),
] {
assert_eq!(url_host(url), Some(host), "{url}");
}
}
#[test]
fn invalid_urls() {
let base = "https://example.com/";
let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1));
assert_eq!(too_long.len(), MAX_URL + 1);
for url in [
"",
"example.com",
"http://example.com/",
"HTTPS://example.com/",
"https:/example.com/",
"https://",
"https:///path",
"ftp://example.com/",
"file:///etc/passwd",
// userinfo
"https://user@example.com/",
"https://user:pw@example.com/",
"https://example.com@evil.org/",
// ports
"https://example.com:8443/",
"https://example.com:80/",
"https://example.com:/",
"https://example.com:443x/",
"https://example.com:4433/",
"https://example.com:443:443/",
// what follows the host must be the end, `:443` or `/`
"https://example.com?q=1",
"https://example.com#frag",
"https://example.com\\@evil.org/",
// hosts that are not host names
"https://localhost/",
"https://127.0.0.1/",
"https://127.1/",
"https://[::1]/",
"https://Example.com/",
"https://example.com./",
"https://b\u{fc}cher.example/",
// the rest must be printable ASCII with no space
"https://example.com/a b",
"https://example.com/a\tb",
"https://example.com/a\nb",
"https://example.com/caf\u{e9}",
"https://example.com/\u{7f}",
" https://example.com/",
"https://example.com/ ",
too_long.as_str(),
] {
assert_eq!(url_host(url), None, "{url:?} should be invalid");
}
}
#[test]
fn each_tool_parses_its_own_arguments() {
assert_eq!(
parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#),
Ok(ToolArgs::ReadFile {
path: "/home/kyle/notes/a.md".to_string()
})
);
assert_eq!(
parse(
ToolName::WriteFile,
r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"#
),
Ok(ToolArgs::WriteFile {
path: "/home/kyle/notes/a.md".to_string(),
content: "line\n".to_string()
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls -l"}"#),
Ok(ToolArgs::Shell {
command: "ls -l".to_string(),
cwd: None
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: Some("/home/kyle".to_string())
})
);
assert_eq!(
parse(
ToolName::HttpFetch,
r#"{"url":"https://www.example.com/a"}"#
),
Ok(ToolArgs::HttpFetch {
url: "https://www.example.com/a".to_string(),
host: "www.example.com".to_string()
})
);
// Field order and white space in the request do not matter.
assert_eq!(
parse(
ToolName::WriteFile,
" { \"content\" : \"x\" , \"path\" : \"/a/b\" } "
),
Ok(ToolArgs::WriteFile {
path: "/a/b".to_string(),
content: "x".to_string()
})
);
// `command` and `content` are not inspected.
assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok());
assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok());
assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok());
}
#[test]
fn arguments_of_the_wrong_shape_are_refused() {
let cases: [(ToolName, &str); 17] = [
(ToolName::ReadFile, ""),
(ToolName::ReadFile, "null"),
(ToolName::ReadFile, "[]"),
(ToolName::ReadFile, r#""/etc/hosts""#),
(ToolName::ReadFile, "{}"),
(ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#),
(ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#),
(ToolName::ReadFile, r#"{"path":7}"#),
(ToolName::ReadFile, r#"{"path":null}"#),
(ToolName::ReadFile, r#"{"path":"/a"} trailing"#),
(ToolName::WriteFile, r#"{"path":"/a/b"}"#),
(ToolName::WriteFile, r#"{"content":"x"}"#),
(
ToolName::WriteFile,
r#"{"path":"/a/b","content":"x","append":true}"#,
),
(ToolName::Shell, r#"{"cwd":"/a"}"#),
(ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#),
(ToolName::Shell, r#"{"command":["ls"]}"#),
(
ToolName::HttpFetch,
r#"{"url":"https://example.com/","method":"POST"}"#,
),
];
for (tool, text) in cases {
match parse(tool, text) {
Err(ArgsError::Shape(_)) => {}
other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"),
}
}
// One tool's arguments do not fit another tool.
assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err());
assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err());
}
#[test]
fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() {
for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] {
let quoted = serde_json::to_string(bad).unwrap();
let read = format!(r#"{{"path":{quoted}}}"#);
let write = format!(r#"{{"path":{quoted},"content":"x"}}"#);
let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#);
assert_eq!(
parse(ToolName::ReadFile, &read),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::WriteFile, &write),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::Shell, &shell),
Err(ArgsError::Path(bad.to_string()))
);
}
assert_eq!(
parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#),
Err(ArgsError::Url("http://example.com/".to_string()))
);
// A NUL can only arrive as a JSON escape; it is refused once decoded.
let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\');
assert!(matches!(
parse(ToolName::ReadFile, &nul),
Err(ArgsError::Path(_))
));
// `cwd: null` is the same as no `cwd`.
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: None
})
);
}
/// What the owner is shown is the parsed value written out again, so two spellings of one path
/// look the same. The escape is built from pieces so that no tool rewrites it on the way here.
#[test]
fn canonical_json_shows_what_was_parsed() {
let escaped_slash = format!("{}u002f", '\\');
let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}");
assert!(sneaky.contains("u002fetc"));
let args = parse(ToolName::ReadFile, &sneaky).unwrap();
assert_eq!(
args,
ToolArgs::ReadFile {
path: "/etc/hosts".to_string()
}
);
assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#);
// Fields come out in the spec's order whatever order they came in.
let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap();
assert_eq!(
write.canonical_json(),
r#"{"path":"/a/b","content":"x\ny"}"#
);
let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap();
assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#);
// An absent cwd is left out, and the host is never written: it is not an argument.
let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap();
assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#);
let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap();
assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#);
assert_eq!(fetch.tool(), ToolName::HttpFetch);
assert_eq!(write.tool(), ToolName::WriteFile);
}
@@ -0,0 +1,258 @@
//! The audit writer: the chain it writes, the lock, rollover, and stopping after a failed
//! write. Do not edit. Startup checks, recovery and accepted breaks are in `audit_startup.rs`.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use std::os::unix::fs::PermissionsExt;
use audit_dir::{D1, D2, TempDir, denied, lines, ts};
use brokerd::audit::{AuditError, RECOVERED_NOTICE, Writer, verify_dir};
use proto::{AuditRecord, Hash32, sha256};
fn mode(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn the_first_record_starts_the_chain() {
let dir = TempDir::unmade("first");
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
assert!(opened.accepted.is_none());
let mut writer = opened.writer;
assert_eq!(writer.next_seq(), 0);
let seq = writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
assert_eq!(seq, 0);
assert_eq!(writer.next_seq(), 1);
let text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
assert!(text.ends_with('\n'), "a record is one line and its newline");
assert_eq!(text.lines().count(), 1);
let record: AuditRecord = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!((record.seq, record.prev), (0, Hash32::ZERO));
assert_eq!(record.time, ts("2026-09-17T08:00:00.000Z"));
assert_eq!(record.event, denied(1));
assert_eq!(mode(&dir.path), 0o700, "the directory open() made");
assert_eq!(mode(&dir.path.join(D1)), 0o600);
}
#[test]
fn the_chain_runs_across_a_day_boundary() {
let dir = TempDir::unmade("days");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
let times = [
"2026-09-17T23:59:58.000Z",
"2026-09-17T23:59:59.999Z",
"2026-09-18T00:00:00.000Z",
"2026-09-18T00:00:01.000Z",
];
for (i, time) in times.iter().enumerate() {
assert_eq!(writer.append(ts(time), denied(i as u64)).unwrap(), i as u64);
}
let (day1, day2) = (lines(&dir.path, D1), lines(&dir.path, D2));
assert_eq!((day1.len(), day2.len()), (2, 2));
// seq goes on across files, and the new file chains from the last line of the old one.
let first: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(first.seq, 2);
assert_eq!(first.prev, sha256(day1[1].as_bytes()).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!((report.records, report.next_seq), (4, 4));
assert!(report.clock_warnings.is_empty());
}
#[test]
fn reopening_continues_the_chain() {
let dir = TempDir::unmade("reopen");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
writer
.append(ts("2026-09-17T08:00:01.000Z"), denied(2))
.unwrap();
drop(writer);
// One file: the whole of it is checked.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 2);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(3))
.unwrap(),
2
);
drop(writer);
// Two files: the latest is checked, resumed from the last line of the one before.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 3);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:01.000Z"), denied(4))
.unwrap(),
3
);
drop(writer);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.records, 4);
}
#[test]
fn an_empty_latest_file_gets_the_next_record_as_its_first_line() {
let dir = TempDir::case("empty-latest", None);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
let mut writer = opened.writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(9))
.unwrap(),
5
);
let day2 = lines(&dir.path, D2);
assert_eq!(day2.len(), 1);
let record: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(
record.prev,
sha256(lines(&dir.path, D1)[4].as_bytes()).unwrap()
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_second_writer_is_refused() {
let dir = TempDir::unmade("lock");
let first = Writer::open(&dir.path, false).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
assert!(matches!(error, AuditError::Locked), "{error}");
let text = error.to_string();
assert!(text.starts_with("brokerd is already running"), "{text}");
assert!(
text.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The lock goes when the writer goes, however that happens.
drop(first);
assert!(Writer::open(&dir.path, false).is_ok());
}
#[test]
fn the_writer_never_goes_back_to_an_earlier_file() {
let dir = TempDir::unmade("clock");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-18T00:00:05.000Z"), denied(1))
.unwrap();
// The clock is stepped back over midnight.
writer
.append(ts("2026-09-17T23:59:50.000Z"), denied(2))
.unwrap();
assert!(
!dir.path.join(D1).exists(),
"a record went into an earlier file"
);
assert_eq!(lines(&dir.path, D2).len(), 2);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.clock_warnings.len(), 1);
// It holds across a restart too.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T23:59:55.000Z"), denied(3))
.unwrap();
assert!(!dir.path.join(D1).exists());
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn files_that_are_not_log_files_are_ignored() {
let dir = TempDir::case("good", None);
std::fs::write(dir.path.join("notes.txt"), "not a log\n").unwrap();
std::fs::write(dir.path.join("2026-09-19.jsonl.bak"), "not a log\n").unwrap();
std::fs::write(dir.path.join("latest.jsonl"), "not a log\n").unwrap();
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 10);
writer
.append(ts("2026-09-18T10:00:00.000Z"), denied(9))
.unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().records, 11);
}
/// After one failed write the writer writes nothing more, even when the cause has gone: part of
/// a line may be on disk, and only the next start deals with that.
#[test]
fn a_failed_write_stops_the_writer() {
let dir = TempDir::unmade("sticky");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
// A new day needs a new file, and the directory no longer allows one.
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(dir.path.join("probe"), "").is_ok() {
eprintln!("skipped: this user can write to a read-only directory (root?)");
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
return;
}
let error = writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(2))
.unwrap_err();
assert!(matches!(error, AuditError::Io { .. }), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
for time in ["2026-09-18T08:00:01.000Z", "2026-09-17T08:00:02.000Z"] {
let error = writer.append(ts(time), denied(3)).unwrap_err();
assert!(matches!(error, AuditError::Stopped), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
}
assert_eq!(
lines(&dir.path, D1).len(),
1,
"a stopped writer wrote something"
);
assert!(!dir.path.join(D2).exists());
// A restart puts it right.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:03.000Z"), denied(4))
.unwrap(),
1
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn the_recovered_notice_names_its_runbook_entry() {
assert!(RECOVERED_NOTICE.starts_with("audit: recovered a torn final line"));
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
@@ -0,0 +1,300 @@
//! What `Writer::open` does with the log it finds: refuse a broken chain, recover a torn tail,
//! accept a break when told to. Do not edit. The fixture logs are in
//! `crates/proto/tests/fixtures/audit/`; each test works on a copy.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use audit_dir::{D1, D2, TempDir, denied, lines, snapshot};
use brokerd::audit::{AuditError, Writer, verify_dir};
use proto::{AuditEvent, AuditRecord, Location, Timestamp};
type Case = (
&'static str,
Option<&'static [&'static str]>,
&'static str,
u64,
&'static str,
);
fn at(file: &str, line: u64) -> Location {
Location {
file: file.to_string(),
line,
}
}
/// An ordinary start checks the latest file only, so each damaged file is copied alone: it is
/// then the latest. Nothing may be written to a log that does not verify.
#[test]
fn a_broken_chain_refuses_to_start_and_writes_nothing() {
let parse = "does not parse as an audit record";
// (case, the files to copy, then the failure's file, line and text)
let cases: [Case; 9] = [
(
"changed-byte",
Some(&[D1]),
D1,
4,
"prev is not the hash of the line before",
),
("deleted-line", Some(&[D1]), D1, 3, "seq is 3, expected 2"),
("swapped-lines", Some(&[D1]), D1, 2, "seq is 2, expected 1"),
("seq-gap", None, D1, 3, "seq is 3, expected 2"),
("cut-short", Some(&[D1]), D1, 3, parse),
// Both files: the latest does not chain from the last line of the one before.
(
"file-not-chained",
None,
D2,
1,
"does not chain from the last line of the file before",
),
(
"break-without-failure",
None,
D2,
6,
"an accepted break with no failure before it",
),
("recovery-wrong-hash", None, D2, 6, parse),
("torn-recovery", None, D2, 6, parse),
];
for (case, only, file, line, what) in cases {
let dir = TempDir::case(case, only);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, false)
.err()
.unwrap_or_else(|| panic!("{case}: started"));
let AuditError::Broken(failure) = &error else {
panic!("{case}: {error}");
};
assert_eq!(
(failure.file.as_str(), failure.line, failure.what.as_str()),
(file, line, what),
"{case}"
);
let text = error.to_string();
assert!(
text.starts_with(&format!("{file}:{line}: {what}")),
"{case}: {text}"
);
assert!(
text.ends_with("see docs/runbook.md#audit-chain-broken"),
"{case}: {text}"
);
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A torn tail is recovered: the torn bytes stay, a newline ends them if one is missing, and a
/// `Recovery` record follows in the same file, whatever today's date is.
#[test]
fn a_torn_tail_is_recovered() {
// (case, the torn line's file and number, newline already there)
let cases = [
("torn-tail", D2, 6, false),
("torn-tail-complete-json", D2, 6, false),
("torn-unparseable-newline", D2, 6, true),
("torn-first-line", D2, 1, false),
];
for (case, file, line, has_newline) in cases {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, false).unwrap_or_else(|e| panic!("{case}: {e}"));
assert!(opened.recovered, "{case}");
assert!(opened.accepted.is_none(), "{case}");
let after = snapshot(&dir.path);
assert_eq!(
after.len(),
before.len(),
"{case}: the Recovery went into a new file"
);
let (old, new) = (&before[file], &after[file]);
assert!(
new.starts_with(old),
"{case}: bytes already on disk were changed"
);
let added = &new[old.len()..];
// One newline to end the torn line if it had none, then one line.
let added = if has_newline {
added
} else {
added.strip_prefix(b"\n").expect(case)
};
assert_eq!(added.iter().filter(|b| **b == b'\n').count(), 1, "{case}");
let record: AuditRecord =
serde_json::from_slice(added.strip_suffix(b"\n").expect(case)).expect(case);
assert!(
matches!(record.event, AuditEvent::Recovery { .. }),
"{case}"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.torn_tail, None, "{case}");
assert_eq!(report.recoveries, vec![at(file, line)], "{case}");
// The chain goes on from the Recovery, and the next start finds nothing to recover.
let mut writer = opened.writer;
assert_eq!(
writer.append(Timestamp::now(), denied(9)).unwrap(),
record.seq + 1,
"{case}"
);
drop(writer);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered, "{case}");
assert_eq!(verify_dir(&dir.path).unwrap().failure, None, "{case}");
}
}
/// Damage in an older file is not seen by an ordinary start. `bxctl audit verify` sees it, and
/// `--accept-break` must too: it verifies the whole log.
#[test]
fn a_break_in_an_older_file_can_be_accepted() {
let dir = TempDir::case("changed-byte", None);
let before = snapshot(&dir.path);
drop(Writer::open(&dir.path, false).expect("the latest file verifies"));
assert_eq!(snapshot(&dir.path), before);
let failure = verify_dir(&dir.path).unwrap().failure.unwrap();
assert_eq!((failure.file.as_str(), failure.line), (D1, 4));
let opened = Writer::open(&dir.path, true).unwrap();
assert!(!opened.recovered);
assert_eq!(
opened.accepted.as_ref().map(|f| (f.file.as_str(), f.line)),
Some((D1, 4))
);
let after = snapshot(&dir.path);
assert_eq!(after[D1], before[D1], "nothing is repaired");
assert!(after[D2].starts_with(&before[D2]));
let last: AuditRecord = serde_json::from_str(lines(&dir.path, D2).last().unwrap()).unwrap();
assert_eq!(
last.event,
AuditEvent::AcceptedBreak {
file: D1.to_string(),
line: 4,
last_good: failure.last_good,
}
);
assert_eq!(
last.seq, 10,
"seq 3 for the failing line, and seven lines to the break"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D2, 6)]);
let mut writer = opened.writer;
assert_eq!(writer.append(Timestamp::now(), denied(9)).unwrap(), 11);
drop(writer);
// The next ordinary start resumes at the latest file and meets a break that names a file
// it has not read.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.append(Timestamp::now(), denied(10)).unwrap(), 12);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_break_in_the_latest_file_can_be_accepted() {
// (case, failing line, seq of the break record)
for (case, line, seq) in [("recovery-wrong-hash", 6, 12), ("torn-recovery", 6, 12)] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, true).unwrap_or_else(|e| panic!("{case}: {e}"));
assert_eq!(
opened.accepted.as_ref().map(|f| f.line),
Some(line),
"{case}"
);
let after = snapshot(&dir.path);
assert!(after[D2].starts_with(&before[D2]), "{case}");
// torn-recovery ends without a newline: the break record must start on its own line.
let all = lines(&dir.path, D2);
let last: AuditRecord = serde_json::from_str(all.last().unwrap()).expect(case);
assert!(
matches!(last.event, AuditEvent::AcceptedBreak { .. }),
"{case}"
);
assert_eq!(last.seq, seq, "{case}");
assert_eq!(all.len(), 8, "{case}");
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.accepted_breaks, vec![at(D2, 8)], "{case}");
drop(opened);
assert!(Writer::open(&dir.path, false).is_ok(), "{case}");
}
}
/// The short check is a shortcut and never the last word: when it cannot be made, or fails, the
/// whole log is verified and that verdict stands. Here the last line of the older file is the
/// damage, so there is nothing to resume from.
#[test]
fn an_accepted_break_at_the_end_of_an_older_file_does_not_stop_later_starts() {
let dir = TempDir::case("good", None);
let day1 = std::fs::read_to_string(dir.path.join(D1)).unwrap();
let cut = format!("{}\n", &day1[..day1.len() - 40]);
std::fs::write(dir.path.join(D1), cut).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 5));
drop(Writer::open(&dir.path, true).unwrap());
let mut writer = Writer::open(&dir.path, false)
.expect("the break was accepted")
.writer;
writer.append(Timestamp::now(), denied(9)).unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn accept_break_with_nothing_to_accept_is_an_error() {
for case in ["good", "torn-tail"] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, true).unwrap_err();
assert!(
matches!(error, AuditError::NothingToAccept),
"{case}: {error}"
);
assert!(error.to_string().starts_with("nothing to accept"), "{case}");
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A second failure after an accepted break needs its own break.
#[test]
fn damage_after_a_break_is_a_new_failure() {
let dir = TempDir::case("accepted-break", None);
drop(Writer::open(&dir.path, false).expect("the fixture verifies"));
let mut text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
text.push_str("{}\n{}\n");
std::fs::write(dir.path.join(D1), text).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 8));
drop(Writer::open(&dir.path, true).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D1, 6), at(D1, 10)]);
}
@@ -0,0 +1,324 @@
//! One `broker.sock` connection: decision, record, run, answer; and every way it fails closed.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use brokerd::broker;
use brokerd::ledger::NOT_RECORDED;
use brokerd::runner::{REFUSING, RunError};
use client::{Serve, call, next, open};
use proto::{
ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty,
Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse,
Turn, TurnDone, TurnEvent, Usage, WireError,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
const NOTES: &str = r#"{"path":"/n/a"}"#;
fn read_notes(call_id: u64) -> proto::ToolRequest {
request("s1", call_id, "read_file", NOTES)
}
fn only(frames: &[Envelope]) -> &ToolResponse {
assert_eq!(frames.len(), 1, "{frames:?}");
assert!(frames[0].r#final);
match &frames[0].msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
fn auto_notes(rig: &Rig, class: &str) {
let text = grant_text("read_file", "auto", "", "paths = [\"/n\"]").replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
);
rig.grant("notes", &text);
}
#[test]
fn a_call_no_grant_allows_is_denied_and_recorded() {
let rig = Rig::new("broker-nogrant");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(41));
assert_eq!(frames[0].id, 41, "the answer carries the request's id");
assert_eq!(only(&frames), &denied(DenyReason::NoGrant));
assert_eq!(rt.count(), 0);
match rig.events().as_slice() {
[AuditEvent::Decision { outcome, .. }] => assert_eq!(
*outcome,
DecisionRecord::Denied {
reason: DenyReason::NoGrant
}
),
other => panic!("{other:?}"),
}
}
#[test]
fn an_allowed_call_runs_and_its_result_raises_the_state() {
let rig = Rig::new("broker-allowed");
auto_notes(&rig, "secret");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(7));
assert_eq!(
only(&frames),
&ToolResponse::Result {
content: "the notes".to_string(),
class: DataClass::Secret,
untrusted: false,
truncated: false,
}
);
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
decision: 0,
status: ResultStatus::Result,
taint_after: DataClass::Secret,
..
}
]
));
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
#[test]
fn a_runtime_failure_is_passed_on_and_recorded_as_failed() {
let rig = Rig::new("broker-refusing");
auto_notes(&rig, "secret");
let rt = Recording::with(Err(RunError::Unavailable(REFUSING.to_string())));
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
match &rig.events()[1] {
AuditEvent::Result {
status,
taint_after,
..
} => assert_eq!(
(*status, *taint_after),
(ResultStatus::Failed, DataClass::Private)
),
other => panic!("{other:?}"),
}
assert!(!rig.state_file("s1").exists(), "a failure changes no state");
}
fn every_kind_but_tool_request() -> Vec<Message> {
let usage = Usage {
cache_n: 0,
prompt_n: 0,
predicted_n: 0,
reasoning_tokens: 0,
thinking_capped: false,
};
vec![
Message::ToolResponse(denied(DenyReason::NoGrant)),
Message::Error(WireError {
code: ErrorCode::Internal,
detail: String::new(),
}),
Message::Turn(Turn {
session: SessionId::new("s1").unwrap(),
content: String::new(),
resume: false,
}),
Message::TurnEvent(TurnEvent::Content {
text: String::new(),
}),
Message::TurnDone(TurnDone {
content: String::new(),
usage,
}),
Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }),
Message::Approve(Approve { approval: 0 }),
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Allowed {},
}),
Message::Refuse(Refuse {
approval: 0,
reason: None,
}),
Message::Ok(Empty {}),
Message::CheckGrants(Empty {}),
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
}),
]
}
#[test]
fn every_other_kind_on_broker_sock_is_forbidden() {
let rig = Rig::new("broker-forbidden");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let kinds = every_kind_but_tool_request();
assert_eq!(kinds.len(), 13, "every Message variant but tool_request");
for (i, msg) in kinds.into_iter().enumerate() {
let id = 100 + i as u64;
let mut stream = open(&broker, broker::handle, id, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (id, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden),
other => panic!("{other:?}"),
}
}
let lines = rig.lines.with("broker.sock");
assert_eq!(lines.len(), 13, "{:?}", rig.lines.all());
for line in &lines {
assert!(
line.ends_with("\nsee docs/runbook.md#socket-forbidden"),
"{line}"
);
}
assert_eq!(rig.lines.with("kind approve on broker.sock").len(), 1);
assert!(
rig.events().is_empty(),
"nothing is recorded for a refused kind"
);
}
#[test]
fn a_frame_that_is_not_json_is_answered_with_bad_message() {
let rig = Rig::new("broker-badframe");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut client, server) = UnixStream::pair().unwrap();
let b = Arc::clone(&broker);
std::thread::spawn(move || broker::handle(server, &b));
client.write_all(&3u32.to_be_bytes()).unwrap();
client.write_all(b"{{{").unwrap();
let env = next(&mut client);
assert_eq!((env.id, env.r#final), (0, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::BadMessage),
other => panic!("{other:?}"),
}
assert!(rig.events().is_empty());
}
#[test]
fn one_invalid_grant_file_denies_a_call_a_valid_file_would_allow() {
let rig = Rig::new("broker-invalid");
auto_notes(&rig, "private");
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
for id in [1, 2] {
let frames = call(&broker, read_notes(id));
assert_eq!(only(&frames), &denied(DenyReason::GrantsInvalid));
}
assert_eq!(rt.count(), 0);
// Printed once for the two calls, with the pointer.
let printed = rig.lines.with("bad.toml");
assert_eq!(printed.len(), 1, "{:?}", rig.lines.all());
assert!(
printed[0].ends_with("see docs/runbook.md#grants-invalid"),
"{}",
printed[0]
);
rig.remove_grant("bad");
let frames = call(&broker, read_notes(3));
assert!(matches!(only(&frames), ToolResponse::Result { .. }));
assert_eq!(rt.count(), 1);
}
#[test]
fn a_damaged_session_state_is_state_unreadable() {
let rig = Rig::new("broker-state");
auto_notes(&rig, "private");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "{\"taint\":\"loud\"}").unwrap();
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::StateUnreadable));
assert_eq!(rt.count(), 0);
let lines = rig.lines.with("s1.json");
assert!(
lines
.iter()
.any(|l| l.ends_with("see docs/runbook.md#broker-state-damaged")),
"{lines:?}"
);
}
#[test]
fn a_failed_audit_write_runs_nothing_now_or_later() {
let rig = Rig::new("broker-audit");
auto_notes(&rig, "private");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
rig.switch.fail(true);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
rig.switch.fail(false);
let frames = call(&broker, read_notes(2));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
assert_eq!(rt.count(), 0);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("broker-ro");
auto_notes(&rig, "secret");
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let rt = Recording::answering("the secret");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rt.count(), 1, "it ran; its content is what is withheld");
let text = format!("{frames:?}");
assert!(!text.contains("the secret"), "{text}");
}
@@ -0,0 +1,230 @@
//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester
//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::broker::{self, Broker, GONE};
use brokerd::ledger::Answer;
use client::{Serve, next, open};
use proto::{
AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig, extra: &str) {
rig.grant(
"notes",
&grant_text("read_file", "ask", extra, "paths = [\"/n\"]"),
);
}
/// Sends a call and reads its pending frame. Returns the connection and the frame's values.
fn start(broker: &Arc<Broker>, arguments: &str) -> (UnixStream, u64, Timestamp) {
let req = request("s1", 9, "read_file", arguments);
let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req));
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (9, false), "{env:?}");
match env.msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => {
(stream, approval, expires)
}
other => panic!("{other:?}"),
}
}
/// What `admin` does for an entry it took: record the answer, send the verdict.
fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord {
let entry = broker.table().take(id).expect("the entry is pending");
answer_entry(broker, entry, answer)
}
fn answer_entry(
broker: &Broker,
entry: brokerd::approvals::Entry,
answer: Answer,
) -> DecisionRecord {
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
entry.info.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
done.outcome
}
fn approved() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
let env = next(stream);
assert!(env.r#final, "{env:?}");
match env.msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn eventually(what: &str, mut done: impl FnMut() -> bool) {
let until = Instant::now() + Duration::from_secs(10);
while !done() {
assert!(Instant::now() < until, "never happened: {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn an_ask_call_waits_is_listed_and_runs_when_approved() {
let rig = Rig::new("pending-approve");
ask_notes(&rig, "");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let before = Timestamp::now();
// Spaced JSON: the table shows the arguments as policy parsed them, not as sent.
let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#);
assert_eq!(approval, 0, "the approval id is the decision's seq");
let items = broker.table().list();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.approval, 0);
assert_eq!((item.session.as_str(), item.call.0), ("s1", 9));
assert_eq!(item.tool, "read_file");
assert_eq!(item.arguments, r#"{"path":"/n/a"}"#);
assert_eq!(
(item.grant.as_str(), item.taint),
("notes", DataClass::Private)
);
assert!(item.created >= before);
assert_eq!(item.expires, expires);
assert_eq!(
expires.unix_millis() - item.created.unix_millis(),
900_000,
"now plus ttl_ms"
);
assert_eq!(rt.count(), 0, "nothing runs while it waits");
assert_eq!(
answer_by_hand(&broker, 0, approved()),
DecisionRecord::Ask {}
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(
matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Approval { decision: 0, .. },
AuditEvent::Result { decision: 0, .. }
]
),
"{events:?}"
);
}
#[test]
fn the_expiry_is_the_grants_when_that_is_earlier() {
let rig = Rig::new("pending-grant-expiry");
let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap();
ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339()));
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#);
assert_eq!(expires, soon);
}
#[test]
fn a_denied_verdict_reaches_the_requester_and_nothing_runs() {
let rig = Rig::new("pending-refuse");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: None,
};
answer_by_hand(&broker, approval, refused);
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused
}
);
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() {
let rig = Rig::new("pending-leave");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#);
drop(stream);
// The waiting thread looks at its connection every second.
eventually("the entry is removed", || broker.table().list().is_empty());
std::thread::sleep(Duration::from_millis(200));
assert_eq!(rig.events().len(), 1, "only the decision");
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert_eq!(report.abandoned, [0]);
assert!(report.failure.is_none());
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() {
let rig = Rig::new("pending-lastlook");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
// Take the entry first, so the waiting thread cannot take it back when it sees the
// connection gone; then approve.
let entry = broker.table().take(approval).unwrap();
drop(stream);
answer_entry(&broker, entry, approved());
eventually("the call is closed", || rig.events().len() == 3);
match &rig.events()[2] {
AuditEvent::Result {
status,
decision,
sha256,
bytes,
..
} => {
assert_eq!((*status, *decision), (ResultStatus::Failed, 0));
assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap());
assert_eq!(*bytes, GONE.len() as u64);
}
other => panic!("{other:?}"),
}
assert_eq!(GONE, "the requester went away");
assert_eq!(rt.count(), 0);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.unfinished.is_empty() && report.abandoned.is_empty());
}
@@ -0,0 +1,238 @@
//! Properties over sequences of calls, through `broker::handle` with many threads at once:
//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the
//! record that let its call run; the runtime sees a call only after `allowed` or an approval.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use brokerd::ledger::Answer;
use client::{Serve, call};
use proto::{
AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn grants(rig: &Rig) {
let with = |class: &str, max: &str| {
grant_text("read_file", "auto", "", "")
.replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
)
.replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\""))
};
rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n"));
rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n"));
// Stops applying once a session has read a secret.
rig.grant(
"public",
&(with("public", "private") + "paths = [\"/p\"]\n"),
);
rig.grant(
"never",
&grant_text("read_file", "deny", "", "paths = [\"/d\"]"),
);
rig.grant(
"asked",
&grant_text("read_file", "ask", "", "paths = [\"/a\"]"),
);
}
/// Which call ran on what authority: (session, call) of every Decision that allowed and every
/// Approval whose re-decision let the call run, keyed by the decision's seq.
fn check_sequence(records: &[AuditRecord]) -> usize {
let mut may_run: BTreeMap<u64, (SessionId, CallId)> = BTreeMap::new();
let mut taint: BTreeMap<String, DataClass> = BTreeMap::new();
let mut results = 0;
for (i, r) in records.iter().enumerate() {
assert_eq!(r.seq, i as u64, "no seq repeats or skips");
match &r.event {
AuditEvent::Decision {
session,
call,
outcome: DecisionRecord::Allowed {},
..
} => {
may_run.insert(r.seq, (session.clone(), *call));
}
AuditEvent::Approval {
session,
call,
decision,
outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {},
..
} => {
may_run.insert(*decision, (session.clone(), *call));
}
AuditEvent::Result {
session,
call,
decision,
taint_after,
..
} => {
results += 1;
let allowed = may_run.remove(decision);
assert_eq!(
allowed,
Some((session.clone(), *call)),
"seq {}: a Result with no record letting its call run",
r.seq
);
let before = taint.insert(session.as_str().to_string(), *taint_after);
assert!(
before.is_none_or(|b| b <= *taint_after),
"seq {}: taint went down",
r.seq
);
}
_ => {}
}
}
assert!(
may_run.is_empty(),
"calls allowed but never finished: {may_run:?}"
);
results
}
#[test]
fn eight_threads_two_sessions_fifty_calls_each() {
let rig = Rig::new("sequence-many");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"];
let threads: Vec<_> = (0..8u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
let session = if t % 2 == 0 { "s-even" } else { "s-odd" };
for i in 0..50u64 {
let path = paths[((t + i) % 5) as usize];
let args = format!(r#"{{"path":"{path}"}}"#);
let req = request(session, t * 1000 + i, "read_file", &args);
let frames = call(&broker, req);
assert_eq!(frames.len(), 1);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none(), "{:?}", report.failure);
assert!(report.unfinished.is_empty());
let records = rig.records();
let decisions = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Decision { .. }))
.count();
assert_eq!(decisions, 400);
let results = check_sequence(&records);
assert_eq!(
rt.count(),
results,
"the runtime saw exactly the allowed calls"
);
assert!(results > 0 && results < 400);
// Both sessions read a secret.
for s in ["s-even", "s-odd"] {
let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
}
#[test]
fn approved_and_refused_calls_run_only_after_an_approval() {
let rig = Rig::new("sequence-ask");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
// An owner who approves even ids and refuses odd ones, as fast as they appear.
let stop = Arc::new(AtomicBool::new(false));
let owner = {
let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop));
std::thread::spawn(move || {
while !stop.load(Ordering::SeqCst) {
for item in broker.table().list() {
let Some(entry) = broker.table().take(item.approval) else {
continue;
};
let answer = if item.approval % 2 == 0 {
Answer::Approved { by: None }
} else {
Answer::Refused {
by: None,
reason: None,
}
};
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
item.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
}
std::thread::sleep(Duration::from_millis(2));
}
})
};
let threads: Vec<_> = (0..4u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
for i in 0..10u64 {
let path = if i % 2 == 0 { "/a/x" } else { "/n/x" };
let args = format!(r#"{{"path":"{path}"}}"#);
let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args));
let last = frames.last().unwrap();
assert!(
matches!(
&last.msg,
Message::ToolResponse(
ToolResponse::Result { .. } | ToolResponse::Denied { .. }
)
),
"{last:?}"
);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
stop.store(true, Ordering::SeqCst);
owner.join().unwrap();
let records = rig.records();
let approvals = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Approval { .. }))
.count();
assert_eq!(approvals, 20, "one Approval for each of the twenty asks");
let results = check_sequence(&records);
assert_eq!(rt.count(), results);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none() && report.abandoned.is_empty());
}
@@ -0,0 +1,118 @@
//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour.
use brokerd::config::{Approvals, Config, ConfigError, Sockets};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
/// What `home` must default to in this process. The test does not set the variable: changing the
/// environment of a running test binary would race with the other tests.
fn default_home() -> PathBuf {
std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"))
}
#[test]
fn an_empty_file_gets_every_default() {
let c = Config::load(&fixture("empty.toml")).unwrap();
assert_eq!(c.paths.home, default_home());
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
assert_eq!(c.sockets, Sockets::default());
assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 });
assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 });
assert_eq!(c, Config::parse("").unwrap());
assert_eq!(c, Config::default());
}
#[test]
fn sockets_and_directories_default_to_places_under_home() {
let c = Config::load(&fixture("home_only.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock")
);
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
// The grants are not under home: the owner writes them, brokerd only reads them.
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
}
#[test]
fn every_key_can_be_set() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants"));
assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock"));
assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock"));
assert_eq!(c.approvals.ttl_ms, 60_000);
// The two directories always follow home.
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
}
#[test]
fn one_socket_set_leaves_the_other_at_its_default() {
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap();
assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
}
#[test]
fn unknown_keys_and_tables_are_errors() {
for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] {
match Config::load(&fixture(name)) {
Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)),
other => panic!("{name}: expected a parse error, got {other:?}"),
}
}
// In every table, not only the one the fixture shows.
for text in [
"[paths]\nhome = \"/h\"\nhouse = \"/h\"\n",
"[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n",
"[approvals]\nttl_ms = 1\nttl_s = 1\n",
"top = 1\n",
"[approvals]\nttl_ms = -5\n",
] {
assert!(Config::parse(text).is_err(), "accepted: {text}");
}
}
#[test]
fn a_missing_file_is_a_read_error_that_names_the_file() {
let path = fixture("does-not-exist.toml");
match Config::load(&path) {
Err(ConfigError::Read(p, _)) => assert_eq!(p, path),
other => panic!("expected a read error, got {other:?}"),
}
let text = Config::load(&path).unwrap_err().to_string();
assert!(text.contains("does-not-exist.toml"), "{text}");
}
#[test]
fn a_parse_error_names_the_file_and_the_key() {
let text = Config::load(&fixture("unknown_key.toml"))
.unwrap_err()
.to_string();
assert!(text.contains("unknown_key.toml"), "{text}");
assert!(text.contains("ttl"), "{text}");
}
@@ -0,0 +1 @@
# Nothing set: every value is a default.
@@ -0,0 +1,11 @@
# Every key set.
[paths]
home = "/srv/boxmaker"
grants = "/srv/boxmaker-grants"
[sockets]
broker = "/run/bx/broker.sock"
admin = "/run/bx/admin.sock"
[approvals]
ttl_ms = 60000
@@ -0,0 +1,2 @@
[paths]
home = "/srv/boxmaker"
@@ -0,0 +1,3 @@
[approvals]
ttl_ms = 60000
ttl = 5
@@ -0,0 +1,2 @@
[secrets]
store = "/etc/boxmaker/secrets"
@@ -0,0 +1,2 @@
[approvals]
ttl_ms = "15 min"
@@ -0,0 +1 @@
An empty set of grants is valid: every call is denied with no_grant.
@@ -0,0 +1,8 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
secret = "api-token"
[constraints]
hosts = ["api.example.com"]
patterns = ["^GET "]
@@ -0,0 +1,7 @@
tool = "read_file"
mode = "auto"
max_taint = "private"
[constraints]
paths = ["notes", "/home/kyle/../etc", "/"]
hosts = ["example.com"]
@@ -0,0 +1,3 @@
tool = "shell"
mode = "auto"
max_taint =
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,7 @@
# The owner meant `mode`. If this file were skipped, fetch-example would allow what it forbids.
tool = "http_fetch"
mdoe = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1 @@
Grants for the tests. This file is not a grant and is ignored.
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,6 @@
tool = "http_fetch"
mode = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,6 @@
tool = "write_file"
mode = "ask"
max_taint = "private"
[constraints]
paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"]
@@ -0,0 +1,4 @@
# A shell with nothing mounted.
tool = "shell"
mode = "ask"
max_taint = "secret"
@@ -0,0 +1,482 @@
//! Tests for loading grant files. Do not edit these or the fixtures.
//!
//! One case per loading rule in the M3a spec, each with words its problem text must contain, and
//! the rule that matters most: one invalid file makes the whole set invalid.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::grants::{GrantSet, LoadedGrant, RUNBOOK, load, render, valid_id};
use proto::{Constraints, DataClass, Grant, GrantProblem, Hash32, Mode};
use std::path::{Path, PathBuf};
use tmp::TempDir;
fn fixture(case: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/grants")
.join(case)
}
const GOOD: &str = "tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n\n\
[constraints]\npaths = [\"/home/kyle/notes\"]\n";
/// A directory holding one good grant and the given files; returns the problems of loading it.
fn problems_of(files: &[(&str, &str)]) -> Vec<GrantProblem> {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
for (name, text) in files {
dir.write(name, text);
}
load(dir.path()).expect_err("the set should be invalid")
}
/// Exactly one problem, in `file`, whose text contains every one of `words`.
fn one_problem(files: &[(&str, &str)], file: &str, words: &[&str]) -> GrantProblem {
let problems = problems_of(files);
assert_eq!(problems.len(), 1, "{problems:?}");
let p = problems.into_iter().next().unwrap();
assert_eq!(p.file, file);
for word in words {
assert!(p.problem.contains(word), "{:?} lacks {word:?}", p.problem);
}
p
}
fn body(tool: &str, mode: &str, rest: &str) -> String {
format!("tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n{rest}")
}
#[test]
fn the_valid_fixture_loads_in_id_order_with_file_hashes() {
let set = load(&fixture("valid")).unwrap();
let ids: Vec<&str> = set.grants().iter().map(|g| g.id.as_str()).collect();
assert_eq!(
ids,
[
"fetch-example",
"no-fetch-internal",
"notes-read",
"scratch-write",
"shell-bare"
]
);
let notes = &set.grants()[2];
assert_eq!(notes.grant.tool, "read_file");
assert_eq!(notes.grant.mode, Mode::Auto);
assert!(!notes.grant.untrusted);
assert_eq!(notes.grant.constraints.paths, ["/home/kyle/notes"]);
let bytes = std::fs::read(fixture("valid").join("notes-read.toml")).unwrap();
assert_eq!(notes.sha256, proto::sha256(&bytes).unwrap());
// Defaults from `proto::Grant`.
let scratch = &set.grants()[3];
assert_eq!(scratch.grant.result_class, DataClass::Private);
assert!(scratch.grant.untrusted);
assert_eq!(scratch.grant.expires, None);
}
#[test]
fn files_that_do_not_end_in_toml_are_ignored() {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
dir.write("good.toml~", "not toml at all {{{");
dir.write("good.toml.bak", "not toml at all {{{");
dir.write("README.md", "# notes");
dir.write("toml", "x");
std::fs::create_dir(dir.path().join("archive")).unwrap();
let set = load(dir.path()).unwrap();
assert_eq!(set.grants().len(), 1);
assert_eq!(set.grants()[0].id, "good");
}
#[test]
fn an_empty_directory_is_a_valid_empty_set() {
let set = load(&fixture("empty")).unwrap();
assert!(set.grants().is_empty());
assert_eq!(set, GrantSet::default());
}
#[test]
fn a_missing_directory_is_a_problem_not_an_empty_set() {
let missing = fixture("does-not-exist");
let problems = load(&missing).unwrap_err();
assert_eq!(problems.len(), 1);
assert!(problems[0].file.contains("does-not-exist"), "{problems:?}");
assert!(problems[0].problem.contains("cannot be read"));
// A file where the directory should be is the same.
let dir = TempDir::new("grants");
let file = dir.write("grants", "x");
assert!(load(&file).is_err());
}
/// The rule the whole design leans on. `fetch-example` alone would allow a fetch that the
/// mistyped `no-fetch-internal` was written to forbid, so nothing loads at all.
#[test]
fn one_invalid_file_makes_the_whole_set_invalid() {
let problems = load(&fixture("one-bad")).unwrap_err();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].file, "no-fetch-internal.toml");
assert_eq!(problems[0].line, Some(3));
assert!(problems[0].problem.contains("mdoe"), "{problems:?}");
// The same directory without the bad file is fine.
let dir = TempDir::new("grants");
for name in ["notes-read.toml", "fetch-example.toml"] {
let text = std::fs::read_to_string(fixture("one-bad").join(name)).unwrap();
dir.write(name, &text);
}
assert_eq!(load(dir.path()).unwrap().grants().len(), 2);
}
#[test]
fn every_problem_in_every_file_is_reported() {
let problems = load(&fixture("many-bad")).unwrap_err();
let got: Vec<(&str, Option<u64>)> =
problems.iter().map(|p| (p.file.as_str(), p.line)).collect();
assert_eq!(
got,
[
("a-secret.toml", None),
("a-secret.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("c-syntax.toml", Some(3)),
],
"{problems:?}"
);
let all: String = problems
.iter()
.map(|p| format!("{}\n", p.problem))
.collect();
for words in [
"secrets are not supported until M4",
"patterns are not supported",
"read_file does not take hosts",
"\"notes\" is not a valid absolute path",
"\"/home/kyle/../etc\" is not a valid absolute path",
"a grant of the whole file system is not supported",
] {
assert!(all.contains(words), "missing {words:?} in:\n{all}");
}
}
#[test]
fn rule_1_unreadable_not_utf8_or_not_a_grant() {
one_problem(&[("bad.toml", "tool = ")], "bad.toml", &[]);
let p = one_problem(
&[("bad.toml", &body("shell", "auto", "colour = \"red\"\n"))],
"bad.toml",
&["colour"],
);
assert_eq!(p.line, Some(4));
one_problem(
&[("bad.toml", "tool = \"shell\"\nmode = \"auto\"\n")],
"bad.toml",
&["max_taint"],
);
one_problem(
&[("bad.toml", &body("shell", "sometimes", ""))],
"bad.toml",
&["sometimes"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "auto", "[constraints]\ncwd = [\"/a\"]\n"),
)],
"bad.toml",
&["cwd"],
);
// Not UTF-8.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::write(dir.path().join("latin1.toml"), b"tool = \"caf\xe9\"\n").unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "latin1.toml");
assert!(problems[0].problem.contains("UTF-8"), "{problems:?}");
// Exists but cannot be read: a directory with a grant's name.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::create_dir(dir.path().join("folder.toml")).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "folder.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_1_a_file_without_read_permission_is_a_problem() {
use std::os::unix::fs::PermissionsExt;
if tmp::running_as_root("rule_1_a_file_without_read_permission_is_a_problem") {
return;
}
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
let locked = dir.write("locked.toml", GOOD);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "locked.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_2_the_file_stem_is_the_id() {
for id in ["a", "notes-read", "0", "a-1-b", &"x".repeat(64)] {
assert!(valid_id(id), "{id:?}");
}
for id in [
"",
"Notes",
"notes_read",
"notes.read",
"notes read",
".hidden",
&"x".repeat(65),
] {
assert!(!valid_id(id), "{id:?}");
}
one_problem(
&[("Bad_Name.toml", GOOD)],
"Bad_Name.toml",
&["not a valid grant id"],
);
one_problem(&[(".toml", GOOD)], ".toml", &["not a valid grant id"]);
one_problem(&[("a.b.toml", GOOD)], "a.b.toml", &["not a valid grant id"]);
}
#[test]
fn rule_3_the_tool_is_one_of_the_four() {
for tool in ["echo", "clock", "Read_File", ""] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[
"unknown tool",
"read_file, write_file, shell and http_fetch",
],
);
}
}
#[test]
fn rules_4_and_5_secrets_and_patterns_are_not_supported() {
one_problem(
&[("bad.toml", &body("shell", "ask", "secret = \"token\"\n"))],
"bad.toml",
&["secrets are not supported until M4"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "ask", "[constraints]\npatterns = [\"^ls\"]\n"),
)],
"bad.toml",
&["patterns are not supported"],
);
// An empty list is the same as no list.
let dir = TempDir::new("grants");
dir.write(
"ok.toml",
&body("shell", "ask", "[constraints]\npatterns = []\nhosts = []\n"),
);
assert!(load(dir.path()).is_ok());
}
/// The table of rule 6, cell by cell.
#[test]
fn rule_6_each_tool_takes_its_own_constraints() {
let paths = "[constraints]\npaths = [\"/a\"]\n";
let hosts = "[constraints]\nhosts = [\"example.com\"]\n";
let both = "[constraints]\npaths = [\"/a\"]\nhosts = [\"example.com\"]\n";
for tool in ["read_file", "write_file"] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[tool, "needs at least one path"],
);
one_problem(
&[("bad.toml", &body(tool, "auto", both))],
"bad.toml",
&[tool, "does not take hosts"],
);
}
one_problem(
&[("bad.toml", &body("shell", "auto", both))],
"bad.toml",
&["shell does not take hosts"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", ""))],
"bad.toml",
&["http_fetch needs at least one host"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", both))],
"bad.toml",
&["http_fetch does not take paths"],
);
// The allowed cells.
let dir = TempDir::new("grants");
dir.write("r.toml", &body("read_file", "auto", paths));
dir.write("w.toml", &body("write_file", "auto", paths));
dir.write("s1.toml", &body("shell", "auto", paths));
dir.write("s2.toml", &body("shell", "auto", ""));
dir.write("h.toml", &body("http_fetch", "auto", hosts));
assert_eq!(load(dir.path()).unwrap().grants().len(), 5);
// Two wrong cells in one file are two problems.
let wrong = body("http_fetch", "auto", paths);
assert_eq!(problems_of(&[("bad.toml", &wrong)]).len(), 2);
}
#[test]
fn rule_7_paths_are_valid_absolute_paths_and_never_the_root() {
for bad in ["notes", "/a/../b", "/a//b", "/a/./b", "/a/", ""] {
let text = body(
"shell",
"auto",
&format!("[constraints]\npaths = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["is not a valid absolute path"],
);
}
let root = body(
"shell",
"auto",
"[constraints]\npaths = [\"/home/kyle\", \"/\"]\n",
);
one_problem(
&[("bad.toml", &root)],
"bad.toml",
&["a grant of the whole file system is not supported"],
);
}
#[test]
fn rule_8_hosts_are_valid_host_patterns() {
for bad in [
"Example.com",
"example.com:443",
"127.0.0.1",
"localhost",
"*.com",
"https://example.com",
] {
let text = body(
"http_fetch",
"auto",
&format!("[constraints]\nhosts = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&[bad, "is not a valid host pattern"],
);
}
}
#[test]
fn rule_9_a_deny_grant_applies_at_every_taint() {
for taint in ["public", "private"] {
let text = format!("tool = \"shell\"\nmode = \"deny\"\nmax_taint = \"{taint}\"\n");
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["a deny grant must apply at every taint"],
);
}
let dir = TempDir::new("grants");
dir.write("no-shell.toml", &body("shell", "deny", ""));
assert!(load(dir.path()).is_ok());
// The rule is about deny only.
dir.write(
"ask.toml",
"tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"public\"\n",
);
assert!(load(dir.path()).is_ok());
}
fn loaded(id: &str, tool: &str, mode: Mode) -> LoadedGrant {
LoadedGrant {
id: id.to_string(),
grant: Grant {
tool: tool.to_string(),
mode,
max_taint: DataClass::Secret,
result_class: DataClass::Private,
untrusted: true,
expires: None,
secret: None,
constraints: Constraints::default(),
},
sha256: Hash32::ZERO,
}
}
/// `from_grants` is how tests and the property test build a set without files. It applies the
/// same value rules, sorts by id, and refuses two grants with one id.
#[test]
fn from_grants_applies_the_value_rules() {
let set = GrantSet::from_grants(vec![
loaded("zz", "shell", Mode::Ask),
loaded("aa", "shell", Mode::Deny),
])
.unwrap();
assert_eq!(set.grants()[0].id, "aa");
assert_eq!(set.grants()[1].id, "zz");
let problems = GrantSet::from_grants(vec![
loaded("ok", "shell", Mode::Auto),
loaded("no-paths", "read_file", Mode::Auto),
loaded("Bad", "shell", Mode::Auto),
])
.unwrap_err();
let files: Vec<&str> = problems.iter().map(|p| p.file.as_str()).collect();
assert_eq!(files, ["Bad.toml", "no-paths.toml"]);
let twice = GrantSet::from_grants(vec![
loaded("same", "shell", Mode::Auto),
loaded("same", "shell", Mode::Ask),
])
.unwrap_err();
assert!(
twice[0].problem.contains("two grants have this id"),
"{twice:?}"
);
}
#[test]
fn render_prints_every_problem_and_then_the_runbook_pointer() {
let problems = [
GrantProblem {
file: "a.toml".to_string(),
line: Some(3),
problem: "unknown field `mdoe`".to_string(),
},
GrantProblem {
file: "Bad_Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
];
assert_eq!(
render(&problems),
"a.toml:3: unknown field `mdoe`\n\
Bad_Name.toml: the file name is not a valid grant id\n\
see docs/runbook.md#grants-invalid\n"
);
assert_eq!(RUNBOOK, "see docs/runbook.md#grants-invalid");
assert!(render(&problems).trim_end().ends_with(RUNBOOK));
}
@@ -0,0 +1,383 @@
//! The ledger's first and third steps: decide and record; raise the state and record the result.
//! And what happens after a failed append or a panic. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::panic::AssertUnwindSafe;
use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED};
use brokerd::policy::{Label, SessionState};
use build::{grant, now, read, set};
use proto::{
AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus,
SessionId, ToolResponse,
};
use rig::Rig;
fn one(grants: Vec<build::Build>) -> Grants {
Ok(set(grants))
}
fn notes(mode: Mode) -> Grants {
one(vec![grant("n", "read_file", mode).paths(&["/n"])])
}
fn call(label: Label) -> Call {
Call {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
label,
}
}
fn secret() -> Label {
Label {
class: DataClass::Secret,
untrusted: true,
}
}
fn result(content: &str, label: Label) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: label.class,
untrusted: label.untrusted,
truncated: false,
}
}
#[test]
fn an_allowed_call_is_recorded_in_full() {
let rig = Rig::new("ledger-allowed");
let ledger = rig.ledger();
let request = read("/n/a");
let arguments = request.arguments.clone();
match ledger.decide(request, &notes(Mode::Auto), now()) {
Decided::Allowed { decision, seq } => {
assert_eq!(seq, 0);
assert_eq!(decision.grant(), "n");
}
other => panic!("{other:?}"),
}
let records = rig.records();
assert_eq!(records.len(), 1);
assert_eq!(records[0].time, now());
assert_eq!(
records[0].event,
AuditEvent::Decision {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments,
outcome: DecisionRecord::Allowed {},
grant: Some("n".to_string()),
grant_sha256: Some(proto::sha256(b"n").unwrap()),
taint: DataClass::Private,
untrusted: false,
}
);
}
#[test]
fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() {
let rig = Rig::new("ledger-ask");
let ledger = rig.ledger();
match ledger.decide(read("/n/a"), &notes(Mode::Ask), now()) {
Decided::Ask { ask, seq, state } => {
assert_eq!((seq, ask.grant()), (0, "n"));
assert_eq!(state, SessionState::default());
}
other => panic!("{other:?}"),
}
match &rig.events()[0] {
AuditEvent::Decision { outcome, grant, .. } => {
assert_eq!(*outcome, DecisionRecord::Ask {});
assert_eq!(grant.as_deref(), Some("n"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_denial_names_a_grant_only_when_a_grant_denied() {
let rig = Rig::new("ledger-denied");
let ledger = rig.ledger();
let none = ledger.decide(read("/n/a"), &one(Vec::new()), now());
assert!(
matches!(none, Decided::Denied(DenyReason::NoGrant)),
"{none:?}"
);
let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]);
let by = ledger.decide(read("/n/a"), &deny, now());
assert!(
matches!(by, Decided::Denied(DenyReason::DeniedByGrant)),
"{by:?}"
);
let events = rig.events();
let named: Vec<(DecisionRecord, Option<String>)> = events
.iter()
.map(|e| match e {
AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
named,
[
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant
},
None
),
(
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant
},
Some("d".to_string())
),
]
);
}
#[test]
fn invalid_grants_come_first_then_an_unreadable_state() {
let rig = Rig::new("ledger-order");
let ledger = rig.ledger();
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "not json").unwrap();
let invalid: Grants = Err(vec![GrantProblem {
file: "x.toml".to_string(),
line: None,
problem: "bad".to_string(),
}]);
let first = ledger.decide(read("/n/a"), &invalid, now());
assert!(
matches!(first, Decided::Denied(DenyReason::GrantsInvalid)),
"{first:?}"
);
let second = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(second, Decided::Denied(DenyReason::StateUnreadable)),
"{second:?}"
);
// A state brokerd cannot read is recorded as the most sensitive one.
for event in rig.events() {
match event {
AuditEvent::Decision {
taint, untrusted, ..
} => assert_eq!((taint, untrusted), (DataClass::Secret, true)),
other => panic!("{other:?}"),
}
}
let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged");
assert!(!damaged.is_empty(), "{:?}", rig.lines.all());
}
#[test]
fn a_failed_append_denies_this_call_and_every_later_one() {
let rig = Rig::new("ledger-stop");
let ledger = rig.ledger();
rig.switch.fail(true);
let first = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(first, Decided::Denied(DenyReason::AuditUnavailable)),
"{first:?}"
);
assert_eq!(rig.switch.attempts(), 1);
// The sink would work again; the ledger does not try it.
rig.switch.fail(false);
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
let finished = ledger.finish(&call(secret()), result("x", secret()), now());
assert_eq!(
finished,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rig.switch.attempts(), 1, "nothing more was written");
assert!(rig.records().is_empty());
assert!(!rig.state_file("s1").exists(), "the state was not raised");
assert!(!rig.lines.with(STOPPED).is_empty());
assert!(
rig.lines
.all()
.iter()
.all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")),
"{:?}",
rig.lines.all()
);
}
#[test]
fn a_panic_while_holding_the_ledger_denies_every_later_call() {
let rig = Rig::new("ledger-poison");
let ledger = rig.ledger();
rig.switch.panic_next();
let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| {
ledger.decide(read("/n/a"), &notes(Mode::Auto), now())
}));
assert!(panicked.is_err());
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
assert_eq!(rig.switch.attempts(), 1);
assert!(
!rig.lines.with(POISONED).is_empty(),
"{:?}",
rig.lines.all()
);
assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable"));
}
#[test]
fn a_result_raises_the_state_then_is_recorded() {
let rig = Rig::new("ledger-result");
let ledger = rig.ledger();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(answer, result("key", secret()), "passed on unchanged");
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(
state,
SessionState {
taint: DataClass::Secret,
untrusted: true
}
);
assert_eq!(
rig.events(),
[AuditEvent::Result {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
status: ResultStatus::Result,
class: DataClass::Secret,
untrusted: true,
truncated: false,
bytes: 3,
sha256: proto::sha256(b"key").unwrap(),
taint_after: DataClass::Secret,
}]
);
}
#[test]
fn taint_never_goes_down() {
let rig = Rig::new("ledger-down");
let ledger = rig.ledger();
let public = Label {
class: DataClass::Public,
untrusted: false,
};
ledger.finish(&call(secret()), result("a", secret()), now());
ledger.finish(&call(public), result("b", public), now());
let after: Vec<DataClass> = rig
.events()
.iter()
.map(|e| match e {
AuditEvent::Result { taint_after, .. } => *taint_after,
other => panic!("{other:?}"),
})
.collect();
assert_eq!(after, [DataClass::Secret, DataClass::Secret]);
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true));
}
#[test]
fn a_failure_changes_no_state_and_is_recorded_by_its_message() {
let rig = Rig::new("ledger-failed");
let ledger = rig.ledger();
let failed = ToolResponse::Failed {
message: "the tool timed out".to_string(),
};
assert_eq!(
ledger.finish(&call(secret()), failed.clone(), now()),
failed
);
assert!(!rig.state_file("s1").exists());
match &rig.events()[0] {
AuditEvent::Result {
status,
class,
bytes,
sha256,
taint_after,
..
} => {
assert_eq!(*status, ResultStatus::Failed);
assert_eq!(
*class,
DataClass::Secret,
"the label the result would have had"
);
assert_eq!(*bytes, 18);
assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap());
assert_eq!(*taint_after, DataClass::Private, "the taint before");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("ledger-ro");
let ledger = rig.ledger();
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
rig.records().is_empty(),
"no Result for a state not on disk"
);
assert!(
!rig.lines
.with("see docs/runbook.md#broker-state-damaged")
.is_empty()
);
}
#[test]
fn a_result_record_that_cannot_be_written_withholds_the_content() {
let rig = Rig::new("ledger-norecord");
let ledger = rig.ledger();
rig.switch.fail(true);
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
@@ -0,0 +1,262 @@
//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry
//! are recorded as denials. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::approvals::Verdict;
use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger};
use brokerd::policy::{Ask, Label};
use build::{grant, now, read, set};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId,
};
use rig::Rig;
fn asking() -> Grants {
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
}
fn pending(ledger: &Ledger) -> Ask {
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
Decided::Ask { ask, seq: 0, .. } => ask,
other => panic!("{other:?}"),
}
}
fn bxctl() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn denied(reason: DenyReason) -> DecisionRecord {
DecisionRecord::Denied { reason }
}
fn verdict_reason(answered: &Answered) -> Option<DenyReason> {
match &answered.verdict {
Verdict::Run(_) => None,
Verdict::Denied(reason) => Some(*reason),
}
}
/// The one `Approval` record, as (answer, by, reason, outcome, grant).
type Row = (
ApprovalAnswer,
Option<String>,
Option<String>,
DecisionRecord,
Option<String>,
);
fn approval(rig: &Rig) -> Row {
let events = rig.events();
assert_eq!(events.len(), 2, "a decision and one approval: {events:?}");
match &events[1] {
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome,
grant,
..
} => {
assert_eq!(session, &SessionId::new("s1").unwrap());
assert_eq!((*call, *decision, post), (CallId(1), 0, &None));
(
*answer,
by.clone(),
reason.clone(),
outcome.clone(),
grant.clone(),
)
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_that_still_asks_lets_the_call_run() {
let rig = Rig::new("answer-ask");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "n"),
other => panic!("{other:?}"),
}
assert_eq!(answered.outcome, DecisionRecord::Ask {});
let row = approval(&rig);
assert_eq!(
row,
(
ApprovalAnswer::Approved,
Some("bxctl".to_string()),
None,
DecisionRecord::Ask {},
Some("n".to_string())
)
);
}
#[test]
fn an_approval_under_a_grant_that_is_now_auto_records_allowed() {
let rig = Rig::new("answer-auto");
let ledger = rig.ledger();
let ask = pending(&ledger);
let now_auto = Ok(set(vec![
grant("n", "read_file", Mode::Auto).paths(&["/n"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now());
assert!(matches!(answered.verdict, Verdict::Run(_)));
assert_eq!(answered.outcome, DecisionRecord::Allowed {});
}
#[test]
fn the_approval_names_the_grant_matched_now() {
let rig = Rig::new("answer-grant");
let ledger = rig.ledger();
let ask = pending(&ledger);
let wider = Ok(set(vec![
grant("n", "read_file", Mode::Ask).paths(&["/n"]),
grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &wider, now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"),
other => panic!("{other:?}"),
}
assert_eq!(approval(&rig).4.as_deref(), Some("z-deep"));
}
#[test]
fn an_approval_after_the_grant_is_gone_is_denied() {
let rig = Rig::new("answer-gone");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant));
assert_eq!(answered.outcome, denied(DenyReason::NoGrant));
let row = approval(&rig);
assert_eq!(
(row.0, row.3, row.4),
(ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None)
);
}
#[test]
fn an_approval_after_the_taint_rose_is_denied() {
let rig = Rig::new("answer-taint");
let ledger = rig.ledger();
let low = || {
Ok(set(vec![
grant("n", "read_file", Mode::Ask)
.paths(&["/n"])
.max_taint(DataClass::Private),
]))
};
let ask = match ledger.decide(read("/n/a"), &low(), now()) {
Decided::Ask { ask, .. } => ask,
other => panic!("{other:?}"),
};
// Another call of the session read a secret while this one waited.
let s1 = SessionId::new("s1").unwrap();
let label = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), label)
.unwrap();
let answered = ledger.answer(ask, 0, bxctl(), &low(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh));
match &rig.events()[1] {
AuditEvent::Approval { taint, outcome, .. } => {
assert_eq!(*taint, DataClass::Secret, "the state at the re-decision");
assert_eq!(*outcome, denied(DenyReason::TaintTooHigh));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_with_invalid_grants_is_denied() {
let rig = Rig::new("answer-invalid");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid));
}
#[test]
fn a_refusal_is_recorded_with_the_owners_reason() {
let rig = Rig::new("answer-refuse");
let ledger = rig.ledger();
let ask = pending(&ledger);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: Some("not now".to_string()),
};
let answered = ledger.answer(ask, 0, refused, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Refused,
Some("bxctl".to_string()),
Some("not now".to_string()),
denied(DenyReason::ApprovalRefused),
None
)
);
}
#[test]
fn an_expiry_is_recorded_by_nobody() {
let rig = Rig::new("answer-expire");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Expired,
None,
None,
denied(DenyReason::ApprovalExpired),
None
)
);
}
#[test]
fn an_approval_that_cannot_be_recorded_does_not_run() {
let rig = Rig::new("answer-norecord");
let ledger = rig.ledger();
let ask = pending(&ledger);
rig.switch.fail(true);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
assert_eq!(
verdict_reason(&answered),
Some(DenyReason::AuditUnavailable)
);
assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable));
assert_eq!(rig.events().len(), 1, "only the decision");
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
@@ -0,0 +1,213 @@
//! Table tests for `policy::decide`: which arguments each tool's grants cover. Do not edit.
//! `policy_matching.rs` covers how a winner, a label and a reason are picked, `policy_redecide.rs`
//! covers approvals, and `policy_property.rs` checks all of it against an oracle.
#[path = "support/build.rs"]
mod build;
use brokerd::policy::decide;
use build::{allowed, fetch, grant, now, private, read, reason, request, set, shell, write};
use proto::{DenyReason, Mode};
#[test]
fn no_grants_means_no_grant() {
let none = set(vec![]);
for req in [
read("/etc/hosts"),
write("/tmp/x"),
shell(None),
fetch("https://example.com/"),
] {
assert_eq!(
reason(decide(req, &none, private(), now())),
DenyReason::NoGrant
);
}
}
#[test]
fn a_tool_that_is_not_one_of_the_four_is_no_grant_and_its_arguments_are_not_parsed() {
let grants = set(vec![grant("s", "shell", Mode::Auto)]);
for tool in ["echo", "clock", "call_tool", "", "Shell"] {
for arguments in ["{}", "not json", r#"{"command":"ls"}"#] {
let outcome = decide(request(tool, arguments), &grants, private(), now());
assert_eq!(
reason(outcome),
DenyReason::NoGrant,
"{tool:?} {arguments:?}"
);
}
}
}
/// Invalid arguments are refused before matching, so the answer is the same with a grant that
/// would cover them, with a `deny` grant, and with no grant at all.
#[test]
fn invalid_arguments_are_refused_before_matching() {
let covering = set(vec![
grant("r", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
grant("no", "read_file", Mode::Deny).paths(&["/home/kyle"]),
]);
let none = set(vec![]);
for grants in [&covering, &none] {
for req in [
// The rows of the "Paths" table that are about form.
read("/home/kyle/notes/../.ssh/id"),
read("notes/a.md"),
read("/home/kyle//notes/./a.md"),
request("read_file", "{}"),
request(
"read_file",
r#"{"path":"/home/kyle/notes/a.md","mode":"r"}"#,
),
request("read_file", "not json"),
write("/home/kyle/notes/"),
shell(Some("relative")),
fetch("http://example.com/"),
fetch("https://127.0.0.1/"),
fetch("https://user@example.com/"),
] {
let text = req.arguments.clone();
assert_eq!(
reason(decide(req, grants, private(), now())),
DenyReason::InvalidArguments,
"{text}"
);
}
}
}
/// The "Paths" table, the rows about containment.
#[test]
fn read_file_is_covered_inside_a_granted_path() {
let grants = set(vec![
grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
]);
for path in [
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/home/kyle/notes/x/y/z",
] {
let d = allowed(decide(read(path), &grants, private(), now()));
assert_eq!(d.grant(), "notes");
assert_eq!(d.matched_path(), Some("/home/kyle/notes"));
}
for path in [
"/home/kyle/notes2/a.md",
"/home/kyle",
"/",
"/etc/passwd",
"/home/kyle/note",
] {
assert_eq!(
reason(decide(read(path), &grants, private(), now())),
DenyReason::NoGrant,
"{path}"
);
}
// A grant is for one tool.
let outcome = decide(write("/home/kyle/notes/a.md"), &grants, private(), now());
assert_eq!(reason(outcome), DenyReason::NoGrant);
}
#[test]
fn write_file_is_covered_inside_a_granted_path_but_not_at_the_path_itself() {
let grants = set(vec![
grant("scratch", "write_file", Mode::Auto)
.paths(&["/home/kyle/scratch", "/home/kyle/scratch/out"]),
]);
let d = allowed(decide(
write("/home/kyle/scratch/a.txt"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
// The longest path that holds the argument is the matched one.
let d = allowed(decide(
write("/home/kyle/scratch/out/b.txt"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch/out"));
// A granted path itself cannot be written, but it can lie inside another granted path.
let d = allowed(decide(
write("/home/kyle/scratch/out"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
let outcome = decide(write("/home/kyle/scratch"), &grants, private(), now());
assert_eq!(reason(outcome), DenyReason::NoGrant);
}
#[test]
fn shell_is_covered_by_no_paths_and_no_cwd_or_by_a_cwd_inside_a_path() {
let bare = set(vec![grant("bare", "shell", Mode::Auto)]);
let d = allowed(decide(shell(None), &bare, private(), now()));
assert_eq!((d.matched_path(), d.paths().len()), (None, 0));
assert_eq!(
reason(decide(shell(Some("/home/kyle")), &bare, private(), now())),
DenyReason::NoGrant
);
let scoped = set(vec![
grant("scoped", "shell", Mode::Auto).paths(&["/home/kyle/a", "/srv/b"]),
]);
let d = allowed(decide(shell(Some("/srv/b/sub")), &scoped, private(), now()));
assert_eq!(d.matched_path(), Some("/srv/b"));
// The runner mounts every path of the grant, so the decision carries them all.
assert_eq!(d.paths(), ["/home/kyle/a", "/srv/b"]);
assert_eq!(
reason(decide(shell(None), &scoped, private(), now())),
DenyReason::NoGrant
);
assert_eq!(
reason(decide(shell(Some("/srv")), &scoped, private(), now())),
DenyReason::NoGrant
);
}
/// The "Hosts" table, row by row.
#[test]
fn http_fetch_is_covered_when_the_host_matches() {
let exact = set(vec![
grant("exact", "http_fetch", Mode::Auto).hosts(&["example.com"]),
]);
let wild = set(vec![
grant("wild", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
]);
let d = allowed(decide(
fetch("https://example.com/a?b=c"),
&exact,
private(),
now(),
));
assert_eq!(d.hosts(), ["example.com"]);
assert_eq!(d.matched_path(), None);
assert_eq!(
reason(decide(
fetch("https://www.example.com/"),
&exact,
private(),
now()
)),
DenyReason::NoGrant
);
for url in ["https://www.example.com/", "https://a.b.example.com:443/x"] {
allowed(decide(fetch(url), &wild, private(), now()));
}
for url in [
"https://example.com/",
"https://badexample.com/",
"https://example.com.evil.org/",
] {
assert_eq!(
reason(decide(fetch(url), &wild, private(), now())),
DenyReason::NoGrant,
"{url}"
);
}
}
@@ -0,0 +1,363 @@
//! Table tests for `policy::decide`: among the grants that cover a call, which one wins, what
//! the result is labelled, and which reason is given when none is left. Do not edit.
#[path = "support/build.rs"]
mod build;
use brokerd::policy::{Label, decide};
use build::{
allowed, asked, denied, fetch, grant, now, private, read, reason, request, secret, set, shell,
};
use proto::{DataClass, DenyReason, Mode};
#[test]
fn the_most_restrictive_mode_wins_among_three_matching_grants() {
let paths = &["/home/kyle/notes"];
let auto = || grant("b-auto", "read_file", Mode::Auto).paths(paths);
let ask = || grant("c-ask", "read_file", Mode::Ask).paths(paths);
let deny = || grant("a-deny", "read_file", Mode::Deny).paths(paths);
let req = || read("/home/kyle/notes/a.md");
let denial = denied(decide(
req(),
&set(vec![auto(), ask(), deny()]),
private(),
now(),
));
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("a-deny"));
assert_eq!(denial.grant_sha256, Some(deny().done().sha256));
let ask_wins = asked(decide(req(), &set(vec![auto(), ask()]), private(), now()));
assert_eq!(ask_wins.grant(), "c-ask");
assert_eq!(
allowed(decide(req(), &set(vec![auto()]), private(), now())).grant(),
"b-auto"
);
// Deny beats a longer path and a lower id: the mode comes first.
let narrow_auto = grant("a-auto", "read_file", Mode::Auto).paths(&["/home/kyle/notes/deep"]);
let wide_deny = grant("z-deny", "read_file", Mode::Deny).paths(&["/home"]);
let outcome = decide(
read("/home/kyle/notes/deep/x"),
&set(vec![narrow_auto, wide_deny]),
private(),
now(),
);
assert_eq!(denied(outcome).grant.as_deref(), Some("z-deny"));
}
#[test]
fn within_a_mode_the_longest_matched_path_wins_and_then_the_lowest_id() {
let grants = set(vec![
grant("a-wide", "read_file", Mode::Auto).paths(&["/home/kyle"]),
grant("z-narrow", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
]);
let d = allowed(decide(
read("/home/kyle/notes/a.md"),
&grants,
private(),
now(),
));
assert_eq!(
(d.grant(), d.matched_path()),
("z-narrow", Some("/home/kyle/notes"))
);
let d = allowed(decide(read("/home/kyle/other"), &grants, private(), now()));
assert_eq!(d.grant(), "a-wide");
// Equal paths: the lowest id in byte order, whatever order the grants were given in.
let tie = set(vec![
grant("g-10", "read_file", Mode::Auto).paths(&["/srv"]),
grant("g-2", "read_file", Mode::Auto).paths(&["/srv"]),
grant("g-1z", "read_file", Mode::Auto).paths(&["/srv"]),
]);
assert_eq!(
allowed(decide(read("/srv/x"), &tie, private(), now())).grant(),
"g-10"
);
// Grants with no matched path all tie, so the id decides.
let hosts = set(vec![
grant("m", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
grant("b", "http_fetch", Mode::Auto).hosts(&["www.example.com"]),
]);
let d = allowed(decide(
fetch("https://www.example.com/"),
&hosts,
private(),
now(),
));
assert_eq!(d.grant(), "b");
}
/// The example in the spec. Whichever id sorts first, the read is labelled `secret`, and the
/// narrower grant is the one recorded and mounted.
#[test]
fn the_label_is_combined_over_every_matching_grant() {
for (home, keys) in [("a-home", "b-keys"), ("z-home", "b-keys")] {
let grants = set(vec![
grant(home, "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.trusted(),
grant(keys, "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
]);
let d = allowed(decide(
read("/home/kyle/keys/id"),
&grants,
private(),
now(),
));
assert_eq!(d.grant(), keys);
assert_eq!(
d.label(),
Label {
class: DataClass::Secret,
untrusted: false
}
);
// Outside `keys` only the wide grant matches, so only its label counts.
let d = allowed(decide(read("/home/kyle/todo"), &grants, private(), now()));
assert_eq!(
d.label(),
Label {
class: DataClass::Private,
untrusted: false
}
);
}
// The winner says trusted and public; another matching grant says otherwise, and it counts.
let grants = set(vec![
grant("narrow", "read_file", Mode::Auto)
.paths(&["/srv/pub/docs"])
.class(DataClass::Public)
.trusted(),
grant("wide", "read_file", Mode::Auto)
.paths(&["/srv/pub"])
.class(DataClass::Private),
]);
let d = allowed(decide(read("/srv/pub/docs/x"), &grants, private(), now()));
assert_eq!(d.grant(), "narrow");
assert_eq!(
d.label(),
Label {
class: DataClass::Private,
untrusted: true
}
);
// An `ask` winner carries the combined label too.
let grants = set(vec![
grant("asks", "read_file", Mode::Ask)
.paths(&["/srv"])
.class(DataClass::Public)
.trusted(),
grant("labels", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Secret),
]);
let ask = asked(decide(read("/srv/x"), &grants, private(), now()));
assert_eq!(
ask.label(),
Label {
class: DataClass::Secret,
untrusted: true
}
);
}
#[test]
fn a_grant_ruled_out_by_taint_or_expiry_adds_nothing_to_the_label() {
let grants = set(vec![
grant("live", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Public)
.trusted(),
grant("old", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Secret)
.expires("2026-01-01T00:00:00.000Z"),
]);
let d = allowed(decide(read("/srv/x"), &grants, private(), now()));
assert_eq!(
d.label(),
Label {
class: DataClass::Public,
untrusted: false
}
);
}
#[test]
fn a_grant_expires_exactly_at_its_time() {
let at = |when: &str| set(vec![grant("g", "shell", Mode::Auto).expires(when)]);
let d = allowed(decide(
shell(None),
&at("2026-09-18T12:00:00.001Z"),
private(),
now(),
));
assert_eq!(d.expires(), Some(build::ts("2026-09-18T12:00:00.001Z")));
for when in [
"2026-09-18T12:00:00.000Z",
"2026-09-18T11:59:59.999Z",
"2020-01-01T00:00:00.000Z",
] {
assert_eq!(
reason(decide(shell(None), &at(when), private(), now())),
DenyReason::GrantExpired,
"{when}"
);
}
let never = set(vec![grant("g", "shell", Mode::Auto)]);
assert_eq!(
allowed(decide(shell(None), &never, private(), now())).expires(),
None
);
}
#[test]
fn a_grant_applies_up_to_its_max_taint() {
let grants = set(vec![
grant("g", "shell", Mode::Auto).max_taint(DataClass::Private),
]);
allowed(decide(shell(None), &grants, private(), now()));
allowed(decide(
shell(None),
&grants,
build::at(DataClass::Public),
now(),
));
assert_eq!(
reason(decide(shell(None), &grants, secret(), now())),
DenyReason::TaintTooHigh
);
// The untrusted flag is not an input to matching.
let mut state = private();
state.untrusted = true;
allowed(decide(shell(None), &grants, state, now()));
}
#[test]
fn the_reason_when_nothing_is_left() {
let expired = || grant("e", "shell", Mode::Auto).expires("2026-01-01T00:00:00.000Z");
let tainted = || grant("t", "shell", Mode::Auto).max_taint(DataClass::Private);
let both = || {
grant("b", "shell", Mode::Auto)
.expires("2026-01-01T00:00:00.000Z")
.max_taint(DataClass::Private)
};
let cases = [
// One candidate expired and another too tainted: expiry is reported first.
(set(vec![expired(), tainted()]), DenyReason::GrantExpired),
(set(vec![tainted(), both()]), DenyReason::TaintTooHigh),
// Ruled out by both is ruled out "only" by neither.
(set(vec![both()]), DenyReason::NoGrant),
(set(vec![expired()]), DenyReason::GrantExpired),
];
for (grants, want) in cases {
assert_eq!(reason(decide(shell(None), &grants, secret(), now())), want);
}
// "Only by expiry" means it would have matched: an expired grant for other arguments, or
// for another tool, is no reason to say `grant_expired`.
let elsewhere = set(vec![
grant("p", "read_file", Mode::Auto)
.paths(&["/srv"])
.expires("2026-01-01T00:00:00.000Z"),
grant("w", "write_file", Mode::Auto)
.paths(&["/home"])
.expires("2026-01-01T00:00:00.000Z"),
]);
assert_eq!(
reason(decide(read("/home/kyle/x"), &elsewhere, private(), now())),
DenyReason::NoGrant
);
}
#[test]
fn a_deny_grant_denies_at_every_taint_until_it_expires() {
let grants = |deny_expires: Option<&str>| {
let deny = grant("no-internal", "http_fetch", Mode::Deny).hosts(&["internal.example.com"]);
let deny = match deny_expires {
Some(when) => deny.expires(when),
None => deny,
};
set(vec![
grant("any", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
deny,
])
};
let url = "https://internal.example.com/";
for state in [build::at(DataClass::Public), private(), secret()] {
let denial = denied(decide(fetch(url), &grants(None), state, now()));
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("no-internal"));
}
allowed(decide(
fetch("https://www.example.com/"),
&grants(None),
secret(),
now(),
));
// An expired deny no longer denies: `expires` on a deny grant means "forbid this until then".
let lapsed = grants(Some("2026-09-18T12:00:00.000Z"));
assert_eq!(
allowed(decide(fetch(url), &lapsed, private(), now())).grant(),
"any"
);
}
/// Documented, not liked: an `ask` grant with a lower `max_taint` than an `auto` grant over the
/// same arguments drops out when taint rises, and the call then runs without asking.
#[test]
fn an_ask_grant_with_a_lower_max_taint_stops_asking_when_taint_rises() {
let grants = set(vec![
grant("asks", "shell", Mode::Ask).max_taint(DataClass::Private),
grant("runs", "shell", Mode::Auto),
]);
assert_eq!(
asked(decide(shell(None), &grants, private(), now())).grant(),
"asks"
);
assert_eq!(
allowed(decide(shell(None), &grants, secret(), now())).grant(),
"runs"
);
}
#[test]
fn a_decision_and_an_ask_carry_what_the_broker_and_the_runner_need() {
let grants = set(vec![
grant("asks", "write_file", Mode::Ask)
.paths(&["/home/kyle/scratch"])
.expires("2027-01-01T00:00:00.000Z")
.class(DataClass::Public),
]);
let req = request(
"write_file",
r#"{ "content": "hello", "path": "/home/kyle/scratch/a.txt" }"#,
);
let ask = asked(decide(req.clone(), &grants, private(), now()));
assert_eq!(ask.request(), &req);
assert_eq!(
ask.args().canonical_json(),
r#"{"path":"/home/kyle/scratch/a.txt","content":"hello"}"#
);
assert_eq!(ask.grant(), "asks");
assert_eq!(ask.grant_sha256(), proto::sha256(b"asks").unwrap());
assert_eq!(ask.matched_path(), Some("/home/kyle/scratch"));
assert_eq!(ask.paths(), ["/home/kyle/scratch"]);
assert!(ask.hosts().is_empty());
assert_eq!(ask.expires(), Some(build::ts("2027-01-01T00:00:00.000Z")));
assert_eq!(
ask.label(),
Label {
class: DataClass::Public,
untrusted: true
}
);
}
@@ -0,0 +1,234 @@
//! Property test for `policy`: random grant sets, states and requests, each decided twice, once
//! by `policy::decide` and once by an oracle. Every case must agree. Do not edit.
//!
//! The generator and the oracle are in `support/oracle.rs`. If this test fails, the oracle is
//! the specification and `policy` is wrong.
//!
//! The generator is a seeded xorshift, so a failure can be replayed: the message names the seed
//! and the case. `BOXMAKER_POLICY_SEED=<n>` runs one more seed, and `BOXMAKER_POLICY_CASES=<n>`
//! changes how many cases each seed runs (default 3000).
#[path = "support/oracle.rs"]
mod oracle;
use brokerd::grants::{GrantSet, LoadedGrant};
use brokerd::policy::{Outcome, SessionState, decide, redecide};
use oracle::{CLASSES, Expected, NOW_MS, Rng, oracle, some_grant, some_grants, some_request};
use proto::{DataClass, DenyReason, Mode, Timestamp};
const SEEDS: [u64; 5] = [1, 2, 3, 0xB0C5, 20_260_918];
// ---------------------------------------------------------------------------------------------
// The comparison.
fn observed(outcome: &Outcome) -> Expected {
match outcome {
Outcome::Allowed(d) => Expected::Allowed {
grant: d.grant().to_string(),
path: d.matched_path().map(str::to_string),
class: d.label().class,
untrusted: d.label().untrusted,
},
Outcome::Ask(a) => Expected::Ask {
grant: a.grant().to_string(),
path: a.matched_path().map(str::to_string),
class: a.label().class,
untrusted: a.label().untrusted,
},
Outcome::Denied(denial) => Expected::Denied {
reason: denial.reason,
grant: denial.grant.clone(),
},
}
}
/// Allowed is 0, ask is 1, denied is 2.
fn restrictiveness(expected: &Expected) -> u8 {
match expected {
Expected::Allowed { .. } => 0,
Expected::Ask { .. } => 1,
Expected::Denied { .. } => 2,
}
}
fn cases() -> usize {
match std::env::var("BOXMAKER_POLICY_CASES") {
Ok(text) => text
.parse()
.expect("BOXMAKER_POLICY_CASES must be a number"),
Err(_) => 3000,
}
}
fn seeds() -> Vec<u64> {
let mut seeds = SEEDS.to_vec();
if let Ok(text) = std::env::var("BOXMAKER_POLICY_SEED") {
seeds.push(text.parse().expect("BOXMAKER_POLICY_SEED must be a number"));
}
seeds
}
fn now() -> Timestamp {
Timestamp::from_unix_millis(NOW_MS).unwrap()
}
fn state(rng: &mut Rng) -> SessionState {
SessionState {
taint: rng.pick(&CLASSES),
untrusted: rng.chance(50),
}
}
fn valid(grants: &[LoadedGrant]) -> GrantSet {
GrantSet::from_grants(grants.to_vec()).expect("the generator only makes valid grants")
}
#[test]
fn decide_agrees_with_the_oracle() {
let mut kinds = [0usize; 3];
for seed in seeds() {
let mut rng = Rng::new(seed);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let state = state(&mut rng);
let want = oracle(&request, &grants, state);
let got = observed(&decide(request.clone(), &valid(&grants), state, now()));
assert_eq!(
got, want,
"seed {seed} case {case}\nrequest: {request:?}\nstate: {state:?}\ngrants: {grants:#?}"
);
kinds[restrictiveness(&want) as usize] += 1;
}
}
// The generator must reach every kind of outcome, or the test proves little.
for (kind, count) in ["allowed", "ask", "denied"].iter().zip(kinds) {
assert!(count > 200, "only {count} cases were {kind}");
}
}
#[test]
fn redecide_agrees_with_the_oracle_under_new_grants_and_a_new_state() {
let mut approvals = 0;
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0xA5A5);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let first = state(&mut rng);
let Outcome::Ask(ask) = decide(request.clone(), &valid(&grants), first, now()) else {
continue;
};
approvals += 1;
// Half the time nothing has changed; otherwise the grants or the state have.
let (later_grants, later) = if rng.chance(50) {
(grants.clone(), first)
} else {
(some_grants(&mut rng), state(&mut rng))
};
let want = oracle(&request, &later_grants, later);
let got = redecide(ask, &valid(&later_grants), later, now());
let context = format!(
"seed {seed} case {case}\nrequest: {request:?}\nlater: {later:?}\ngrants: {later_grants:#?}"
);
match (want, got) {
(
Expected::Allowed {
grant,
path,
class,
untrusted,
},
Ok(d),
)
| (
Expected::Ask {
grant,
path,
class,
untrusted,
},
Ok(d),
) => {
assert_eq!(d.grant(), grant, "{context}");
assert_eq!(d.matched_path().map(str::to_string), path, "{context}");
assert_eq!(
(d.label().class, d.label().untrusted),
(class, untrusted),
"{context}"
);
assert_eq!(d.request(), &request, "{context}");
}
(Expected::Denied { reason, grant }, Err(denial)) => {
assert_eq!((denial.reason, denial.grant), (reason, grant), "{context}");
}
(want, got) => panic!("wanted {want:?}, got {got:?}\n{context}"),
}
}
}
assert!(approvals > 200, "only {approvals} cases asked");
}
/// Adding a `deny` grant to a set never makes any outcome less restrictive.
#[test]
fn adding_a_deny_grant_never_loosens_an_outcome() {
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0x5A5A);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let state = state(&mut rng);
let mut extra = some_grant(&mut rng, 90);
extra.grant.mode = Mode::Deny;
extra.grant.max_taint = DataClass::Secret;
let mut with_deny = grants.clone();
with_deny.push(extra);
let before = observed(&decide(request.clone(), &valid(&grants), state, now()));
let after = observed(&decide(request.clone(), &valid(&with_deny), state, now()));
assert!(
restrictiveness(&after) >= restrictiveness(&before),
"seed {seed} case {case}: {before:?} became {after:?}\nrequest: {request:?}\ngrants: {with_deny:#?}"
);
}
}
}
/// A call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint:
/// reading a secret can never switch off a prohibition.
#[test]
fn a_prohibition_holds_at_every_higher_taint() {
let mut prohibitions = 0;
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0x0F0F);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let mut denied_below = false;
for taint in CLASSES {
let state = SessionState {
taint,
untrusted: false,
};
let outcome = observed(&decide(request.clone(), &valid(&grants), state, now()));
let by_grant = matches!(
outcome,
Expected::Denied {
reason: DenyReason::DeniedByGrant,
..
}
);
assert!(
by_grant || !denied_below,
"seed {seed} case {case}: a deny stopped applying at {taint:?}\nrequest: {request:?}\ngrants: {grants:#?}"
);
denied_below = by_grant;
}
prohibitions += usize::from(denied_below);
}
}
assert!(
prohibitions > 200,
"only {prohibitions} cases were prohibited"
);
}
@@ -0,0 +1,117 @@
//! Table tests for `policy::redecide`: an approval lets a call through only if the grants and
//! the session's state, as they are when it is approved, still say `ask` or `auto`. Do not edit.
#[path = "support/build.rs"]
mod build;
use brokerd::grants::GrantSet;
use brokerd::policy::{Ask, Label, Outcome, decide, redecide};
use build::{grant, now, private, read, secret, set};
use proto::{DataClass, DenyReason, Mode};
const PATH: &str = "/home/kyle/notes/a.md";
fn asking() -> build::Build {
grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/notes"])
}
/// An `Ask` for `PATH`, decided under `asking()` alone at `private`.
fn pending() -> Ask {
match decide(read(PATH), &set(vec![asking()]), private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
}
}
#[test]
fn still_ask_lets_the_call_run_under_the_same_grant() {
let decision = redecide(pending(), &set(vec![asking()]), private(), now()).unwrap();
assert_eq!(decision.grant(), "asks");
assert_eq!(decision.request(), &read(PATH));
assert_eq!(decision.matched_path(), Some("/home/kyle/notes"));
assert_eq!(
decision.label(),
Label {
class: DataClass::Private,
untrusted: true
}
);
}
#[test]
fn auto_now_lets_the_call_run_under_the_grant_that_matches_now() {
// The owner has since replaced the ask grant with an auto grant of another name and label.
let grants = set(vec![
grant("now-auto", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Secret)
.trusted(),
]);
let decision = redecide(pending(), &grants, private(), now()).unwrap();
assert_eq!(decision.grant(), "now-auto");
assert_eq!(decision.grant_sha256(), proto::sha256(b"now-auto").unwrap());
assert_eq!(decision.matched_path(), Some("/home/kyle"));
assert_eq!(decision.paths(), ["/home/kyle"]);
assert_eq!(
decision.label(),
Label {
class: DataClass::Secret,
untrusted: false
}
);
}
#[test]
fn the_grant_file_was_removed() {
let denial = redecide(pending(), &GrantSet::default(), private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::NoGrant);
assert_eq!(denial.grant, None);
}
#[test]
fn the_taint_rose_past_max_taint_while_the_approval_waited() {
let narrow = || asking().max_taint(DataClass::Private);
let ask = match decide(read(PATH), &set(vec![narrow()]), private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
};
let denial = redecide(ask, &set(vec![narrow()]), secret(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::TaintTooHigh);
}
#[test]
fn the_grant_expired_while_the_approval_waited() {
let grants = set(vec![asking().expires("2026-09-18T12:10:00.000Z")]);
let ask = match decide(read(PATH), &grants, private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
};
assert_eq!(ask.expires(), Some(build::ts("2026-09-18T12:10:00.000Z")));
let later = build::ts("2026-09-18T12:10:00.000Z");
let denial = redecide(ask, &grants, private(), later).unwrap_err();
assert_eq!(denial.reason, DenyReason::GrantExpired);
}
#[test]
fn a_deny_grant_was_added_while_the_approval_waited() {
let grants = set(vec![
asking(),
grant("no-notes", "read_file", Mode::Deny).paths(&["/home/kyle"]),
]);
let denial = redecide(pending(), &grants, private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("no-notes"));
assert_eq!(
denial.grant_sha256,
Some(proto::sha256(b"no-notes").unwrap())
);
}
#[test]
fn the_grants_now_cover_other_arguments_only() {
let grants = set(vec![
grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/other"]),
]);
let denial = redecide(pending(), &grants, private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::NoGrant);
}
@@ -0,0 +1,175 @@
//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not
//! edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/runtime.rs"]
mod runtime;
use brokerd::args::{ToolArgs, ToolName};
use brokerd::policy::{Decision, Outcome, SessionState, decide};
use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run};
use build::{grant, now, read, request, set};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
use runtime::Recording;
fn allowed(grants: Vec<build::Build>, request: ToolRequest) -> Decision {
match decide(request, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(decision) => decision,
other => panic!("the test's call is not allowed: {other:?}"),
}
}
fn mount(path: &str, writable: bool) -> Mount {
Mount {
path: path.to_string(),
writable,
}
}
#[test]
fn read_file_mounts_the_matched_path_read_only_and_has_no_network() {
let d = allowed(
vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])],
read("/h/notes/deep/a.md"),
);
let rt = Recording::answering("text");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].tool, ToolName::ReadFile);
assert_eq!(
seen[0].arguments,
ToolArgs::ReadFile {
path: "/h/notes/deep/a.md".to_string()
}
);
// The longest path that holds the argument, and only that one.
assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn write_file_mounts_the_matched_path_writable() {
// A grant path equal to the argument does not count, so `/s/out` is written through `/s`.
let d = allowed(
vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])],
request("write_file", r#"{"path":"/s/out","content":"x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::WriteFile);
assert_eq!(seen[0].mounts, [mount("/s", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_mounts_every_path_of_the_grant_writable() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])],
request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::Shell);
assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_without_paths_mounts_nothing() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto)],
request("shell", r#"{"command":"date"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].mounts, []);
assert_eq!(seen[0].egress, None);
}
#[test]
fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() {
let d = allowed(
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])],
request("http_fetch", r#"{"url":"https://www.example.org/x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::HttpFetch);
assert_eq!(seen[0].mounts, []);
assert_eq!(
seen[0].egress,
Some(vec!["example.com".to_string(), "*.example.org".to_string()])
);
}
#[test]
fn a_result_carries_the_label_combined_over_every_matching_grant() {
// `b-keys` has the longer path and wins the mount; the label is the highest class of both
// grants, and untrusted because `a-home` says so.
let d = allowed(
vec![
grant("a-home", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Private),
grant("b-keys", "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
],
read("/home/kyle/keys/id"),
);
let rt = Recording::with(Ok(RunOutput {
content: "key".to_string(),
truncated: true,
}));
let answer = run(d, rt.as_ref());
assert_eq!(
answer,
ToolResponse::Result {
content: "key".to_string(),
class: DataClass::Secret,
untrusted: true,
truncated: true,
}
);
assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]);
}
#[test]
fn a_run_error_is_a_failure_with_the_runtimes_sentence() {
for error in [
RunError::Failed("the tool timed out".to_string()),
RunError::Unavailable("the container could not start".to_string()),
] {
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
let text = match &error {
RunError::Failed(t) | RunError::Unavailable(t) => t.clone(),
};
let rt = Recording::with(Err(error));
assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text });
}
}
#[test]
fn the_production_runtime_refuses_every_call() {
assert_eq!(REFUSING, "the runner arrives in M3b");
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
assert_eq!(
run(d, &Refusing),
ToolResponse::Failed {
message: REFUSING.to_string()
}
);
}
@@ -0,0 +1,405 @@
//! `brokerd serve` as a process: its startup, both sockets, and the expiry thread. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use brokerd::audit::RECOVERED_NOTICE;
use brokerd::runner::REFUSING;
use proto::{
AuditEvent, CallId, DenyReason, Empty, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
ResultStatus, SessionId, ToolRequest, ToolResponse,
};
use tmp::TempDir;
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
fn new(tag: &str, ttl_ms: u64) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let text = format!(
"[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[approvals]\nttl_ms = {ttl_ms}\n",
home = dir.path().display()
);
let config = dir.write("brokerd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn tools(&self) -> PathBuf {
self.path("run/loop-broker/broker.sock")
}
fn admin(&self) -> PathBuf {
self.path("run/owner-broker/admin.sock")
}
fn grant(&self, id: &str, mode: &str) {
let text = format!(
"tool = \"read_file\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n\
result_class = \"private\"\nuntrusted = false\n[constraints]\npaths = [\"/n\"]\n"
);
std::fs::write(self.path(&format!("grants/{id}.toml")), text).unwrap();
}
fn command(&self, extra: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_brokerd"));
command
.args(["serve", "--config"])
.arg(&self.config)
.args(extra);
command
}
/// Starts `brokerd serve` and waits until both sockets answer.
fn serve(&self) -> Running {
let child = self
.command(&[])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(Some(child));
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(self.tools()).is_err()
|| UnixStream::connect(self.admin()).is_err()
{
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
running
}
/// Runs `brokerd serve` expecting it to exit by itself.
fn run(&self, extra: &[&str]) -> Output {
self.command(extra).output().unwrap()
}
fn events(&self) -> Vec<AuditEvent> {
let dir = self.path("audit");
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
names
.iter()
.flat_map(|n| {
let text = std::fs::read_to_string(dir.join(n)).unwrap();
text.lines()
.map(|l| serde_json::from_str::<proto::AuditRecord>(l).unwrap().event)
.collect::<Vec<_>>()
})
.collect()
}
}
/// Kills the daemon when dropped; `stop` returns what it printed.
struct Running(Option<Child>);
impl Running {
fn stop(mut self) -> String {
let mut child = self.0.take().unwrap();
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
String::from_utf8_lossy(&output.stderr).to_string()
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn exchange(socket: &Path, id: u64, msg: Message) -> Vec<Envelope> {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut stream, &env).unwrap();
let mut frames = Vec::new();
loop {
let env = proto::read_frame(&mut stream).unwrap();
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
fn read_notes(call: u64) -> Message {
Message::ToolRequest(ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
})
}
fn last_response(frames: &[Envelope]) -> &ToolResponse {
match &frames.last().unwrap().msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
#[test]
fn it_makes_its_directories_0700_and_its_sockets_0600() {
let home = Home::new("serve-modes", 900_000);
// One directory found too open, one made.
std::fs::create_dir_all(home.path("run/owner-broker")).unwrap();
std::fs::set_permissions(
home.path("run/owner-broker"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let running = home.serve();
assert_eq!(mode(&home.path("run/loop-broker")), 0o700);
assert_eq!(mode(&home.path("run/owner-broker")), 0o700);
assert_eq!(mode(&home.tools()), 0o600);
assert_eq!(mode(&home.admin()), 0o600);
assert_eq!(mode(&home.path("audit")), 0o700);
let printed = running.stop();
assert!(printed.contains("serving"), "{printed}");
}
#[test]
fn a_stale_socket_is_replaced() {
let home = Home::new("serve-stale", 900_000);
std::fs::create_dir_all(home.path("run/loop-broker")).unwrap();
drop(UnixListener::bind(home.tools()).unwrap());
assert!(home.tools().exists(), "the stale socket file is there");
let _running = home.serve();
let frames = exchange(&home.tools(), 3, read_notes(3));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::NoGrant
}
);
}
#[test]
fn a_second_brokerd_on_the_same_home_refuses_to_start() {
let home = Home::new("serve-twice", 900_000);
let _running = home.serve();
let second = home.run(&[]);
assert_eq!(second.status.code(), Some(1));
let text = stderr(&second);
assert!(text.contains("brokerd is already running"), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The first still has its sockets.
let frames = exchange(&home.admin(), 1, Message::Approvals(Empty {}));
assert!(matches!(frames[0].msg, Message::ApprovalList(_)));
}
#[test]
fn it_answers_each_socket_and_refuses_the_other_kinds() {
let home = Home::new("serve-kinds", 900_000);
home.grant("notes", "auto");
let running = home.serve();
let frames = exchange(&home.tools(), 7, read_notes(7));
assert_eq!(frames[0].id, 7);
// The production runtime runs nothing.
assert_eq!(
last_response(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
let wrong = exchange(&home.tools(), 8, Message::Approvals(Empty {}));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let wrong = exchange(&home.admin(), 9, read_notes(9));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let printed = running.stop();
assert!(
printed.contains("approvals on broker.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(
printed.contains("tool_request on admin.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(matches!(
home.events().as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
status: ResultStatus::Failed,
..
}
]
));
}
#[test]
fn an_approval_nobody_answers_expires() {
let home = Home::new("serve-expire", 100);
home.grant("notes", "ask");
let _running = home.serve();
let started = Instant::now();
let frames = exchange(&home.tools(), 2, read_notes(2));
assert_eq!(frames.len(), 2, "{frames:?}");
assert!(matches!(
&frames[0].msg,
Message::ToolResponse(ToolResponse::PendingApproval { approval: 0, .. })
));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::ApprovalExpired
}
);
// The expiry thread looks every second.
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
fn copy_case(home: &Home, case: &str, only: &[&str]) {
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::create_dir_all(home.path("audit")).unwrap();
for name in only {
std::fs::copy(
format!("{from}/{name}"),
home.path(&format!("audit/{name}")),
)
.unwrap();
}
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut all: Vec<(String, Vec<u8>)> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|e| {
(
e.file_name().into_string().unwrap(),
std::fs::read(e.path()).unwrap(),
)
})
.collect();
all.sort();
all
}
#[test]
fn a_broken_chain_stops_it_before_any_socket_and_nothing_is_written() {
let home = Home::new("serve-broken", 900_000);
copy_case(&home, "changed-byte", &["2026-09-17.jsonl"]);
let before = snapshot(&home.path("audit"));
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
let text = stderr(&output);
assert!(text.contains("2026-09-17.jsonl:4: "), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#audit-chain-broken"),
"{text}"
);
assert_eq!(snapshot(&home.path("audit")), before);
assert!(!home.tools().exists() && !home.admin().exists());
}
#[test]
fn a_torn_tail_is_recovered_and_it_serves() {
let home = Home::new("serve-torn", 900_000);
copy_case(
&home,
"torn-tail",
&["2026-09-17.jsonl", "2026-09-18.jsonl"],
);
let running = home.serve();
let printed = running.stop();
assert!(printed.contains(RECOVERED_NOTICE), "{printed}");
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
#[test]
fn accept_break_with_nothing_to_accept_exits_2() {
let home = Home::new("serve-nothing", 900_000);
let output = home.run(&["--accept-break"]);
assert_eq!(output.status.code(), Some(2));
assert!(
stderr(&output).contains("nothing to accept"),
"{}",
stderr(&output)
);
}
#[test]
fn bad_arguments_and_bad_configs_do_not_start() {
let brokerd = env!("CARGO_BIN_EXE_brokerd");
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["serve", "--config", "a", "--config", "b"][..],
&["serve", "--config", "a", "--loud"][..],
&["run", "--config", "a"][..],
] {
let output = Command::new(brokerd).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}");
assert!(
stderr(&output).starts_with("usage: brokerd serve"),
"{args:?}"
);
}
let home = Home::new("serve-config", 900_000);
std::fs::write(&home.config, "[paths]\nhoem = \"/x\"\n").unwrap();
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("brokerd.toml"),
"{}",
stderr(&output)
);
std::fs::remove_file(&home.config).unwrap();
assert_eq!(home.run(&[]).status.code(), Some(1));
assert!(
!home.path("audit").exists(),
"nothing made before the config is read"
);
}
@@ -0,0 +1,281 @@
//! Tests for the session state files. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::policy::{Label, SessionState};
use brokerd::state::{RUNBOOK, StateError, StateStore};
use proto::{DataClass, SessionId};
use std::os::unix::fs::PermissionsExt;
use tmp::TempDir;
fn id(text: &str) -> SessionId {
SessionId::new(text).unwrap()
}
fn label(class: DataClass, untrusted: bool) -> Label {
Label { class, untrusted }
}
fn state(taint: DataClass, untrusted: bool) -> SessionState {
SessionState { taint, untrusted }
}
/// The store's directory is two levels below the temporary one and does not exist yet, as on a
/// fresh install.
fn store(home: &TempDir) -> StateStore {
StateStore::new(&home.path().join("broker/sessions"))
}
#[test]
fn a_session_with_no_file_is_private_and_trusted() {
let home = TempDir::new("state");
let store = store(&home);
let fresh = store.read(&id("chat-1")).unwrap();
assert_eq!(fresh, state(DataClass::Private, false));
assert_eq!(fresh, SessionState::default());
// Reading creates nothing.
assert!(!home.path().join("broker").exists());
}
#[test]
fn the_first_result_creates_the_file_and_its_directory() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("chat-1");
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, false),
)
.unwrap();
assert_eq!(next, state(DataClass::Private, false));
let path = home.path().join("broker/sessions/chat-1.json");
assert_eq!(store.path(&session), path);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\"taint\":\"private\",\"untrusted\":false}\n"
);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(&home.path().join("broker/sessions")), 0o700);
assert_eq!(mode(&home.path().join("broker")), 0o700);
// No temporary file is left behind.
assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists());
assert_eq!(store.read(&session).unwrap(), next);
}
#[test]
fn taint_and_the_untrusted_flag_only_go_up() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let steps = [
(
label(DataClass::Public, false),
state(DataClass::Private, false),
),
(
label(DataClass::Private, true),
state(DataClass::Private, true),
),
(
label(DataClass::Secret, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Public, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Private, false),
state(DataClass::Secret, true),
),
];
let mut current = store.read(&session).unwrap();
for (result, want) in steps {
current = store.raise(&session, current, result).unwrap();
assert_eq!(current, want);
assert_eq!(store.read(&session).unwrap(), want, "what is on disk");
}
assert_eq!(
std::fs::read_to_string(store.path(&session)).unwrap(),
"{\"taint\":\"secret\",\"untrusted\":true}\n"
);
}
#[test]
fn sessions_do_not_share_state() {
let home = TempDir::new("state");
let store = store(&home);
store
.raise(
&id("a"),
SessionState::default(),
label(DataClass::Secret, true),
)
.unwrap();
assert_eq!(store.read(&id("b")).unwrap(), SessionState::default());
assert_eq!(
store.read(&id("a")).unwrap(),
state(DataClass::Secret, true)
);
}
/// A file that exists but does not hold a valid state is an error, never "no file".
#[test]
fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() {
let home = TempDir::new("state");
let store = store(&home);
std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap();
let session = id("hurt");
for text in [
"",
"{",
"null",
"[]",
"{\"taint\":\"secret\"}",
"{\"untrusted\":false}",
"{\"taint\":\"internal\",\"untrusted\":false}",
"{\"taint\":\"secret\",\"untrusted\":\"no\"}",
"{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}",
"{\"taint\":\"secret\",\"untrusted\":false} trailing",
// A session is never below private, so this file was not written by brokerd.
"{\"taint\":\"public\",\"untrusted\":false}",
] {
std::fs::write(store.path(&session), text).unwrap();
let err = store.read(&session).expect_err(text);
assert!(
matches!(err, StateError::Unreadable(..)),
"{text:?}: {err:?}"
);
let shown = err.to_string();
assert!(shown.contains("hurt.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
}
assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged");
// A good file with or without its final newline reads fine.
for text in [
"{\"taint\":\"secret\",\"untrusted\":true}\n",
"{\"taint\":\"secret\",\"untrusted\":true}",
] {
std::fs::write(store.path(&session), text).unwrap();
assert_eq!(
store.read(&session).unwrap(),
state(DataClass::Secret, true)
);
}
// Not valid UTF-8, and a directory where the file should be.
std::fs::write(store.path(&session), b"\xff\xfe").unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::remove_file(store.path(&session)).unwrap();
std::fs::create_dir(store.path(&session)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
}
#[test]
fn a_file_without_read_permission_is_an_error_not_a_fresh_session() {
if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("locked");
store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
let path = store.path(&session);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn a_failed_write_is_an_error_and_leaves_the_old_state() {
if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let before = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, true),
)
.unwrap();
let dir = home.path().join("broker/sessions");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let err = store
.raise(&session, before, label(DataClass::Secret, false))
.expect_err("the directory is read-only");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(err, StateError::Write(..)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("s.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
assert_eq!(store.read(&session).unwrap(), before);
}
/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does
/// not stop the next write, and the next write replaces it.
#[test]
fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let dir = home.path().join("broker/sessions");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap();
assert_eq!(store.read(&session).unwrap(), SessionState::default());
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
assert_eq!(store.read(&session).unwrap(), next);
assert!(!dir.join("s.json.tmp").exists());
}
#[test]
fn raise_trusts_the_state_it_is_given_not_the_file() {
// The caller read the state under the ledger lock a moment ago; `raise` does not read again.
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let given = state(DataClass::Secret, true);
let next = store
.raise(&session, given, label(DataClass::Public, false))
.unwrap();
assert_eq!(next, given);
// Even a state below private is lifted to private on the way to disk.
let low = store
.raise(
&id("low"),
state(DataClass::Public, false),
label(DataClass::Public, false),
)
.unwrap();
assert_eq!(low, state(DataClass::Private, false));
}
@@ -0,0 +1,98 @@
//! Temporary audit directories for the audit tests. Do not edit.
#![allow(dead_code)] // each test file uses its own part of this
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use proto::{AuditEvent, CallId, DataClass, DecisionRecord, SessionId, Timestamp};
pub const D1: &str = "2026-09-17.jsonl";
pub const D2: &str = "2026-09-18.jsonl";
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A directory under the system's temporary directory, removed when dropped.
pub struct TempDir {
pub path: PathBuf,
}
impl TempDir {
/// A path that does not exist yet.
pub fn unmade(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("brokerd-{tag}-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
TempDir { path }
}
/// A copy of the fixture log `case` from `crates/proto/tests/fixtures/audit/`. With `only`,
/// just those files: damage in an older file is not seen by an ordinary start, so tests of
/// the startup check copy the damaged file alone.
pub fn case(case: &str, only: Option<&[&str]>) -> TempDir {
let dir = TempDir::unmade(case);
std::fs::create_dir_all(&dir.path).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
let mut copied = 0;
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
let name = entry.file_name().into_string().unwrap();
if only.is_none_or(|names| names.contains(&name.as_str())) {
std::fs::copy(entry.path(), dir.path.join(&name)).unwrap();
copied += 1;
}
}
assert!(copied > 0, "{from}: nothing copied");
dir
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// Every log file in `dir` with its bytes.
pub fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap())
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|entry| {
let name = entry.file_name().into_string().unwrap();
(name, std::fs::read(entry.path()).unwrap())
})
.collect()
}
pub fn lines(dir: &Path, file: &str) -> Vec<String> {
let text = std::fs::read_to_string(dir.join(file)).unwrap();
text.lines().map(str::to_string).collect()
}
pub fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
/// A denied decision for call `call`: an event that leaves nothing open in the report.
pub fn denied(call: u64) -> AuditEvent {
AuditEvent::Decision {
session: SessionId::new("chat-1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
outcome: DecisionRecord::Denied {
reason: proto::DenyReason::NoGrant,
},
grant: None,
grant_sha256: None,
taint: DataClass::Private,
untrusted: false,
}
}
@@ -0,0 +1,156 @@
//! Builders for grants and requests, for the policy tests. Do not edit.
//!
//! Included with `#[path = "support/build.rs"] mod build;`.
#![allow(dead_code)] // each test file uses a different part of this module
use brokerd::grants::{GrantSet, LoadedGrant};
use brokerd::policy::{Ask, Decision, Denial, Outcome, SessionState};
use proto::{
CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest,
};
/// The moment every policy test decides at.
pub const NOW: &str = "2026-09-18T12:00:00.000Z";
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub fn now() -> Timestamp {
ts(NOW)
}
pub struct Build(LoadedGrant);
/// A grant with the widest settings: it applies at every taint, never expires, and labels its
/// results `private` and untrusted. Each test narrows what it is about.
pub fn grant(id: &str, tool: &str, mode: Mode) -> Build {
Build(LoadedGrant {
id: id.to_string(),
grant: Grant {
tool: tool.to_string(),
mode,
max_taint: DataClass::Secret,
result_class: DataClass::Private,
untrusted: true,
expires: None,
secret: None,
constraints: Constraints::default(),
},
// Stands in for the file's hash, and differs from grant to grant.
sha256: proto::sha256(id.as_bytes()).unwrap(),
})
}
impl Build {
pub fn paths(mut self, paths: &[&str]) -> Build {
self.0.grant.constraints.paths = paths.iter().map(|p| p.to_string()).collect();
self
}
pub fn hosts(mut self, hosts: &[&str]) -> Build {
self.0.grant.constraints.hosts = hosts.iter().map(|h| h.to_string()).collect();
self
}
pub fn max_taint(mut self, class: DataClass) -> Build {
self.0.grant.max_taint = class;
self
}
pub fn class(mut self, class: DataClass) -> Build {
self.0.grant.result_class = class;
self
}
pub fn trusted(mut self) -> Build {
self.0.grant.untrusted = false;
self
}
pub fn expires(mut self, at: &str) -> Build {
self.0.grant.expires = Some(ts(at));
self
}
pub fn done(self) -> LoadedGrant {
self.0
}
}
pub fn set(grants: Vec<Build>) -> GrantSet {
GrantSet::from_grants(grants.into_iter().map(Build::done).collect())
.unwrap_or_else(|problems| panic!("the test's grants are not valid: {problems:?}"))
}
pub fn request(tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
pub fn read(path: &str) -> ToolRequest {
request("read_file", &format!(r#"{{"path":"{path}"}}"#))
}
pub fn write(path: &str) -> ToolRequest {
request(
"write_file",
&format!(r#"{{"path":"{path}","content":"x"}}"#),
)
}
pub fn shell(cwd: Option<&str>) -> ToolRequest {
match cwd {
Some(cwd) => request("shell", &format!(r#"{{"command":"ls","cwd":"{cwd}"}}"#)),
None => request("shell", r#"{"command":"ls"}"#),
}
}
pub fn fetch(url: &str) -> ToolRequest {
request("http_fetch", &format!(r#"{{"url":"{url}"}}"#))
}
pub fn at(taint: DataClass) -> SessionState {
SessionState {
taint,
untrusted: false,
}
}
pub fn private() -> SessionState {
at(DataClass::Private)
}
pub fn secret() -> SessionState {
at(DataClass::Secret)
}
pub fn allowed(outcome: Outcome) -> Decision {
match outcome {
Outcome::Allowed(decision) => decision,
other => panic!("expected allowed, got {other:?}"),
}
}
pub fn asked(outcome: Outcome) -> Ask {
match outcome {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
}
}
pub fn denied(outcome: Outcome) -> Denial {
match outcome {
Outcome::Denied(denial) => denial,
other => panic!("expected denied, got {other:?}"),
}
}
/// The reason of a denial. Only `denied_by_grant` may name a grant.
pub fn reason(outcome: Outcome) -> DenyReason {
let denial = denied(outcome);
if denial.reason != DenyReason::DeniedByGrant {
assert_eq!(denial.grant, None, "only denied_by_grant names a grant");
assert_eq!(denial.grant_sha256, None);
}
denial.reason
}
@@ -0,0 +1,77 @@
//! A client for `broker::handle` and `admin::handle` over a socket pair, and a `Broker` built on
//! a `Rig`. Do not edit.
//!
//! Included with `#[path = "support/client.rs"] mod client;`, beside `rig`, `runtime`, `sink`
//! and `tmp`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use brokerd::broker::{self, Broker};
use proto::{Envelope, Message, PROTOCOL_VERSION, ToolRequest};
use crate::rig::Rig;
use crate::runtime::{Recording, Shared};
pub trait Serve {
/// A `Broker` on this rig's home, with `runtime` and the rig's flaky sink and log.
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker>;
}
impl Serve for Rig {
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker> {
let runtime = Box::new(Shared(Arc::clone(runtime)));
Arc::new(Broker::new(
self.cfg.clone(),
self.ledger(),
runtime,
self.lines.sink(),
))
}
}
/// A connection to `handler` running on its own thread, with `msg` already sent under `id`.
pub fn open(
broker: &Arc<Broker>,
handler: fn(UnixStream, &Broker),
id: u64,
msg: Message,
) -> UnixStream {
let (mut client, server) = UnixStream::pair().unwrap();
let broker = Arc::clone(broker);
std::thread::spawn(move || handler(server, &broker));
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut client, &env).unwrap();
client
}
/// The next frame, waiting at most ten seconds.
pub fn next(stream: &mut UnixStream) -> Envelope {
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
proto::read_frame(stream).unwrap()
}
/// Sends a tool request to `broker::handle` and reads every frame up to the final one.
pub fn call(broker: &Arc<Broker>, req: ToolRequest) -> Vec<Envelope> {
let id = req.call.0;
let mut stream = open(broker, broker::handle, id, Message::ToolRequest(req));
let mut frames = Vec::new();
loop {
let env = next(&mut stream);
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
@@ -0,0 +1,388 @@
//! The generator and the oracle of the policy property test. Do not edit.
//!
//! Included with `#[path = "support/oracle.rs"] mod oracle;`.
//!
//! The oracle is written to be obviously right, not fast or short. It shares no code with
//! `brokerd`: it splits paths and host names into their parts itself and compares the parts.
//! If the property test fails, the oracle is the specification and `policy` is wrong.
#![allow(dead_code)] // the property test does not use every helper in every build
use brokerd::grants::LoadedGrant;
use brokerd::policy::SessionState;
use proto::{
CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest,
};
/// The moment every case is decided at: 2026-09-18T12:00:00.000Z.
pub const NOW_MS: u64 = 1_789_732_800_000;
// ---------------------------------------------------------------------------------------------
// The generator.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
// xorshift must not start at zero.
Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
}
pub fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
pub fn pick<T: Copy>(&mut self, items: &[T]) -> T {
items[self.below(items.len())]
}
pub fn chance(&mut self, percent: u64) -> bool {
self.next() % 100 < percent
}
}
pub const TOOLS: [&str; 4] = ["read_file", "write_file", "shell", "http_fetch"];
pub const CLASSES: [DataClass; 3] = [DataClass::Public, DataClass::Private, DataClass::Secret];
pub const MODES: [Mode; 3] = [Mode::Auto, Mode::Ask, Mode::Deny];
pub const HOSTS: [&str; 6] = [
"example.com",
"www.example.com",
"a.b.example.com",
"other.org",
"www.other.org",
"badexample.com",
];
pub const PATTERNS: [&str; 6] = [
"example.com",
"*.example.com",
"www.example.com",
"*.b.example.com",
"other.org",
"*.other.org",
];
/// A path of one to four components over a tiny alphabet, so that grants and requests overlap
/// often: `/a`, `/a/b`, `/ab/a/c` and so on. `ab` is there to catch prefix matching by bytes.
pub fn path(rng: &mut Rng) -> String {
let depth = 1 + rng.below(4);
let mut text = String::new();
for _ in 0..depth {
text.push('/');
text.push_str(rng.pick(&["a", "b", "c", "ab"]));
}
text
}
pub fn some_grant(rng: &mut Rng, id: usize) -> LoadedGrant {
let tool = rng.pick(&TOOLS);
let mode = rng.pick(&MODES);
let mut constraints = Constraints::default();
match tool {
"http_fetch" => {
for _ in 0..1 + rng.below(2) {
constraints.hosts.push(rng.pick(&PATTERNS).to_string());
}
}
"shell" if rng.chance(40) => {}
_ => {
for _ in 0..1 + rng.below(3) {
constraints.paths.push(path(rng));
}
}
}
// Expiry around the moment of decision: before it, exactly at it, after it, or never.
let expires = match rng.below(5) {
0 => Some(NOW_MS - 1),
1 => Some(NOW_MS),
2 => Some(NOW_MS + 1),
_ => None,
};
LoadedGrant {
id: format!("g{id:02}"),
grant: Grant {
tool: tool.to_string(),
mode,
// A deny grant must apply at every taint, or the set is invalid.
max_taint: if mode == Mode::Deny {
DataClass::Secret
} else {
rng.pick(&CLASSES)
},
result_class: rng.pick(&CLASSES),
untrusted: rng.chance(50),
expires: expires.map(|ms| Timestamp::from_unix_millis(ms).unwrap()),
secret: None,
constraints,
},
sha256: proto::sha256(format!("file {id}").as_bytes()).unwrap(),
}
}
/// Zero to seven grants, with ids handed out in a scrambled order so that the order of the list
/// says nothing about the order of the ids.
pub fn some_grants(rng: &mut Rng) -> Vec<LoadedGrant> {
let count = rng.below(8);
let mut ids: Vec<usize> = (0..count).collect();
for i in (1..ids.len()).rev() {
ids.swap(i, rng.below(i + 1));
}
ids.into_iter().map(|id| some_grant(rng, id)).collect()
}
pub fn some_request(rng: &mut Rng) -> ToolRequest {
let (tool, arguments) = match rng.below(20) {
0 => ("echo".to_string(), "{}".to_string()),
1 => ("read_file".to_string(), r#"{"path":"a/b"}"#.to_string()),
2 => (
"shell".to_string(),
r#"{"command":"ls","cwd":"/a/../b"}"#.to_string(),
),
3 => (
"http_fetch".to_string(),
r#"{"url":"http://example.com/"}"#.to_string(),
),
4 => ("write_file".to_string(), r#"{"path":"/a/b"}"#.to_string()),
_ => match rng.pick(&TOOLS) {
"read_file" => (
"read_file".to_string(),
format!(r#"{{"path":"{}"}}"#, path(rng)),
),
"write_file" => (
"write_file".to_string(),
format!(r#"{{"path":"{}","content":"x"}}"#, path(rng)),
),
"shell" if rng.chance(40) => ("shell".to_string(), r#"{"command":"ls"}"#.to_string()),
"shell" => (
"shell".to_string(),
format!(r#"{{"command":"ls","cwd":"{}"}}"#, path(rng)),
),
_ => (
"http_fetch".to_string(),
format!(r#"{{"url":"https://{}/x"}}"#, rng.pick(&HOSTS)),
),
},
};
ToolRequest {
session: SessionId::new("prop").unwrap(),
call: CallId(1),
tool,
arguments,
}
}
// ---------------------------------------------------------------------------------------------
// The oracle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expected {
Allowed {
grant: String,
path: Option<String>,
class: DataClass,
untrusted: bool,
},
Ask {
grant: String,
path: Option<String>,
class: DataClass,
untrusted: bool,
},
Denied {
reason: DenyReason,
grant: Option<String>,
},
}
pub fn parts(path: &str) -> Vec<&str> {
path.split('/').filter(|part| !part.is_empty()).collect()
}
/// `inner` is `outer` or lies under it: `outer`'s components are the first of `inner`'s.
pub fn under(outer: &str, inner: &str) -> bool {
let (outer, inner) = (parts(outer), parts(inner));
outer.len() <= inner.len() && outer.iter().zip(&inner).all(|(a, b)| a == b)
}
pub fn host_fits(pattern: &str, host: &str) -> bool {
let host: Vec<&str> = host.split('.').collect();
match pattern.strip_prefix("*.") {
None => pattern.split('.').collect::<Vec<_>>() == host,
Some(base) => {
let base: Vec<&str> = base.split('.').collect();
host.len() > base.len() && host[host.len() - base.len()..] == base[..]
}
}
}
/// What the oracle needs from a request: `None` if the broker must refuse it before matching.
pub enum Call {
UnknownTool,
Invalid,
Read(String),
Write(String),
Shell(Option<String>),
Fetch(String),
}
pub fn understand(request: &ToolRequest) -> Call {
if !TOOLS.contains(&request.tool.as_str()) {
return Call::UnknownTool;
}
// The generator only ever writes the five invalid forms below.
let text = request.arguments.as_str();
let invalid = text.contains("\"a/b\"")
|| text.contains("..")
|| text.contains("http://")
|| (request.tool == "write_file" && !text.contains("content"));
if invalid {
return Call::Invalid;
}
let value: serde_json::Value = serde_json::from_str(text).unwrap();
let field = |name: &str| value.get(name).and_then(|v| v.as_str()).map(str::to_string);
match request.tool.as_str() {
"read_file" => Call::Read(field("path").unwrap()),
"write_file" => Call::Write(field("path").unwrap()),
"shell" => Call::Shell(field("cwd")),
_ => {
let url = field("url").unwrap();
let host = url
.trim_start_matches("https://")
.split('/')
.next()
.unwrap();
Call::Fetch(host.to_string())
}
}
}
/// Whether the grant covers the call, and with which of its paths (the longest that holds it).
pub fn coverage(grant: &Grant, call: &Call) -> Option<Option<String>> {
let holding = |path: &str, itself_counts: bool| -> Option<Option<String>> {
let mut best: Option<&String> = None;
for candidate in &grant.constraints.paths {
if !under(candidate, path) || (!itself_counts && parts(candidate) == parts(path)) {
continue;
}
if best.is_none_or(|b| candidate.len() > b.len()) {
best = Some(candidate);
}
}
best.map(|b| Some(b.clone()))
};
match call {
Call::Read(path) => holding(path, true),
Call::Write(path) => holding(path, false),
Call::Shell(None) if grant.constraints.paths.is_empty() => Some(None),
Call::Shell(None) => None,
Call::Shell(Some(cwd)) => holding(cwd, true),
Call::Fetch(host) => grant
.constraints
.hosts
.iter()
.any(|pattern| host_fits(pattern, host))
.then_some(None),
Call::UnknownTool | Call::Invalid => None,
}
}
pub fn oracle(request: &ToolRequest, grants: &[LoadedGrant], state: SessionState) -> Expected {
let denied = |reason| Expected::Denied {
reason,
grant: None,
};
let call = understand(request);
match call {
Call::UnknownTool => return denied(DenyReason::NoGrant),
Call::Invalid => return denied(DenyReason::InvalidArguments),
_ => {}
}
struct Left<'a> {
id: &'a str,
mode: Mode,
path: Option<String>,
class: DataClass,
untrusted: bool,
}
let mut left: Vec<Left> = Vec::new();
let (mut would_match_but_expired, mut would_match_but_tainted) = (false, false);
for loaded in grants {
let g = &loaded.grant;
if g.tool != request.tool {
continue;
}
let Some(path) = coverage(g, &call) else {
continue;
};
let expired = g.expires.is_some_and(|at| at.unix_millis() <= NOW_MS);
let tainted = state.taint > g.max_taint;
if expired && !tainted {
would_match_but_expired = true;
}
if tainted && !expired {
would_match_but_tainted = true;
}
if !expired && !tainted {
left.push(Left {
id: &loaded.id,
mode: g.mode,
path,
class: g.result_class,
untrusted: g.untrusted,
});
}
}
if left.is_empty() {
return if would_match_but_expired {
denied(DenyReason::GrantExpired)
} else if would_match_but_tainted {
denied(DenyReason::TaintTooHigh)
} else {
denied(DenyReason::NoGrant)
};
}
let class = left.iter().map(|l| l.class).max().unwrap();
let untrusted = left.iter().any(|l| l.untrusted);
// The winner: try each mode from the most restrictive; within it the longest path, then
// the lowest id.
for mode in [Mode::Deny, Mode::Ask, Mode::Auto] {
let mut of_mode: Vec<&Left> = left.iter().filter(|l| l.mode == mode).collect();
if of_mode.is_empty() {
continue;
}
of_mode.sort_by(|a, b| {
let (la, lb) = (
a.path.as_ref().map_or(0, String::len),
b.path.as_ref().map_or(0, String::len),
);
lb.cmp(&la).then(a.id.cmp(b.id))
});
let winner = of_mode[0];
let (grant, path) = (winner.id.to_string(), winner.path.clone());
return match mode {
Mode::Deny => Expected::Denied {
reason: DenyReason::DeniedByGrant,
grant: Some(grant),
},
Mode::Ask => Expected::Ask {
grant,
path,
class,
untrusted,
},
Mode::Auto => Expected::Allowed {
grant,
path,
class,
untrusted,
},
};
}
unreachable!("left is not empty, so one of the three modes has a grant")
}
@@ -0,0 +1,118 @@
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
//! a log to read. Do not edit.
//!
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
//! tests add `client`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use brokerd::audit::Writer;
use brokerd::config::{Approvals, Config, Paths, Sockets};
use brokerd::ledger::Ledger;
use brokerd::state::StateStore;
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
use crate::sink::{Flaky, Lines, Switch};
use crate::tmp::TempDir;
pub struct Rig {
pub dir: TempDir,
pub cfg: Config,
pub switch: Switch,
pub lines: Lines,
}
impl Rig {
pub fn new(tag: &str) -> Rig {
Rig::with_ttl(tag, 900_000)
}
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
let dir = TempDir::new(tag);
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
let cfg = Config {
paths: Paths {
home: dir.path().to_path_buf(),
grants,
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
};
Rig {
dir,
cfg,
switch: Switch::default(),
lines: Lines::default(),
}
}
pub fn state(&self) -> StateStore {
StateStore::new(&self.cfg.state_dir())
}
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
pub fn ledger(&self) -> Ledger {
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
let sink = Flaky {
writer: opened.writer,
switch: self.switch.clone(),
};
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
}
/// Writes `grants/<id>.toml`.
pub fn grant(&self, id: &str, text: &str) {
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
}
pub fn remove_grant(&self, id: &str) {
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
}
pub fn state_file(&self, session: &str) -> PathBuf {
self.cfg.state_dir().join(format!("{session}.json"))
}
/// Every record in the audit log, in order.
pub fn records(&self) -> Vec<AuditRecord> {
let dir = self.cfg.audit_dir();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let text = std::fs::read_to_string(dir.join(name)).unwrap();
for line in text.lines() {
out.push(serde_json::from_str(line).unwrap());
}
}
out
}
pub fn events(&self) -> Vec<AuditEvent> {
self.records().into_iter().map(|r| r.event).collect()
}
}
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
format!(
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
)
}
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new(session).unwrap(),
call: CallId(call),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
@@ -0,0 +1,70 @@
//! A runtime that records what it is asked to run, for the runner and broker tests. Do not edit.
//!
//! Included with `#[path = "support/runtime.rs"] mod runtime;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::{Arc, Mutex};
use brokerd::args::{ToolArgs, ToolName};
use brokerd::runner::{Mount, RunError, RunOutput, RunSpec, Runtime};
/// What one `run` was given, copied out of the `RunSpec`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seen {
pub tool: ToolName,
pub arguments: ToolArgs,
pub mounts: Vec<Mount>,
pub egress: Option<Vec<String>>,
}
pub struct Recording {
seen: Mutex<Vec<Seen>>,
answer: Result<RunOutput, RunError>,
}
impl Recording {
/// Answers every call with `content`, not truncated.
pub fn answering(content: &str) -> Arc<Recording> {
Recording::with(Ok(RunOutput {
content: content.to_string(),
truncated: false,
}))
}
pub fn with(answer: Result<RunOutput, RunError>) -> Arc<Recording> {
Arc::new(Recording {
seen: Mutex::new(Vec::new()),
answer,
})
}
pub fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
pub fn count(&self) -> usize {
self.seen.lock().unwrap().len()
}
}
impl Runtime for Recording {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.seen.lock().unwrap().push(Seen {
tool: spec.tool(),
arguments: spec.arguments().clone(),
mounts: spec.mounts().to_vec(),
egress: spec.egress().map(<[String]>::to_vec),
});
self.answer.clone()
}
}
/// Lets a test keep its `Arc<Recording>` while the broker owns a `Box<dyn Runtime>`.
pub struct Shared(pub Arc<Recording>);
impl Runtime for Shared {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.0.run(spec)
}
}
@@ -0,0 +1,78 @@
//! An audit sink that fails on demand, and a log that tests can read. Do not edit.
//!
//! Included with `#[path = "support/sink.rs"] mod sink;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use brokerd::audit::{AuditError, Writer};
use brokerd::ledger::AuditSink;
use proto::{AuditEvent, Timestamp};
/// Switches shared between a test and its `Flaky` sink.
#[derive(Clone, Default)]
pub struct Switch {
fail: Arc<AtomicBool>,
panic: Arc<AtomicBool>,
attempts: Arc<AtomicUsize>,
}
impl Switch {
/// Every append from now on fails, without writing anything.
pub fn fail(&self, on: bool) {
self.fail.store(on, Ordering::SeqCst);
}
/// The next append panics, as a bug part-way through a write would.
pub fn panic_next(&self) {
self.panic.store(true, Ordering::SeqCst);
}
/// How many appends the ledger has asked for.
pub fn attempts(&self) -> usize {
self.attempts.load(Ordering::SeqCst)
}
}
/// A real `Writer` behind a switch.
pub struct Flaky {
pub writer: Writer,
pub switch: Switch,
}
impl AuditSink for Flaky {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
self.switch.attempts.fetch_add(1, Ordering::SeqCst);
if self.switch.panic.swap(false, Ordering::SeqCst) {
panic!("a bug part-way through a write");
}
if self.switch.fail.load(Ordering::SeqCst) {
return Err(AuditError::Io {
what: "cannot write to the test log".to_string(),
source: std::io::Error::other("the disk is full"),
});
}
self.writer.append(time, event)
}
}
/// Collects every line a ledger or broker prints.
#[derive(Clone, Default)]
pub struct Lines(Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Box<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Box::new(move |line| lines.lock().unwrap().push(line.to_string()))
}
pub fn all(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
/// The lines that hold `text`.
pub fn with(&self, text: &str) -> Vec<String> {
self.all()
.into_iter()
.filter(|l| l.contains(text))
.collect()
}
}
@@ -0,0 +1,57 @@
//! Temporary directories for tests. Do not edit.
//!
//! Included with `#[path = "support/tmp.rs"] mod tmp;`. No crate is used: the name is made from
//! the process id and a counter, and the directory is removed when the value is dropped.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// Writes `text` to `name` inside the directory and returns the full path.
pub fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
// Put back the permissions a test may have taken away, or the removal fails.
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// True when the tests run as root, for whom file permissions do not apply. Tests that depend on
/// a permission error print why they are skipped and return.
pub fn running_as_root(test: &str) -> bool {
let probe = TempDir::new("rootprobe");
let file = probe.write("probe", "x");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
let root = std::fs::read(&file).is_ok();
if root {
eprintln!("{test}: skipped, because this user can read a mode 000 file (root?)");
}
root
}
@@ -0,0 +1,497 @@
//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`.
//! Do not edit.
mod support;
use bxctl::admin::{
AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name,
request, write_block,
};
use proto::{
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport,
Message, PROTOCOL_VERSION, Refuse,
};
use std::process::Command;
use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error};
const BACKSLASH: char = '\\';
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn text(out: Vec<u8>) -> String {
String::from_utf8(out).unwrap()
}
const ALLOWED: DecisionRecord = DecisionRecord::Allowed {};
// ---- the client ----
#[test]
fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() {
let fake = fake_brokerd_frames(|request| {
assert_eq!(request.v, PROTOCOL_VERSION);
assert_eq!(request.id, 1);
assert!(request.r#final);
vec![Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap();
assert_eq!(answer, Message::Ok(Empty {}));
assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]);
}
#[test]
fn an_error_frame_is_refused_with_its_code_and_detail() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket"));
match request(&fake.socket, Message::Approvals(Empty {})) {
Err(AdminError::Refused(w)) => {
assert_eq!(w.code, ErrorCode::Forbidden);
assert_eq!(w.detail, "not on this socket");
}
other => panic!("{other:?}"),
}
let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err();
assert_eq!(e.to_string(), "forbidden: not on this socket");
}
#[test]
fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() {
let not_final = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: false,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&not_final.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
let other_id = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id + 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&other_id.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
}
#[test]
fn a_connection_closed_without_an_answer_is_a_frame_error() {
let fake = fake_brokerd_frames(|_| Vec::new());
assert!(matches!(
request(&fake.socket, Message::Approvals(Empty {})),
Err(AdminError::Frame(_))
));
}
#[test]
fn no_brokerd_is_a_connect_error_that_names_the_socket() {
let missing = support::temp_socket("nobody-listens.sock");
let e = request(&missing, Message::Approvals(Empty {})).unwrap_err();
assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}");
let message = e.to_string();
assert!(message.starts_with("cannot reach brokerd at "), "{message}");
assert!(message.contains(missing.to_str().unwrap()), "{message}");
}
#[test]
fn list_returns_the_items_and_rejects_any_other_kind() {
let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")];
let fake = brokerd_with(items.clone(), ALLOWED);
assert_eq!(list(&fake.socket).unwrap(), items);
assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]);
let wrong = fake_brokerd(|_| Message::Ok(Empty {}));
assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_))));
}
#[test]
fn reason_names_are_the_wire_names() {
for reason in [
DenyReason::NoGrant,
DenyReason::GrantExpired,
DenyReason::TaintTooHigh,
DenyReason::DeniedByGrant,
DenyReason::ApprovalRefused,
DenyReason::ApprovalExpired,
DenyReason::GrantsInvalid,
DenyReason::AuditUnavailable,
DenyReason::InvalidArguments,
DenyReason::StateUnreadable,
] {
let wire = serde_json::to_string(&reason).unwrap();
assert_eq!(format!("\"{}\"", reason_name(reason)), wire);
}
}
// ---- the block ----
fn block(item: &proto::PendingApproval, now: &str) -> String {
let mut out = Vec::new();
write_block(&mut out, item, ts(now)).unwrap();
text(out)
}
#[test]
fn the_block_is_two_lines_in_this_exact_form() {
let item = pending(
41,
"shell",
r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#,
);
assert_eq!(
block(&item, "2026-09-18T12:02:00.000Z"),
concat!(
"41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n",
" shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n",
)
);
}
#[test]
fn times_are_whole_seconds_minutes_or_hours_rounded_down() {
let item = pending(41, "shell", "{}");
let first = |now: &str| block(&item, now).lines().next().unwrap().to_string();
// created 12:00:00, expires 12:15:00
assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min "));
assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min "));
assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min "));
assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s "));
// At or after `expires` there is nothing left to wait for.
assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired "));
assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired "));
// A clock that is behind the broker's must not underflow.
assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min "));
let mut long = pending(41, "shell", "{}");
long.expires = ts("2026-09-18T15:30:00.000Z");
assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h "));
}
#[test]
fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() {
let mut item = pending(41, "shell", "{}");
for (id, shown) in [
("s1", "session s1 "),
("abcdefghij", "session abcdefghij "),
("abcdefghijk", "session abcdefghi… "),
] {
item.session = proto::SessionId::new(id).unwrap();
let got = block(&item, "2026-09-18T12:00:00.000Z");
assert!(got.contains(shown), "{id}: {got}");
}
}
#[test]
fn taint_is_the_wire_name() {
let mut item = pending(41, "shell", "{}");
item.taint = proto::DataClass::Secret;
assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n"));
}
/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through
/// `escape_json_text`.
#[test]
fn nothing_in_the_block_reaches_the_terminal_raw() {
let rlo = char::from_u32(0x202e).unwrap();
let zwsp = char::from_u32(0x200b).unwrap();
let isolate = char::from_u32(0x2066).unwrap();
let arguments =
format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}");
let mut item = pending(41, "read\x1b[1mfile", &arguments);
item.grant = "notes\nread".to_string();
let got = block(&item, "2026-09-18T12:00:00.000Z");
for c in ['\x1b', rlo, zwsp, isolate] {
assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c));
}
assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}");
assert!(
got.contains(&format!(
"/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J",
esc(0x202e),
esc(0x200b),
esc(0x2066),
esc(0x1b),
esc(0x1b)
)),
"{got:?}"
);
assert!(
got.contains(&format!(" read{}[1mfile ", esc(0x1b))),
"{got:?}"
);
assert!(
got.contains(&format!("grant notes{}read ", esc(0x0a))),
"{got:?}"
);
}
// ---- the commands ----
#[test]
fn approvals_prints_one_block_per_item_in_the_order_given() {
let fake = brokerd_with(
vec![
pending(41, "shell", r#"{"command":"ls"}"#),
pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#),
],
ALLOWED,
);
let mut out = Vec::new();
let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap();
assert!(ok);
let got = text(out);
let lines: Vec<&str> = got.lines().collect();
assert_eq!(lines.len(), 4, "{got}");
assert!(lines[0].starts_with("41 2 min ago "), "{got}");
assert_eq!(lines[1], r#" shell {"command":"ls"}"#);
assert!(lines[2].starts_with("44 2 min ago "), "{got}");
assert_eq!(
lines[3],
r#" read_file {"path":"/home/kyle/notes/a.md"}"#
);
}
#[test]
fn approvals_with_nothing_pending_says_so() {
let fake = brokerd_with(Vec::new(), ALLOWED);
let mut out = Vec::new();
assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap());
assert_eq!(text(out), "no pending approvals\n");
}
#[test]
fn approve_reports_the_re_decision() {
// `ask` and `allowed` both let the call run; only a denial stops it.
for (outcome, line, ok) in [
(DecisionRecord::Allowed {}, "approved 41: runs\n", true),
(DecisionRecord::Ask {}, "approved 41: runs\n", true),
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant,
},
"approved 41: denied (no_grant)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::TaintTooHigh,
},
"approved 41: denied (taint_too_high)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::AuditUnavailable,
},
"approved 41: denied (audit_unavailable)\n",
false,
),
] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome);
let mut out = Vec::new();
assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok);
assert_eq!(text(out), line);
assert_eq!(
fake.requests(),
vec![Message::Approve(Approve { approval: 41 })]
);
}
}
#[test]
fn refuse_sends_the_reason_when_there_is_one() {
for reason in [None, Some("not on a Friday")] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let mut out = Vec::new();
assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap());
assert_eq!(text(out), "refused 41\n");
assert_eq!(
fake.requests(),
vec![Message::Refuse(Refuse {
approval: 41,
reason: reason.map(str::to_string)
})]
);
}
}
#[test]
fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let want = "99: no such approval (already answered or expired)\n";
let mut out = Vec::new();
assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap());
assert_eq!(text(out), want);
let mut out = Vec::new();
assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap());
assert_eq!(text(out), want);
}
/// Every exit of every command: any other error frame is an error, and so is an answer of the
/// wrong kind. Nothing is printed for them.
#[test]
fn other_errors_and_wrong_kinds_are_errors_for_every_command() {
let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let wrong = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {}));
let now = ts("2026-09-18T12:00:00.000Z");
let mut out = Vec::new();
assert!(matches!(
cmd_approvals(&refused.socket, now, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approve(&refused.socket, 41, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_refuse(&refused.socket, 41, None, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_grants_check(&refused.socket, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approvals(&wrong.socket, now, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_approve(&wrong.socket, 41, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_refuse(&wrong.socket, 41, None, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_grants_check(&wrong_for_grants.socket, &mut out),
Err(AdminError::Protocol(_))
));
assert_eq!(text(out), "", "an error prints nothing on the output");
}
#[test]
fn grants_check_prints_ok_or_every_problem() {
let fine = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let mut out = Vec::new();
assert!(cmd_grants_check(&fine.socket, &mut out).unwrap());
assert_eq!(text(out), "grants: ok\n");
assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]);
let broken = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: vec![
GrantProblem {
file: "notes-read.toml".to_string(),
line: Some(3),
problem: "unknown field `mod`".to_string(),
},
GrantProblem {
file: "Bad Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
GrantProblem {
file: "x.toml".to_string(),
line: Some(1),
problem: "two\nlines".to_string(),
},
],
})
});
let mut out = Vec::new();
assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap());
assert_eq!(
text(out),
format!(
"notes-read.toml:3: unknown field `mod`\n\
Bad Name.toml: the file name is not a valid grant id\n\
x.toml:1: two{}lines\n",
esc(0x0a)
),
"every problem, one line each; file and problem go through escape_json_text"
);
}
// ---- the binary ----
fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(args)
.arg("--admin-socket")
.arg(socket)
.output()
.unwrap()
}
#[test]
fn the_binary_prints_on_stdout_and_sets_the_exit_status() {
let fake = brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant,
},
);
let output = bxctl(&["approvals"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.ends_with(" shell {\"command\":\"ls\"}\n"),
"{stdout}"
);
let output = bxctl(&["approve", "41"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"approved 41: denied (denied_by_grant)\n"
);
let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n");
let output = bxctl(&["refuse", "7"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"7: no such approval (already answered or expired)\n"
);
}
#[test]
fn the_binary_reports_an_error_on_stderr_with_status_1() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let output = bxctl(&["grants", "check"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"bxctl: internal: boom\n"
);
}
@@ -0,0 +1,487 @@
//! Tests for approvals in `bxctl chat`, against a fake `loopd` and a fake `brokerd`. Do not edit.
//!
//! What the owner is shown comes from `brokerd`, never from `loopd`'s event, and only the
//! approval's id, typed in full, approves.
mod support;
use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, handle_pending, stream_turn};
use proto::{
Approve, DecisionRecord, Empty, ErrorCode, Message, Refuse, SessionId, Timestamp, TurnEvent,
};
use std::io::{BufRead, Cursor, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use support::{FakeBrokerd, brokerd_with, fake_brokerd, fake_loopd, pending, ts, wire_error};
const BACKSLASH: char = '\\';
const NOW: &str = "2026-09-18T12:02:00.000Z";
const PROMPT: &str = "type 41 to approve, anything else refuses: ";
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn approvals_request() -> Message {
Message::Approvals(Empty {})
}
fn approve_request() -> Message {
Message::Approve(Approve { approval: 41 })
}
fn refuse_request() -> Message {
Message::Refuse(Refuse {
approval: 41,
reason: None,
})
}
/// A `brokerd` with approval 41 pending, for `shell`.
fn broker() -> FakeBrokerd {
brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Allowed {},
)
}
/// Runs `handle_pending` for approval 41 with `typed` waiting on stdin. Returns what was
/// printed and what was left unread.
fn handle(socket: &Path, ask: bool, typed: &str) -> (String, String) {
let mut input = Cursor::new(typed.as_bytes().to_vec());
let mut out = Vec::new();
handle_pending(socket, 41, ask, ts(NOW), &mut input, &mut out).unwrap();
let mut left = String::new();
std::io::Read::read_to_string(&mut input, &mut left).unwrap();
(String::from_utf8(out).unwrap(), left)
}
// ---- handle_pending ----
#[test]
fn the_block_then_the_question_and_the_id_approves() {
let fake = broker();
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(
out,
format!(
"\x1b[0m41 2 min ago expires in 13 min session chat-1758… grant shell-scratch \
taint private\n shell {{\"command\":\"ls\"}}\n{PROMPT}approved 41: runs\n"
),
"attributes reset, the block, the question, the outcome"
);
assert_eq!(left, "");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_id_with_a_carriage_return_also_approves() {
let fake = broker();
handle(&fake.socket, true, "41\r\n");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
/// Everything that is not exactly the id refuses: `y`, a near miss, an empty line, the end of
/// the input.
#[test]
fn anything_else_refuses() {
for typed in [
"y\n",
"Y\n",
"yes\n",
"\n",
"",
" 41\n",
"41 \n",
"041\n",
"+41\n",
"4 1\n",
"42\n",
"approve 41\n",
"41",
] {
let fake = broker();
let (out, _) = handle(&fake.socket, true, typed);
if typed == "41" {
// The last line of the input need not end in a newline; it is still the id.
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
continue;
}
assert_eq!(
fake.requests(),
vec![approvals_request(), refuse_request()],
"{typed:?}"
);
assert!(
out.ends_with(&format!("{PROMPT}refused 41\n")),
"{typed:?}: {out:?}"
);
}
}
/// A line typed while the turn ran is already waiting when the question is asked. It is the
/// answer, it is not the id, so it refuses; and only that one line is read.
#[test]
fn a_line_already_waiting_refuses_and_only_one_line_is_read() {
let fake = broker();
let (_, left) = handle(&fake.socket, true, "are you still there?\n41\n");
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(left, "41\n", "the next line is left for the chat");
}
#[test]
fn an_id_that_is_not_listed_is_no_longer_pending_and_nothing_is_asked() {
let fake = brokerd_with(
vec![pending(40, "shell", "{}"), pending(42, "shell", "{}")],
DecisionRecord::Allowed {},
);
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(out, "approval 41 is no longer pending\n");
assert_eq!(left, "41\n", "nothing was read");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn without_ask_the_block_is_shown_and_nothing_is_asked_or_read() {
let fake = broker();
let (out, left) = handle(&fake.socket, false, "41\n");
assert!(out.starts_with("\x1b[0m41 2 min ago "), "{out:?}");
assert!(out.ends_with(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(left, "41\n");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn a_brokerd_that_cannot_be_reached_is_reported_and_nothing_is_asked() {
let missing = support::temp_socket("nobody-listens.sock");
let (out, left) = handle(&missing, true, "41\n");
assert!(
out.starts_with("approval 41: cannot ask brokerd: cannot reach brokerd at "),
"{out:?}"
);
assert!(out.ends_with('\n') && out.lines().count() == 1, "{out:?}");
assert_eq!(left, "41\n");
}
/// The approval can expire between the list and the answer.
#[test]
fn an_answer_that_comes_too_late_is_reported() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::NoSuchApproval, "no such approval"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
let (out, _) = handle(&fake.socket, true, "no\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
}
/// Any other failure of the answer is reported on one line, and the turn goes on: the call is
/// still pending at `brokerd`, and `bxctl approve` from another terminal can answer it.
#[test]
fn an_answer_that_fails_is_reported_and_is_not_an_error() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::Internal, "boom"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!("{PROMPT}approval 41: internal: boom\n")),
"{out:?}"
);
}
/// What `brokerd` sends is printed as data here too.
#[test]
fn the_block_in_chat_is_escaped() {
let rlo = char::from_u32(0x202e).unwrap();
let arguments = format!("{{\"path\":\"/home/kyle/{rlo}dm\x1b[8m\"}}");
let fake = brokerd_with(
vec![pending(41, "read_file", &arguments)],
DecisionRecord::Allowed {},
);
let (out, _) = handle(&fake.socket, false, "");
assert_eq!(out.matches('\x1b').count(), 1, "only the reset: {out:?}");
assert!(!out.contains(rlo), "{out:?}");
assert!(
out.contains(&format!("/home/kyle/{}dm{}[8m", esc(0x202e), esc(0x1b))),
"{out:?}"
);
}
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("the terminal went away"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("the terminal went away"))
}
}
/// Every write can fail, and none is ignored: not the "no longer pending" line, not the block,
/// not the question. Nothing is approved for an owner who was shown nothing.
#[test]
fn a_failed_write_is_an_error_and_nothing_is_answered() {
let fake = broker();
let mut input = Cursor::new(b"41\n".to_vec());
assert!(handle_pending(&fake.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
assert_eq!(fake.requests(), vec![approvals_request()]);
let none = brokerd_with(Vec::new(), DecisionRecord::Allowed {});
let mut input = Cursor::new(Vec::new());
assert!(handle_pending(&none.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
}
// ---- stream_turn ----
fn pending_event(tool: &str) -> TurnEvent {
TurnEvent::ApprovalPending {
approval: 41,
tool: tool.to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
}
}
/// Runs one turn against a fake `loopd` that sends `events`. Returns what was printed.
fn turn(
events: Vec<TurnEvent>,
admin_socket: &Path,
on_pending: OnPending,
json: bool,
typed: &str,
) -> String {
let loopd = fake_loopd(events, "done");
let mut printer = Printer::new(true, json);
let mut input: Box<dyn BufRead> = Box::new(Cursor::new(typed.as_bytes().to_vec()));
let mut out = Vec::new();
let approvals = Approvals {
admin_socket,
on_pending,
};
let mut io = TurnIo {
printer: &mut printer,
input: &mut *input,
out: &mut out,
};
let done = stream_turn(
&loopd.socket,
&SessionId::new("s1").unwrap(),
"go",
false,
&approvals,
&mut io,
)
.unwrap()
.unwrap();
assert_eq!(done.content, "done");
String::from_utf8(out).unwrap()
}
/// A compromised `loopd` must not choose what the owner sees: the event says `read_file`, the
/// broker's entry says `shell`, and the block says `shell`.
#[test]
fn the_block_comes_from_brokerd_not_from_the_event() {
let fake = broker();
let out = turn(
vec![pending_event("read_file")],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("read_file"), "{out:?}");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_turn_goes_on_after_the_answer() {
let fake = broker();
let out = turn(
vec![
TurnEvent::Reasoning {
text: "hm".to_string(),
},
pending_event("shell"),
TurnEvent::Content {
text: "It ran.".to_string(),
},
],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(
out.starts_with("\x1b[2mhm\x1b[0m\n\x1b[0m41 "),
"reasoning is ended before the block: {out:?}"
);
assert!(out.ends_with("approved 41: runs\nIt ran."), "{out:?}");
}
#[test]
fn show_prints_the_block_and_answers_nothing() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::Show,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn event_only_prints_the_json_line_and_never_asks_brokerd() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::EventOnly,
true,
"41\n",
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1, "{out:?}");
let event: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(event["event"], "approval_pending");
assert_eq!(event["approval"], 41);
assert_eq!(fake.requests(), Vec::<Message>::new());
}
// ---- the binary ----
fn chat(loopd: &Path, brokerd: &Path, extra: &[&str], typed: &str) -> std::process::Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(loopd)
.arg("--admin-socket")
.arg(brokerd)
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(typed.as_bytes()).unwrap();
}
child.wait_with_output().unwrap()
}
/// With a pipe, everything typed is in the reader's buffer before the first turn is sent. The
/// approval's answer must come from that same reader: a second reader on stdin would see the
/// end of the input, and refuse.
#[test]
fn interactive_mode_reads_the_answer_from_the_same_input_as_the_chat() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\n41\n/quit\n");
assert!(output.status.success());
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
assert_eq!(
*loopd.turns.lock().unwrap(),
vec!["go".to_string()],
"the answer was not sent to the model as a message"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(PROMPT), "{stderr}");
assert!(stderr.contains("approved 41: runs"), "{stderr}");
}
#[test]
fn interactive_mode_refuses_on_a_stray_line() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\ny\n/quit\n");
assert!(output.status.success());
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(*loopd.turns.lock().unwrap(), vec!["go".to_string()]);
}
#[test]
fn say_shows_the_block_and_answers_nothing() {
let loopd = fake_loopd(vec![pending_event("read_file")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "41\n");
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(" shell {\"command\":\"ls\"}\n"),
"{stderr}"
);
assert!(!stderr.contains("to approve"), "{stderr}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn json_prints_only_json_and_never_asks_brokerd() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(
&loopd.socket,
&fake.socket,
&["--json", "--say", "go"],
"41\n",
);
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"not JSON: {line}"
);
}
assert_eq!(stderr.lines().count(), 2, "the event and the done frame");
assert_eq!(fake.requests(), Vec::<Message>::new());
}
/// The answer on stdout is the model's text too.
#[test]
fn the_answer_on_stdout_is_printed_as_data() {
let loopd = fake_loopd(Vec::new(), "a\x1b[8mb\n\tc");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "");
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!("a{}[8mb\n\tc\n", esc(0x1b))
);
}
@@ -0,0 +1,224 @@
//! Tests for how `bxctl chat` prints what the model wrote and what `brokerd` denied. Do not edit.
use bxctl::chat::Printer;
use proto::{DataClass, DenyReason, Timestamp, TurnEvent};
const BACKSLASH: char = '\\';
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn print(printer: &mut Printer, events: &[TurnEvent]) -> String {
let mut out = Vec::new();
for e in events {
printer.event(&mut out, e).unwrap();
}
printer.end_reasoning(&mut out).unwrap();
String::from_utf8(out).unwrap()
}
fn denied(name: &str, reason: DenyReason) -> TurnEvent {
TurnEvent::ToolDenied {
name: name.to_string(),
reason,
}
}
#[test]
fn a_denial_shows_its_reason_by_its_wire_name() {
let mut p = Printer::new(true, false);
assert_eq!(
print(&mut p, &[denied("read_file", DenyReason::NoGrant)]),
"[denied read_file: no_grant]\n"
);
}
/// Walks all ten reasons: the three that mean the harness is refusing to work carry their
/// runbook entry on the next line, and the other seven carry nothing.
#[test]
fn the_three_fail_closed_denials_point_to_the_runbook_and_no_other_does() {
let cases = [
(DenyReason::NoGrant, "no_grant", None),
(DenyReason::GrantExpired, "grant_expired", None),
(DenyReason::TaintTooHigh, "taint_too_high", None),
(DenyReason::DeniedByGrant, "denied_by_grant", None),
(DenyReason::ApprovalRefused, "approval_refused", None),
(DenyReason::ApprovalExpired, "approval_expired", None),
(DenyReason::InvalidArguments, "invalid_arguments", None),
(
DenyReason::GrantsInvalid,
"grants_invalid",
Some("see docs/runbook.md#grants-invalid"),
),
(
DenyReason::AuditUnavailable,
"audit_unavailable",
Some("see docs/runbook.md#audit-unavailable"),
),
(
DenyReason::StateUnreadable,
"state_unreadable",
Some("see docs/runbook.md#broker-state-damaged"),
),
];
for (reason, name, pointer) in cases {
let mut p = Printer::new(true, false);
let got = print(&mut p, &[denied("shell", reason)]);
let want = match pointer {
Some(pointer) => format!("[denied shell: {name}]\n{pointer}\n"),
None => format!("[denied shell: {name}]\n"),
};
assert_eq!(got, want);
}
}
#[test]
fn a_denial_ends_an_open_reasoning_block_first() {
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: "hm".to_string(),
},
denied("shell", DenyReason::NoGrant),
],
);
assert_eq!(got, "\x1b[2mhm\x1b[0m\n[denied shell: no_grant]\n");
}
#[test]
fn reasoning_and_content_are_printed_as_data() {
let hostile = "a\x1b[8mb\x07c\rd";
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: hostile.to_string(),
},
TurnEvent::Content {
text: hostile.to_string(),
},
],
);
let shown = format!("a{}[8mb{}c{}d", esc(0x1b), esc(0x07), esc(0x0d));
// The only escape sequences left are the printer's own: dim on, dim off.
assert_eq!(got, format!("\x1b[2m{shown}\x1b[0m\n{shown}"));
}
#[test]
fn invisible_and_direction_changing_characters_are_shown() {
let rlo = char::from_u32(0x202e).unwrap();
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[TurnEvent::Content {
text: format!("see {rlo}txt.exe"),
}],
);
assert_eq!(got, format!("see {}txt.exe", esc(0x202e)));
}
#[test]
fn newlines_and_tabs_in_model_text_pass_through() {
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[TurnEvent::Content {
text: "one\n\ttwo\n".to_string(),
}],
);
assert_eq!(got, "one\n\ttwo\n");
}
/// The model chooses tool names too: every place a name is printed escapes it.
#[test]
fn tool_names_are_printed_as_data_everywhere() {
let name = "sh\x1b[2Jell";
let shown = format!("sh{}[2Jell", esc(0x1b));
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::ToolCallStarted {
name: name.to_string(),
},
TurnEvent::ToolResult {
name: name.to_string(),
class: DataClass::Public,
truncated: false,
},
TurnEvent::ToolResult {
name: name.to_string(),
class: DataClass::Private,
truncated: true,
},
denied(name, DenyReason::NoGrant),
],
);
assert_eq!(
got,
format!(
"[tool {shown}]\n[{shown}: Public]\n[{shown}: Private, truncated]\n\
[denied {shown}: no_grant]\n"
)
);
assert!(!got.contains('\x1b'));
}
/// The printer shows nothing for a pending approval: the block comes from `brokerd`, through
/// `handle_pending`. It still ends an open reasoning block, so the block is not dimmed.
#[test]
fn a_pending_approval_prints_nothing_itself_but_ends_the_reasoning_block() {
let pending = TurnEvent::ApprovalPending {
approval: 41,
tool: "read_file".to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
};
let mut p = Printer::new(true, false);
assert_eq!(print(&mut p, std::slice::from_ref(&pending)), "");
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: "hm".to_string(),
},
pending,
],
);
assert_eq!(got, "\x1b[2mhm\x1b[0m\n");
}
#[test]
fn json_mode_prints_the_new_events_as_json_lines() {
let mut p = Printer::new(true, true);
let got = print(
&mut p,
&[
TurnEvent::ApprovalPending {
approval: 41,
tool: "read_file".to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
},
denied("shell", DenyReason::GrantsInvalid),
],
);
let lines: Vec<serde_json::Value> = got
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(
lines,
vec![
serde_json::json!({"event": "approval_pending", "approval": 41,
"tool": "read_file", "expires": "2026-09-18T12:15:00.000Z"}),
serde_json::json!({"event": "tool_denied", "name": "shell",
"reason": "grants_invalid"}),
]
);
assert!(!got.contains("runbook"), "json mode adds no prose");
}
@@ -0,0 +1,283 @@
//! Tests for `bxctl`'s command line. Do not edit.
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
use proto::SessionId;
use std::path::{Path, PathBuf};
use std::process::Command as Process;
const HOME: &str = "/srv/bx";
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
fn ok(words: &[&str]) -> Command {
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
}
fn bad(words: &[&str]) {
assert_eq!(
parse(&args(words), Path::new(HOME)),
Err(UsageError),
"{words:?} must be a usage error"
);
}
fn default_admin() -> PathBuf {
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
}
#[test]
fn chat_defaults_come_from_home() {
assert_eq!(
ok(&["chat"]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
admin_socket: default_admin(),
session: None,
show_thinking: true,
say: None,
json: false,
})
);
}
#[test]
fn chat_takes_every_flag_in_any_order() {
assert_eq!(
ok(&[
"chat",
"--json",
"--admin-socket",
"/tmp/a.sock",
"--say",
"hello there",
"--no-thinking",
"--session",
"s-1",
"--socket",
"/tmp/l.sock",
]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/tmp/l.sock"),
admin_socket: PathBuf::from("/tmp/a.sock"),
session: Some(SessionId::new("s-1").unwrap()),
show_thinking: false,
say: Some("hello there".to_string()),
json: true,
})
);
}
#[test]
fn chat_usage_errors() {
bad(&["chat", "--session", "Not Valid!"]);
bad(&["chat", "--session"]);
bad(&["chat", "--socket"]);
bad(&["chat", "--admin-socket"]);
bad(&["chat", "--say"]);
bad(&["chat", "--dance"]);
bad(&["chat", "stray"]);
}
#[test]
fn approvals() {
assert_eq!(
ok(&["approvals"]),
Command::Approvals {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
Command::Approvals {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
bad(&["approvals", "41"]);
bad(&["approvals", "--admin-socket"]);
bad(&["approvals", "--reason", "x"]);
}
#[test]
fn approve() {
assert_eq!(
ok(&["approve", "41"]),
Command::Approve {
admin_socket: default_admin(),
approval: 41
}
);
// The flag may come before or after the id.
for words in [
["approve", "--admin-socket", "/tmp/a.sock", "41"],
["approve", "41", "--admin-socket", "/tmp/a.sock"],
] {
assert_eq!(
ok(&words),
Command::Approve {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41
}
);
}
assert_eq!(
ok(&["approve", "18446744073709551615"]),
Command::Approve {
admin_socket: default_admin(),
approval: u64::MAX
}
);
}
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
#[test]
fn an_approval_id_is_only_digits() {
bad(&["approve"]);
bad(&["approve", "41", "42"]);
bad(&["approve", "+41"]);
bad(&["approve", "-1"]);
bad(&["approve", "4 1"]);
bad(&["approve", " 41"]);
bad(&["approve", "0x29"]);
bad(&["approve", "forty-one"]);
bad(&["approve", ""]);
bad(&["approve", "18446744073709551616"]);
bad(&["approve", "41", "--reason", "x"]);
bad(&["refuse"]);
bad(&["refuse", "+41"]);
bad(&["refuse", "41", "42"]);
}
#[test]
fn refuse() {
assert_eq!(
ok(&["refuse", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: None
}
);
assert_eq!(
ok(&[
"refuse",
"41",
"--reason",
"not on a Friday",
"--admin-socket",
"/tmp/a.sock"
]),
Command::Refuse {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41,
reason: Some("not on a Friday".to_string())
}
);
// A value is a value, even when it looks like a flag.
assert_eq!(
ok(&["refuse", "--reason", "--admin-socket", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: Some("--admin-socket".to_string())
}
);
bad(&["refuse", "41", "--reason"]);
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
}
#[test]
fn grants_check_and_audit_verify() {
assert_eq!(
ok(&["grants", "check"]),
Command::GrantsCheck {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
Command::GrantsCheck {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
assert_eq!(
ok(&["audit", "verify"]),
Command::AuditVerify {
home: PathBuf::from(HOME)
}
);
assert_eq!(
ok(&["audit", "verify", "--home", "/tmp/h"]),
Command::AuditVerify {
home: PathBuf::from("/tmp/h")
}
);
bad(&["grants"]);
bad(&["grants", "list"]);
bad(&["grants", "check", "extra"]);
bad(&["audit"]);
bad(&["audit", "verify", "--home"]);
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
}
#[test]
fn anything_else_is_a_usage_error() {
bad(&[]);
bad(&["dance"]);
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
}
#[test]
fn usage_names_every_command() {
for word in [
"bxctl chat",
"bxctl approvals",
"bxctl approve <id>",
"bxctl refuse <id>",
"bxctl grants check",
"bxctl audit verify",
"--admin-socket",
"--reason",
"--home",
] {
assert!(USAGE.contains(word), "usage lacks {word:?}");
}
}
#[test]
fn the_binary_prints_usage_and_exits_2() {
for words in [
vec![],
vec!["dance"],
vec!["approve", "+41"],
vec!["refuse"],
] {
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.args(&words)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{words:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
"{words:?}"
);
}
}
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
/// to connect, and says where it tried.
#[test]
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.arg("approvals")
.env("BOXMAKER_HOME", &home)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
let want = home.join("run/owner-broker/admin.sock");
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
}
@@ -0,0 +1,150 @@
//! Tests for `bxctl::escape`: text the model wrote is printed as data. Do not edit.
//!
//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this
//! file can turn one into the character it stands for.
use bxctl::escape::{escape_json_text, escape_model_text};
const BACKSLASH: char = '\\';
/// The escape for one code point: a backslash, `u`, and four lowercase hex digits.
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn ch(code: u32) -> char {
char::from_u32(code).unwrap()
}
/// Every code point that must be escaped, as inclusive ranges.
const HIDDEN: [(u32, u32); 6] = [
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
fn hidden(code: u32) -> bool {
HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code))
}
/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left
/// exactly as it is.
#[test]
fn every_listed_code_point_is_escaped_and_no_other() {
let mut escaped = 0;
for code in 0..0x11000u32 {
let Some(c) = char::from_u32(code) else {
continue; // the surrogates are not characters
};
let text = format!("a{c}b");
let got = escape_json_text(&text);
if hidden(code) {
escaped += 1;
assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}");
} else {
assert_eq!(got, text, "U+{code:04X} must pass through");
}
}
assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full");
}
#[test]
fn the_edges_of_each_range() {
for (lo, hi) in HIDDEN {
assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo));
assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi));
if lo > 0 {
let before = ch(lo - 1).to_string();
assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1);
}
let after = ch(hi + 1).to_string();
assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1);
}
}
#[test]
fn hex_digits_are_lowercase_and_there_are_always_four() {
assert_eq!(escape_json_text("\x1b"), esc(0x1b));
assert!(escape_json_text("\x1b").ends_with("001b"));
assert!(escape_json_text("\0").ends_with("0000"));
assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff"));
assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e"));
}
#[test]
fn an_escape_sequence_cannot_reach_the_terminal() {
let text = "before\x1b[8mhidden\x1b[0m\x07after";
let got = escape_json_text(text);
assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}");
assert_eq!(
got,
format!(
"before{}[8mhidden{}[0m{}after",
esc(0x1b),
esc(0x1b),
esc(0x07)
)
);
}
#[test]
fn a_path_cannot_be_shown_backwards() {
// U+202E makes a terminal draw what follows from right to left.
let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e));
assert_eq!(
escape_json_text(&text),
format!("/home/kyle/notes/{}dm.terces", esc(0x202e))
);
let text = format!("a{}b{}c", ch(0x200b), ch(0x2066));
assert_eq!(
escape_json_text(&text),
format!("a{}b{}c", esc(0x200b), esc(0x2066))
);
}
#[test]
fn ordinary_text_is_unchanged() {
for text in [
"",
"plain",
r#"{"command":"ls -l","cwd":"/home/kyle"}"#,
"naïve café 日本語 🙂",
"a backslash \\ and a quote \" stay as they are",
] {
assert_eq!(escape_json_text(text), text);
assert_eq!(escape_model_text(text), text);
}
}
#[test]
fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() {
let text = "one\n\ttwo\r\n";
assert_eq!(
escape_json_text(text),
format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a))
);
assert_eq!(
escape_model_text(text),
format!("one\n\ttwo{}\n", esc(0x0d)),
"only newline and tab pass; a carriage return could overwrite the line"
);
}
#[test]
fn model_text_escapes_everything_else_the_same_way() {
for (lo, hi) in HIDDEN {
for code in lo..=hi {
if code == 0x0a || code == 0x09 {
continue;
}
assert_eq!(
escape_model_text(&ch(code).to_string()),
esc(code),
"U+{code:04X}"
);
}
}
}
@@ -0,0 +1,186 @@
//! Scripted stand-ins for `brokerd`'s admin socket and for `loopd`, for tests. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use proto::{
ApprovalList, CallId, DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
PendingApproval, SessionId, Timestamp, TurnDone, TurnEvent, Usage, WireError, read_frame,
write_frame,
};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A socket path in a fresh temporary directory.
pub fn temp_socket(name: &str) -> PathBuf {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("bxctl-m3a-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub struct FakeBrokerd {
pub socket: PathBuf,
/// Every request message received, in order.
pub requests: Arc<Mutex<Vec<Message>>>,
}
impl FakeBrokerd {
pub fn requests(&self) -> Vec<Message> {
self.requests.lock().unwrap().clone()
}
}
/// Serves `admin.sock`: one request per connection, answered with the frames `answer` returns,
/// written exactly as given (so a test can send a wrong id or a frame that is not final). An
/// empty list closes the connection without an answer.
pub fn fake_brokerd_frames(
answer: impl Fn(&Envelope) -> Vec<Envelope> + Send + 'static,
) -> FakeBrokerd {
let socket = temp_socket("admin.sock");
let listener = UnixListener::bind(&socket).unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
seen.lock().unwrap().push(request.msg.clone());
for frame in answer(&request) {
if write_frame(&mut stream, &frame).is_err() {
break; // the client went away; the next connection is still served
}
}
}
});
FakeBrokerd { socket, requests }
}
/// The usual case: one final frame with the request's id.
pub fn fake_brokerd(answer: impl Fn(&Message) -> Message + Send + 'static) -> FakeBrokerd {
fake_brokerd_frames(move |request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: true,
msg: answer(&request.msg),
}]
})
}
pub fn wire_error(code: ErrorCode, detail: &str) -> Message {
Message::Error(WireError {
code,
detail: detail.to_string(),
})
}
/// A pending approval created at 12:00:00 that expires at 12:15:00 on 2026-09-18.
pub fn pending(approval: u64, tool: &str, arguments: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("chat-1758196800-123456789").unwrap(),
call: CallId(7),
tool: tool.to_string(),
arguments: arguments.to_string(),
grant: "shell-scratch".to_string(),
taint: DataClass::Private,
created: ts("2026-09-18T12:00:00.000Z"),
expires: ts("2026-09-18T12:15:00.000Z"),
}
}
/// A `brokerd` with these approvals pending. `approve` is answered with `outcome`, `refuse` with
/// `ok`; both answer `no_such_approval` for an id that is not in the list.
pub fn brokerd_with(items: Vec<PendingApproval>, outcome: proto::DecisionRecord) -> FakeBrokerd {
fake_brokerd(move |msg| {
let known = |id: u64| items.iter().any(|item| item.approval == id);
match msg {
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
items: items.clone(),
}),
Message::Approve(a) if known(a.approval) => {
Message::ApproveResult(proto::ApproveResult {
outcome: outcome.clone(),
})
}
Message::Refuse(r) if known(r.approval) => Message::Ok(proto::Empty {}),
Message::Approve(_) | Message::Refuse(_) => {
wire_error(ErrorCode::NoSuchApproval, "no such approval")
}
_ => wire_error(ErrorCode::BadMessage, "not an admin request"),
}
})
}
pub struct FakeLoopd {
pub socket: PathBuf,
/// The content of every turn received, in order.
pub turns: Arc<Mutex<Vec<String>>>,
}
pub fn usage() -> Usage {
Usage {
cache_n: 10,
prompt_n: 5,
predicted_n: 7,
reasoning_tokens: 3,
thinking_capped: false,
}
}
/// Serves `loop.sock`: every turn gets the same events, then `turn_done` with `answer`. It does
/// not wait for an approval to be answered: `bxctl` deals with each event before it reads the
/// next frame, so the order of what it does is fixed all the same.
pub fn fake_loopd(events: Vec<TurnEvent>, answer: &str) -> FakeLoopd {
let socket = temp_socket("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
let answer = answer.to_string();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
let Message::Turn(turn) = request.msg else {
continue;
};
seen.lock().unwrap().push(turn.content);
let mut frames: Vec<(bool, Message)> = events
.iter()
.map(|e| (false, Message::TurnEvent(e.clone())))
.collect();
frames.push((
true,
Message::TurnDone(TurnDone {
content: answer.clone(),
usage: usage(),
}),
));
for (last, msg) in frames {
let frame = Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: last,
msg,
};
if write_frame(&mut stream, &frame).is_err() {
break;
}
}
}
});
FakeLoopd { socket, turns }
}
@@ -0,0 +1,179 @@
//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`.
//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped.
struct Home {
path: PathBuf,
}
impl Home {
fn with_case(case: &str) -> Home {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("bxctl-verify-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
let audit = path.join("audit");
std::fs::create_dir_all(&audit).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap();
}
Home { path }
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn run(case: &str) -> (bool, String) {
let home = Home::with_case(case);
// What brokerd leaves beside the log must not be read as part of it.
std::fs::write(home.path.join("audit/.lock"), "").unwrap();
let mut out = Vec::new();
let ok = bxctl::verify::run(&home.path, &mut out).unwrap();
(ok, String::from_utf8(out).unwrap())
}
/// The hex of the hash of the last line of `file` in `case`.
fn head_of(case: &str, file: &str) -> String {
let path = format!(
"{}/../proto/tests/fixtures/audit/{case}/{file}",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(path).unwrap();
proto::sha256(text.lines().last().unwrap().as_bytes())
.unwrap()
.to_hex()
}
#[test]
fn a_good_log() {
let (ok, out) = run("good");
assert!(ok);
let head = head_of("good", "2026-09-18.jsonl");
assert_eq!(
out,
format!(
"audit: ok, 10 records, head {head}\n\
pending or abandoned: approval 6\n\
running or unfinished: decision 7\n"
)
);
}
#[test]
fn everything_worth_knowing_is_listed_one_per_line() {
let (ok, out) = run("recovered-next-day");
assert!(ok);
let lines: Vec<&str> = out.lines().collect();
assert!(
lines[0].starts_with("audit: ok, 11 records, head "),
"{out}"
);
assert_eq!(
lines[1..],
[
"recovered line: 2026-09-17.jsonl:6",
"pending or abandoned: approval 7",
"running or unfinished: decision 8",
]
);
let (ok, out) = run("accepted-break-older-file");
assert!(ok);
assert!(
out.contains("\naccepted break: 2026-09-18.jsonl:6\n"),
"{out}"
);
let (ok, out) = run("clock-back");
assert!(ok);
assert!(
out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"),
"{out}"
);
}
/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is
/// reported and is not a failure.
#[test]
fn a_torn_tail_is_reported_and_is_ok() {
let (ok, out) = run("torn-tail");
assert!(ok);
assert!(out.starts_with("audit: ok, 10 records, head "), "{out}");
assert!(
out.ends_with(
"\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n"
),
"{out}"
);
}
#[test]
fn a_broken_chain_is_two_lines_and_false() {
let cases = [
(
"changed-byte",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"),
(
"cut-short",
"2026-09-17.jsonl:3: does not parse as an audit record",
),
(
"file-not-chained",
"2026-09-18.jsonl:1: does not chain from the last line of the file before",
),
(
"break-wrong-line",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
];
for (case, first) in cases {
let (ok, out) = run(case);
assert!(!ok, "{case}");
assert_eq!(
out,
format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"),
"{case}"
);
}
}
#[test]
fn an_empty_audit_directory_is_an_empty_log() {
let home = Home::with_case("good");
for entry in std::fs::read_dir(home.path.join("audit")).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
let mut out = Vec::new();
assert!(bxctl::verify::run(&home.path, &mut out).unwrap());
assert_eq!(
String::from_utf8(out).unwrap(),
"audit: ok, 0 records, head none\n"
);
}
/// A home with no audit directory is a mistake in `--home`, not a clean log.
#[test]
fn a_missing_audit_directory_is_an_error() {
let home = Home::with_case("good");
std::fs::remove_dir_all(home.path.join("audit")).unwrap();
let mut out = Vec::new();
let error = bxctl::verify::run(&home.path, &mut out).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(out.is_empty());
}
@@ -0,0 +1,314 @@
//! Tests for `BrokerPort` against a fake `brokerd` on a temporary socket: answers, approvals and
//! timeouts. Frames that break the protocol are in `broker_port_bad.rs`. Do not edit.
#[path = "support/broker.rs"]
mod fake;
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::thread;
use std::time::Duration;
use fake::{assert_unavailable, broker, call, frame, in_ms, request, result, send, socket_path};
use loopd::broker_port::{BrokerPort, NoBroker, POINTER, TOO_LARGE};
use loopd::tools::{Pending, ToolPort};
use proto::{
DenyReason, Message, PROTOCOL_VERSION, Timestamp, ToolResponse, read_frame, write_frame,
};
#[test]
fn a_result_comes_back_as_it_was_sent() {
let (socket, broker) = broker(|stream, request| {
send(stream, request.id, true, result("hello\n"));
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, result("hello\n"));
assert!(got.pending.is_empty());
assert!(got.lines.is_empty(), "nothing to report: {:?}", got.lines);
// What the broker received: one final frame holding exactly the request.
let sent = broker.join().unwrap();
assert_eq!(sent.v, PROTOCOL_VERSION);
assert!(sent.r#final, "a request is a single final frame");
assert_eq!(sent.msg, Message::ToolRequest(request()));
}
#[test]
fn every_denial_and_a_failure_come_back_as_they_were_sent() {
let mut answers: Vec<ToolResponse> = [
DenyReason::NoGrant,
DenyReason::GrantExpired,
DenyReason::TaintTooHigh,
DenyReason::DeniedByGrant,
DenyReason::ApprovalRefused,
DenyReason::ApprovalExpired,
DenyReason::GrantsInvalid,
DenyReason::AuditUnavailable,
DenyReason::InvalidArguments,
DenyReason::StateUnreadable,
]
.into_iter()
.map(|reason| ToolResponse::Denied { reason })
.collect();
answers.push(ToolResponse::Failed {
message: "the runner arrives in M3b".to_string(),
});
for answer in answers {
let reply = answer.clone();
let (socket, broker) = broker(move |stream, request| {
send(stream, request.id, true, reply);
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, answer);
assert!(
got.lines.is_empty(),
"a denial is not an outage: {:?}",
got.lines
);
broker.join().unwrap();
}
}
#[test]
fn a_pending_frame_reaches_the_callback_and_the_final_frame_is_the_answer() {
let expires = in_ms(60_000);
let (socket, broker) = broker(move |stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 41,
expires,
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(150));
send(stream, request.id, true, result("approved"));
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, result("approved"));
assert_eq!(
got.pending,
[Pending {
approval: 41,
expires
}],
"called once, with the frame's values"
);
assert!(got.lines.is_empty(), "{:?}", got.lines);
broker.join().unwrap();
}
#[test]
fn an_answer_after_expires_but_within_the_timeout_of_it_is_delivered() {
// expires in 100 ms, timeout 1,500 ms: the port waits until 1,600 ms. The answer comes at
// about 400 ms, well after `expires`: an approval given at the last moment still gets the
// whole timeout to run.
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 1,
expires: in_ms(100),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(400));
send(stream, request.id, true, result("late but good"));
});
let got = call(socket, 1_500, &request());
assert_eq!(got.response, result("late but good"));
assert!(got.lines.is_empty(), "{:?}", got.lines);
broker.join().unwrap();
}
#[test]
fn an_expiry_that_has_already_passed_still_leaves_the_timeout() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 1,
expires: Timestamp::parse("2020-01-01T00:00:00.000Z").unwrap(),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(200));
send(stream, request.id, true, result("fine"));
});
let got = call(socket, 1_500, &request());
assert_eq!(got.response, result("fine"));
broker.join().unwrap();
}
#[test]
fn no_socket_is_unavailable() {
let socket = socket_path();
let _ = std::fs::remove_file(&socket);
let got = call(socket.clone(), 2_000, &request());
assert_unavailable(&got, "no socket file");
assert!(
got.lines[0].contains(&socket.display().to_string()),
"the line names the socket: {}",
got.lines[0]
);
assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took);
}
#[test]
fn a_broker_that_closes_without_answering_is_unavailable() {
let (socket, broker) = broker(|_, _| {});
let got = call(socket, 2_000, &request());
assert_unavailable(&got, "closed before any frame");
assert!(
got.took < Duration::from_millis(1_500),
"a close is seen at once: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn a_broker_that_closes_while_pending_is_unavailable() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 3,
expires: in_ms(60_000),
};
send(stream, request.id, false, pending);
// brokerd was restarted: the connection just ends.
});
let got = call(socket, 2_000, &request());
assert_unavailable(&got, "closed while pending");
assert_eq!(got.pending.len(), 1, "the pending frame was reported first");
assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took);
broker.join().unwrap();
}
#[test]
fn a_broker_that_never_answers_is_unavailable_after_the_timeout() {
let (socket, broker) = broker(|_, _| thread::sleep(Duration::from_millis(1_200)));
let got = call(socket, 300, &request());
assert_unavailable(&got, "silence");
assert!(
got.took >= Duration::from_millis(250),
"gave up early: {:?}",
got.took
);
assert!(
got.took < Duration::from_millis(1_100),
"gave up late: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn a_pending_call_nobody_answers_ends_at_expires_plus_the_timeout() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 3,
expires: in_ms(300),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(1_800));
});
let got = call(socket, 300, &request());
assert_unavailable(&got, "silence while pending");
assert!(
got.took >= Duration::from_millis(550),
"it must wait for expires (300) plus the timeout (300): {:?}",
got.took
);
assert!(
got.took < Duration::from_millis(1_700),
"gave up late: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn the_timeout_is_a_deadline_for_the_frame_not_for_each_read() {
// The four header bytes arrive 250 ms apart, the last at 1,000 ms. A port that only sets a
// 600 ms read timeout sees every single read succeed and returns the result; a port with a
// deadline gives up at 600 ms. The answer tells them apart, so no timing is asserted.
let (socket, broker) = broker(|stream, request| {
let mut bytes = Vec::new();
write_frame(
&mut bytes,
&frame(request.id, true, Message::ToolResponse(result("slow"))),
)
.unwrap();
for byte in bytes.iter().take(4) {
thread::sleep(Duration::from_millis(250));
if stream.write_all(&[*byte]).is_err() {
return;
}
}
let _ = stream.write_all(&bytes[4..]);
});
let got = call(socket, 600, &request());
assert_unavailable(&got, "a trickled frame");
broker.join().unwrap();
}
#[test]
fn a_zero_timeout_fails_closed_and_does_not_panic() {
let (socket, _broker) = broker(|stream, request| {
send(stream, request.id, true, result("too late"));
});
let got = call(socket, 0, &request());
assert_unavailable(&got, "timeout_ms = 0");
}
#[test]
fn a_request_too_large_for_a_frame_is_its_own_failure() {
// Nothing is sent, so the fake broker sees a connection that closes or none at all.
let path = socket_path();
let _ = std::fs::remove_file(&path);
let _listener = UnixListener::bind(&path).unwrap();
let mut big = request();
big.arguments = format!(r#"{{"path":"/{}"}}"#, "a".repeat(proto::MAX_FRAME));
let got = call(path, 1_000, &big);
assert_eq!(
got.response,
ToolResponse::Failed {
message: TOO_LARGE.to_string()
}
);
assert!(
got.lines.is_empty(),
"the broker is fine; this is not an outage: {:?}",
got.lines
);
}
#[test]
fn every_call_is_its_own_connection() {
let path = socket_path();
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap();
let server = thread::spawn(move || {
for n in 0..3u64 {
let (mut stream, _) = listener.accept().unwrap();
let request = read_frame(&mut stream).unwrap();
send(
&mut stream,
request.id,
true,
result(&format!("answer {n}")),
);
}
});
let port = BrokerPort::new(path, Duration::from_millis(2_000));
for n in 0..3 {
let got = port.call(&request(), &mut |_| {});
assert_eq!(got, result(&format!("answer {n}")));
}
server.join().unwrap();
}
#[test]
fn without_a_broker_every_call_fails_in_words_the_model_can_repeat() {
let mut seen = 0;
let got = NoBroker.call(&request(), &mut |_| seen += 1);
assert_eq!(
got,
ToolResponse::Failed {
message: "no tool broker is configured".to_string()
}
);
assert_eq!(seen, 0);
assert_eq!(POINTER, "see docs/runbook.md#broker-unavailable");
}
@@ -0,0 +1,127 @@
//! Tests for `BrokerPort` when the broker's frames break the protocol. Every one ends in the same
//! plain failure and one printed line. Do not edit.
#[path = "support/broker.rs"]
mod fake;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::thread;
use std::time::Duration;
use fake::{assert_unavailable, broker, call, frame, in_ms, request, result, send};
use proto::{Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolResponse, WireError, write_frame};
#[test]
fn frames_that_break_the_protocol_are_unavailable() {
type Script = Box<dyn FnOnce(&mut UnixStream, &Envelope) + Send>;
let pending = |approval| ToolResponse::PendingApproval {
approval,
expires: in_ms(60_000),
};
let cases: Vec<(&str, Script)> = vec![
(
"an answer for another request id",
Box::new(|s, r| send(s, r.id + 1, true, result("x"))),
),
(
"a final frame that is pending",
Box::new(move |s, r| send(s, r.id, true, pending(1))),
),
(
"an answer that is not final",
Box::new(|s, r| send(s, r.id, false, result("x"))),
),
(
"a second pending frame",
Box::new(move |s, r| {
send(s, r.id, false, pending(1));
send(s, r.id, false, pending(2));
thread::sleep(Duration::from_millis(100));
}),
),
(
"pending, then another id",
Box::new(move |s, r| {
send(s, r.id, false, pending(1));
send(s, r.id + 1, true, result("x"));
}),
),
(
"an error message",
Box::new(|s, r| {
let error = Message::Error(WireError {
code: ErrorCode::Forbidden,
detail: "tool requests only".to_string(),
});
let _ = write_frame(s, &frame(r.id, true, error));
}),
),
(
"a message of another kind",
Box::new(|s, r| {
let echo = Message::ToolRequest(request());
let _ = write_frame(s, &frame(r.id, true, echo));
}),
),
(
"another protocol version",
Box::new(|s, r| {
let mut env = frame(r.id, true, Message::ToolResponse(result("x")));
env.v = PROTOCOL_VERSION + 1;
let _ = write_frame(s, &env);
}),
),
(
"a zero length",
Box::new(|s, _| {
let _ = s.write_all(&[0, 0, 0, 0]);
}),
),
(
"a length over the maximum",
Box::new(|s, _| {
let _ = s.write_all(&[0xff, 0xff, 0xff, 0xff]);
}),
),
(
"a body that is not JSON",
Box::new(|s, _| {
let _ = s.write_all(&[0, 0, 0, 5]);
let _ = s.write_all(b"hello");
}),
),
(
"a body cut short",
Box::new(|s, _| {
let _ = s.write_all(&[0, 0, 0, 50]);
let _ = s.write_all(b"{\"v\":1");
}),
),
];
for (why, script) in cases {
let (socket, broker) = broker(script);
let got = call(socket, 1_000, &request());
assert_unavailable(&got, why);
broker.join().unwrap();
}
}
#[test]
fn the_error_message_the_broker_sent_is_in_the_line() {
let (socket, broker) = broker(|s, r| {
let error = Message::Error(WireError {
code: ErrorCode::Internal,
detail: "the ledger is gone".to_string(),
});
let _ = write_frame(s, &frame(r.id, true, error));
});
let got = call(socket, 1_000, &request());
assert_unavailable(&got, "an error message");
assert!(
got.lines[0].contains("the ledger is gone"),
"{}",
got.lines[0]
);
broker.join().unwrap();
}
@@ -0,0 +1,272 @@
//! Tests for `loopd`'s configuration. Do not edit: these define the required behaviour.
use loopd::config::{Config, ConfigError, Limits, Sampling};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
#[test]
fn minimal_file_gets_the_documented_defaults() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(
c.infer.socket,
PathBuf::from("/run/boxmaker/infer/infer.sock")
);
assert_eq!(c.infer.model, "ornith-1.5-35b-a3b");
assert_eq!((c.slots.main, c.slots.background), (0, 1));
assert_eq!(c.expect.n_ctx, 131_072);
assert_eq!(c.expect.slots, 2);
assert_eq!(
c.expect.template_sha256.to_hex(),
"f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
);
assert_eq!(
c.sampling,
Sampling {
temperature: 0.6,
top_p: 0.95,
top_k: 20
}
);
let want = Limits {
poll_ms: 5_000,
busy_wait_ms: 600_000,
load_wait_ms: 180_000,
idle_grace_ms: 30_000,
liveness_ms: 30_000,
thinking_cap: 4_096,
thinking_overrun: 256,
max_tokens: 8_192,
queue_len: 8,
retry_attempts: 4,
retry_backoff_ms: vec![2_000, 8_000, 30_000],
retry_window_ms: 300_000,
};
assert_eq!(c.limits, want);
assert_eq!(Limits::default(), want);
}
#[test]
fn full_file_overrides_every_default() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(
c.sampling,
Sampling {
temperature: 0.2,
top_p: 0.9,
top_k: 40
}
);
let want = Limits {
poll_ms: 50,
busy_wait_ms: 200,
load_wait_ms: 300,
idle_grace_ms: 150,
liveness_ms: 100,
thinking_cap: 20,
thinking_overrun: 10,
max_tokens: 512,
queue_len: 1,
retry_attempts: 2,
retry_backoff_ms: vec![10],
retry_window_ms: 1_000,
};
assert_eq!(c.limits, want);
}
#[test]
fn a_partial_limits_table_keeps_the_other_defaults() {
let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap();
let c = Config::parse(&format!("{text}\n[limits]\nliveness_ms = 1234\n")).unwrap();
assert_eq!(c.limits.liveness_ms, 1234);
assert_eq!(c.limits.poll_ms, 5_000);
}
/// Every table in the file must reject a key it does not know: a misspelt limit that silently
/// fell back to its default would be a limit the owner believes is set and is not.
#[test]
fn unknown_keys_are_errors_in_every_table() {
let text = std::fs::read_to_string(fixture("full.toml")).unwrap();
assert!(Config::parse(&text).is_ok());
for table in ["infer", "slots", "expect", "sampling", "limits"] {
let header = format!("[{table}]\n");
assert!(text.contains(&header), "fixture has no [{table}] table");
let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1);
assert!(
Config::parse(&bad).is_err(),
"[{table}] accepted an unknown key"
);
}
assert!(
Config::parse(&format!("zz_unknown = 1\n{text}")).is_err(),
"top level"
);
assert!(
Config::parse(&format!("{text}\n[zz_unknown]\nx = 1\n")).is_err(),
"unknown table"
);
}
#[test]
fn required_tables_and_values_are_checked() {
let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap();
for table in ["infer", "slots", "expect"] {
let without: String = text
.split("\n\n")
.filter(|block| !block.trim_start().starts_with(&format!("[{table}]")))
.collect::<Vec<_>>()
.join("\n\n");
assert!(Config::parse(&without).is_err(), "[{table}] is required");
}
let bad_hash = text.replace("f55f5293", "F55F5293");
assert!(
Config::parse(&bad_hash).is_err(),
"the hash must be lowercase hex"
);
let negative = text.replace("main = 0", "main = -1");
assert!(Config::parse(&negative).is_err());
}
#[test]
fn load_reports_which_file_failed() {
let missing = fixture("does-not-exist.toml");
match Config::load(&missing) {
Err(ConfigError::Read(path, _)) => assert_eq!(path, missing),
other => panic!("expected a read error, got {other:?}"),
}
let e: Box<dyn std::error::Error> = Box::new(Config::load(&missing).unwrap_err());
assert!(e.to_string().contains("does-not-exist.toml"));
}
// ---- M2b: paths, channel, loop and baseline ----
#[test]
fn the_m2b_tables_have_defaults() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(
c.channel.socket,
PathBuf::new(),
"empty means: under the home"
);
assert_eq!(c.channel_socket(), c.paths.home.join("run/loop/loop.sock"));
assert_eq!(
(
c.r#loop.tool_iterations,
c.r#loop.repeat_detection,
c.r#loop.tool_result_cap
),
(8, true, 16 * 1024)
);
// A relative system prompt is taken from the config file's directory.
assert_eq!(c.baseline.system, fixture("system.md"));
// The home comes from BOXMAKER_HOME when set, else /var/lib/boxmaker. Either way it is absolute.
assert!(c.paths.home.is_absolute(), "{:?}", c.paths.home);
}
#[test]
fn the_m2b_tables_can_be_set() {
let c = Config::load(&fixture("m2b.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.channel.socket,
PathBuf::from("/run/boxmaker/loop/loop.sock")
);
assert_eq!(
c.channel_socket(),
PathBuf::from("/run/boxmaker/loop/loop.sock")
);
assert_eq!(
(
c.r#loop.tool_iterations,
c.r#loop.repeat_detection,
c.r#loop.tool_result_cap
),
(3, false, 1024)
);
assert_eq!(
c.baseline.system,
fixture("prompts/agent.md"),
"relative to the config file"
);
let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
let absolute = text.replace("prompts/agent.md", "/etc/boxmaker/system.md");
assert_eq!(
Config::parse(&absolute).unwrap().baseline.system,
PathBuf::from("/etc/boxmaker/system.md")
);
}
#[test]
fn unknown_keys_are_errors_in_the_m2b_tables_too() {
let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
assert!(Config::parse(&text).is_ok());
for table in ["paths", "channel", "loop", "baseline"] {
let header = format!("[{table}]\n");
let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1);
assert!(
Config::parse(&bad).is_err(),
"[{table}] accepted an unknown key"
);
}
let partial = text.replace("tool_iterations = 3\nrepeat_detection = false\n", "");
let c = Config::parse(&partial).unwrap();
assert_eq!(
(
c.r#loop.tool_iterations,
c.r#loop.repeat_detection,
c.r#loop.tool_result_cap
),
(8, true, 1024),
"a partial table keeps the other defaults"
);
}
#[test]
fn the_broker_table_is_optional_and_its_socket_has_no_default() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(
c.broker.socket, None,
"no socket means no broker, never a guessed path"
);
assert_eq!(c.broker.timeout_ms, 120_000);
}
#[test]
fn the_broker_table_can_be_set_and_rejects_unknown_keys() {
let base = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
let text = format!(
"{base}\n[broker]\nsocket = \"/run/boxmaker/loop-broker/broker.sock\"\ntimeout_ms = 5000\n"
);
let c = Config::parse(&text).unwrap();
assert_eq!(
c.broker.socket,
Some(PathBuf::from("/run/boxmaker/loop-broker/broker.sock"))
);
assert_eq!(c.broker.timeout_ms, 5000);
let only_timeout = format!("{base}\n[broker]\ntimeout_ms = 5000\n");
let c = Config::parse(&only_timeout).unwrap();
assert_eq!((c.broker.socket, c.broker.timeout_ms), (None, 5000));
let only_socket = format!("{base}\n[broker]\nsocket = \"/b.sock\"\n");
let c = Config::parse(&only_socket).unwrap();
assert_eq!(
c.broker.timeout_ms, 120_000,
"a partial table keeps the default"
);
for bad in [
"zz_unknown = 1",
"timeout = 5000",
"timeout_ms = -1",
"timeout_ms = \"5s\"",
"socket = 7",
] {
let text = format!("{base}\n[broker]\n{bad}\n");
assert!(Config::parse(&text).is_err(), "[broker] accepted `{bad}`");
}
}
@@ -0,0 +1,448 @@
//! Checks against the real server. Ignored unless run by `make verify-device`. Do not edit.
//!
//! They need two environment variables:
//! BOXMAKER_INFERPROXY path to the built `inferproxy` binary
//! BOXMAKER_UPSTREAM host:port of the llama-server router, for example straylight:11434
//! and, optionally, BOXMAKER_MODEL (default ornith-1.5-35b-a3b). They use slot 0.
//!
//! They never touch the real `llama-server` process. The "server dies" test kills and restarts
//! its own `inferproxy`.
use loopd::config::Config;
use loopd::llama::info::{CacheOutcome, cache_outcome};
use loopd::llama::{ChatEvent, ChatMessage, ChatRequest, Client};
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
struct Proxy {
child: Child,
socket: PathBuf,
}
impl Proxy {
fn start(socket: &Path) -> Proxy {
let binary = std::env::var("BOXMAKER_INFERPROXY").expect("BOXMAKER_INFERPROXY is not set");
let upstream = std::env::var("BOXMAKER_UPSTREAM").expect("BOXMAKER_UPSTREAM is not set");
let child = Command::new(binary)
.arg("--listen")
.arg(socket)
.arg("--upstream")
.arg(upstream)
.spawn()
.expect("cannot start inferproxy");
let mut proxy = Proxy {
child,
socket: socket.to_path_buf(),
};
for _ in 0..100 {
if socket.exists() {
return proxy;
}
thread::sleep(Duration::from_millis(20));
}
proxy.kill();
panic!("inferproxy did not create {}", socket.display());
}
fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for Proxy {
fn drop(&mut self) {
self.kill();
}
}
fn socket_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("loopd-device-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("infer.sock")
}
fn config(socket: &Path) -> Config {
let model =
std::env::var("BOXMAKER_MODEL").unwrap_or_else(|_| "ornith-1.5-35b-a3b".to_string());
let text = format!(
r#"
[infer]
socket = "{}"
model = "{model}"
[slots]
main = 0
background = 1
[expect]
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
n_ctx = 131072
slots = 2
"#,
socket.display()
);
Config::parse(&text).unwrap()
}
fn user(text: &str) -> ChatMessage {
// A different prompt each run, so that an earlier run's cache cannot make a check pass.
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
ChatMessage::User {
content: format!("{text} (run {nonce})"),
}
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn the_startup_self_test_passes() {
let socket = socket_path("selftest");
let _proxy = Proxy::start(&socket);
let client = Client::new(config(&socket));
loopd::selftest::run(&client, &mut |step| eprintln!("selftest: {step}")).unwrap();
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn a_capped_thinking_block_ends_and_the_answer_arrives() {
let socket = socket_path("cap");
let _proxy = Proxy::start(&socket);
let mut cfg = config(&socket);
cfg.limits.thinking_cap = 60;
let client = Client::new(cfg);
let ask = "Think at great length about how many ways 8 rooks fit on a chessboard without attacking \
each other and with none on the main diagonal. Then answer in one sentence.";
let req = ChatRequest {
slot: 0,
messages: vec![user(ask)],
tools: vec![],
thinking: true,
};
let mut capped = Vec::new();
let done = client
.chat_with_retry(&req, &mut |e| {
if let ChatEvent::ThinkingCapped { tokens } = e {
capped.push(*tokens);
}
})
.unwrap();
assert!(done.thinking_capped);
assert_eq!(capped.len(), 1);
assert!(capped[0] >= 60 && capped[0] < 70, "capped at {capped:?}");
assert!(
done.reasoning_tokens < 60 + 256,
"thinking went on to {}",
done.reasoning_tokens
);
assert!(
done.content.is_some_and(|c| !c.trim().is_empty()),
"an answer followed the forced end"
);
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn a_request_survives_its_proxy_being_killed_and_restarted() {
let socket = socket_path("restart");
let mut proxy = Proxy::start(&socket);
let mut cfg = config(&socket);
cfg.limits.retry_backoff_ms = vec![1_500];
let client = Client::new(cfg);
let ask = "Write about 300 words on the history of cork.";
let req = ChatRequest {
slot: 0,
messages: vec![user(ask)],
tools: vec![],
thinking: false,
};
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut retries = 0;
let mut told = false;
let result = client.chat_with_retry(&req, &mut |e| match e {
ChatEvent::Content(_) if !told => {
told = true;
let _ = tx.send(());
}
ChatEvent::Retrying { .. } => retries += 1,
_ => {}
});
(result, retries)
});
rx.recv_timeout(Duration::from_secs(120))
.expect("no content arrived");
proxy.kill(); // the stream dies in the middle of the answer
thread::sleep(Duration::from_millis(300));
let _proxy = Proxy::start(&proxy.socket);
let (result, retries) = worker.join().unwrap();
let done = result.expect("the retry should have succeeded");
assert!(retries >= 1, "the request was retried");
assert!(
done.content
.is_some_and(|c| c.split_whitespace().count() > 100)
);
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn a_second_turn_reuses_the_first_turns_cache() {
let socket = socket_path("cache");
let _proxy = Proxy::start(&socket);
let client = Client::new(config(&socket));
let mut messages = vec![user(
"What is 17 * 23? Think briefly, then answer in one short sentence.",
)];
let req = ChatRequest {
slot: 0,
messages: messages.clone(),
tools: vec![],
thinking: true,
};
let turn1 = client.chat_with_retry(&req, &mut |_| {}).unwrap();
assert!(
turn1.reasoning_content.is_some(),
"this check is about replaying a thinking block"
);
messages.push(ChatMessage::Assistant {
content: turn1.content.clone(),
reasoning_content: turn1.reasoning_content.clone(),
tool_calls: turn1.tool_calls.clone(),
});
messages.push(user("And 17 * 24?"));
let req = ChatRequest {
slot: 0,
messages,
tools: vec![],
thinking: true,
};
let turn2 = client.chat_with_retry(&req, &mut |_| {}).unwrap();
assert_eq!(
cache_outcome(&turn1.timings, &turn2.timings),
CacheOutcome::Hit,
"{:?} then {:?}",
turn1.timings,
turn2.timings
);
}
// ---- M2b: the agent loop on the real server ----
/// A `loopd serve` on a private home, killed on drop.
struct Served {
child: Child,
home: PathBuf,
socket: PathBuf,
}
fn config_text(infer: &Path, home: &Path) -> String {
let model =
std::env::var("BOXMAKER_MODEL").unwrap_or_else(|_| "ornith-1.5-35b-a3b".to_string());
format!(
r#"
[infer]
socket = "{}"
model = "{model}"
[slots]
main = 0
background = 1
[expect]
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
n_ctx = 131072
slots = 2
[paths]
home = "{}"
"#,
infer.display(),
home.display()
)
}
impl Served {
/// Writes the config and the repository's `system.md` into `home`, and starts `loopd serve`.
fn start(infer: &Path, home: &Path) -> Served {
std::fs::create_dir_all(home).unwrap();
let config = home.join("config.toml");
std::fs::write(&config, config_text(infer, home)).unwrap();
let prompt = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/system.md");
std::fs::copy(&prompt, home.join("system.md"))
.expect("config/system.md exists in the repository");
let socket = home.join("run").join("loop").join("loop.sock");
let _ = std::fs::remove_file(&socket);
let child = Command::new(env!("CARGO_BIN_EXE_loopd"))
.arg("serve")
.arg("--config")
.arg(&config)
.spawn()
.expect("cannot start loopd");
let mut served = Served {
child,
home: home.to_path_buf(),
socket,
};
for _ in 0..600 {
if served.socket.exists() {
return served;
}
thread::sleep(Duration::from_millis(100));
}
served.kill();
panic!("loopd did not come up within 60 s");
}
fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
/// One `bxctl chat --say` turn. Returns the answer.
fn say(&self, session: &str, text: &str) -> String {
let bxctl = std::env::var("BOXMAKER_BXCTL").expect("BOXMAKER_BXCTL is not set");
let output = Command::new(bxctl)
.arg("chat")
.arg("--socket")
.arg(&self.socket)
.args(["--session", session, "--no-thinking", "--say", text])
.output()
.expect("cannot run bxctl");
assert!(
output.status.success(),
"bxctl failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string()
}
fn records(&self, session: &str) -> Vec<proto::LogRecord> {
let text =
std::fs::read_to_string(self.home.join("sessions").join(session).join("0.jsonl"))
.unwrap();
text.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
}
impl Drop for Served {
fn drop(&mut self) {
self.kill();
}
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn the_baseline_fits_the_token_budget() {
let socket = socket_path("budget");
let _proxy = Proxy::start(&socket);
let home = socket.parent().unwrap().join("home");
std::fs::create_dir_all(&home).unwrap();
let mut cfg = config(&socket);
cfg.paths.home = home.clone();
cfg.baseline.system = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/system.md");
let baseline =
loopd::baseline::Baseline::assemble(&cfg, &loopd::tools::Registry::m3a()).unwrap();
let client = Client::new(cfg);
// The system text plus every tool schema as the request carries it.
let mut text = baseline.system.clone();
for tool in &baseline.tools {
text.push('\n');
text.push_str(&serde_json::to_string(&tool).unwrap());
}
let tokens = client.tokenize(&text).unwrap();
eprintln!("baseline: {tokens} tokens");
assert!(
tokens <= 3000,
"the baseline is {tokens} tokens; the brief allows 3000"
);
}
#[test]
#[ignore = "needs the real server; run with make verify-device"]
fn a_conversation_uses_tools_survives_a_restart_and_keeps_its_cache() {
let socket = socket_path("loop");
let _proxy = Proxy::start(&socket);
let home = socket.parent().unwrap().join("home");
let session = format!("device-{}", std::process::id());
let mut served = Served::start(&socket, &home);
let a1 = served.say(&session, "Reply with exactly: box made.");
assert!(a1.to_lowercase().contains("box made"), "{a1}");
let a2 = served.say(
&session,
"What is the current time? Use your clock tool, then tell me the year.",
);
assert!(a2.contains("2026") || a2.contains("202"), "{a2}");
// No broker is configured here, so the call fails in plain words and the turn goes on. What
// is checked is the path: find_tool, then call_tool, then an answer.
let a3 = served.say(
&session,
"Find a tool that reads files, use it to read /etc/hostname, and tell me in one \
sentence what happened.",
);
assert!(!a3.trim().is_empty(), "the turn must end in an answer");
served.kill();
served = Served::start(&socket, &home);
let a4 = served.say(
&session,
"What were the exact words I first asked you to reply with?",
);
assert!(
a4.to_lowercase().contains("box made"),
"after a restart the session must still know: {a4}"
);
let records = served.records(&session);
let tool_names: Vec<String> = records
.iter()
.filter_map(|r| match r {
proto::LogRecord::Assistant { tool_calls, .. } => Some(
tool_calls
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>(),
),
_ => None,
})
.flatten()
.collect();
assert!(tool_names.contains(&"clock".to_string()), "{tool_names:?}");
assert!(
tool_names.contains(&"find_tool".to_string())
&& tool_names.contains(&"call_tool".to_string()),
"{tool_names:?}"
);
let usages = records
.iter()
.filter(|r| matches!(r, proto::LogRecord::Usage { .. }))
.count();
let assistants = records
.iter()
.filter(|r| matches!(r, proto::LogRecord::Assistant { .. }))
.count();
assert_eq!(usages, assistants, "one usage record per completion");
let losses: Vec<&proto::LogRecord> = records
.iter()
.filter(|r| matches!(r, proto::LogRecord::CacheLoss { .. }))
.collect();
assert!(
losses.is_empty(),
"every request hit the cache, including the one after the restart: {losses:?}"
);
let results = records
.iter()
.filter(|r| matches!(r, proto::LogRecord::ToolResult { .. }))
.count();
assert!(
results >= 3,
"clock, find_tool and call_tool each left a result: {results}"
);
}
@@ -0,0 +1,168 @@
//! End to end in two processes: `loopd`'s turn loop, with a `BrokerPort`, against the real
//! `brokerd` binary on a temporary home, and the fake llama server. Do not edit.
//!
//! It cannot be one process: `loopd` may not depend on `brokerd`, even for tests. The path of
//! the `brokerd` binary comes from `BOXMAKER_BROKERD`; without it the test is ignored, and
//! `make gate` builds the workspace and runs it with the variable set.
mod support;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use loopd::baseline::Baseline;
use loopd::broker_port::BrokerPort;
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use proto::{
AuditEvent, AuditRecord, ChainVerifier, DecisionRecord, DenyReason, SessionId, TurnEvent,
};
use support::{FakeServer, Home, Reply};
const CHAT: &str = "/v1/chat/completions";
/// `brokerd serve`, killed when dropped.
struct Brokerd(Child);
impl Drop for Brokerd {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn start_brokerd(home: &Path) -> (Brokerd, PathBuf) {
let binary = std::env::var_os("BOXMAKER_BROKERD")
.expect("set BOXMAKER_BROKERD to the brokerd binary; `make gate` does");
std::fs::create_dir_all(home.join("grants")).unwrap();
let config = home.join("brokerd.toml");
let text = format!(
"[paths]\nhome = \"{h}\"\ngrants = \"{h}/grants\"\n",
h = home.display()
);
std::fs::write(&config, text).unwrap();
let child = Command::new(binary)
.args(["serve", "--config"])
.arg(&config)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let brokerd = Brokerd(child);
let socket = home.join("run/loop-broker/broker.sock");
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(&socket).is_err() {
assert!(
Instant::now() < until,
"brokerd never listened on {}",
socket.display()
);
std::thread::sleep(Duration::from_millis(20));
}
(brokerd, socket)
}
/// Every record under `audit/`, after checking that the chain verifies.
fn audit(dir: &Path) -> Vec<AuditRecord> {
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 verifier = ChainVerifier::new();
let mut records = Vec::new();
for name in &names {
let bytes = std::fs::read(dir.join(name)).unwrap();
verifier.feed(name, &bytes);
for line in String::from_utf8(bytes).unwrap().lines() {
records.push(serde_json::from_str(line).unwrap());
}
}
let report = verifier.finish();
assert!(report.failure.is_none(), "{:?}", report.failure);
assert!(report.torn_tail.is_none());
assert_eq!(report.records, records.len() as u64);
records
}
#[test]
#[ignore = "needs the brokerd binary in BOXMAKER_BROKERD; `make gate` runs it"]
fn a_call_no_grant_allows_reaches_the_model_as_a_denial_and_is_audited() {
let home = Home::new();
let broker_home = home.dir.join("broker-home");
let (_brokerd, socket) = start_brokerd(&broker_home);
let server = FakeServer::start();
// The recorded model calls `read_file` on /etc/hostname, then answers in plain text.
server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let cfg = home.config(&server.socket);
let client = Client::new(cfg.clone());
let port = BrokerPort::new(socket, Duration::from_secs(10));
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let id = SessionId::new("e2e").unwrap();
let mut session = Session::create(&home.dir, id, baseline, cfg.slots.main).unwrap();
let runtime = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(
&mut session,
&runtime,
"what is this host called?",
&mut |e| events.push(e.clone()),
);
assert!(
outcome.is_ok(),
"the turn goes on after a denial: {outcome:?}"
);
assert!(
events.contains(&TurnEvent::ToolDenied {
name: "read_file".to_string(),
reason: DenyReason::NoGrant,
}),
"{events:?}"
);
// The model reads the denial in its next request.
let second = server.requests_to(CHAT)[1].json();
assert_eq!(
second["messages"][3]["content"],
"Denied: no grant allows this call."
);
let records = audit(&broker_home.join("audit"));
assert_eq!(records.len(), 1, "{records:?}");
match &records[0].event {
AuditEvent::Decision {
session,
tool,
arguments,
outcome,
grant,
..
} => {
assert_eq!(session.as_str(), "e2e");
assert_eq!(tool, "read_file");
assert_eq!(arguments, r#"{"path":"/etc/hostname"}"#);
assert_eq!(
*outcome,
DecisionRecord::Denied {
reason: DenyReason::NoGrant
}
);
assert_eq!(*grant, None);
}
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,240 @@
//! Every fail-closed message `loopd` produces ends with the runbook entry that explains it.
//! `scripts/check-runbook.sh` checks that the entries exist; these tests check that the messages
//! name them. Do not edit.
mod support;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use loopd::baseline::Baseline;
use loopd::broker_port::{not_configured_line, unavailable_line};
use loopd::session::{Session, SessionError};
use loopd::tools::Registry;
use proto::SessionId;
use support::{FakeServer, Home, Reply};
// `want` is the whole pointer, written out: scripts/check-runbook.sh reads the anchors of every
// pointer in the source, and cannot read one built with `format!`.
fn ends_with_pointer(message: &str, want: &str) {
assert!(
message.ends_with(want),
"{message:?} must end with {want:?}"
);
assert_eq!(
message.matches("runbook.md#").count(),
1,
"one pointer, not two: {message:?}"
);
}
#[test]
fn a_damaged_session_log_names_its_entry() {
let home = Home::new();
let cfg = home.config(Path::new("/tmp/unused.sock"));
let id = SessionId::new("a").unwrap();
let baseline = Baseline::assemble(&cfg, &Registry::m3a()).unwrap();
drop(Session::create(&home.dir, id.clone(), baseline, 0).unwrap());
let log = home.dir.join("sessions/a/0.jsonl");
let mut text = std::fs::read_to_string(&log).unwrap();
text.push_str("{\"type\":\"user\",\"time\":");
std::fs::write(&log, text).unwrap();
match Session::open(&home.dir, id) {
Err(e @ SessionError::Torn { .. }) => {
let message = e.to_string();
assert!(message.contains("0.jsonl:2: "), "{message}");
ends_with_pointer(&message, "see docs/runbook.md#session-log-damaged");
}
Err(other) => panic!("{other:?}"),
Ok(_) => panic!("a torn log was opened"),
}
}
#[test]
fn only_the_torn_error_points_at_the_damaged_log_entry() {
let home = Home::new();
let missing = Session::open(&home.dir, SessionId::new("nobody").unwrap());
let message = match missing {
Err(e) => e.to_string(),
Ok(_) => panic!("a session nobody created was opened"),
};
assert!(!message.contains("runbook"), "{message}");
}
#[test]
fn an_unreadable_core_memory_names_its_entry_and_a_missing_system_prompt_does_not() {
let home = Home::new();
let cfg = home.config(Path::new("/tmp/unused.sock"));
home.write("memory/core.md", "memory\n");
let core = home.dir.join("memory/core.md");
if !running_as_root() {
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = Baseline::assemble(&cfg, &Registry::m3a());
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap();
let message = result.expect_err("unreadable core.md").to_string();
assert!(message.contains("core.md"), "{message}");
ends_with_pointer(&message, "see docs/runbook.md#core-memory-unreadable");
}
// The system prompt is a different file with a different remedy: no pointer to this entry.
std::fs::remove_file(home.dir.join("system.md")).unwrap();
let message = Baseline::assemble(&cfg, &Registry::m3a())
.expect_err("no system.md")
.to_string();
assert!(!message.contains("core-memory-unreadable"), "{message}");
}
fn running_as_root() -> bool {
std::fs::read_to_string("/proc/self/status")
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
.unwrap_or(false)
}
#[test]
fn the_broker_lines_name_their_entry() {
let line = unavailable_line("cannot connect to /run/x.sock: No such file");
assert_eq!(
line,
"loopd: the tool broker is unavailable: cannot connect to /run/x.sock: No such file; \
see docs/runbook.md#broker-unavailable"
);
ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable");
let line = not_configured_line();
assert!(
line.starts_with("loopd: no tool broker is configured"),
"{line}"
);
ends_with_pointer(&line, "see docs/runbook.md#broker-unavailable");
}
fn config_file(
home: &Home,
server: &FakeServer,
expect_slots: u32,
extra: &str,
) -> std::path::PathBuf {
let text = format!(
r#"
[infer]
socket = "{}"
model = "test-model"
[slots]
main = 0
background = 1
[expect]
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
n_ctx = 131072
slots = {expect_slots}
[limits]
poll_ms = 40
liveness_ms = 500
retry_backoff_ms = [10]
[paths]
home = "{}"
{extra}
"#,
server.socket.display(),
home.dir.display()
);
let path = home.dir.join("config.toml");
std::fs::write(&path, text).unwrap();
path
}
fn healthy_routes(server: &FakeServer) {
server.route("/props", vec![Reply::fixture("props")]);
server.route(
"/v1/chat/completions",
vec![
Reply::fixture("tool_call"),
Reply::fixture("turn1"),
Reply::fixture("turn2"),
Reply::fixture("plain"),
],
);
}
/// Waits for `loopd serve` to bind its socket, stops it, and returns what it printed.
fn stderr_once_serving(mut child: Child, socket: &Path) -> String {
let mut up = false;
for _ in 0..200 {
if socket.exists() {
up = true;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(up, "the socket never appeared; stderr: {stderr}");
stderr
}
fn serve(config: &Path) -> Child {
Command::new(env!("CARGO_BIN_EXE_loopd"))
.args(["serve", "--config"])
.arg(config)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap()
}
#[test]
fn a_failed_self_test_names_its_entry() {
let home = Home::new();
let server = FakeServer::start();
healthy_routes(&server);
// The server has two slots; the config expects three.
let config = config_file(&home, &server, 3, "");
for command in ["selftest", "serve"] {
let output = Command::new(env!("CARGO_BIN_EXE_loopd"))
.args([command, "--config"])
.arg(&config)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{command}: {stderr}");
let line = stderr
.lines()
.find(|l| l.starts_with("selftest: FAILED: "))
.unwrap_or_else(|| panic!("{command}: no FAILED line: {stderr}"));
ends_with_pointer(line, "see docs/runbook.md#loopd-selftest-failed");
}
}
#[test]
fn serve_without_a_broker_says_so_once_with_the_entry() {
let home = Home::new();
let server = FakeServer::start();
healthy_routes(&server);
let config = config_file(&home, &server, 2, "");
let socket = home.dir.join("run/loop/loop.sock");
let stderr = stderr_once_serving(serve(&config), &socket);
let lines: Vec<&str> = stderr
.lines()
.filter(|l| l.contains("no tool broker is configured"))
.collect();
assert_eq!(lines.len(), 1, "once, at startup: {stderr}");
ends_with_pointer(lines[0], "see docs/runbook.md#broker-unavailable");
}
#[test]
fn serve_with_a_broker_socket_does_not_say_it() {
let home = Home::new();
let server = FakeServer::start();
healthy_routes(&server);
// The socket need not exist: loopd connects per call, and a missing broker is not a reason
// to refuse to start.
let extra = format!(
"[broker]\nsocket = \"{}\"\n",
home.dir.join("run/loop-broker/broker.sock").display()
);
let config = config_file(&home, &server, 2, &extra);
let socket = home.dir.join("run/loop/loop.sock");
let stderr = stderr_once_serving(serve(&config), &socket);
assert!(!stderr.contains("no tool broker"), "{stderr}");
assert!(stderr.contains("serving on"), "{stderr}");
}
@@ -0,0 +1,136 @@
//! A fake `brokerd` for the `BrokerPort` tests. Included with `#[path]`. Do not edit.
//!
//! The fake behaves as the real one will: it reads one request frame, answers on the same
//! connection, never half-closes, and closes after the final frame.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use loopd::broker_port::{BrokerPort, UNAVAILABLE};
use loopd::tools::{Pending, ToolPort};
use proto::{
CallId, DataClass, Envelope, Message, PROTOCOL_VERSION, SessionId, Timestamp, ToolRequest,
ToolResponse, read_frame, write_frame,
};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub fn socket_path() -> PathBuf {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!("loopd-broker-{}-{n}.sock", std::process::id()))
}
/// Accepts one connection, reads the request frame and hands both to `script`. The connection
/// closes when `script` returns.
pub fn broker<F>(script: F) -> (PathBuf, JoinHandle<Envelope>)
where
F: FnOnce(&mut UnixStream, &Envelope) + Send + 'static,
{
let path = socket_path();
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read_frame(&mut stream).unwrap();
script(&mut stream, &request);
request
});
(path, handle)
}
pub fn frame(id: u64, r#final: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final,
msg,
}
}
pub fn send(stream: &mut UnixStream, id: u64, r#final: bool, response: ToolResponse) {
// The port may already have given up and gone; the fake does not care.
let _ = write_frame(stream, &frame(id, r#final, Message::ToolResponse(response)));
}
pub fn result(content: &str) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: DataClass::Secret,
untrusted: true,
truncated: false,
}
}
pub fn request() -> ToolRequest {
ToolRequest {
session: SessionId::new("chat-1").unwrap(),
call: CallId(7),
tool: "read_file".to_string(),
arguments: r#"{"path":"/home/kyle/notes/a.md"}"#.to_string(),
}
}
pub fn in_ms(ms: u64) -> Timestamp {
Timestamp::from_unix_millis(Timestamp::now().unix_millis() + ms).unwrap()
}
pub struct Call {
pub response: ToolResponse,
pub pending: Vec<Pending>,
pub lines: Vec<String>,
pub took: Duration,
}
pub fn call(socket: PathBuf, timeout_ms: u64, request: &ToolRequest) -> Call {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = lines.clone();
let port = BrokerPort::with_log(
socket,
Duration::from_millis(timeout_ms),
Box::new(move |line| sink.lock().unwrap().push(line.to_string())),
);
let mut pending = Vec::new();
let started = Instant::now();
let response = port.call(request, &mut |p| pending.push(*p));
let took = started.elapsed();
let lines = lines.lock().unwrap().clone();
Call {
response,
pending,
lines,
took,
}
}
/// The answer is the fixed failure, and `loopd` printed exactly one line, with the pointer.
pub fn assert_unavailable(call: &Call, why: &str) {
assert_eq!(
call.response,
ToolResponse::Failed {
message: UNAVAILABLE.to_string()
},
"{why}"
);
assert_eq!(
call.lines.len(),
1,
"{why}: one line per failed call: {:?}",
call.lines
);
let line = &call.lines[0];
assert!(
line.starts_with("loopd: the tool broker is unavailable: "),
"{why}: {line}"
);
assert!(
line.ends_with("; see docs/runbook.md#broker-unavailable"),
"{why}: {line}"
);
assert!(call.pending.is_empty() || why.contains("pending"), "{why}");
}
@@ -0,0 +1,403 @@
//! A scripted stand-in for `llama-server`, for tests. Do not edit.
//!
//! It listens on a Unix socket in a temporary directory. Each path has a list of replies that are
//! served in order; the last one repeats. A reply is raw bytes, normally a response recorded from
//! the real server (`tests/fixtures/http/*.http`), and can be delayed, sent in small pieces, cut
//! short, or left hanging. Every request is recorded.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
static NEXT: AtomicU32 = AtomicU32::new(0);
pub fn fixture_path(kind: &str, name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(kind)
.join(name)
}
pub fn fixture_bytes(kind: &str, name: &str) -> Vec<u8> {
let path = fixture_path(kind, name);
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
/// A config that points at `socket`, with every limit short enough for a test.
pub fn test_config(socket: &Path) -> loopd::config::Config {
let text = format!(
r#"
[infer]
socket = "{}"
model = "test-model"
[slots]
main = 0
background = 1
[expect]
template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
n_ctx = 131072
slots = 2
[limits]
poll_ms = 40
busy_wait_ms = 400
load_wait_ms = 300
idle_grace_ms = 200
liveness_ms = 150
retry_backoff_ms = [10, 20, 30]
retry_window_ms = 5000
"#,
socket.display()
);
loopd::config::Config::parse(&text).unwrap()
}
/// A temporary `BOXMAKER_HOME` with a `system.md` beside a `config.toml`, for session tests.
pub struct Home {
pub dir: PathBuf,
}
impl Home {
pub fn new() -> Home {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("loopd-home-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("system.md"), "You are Boxmaker, a test agent.\n").unwrap();
Home { dir }
}
/// A config for `socket` whose home, system prompt and channel socket are all under this
/// directory, with the fast test limits.
pub fn config(&self, socket: &Path) -> loopd::config::Config {
let mut cfg = test_config(socket);
cfg.paths.home = self.dir.clone();
cfg.baseline.system = self.dir.join("system.md");
cfg.channel.socket = self.dir.join("loop.sock");
cfg
}
pub fn write(&self, relative: &str, text: &str) {
let path = self.dir.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
pub fn read(&self, relative: &str) -> String {
std::fs::read_to_string(self.dir.join(relative)).unwrap()
}
/// The records of a session's log, epoch 0.
pub fn records(&self, session: &str) -> Vec<proto::LogRecord> {
let text = self.read(&format!("sessions/{session}/0.jsonl"));
text.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
}
/// A tool port that answers from a script and records what it was asked.
pub struct ScriptedPort {
replies: Mutex<VecDeque<proto::ToolResponse>>,
calls: Mutex<Vec<proto::ToolRequest>>,
}
impl ScriptedPort {
/// Replies are given in order; when they run out, every call gets `fallback`.
pub fn new(replies: Vec<proto::ToolResponse>) -> ScriptedPort {
ScriptedPort {
replies: Mutex::new(replies.into()),
calls: Mutex::new(Vec::new()),
}
}
pub fn calls(&self) -> Vec<proto::ToolRequest> {
self.calls.lock().unwrap().clone()
}
}
pub fn ok_result(content: &str) -> proto::ToolResponse {
proto::ToolResponse::Result {
content: content.to_string(),
class: proto::DataClass::Private,
untrusted: true,
truncated: false,
}
}
pub fn pending(approval: u64, expires: &str) -> proto::ToolResponse {
proto::ToolResponse::PendingApproval {
approval,
expires: proto::Timestamp::parse(expires).unwrap(),
}
}
impl loopd::tools::ToolPort for ScriptedPort {
/// A scripted `PendingApproval` is reported through `on_pending`, as the real port does with
/// the broker's pending frame, and the reply after it is the answer. Use
/// `ReturnsPendingPort` for a port that breaks the rule and returns one.
fn call(
&self,
request: &proto::ToolRequest,
on_pending: &mut dyn FnMut(&loopd::tools::Pending),
) -> proto::ToolResponse {
self.calls.lock().unwrap().push(request.clone());
let mut replies = self.replies.lock().unwrap();
let mut reply = replies.pop_front();
if let Some(proto::ToolResponse::PendingApproval { approval, expires }) = reply {
on_pending(&loopd::tools::Pending { approval, expires });
reply = replies.pop_front();
}
reply.unwrap_or_else(|| ok_result("scripted"))
}
}
/// A port that breaks the rule: it returns a pending frame as its final answer.
pub struct ReturnsPendingPort;
impl loopd::tools::ToolPort for ReturnsPendingPort {
fn call(
&self,
_request: &proto::ToolRequest,
_on_pending: &mut dyn FnMut(&loopd::tools::Pending),
) -> proto::ToolResponse {
pending(1, "2026-09-18T12:00:00.000Z")
}
}
/// What an independent script derived from a recorded stream: `tests/fixtures/expected/`.
pub fn expected(name: &str) -> serde_json::Value {
serde_json::from_slice(&fixture_bytes("expected", &format!("{name}.json"))).unwrap()
}
#[derive(Clone)]
pub struct Reply {
bytes: Vec<u8>,
head_delay_ms: u64,
piece: usize,
piece_delay_ms: u64,
stop_after: Option<usize>,
hang_ms: u64,
}
impl Reply {
/// Exactly these bytes, then close.
pub fn raw(bytes: impl Into<Vec<u8>>) -> Reply {
Reply {
bytes: bytes.into(),
head_delay_ms: 0,
piece: usize::MAX,
piece_delay_ms: 0,
stop_after: None,
hang_ms: 0,
}
}
/// A response recorded from the real server: `tests/fixtures/http/<name>.http`.
pub fn fixture(name: &str) -> Reply {
Reply::raw(fixture_bytes("http", &format!("{name}.http")))
}
/// A small JSON response with a content length.
pub fn json(status: u16, body: &str) -> Reply {
Reply::raw(format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
))
}
/// Wait this long before sending the first byte, as a queued request does.
pub fn head_delay(mut self, ms: u64) -> Reply {
self.head_delay_ms = ms;
self
}
/// Send `piece` bytes at a time, waiting `delay_ms` before each piece after the first.
pub fn trickle(mut self, piece: usize, delay_ms: u64) -> Reply {
self.piece = piece.max(1);
self.piece_delay_ms = delay_ms;
self
}
/// Send only the first `bytes` bytes, then close, as a server that dies does.
pub fn cut_after(mut self, bytes: usize) -> Reply {
self.stop_after = Some(bytes);
self
}
/// Send only the first `bytes` bytes, then stay silent for `ms` before closing.
pub fn hang_after(mut self, bytes: usize, ms: u64) -> Reply {
self.stop_after = Some(bytes);
self.hang_ms = ms;
self
}
/// The offset just after the `n`th `data:` line of the body, for use with `cut_after`.
pub fn offset_after_events(&self, n: usize) -> usize {
let mut seen = 0;
let mut at = 0;
while let Some(found) = find(&self.bytes[at..], b"\n\n") {
at += found + 2;
seen += 1;
if seen == n {
return at;
}
}
panic!("the reply has only {seen} events");
}
}
#[derive(Debug, Clone)]
pub struct Recorded {
pub method: String,
/// Path and query as sent.
pub target: String,
/// Header lines as sent, without the request line.
pub headers: Vec<String>,
pub body: Vec<u8>,
}
impl Recorded {
pub fn path(&self) -> &str {
self.target.split('?').next().unwrap_or("")
}
pub fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).expect("request body is JSON")
}
}
struct State {
routes: Mutex<Vec<(String, VecDeque<Reply>)>>,
requests: Mutex<Vec<Recorded>>,
}
pub struct FakeServer {
pub socket: PathBuf,
state: Arc<State>,
}
impl FakeServer {
pub fn start() -> FakeServer {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("loopd-fake-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let socket = dir.join("infer.sock");
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket).unwrap();
let state = Arc::new(State {
routes: Mutex::new(Vec::new()),
requests: Mutex::new(Vec::new()),
});
let accept_state = Arc::clone(&state);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { break };
let state = Arc::clone(&accept_state);
thread::spawn(move || serve(stream, &state));
}
});
FakeServer { socket, state }
}
/// Replies for `path` (the query is ignored), served in order. The last one repeats.
pub fn route(&self, path: &str, replies: Vec<Reply>) {
assert!(!replies.is_empty());
let mut routes = self.state.routes.lock().unwrap();
routes.retain(|(p, _)| p != path);
routes.push((path.to_string(), replies.into()));
}
pub fn requests(&self) -> Vec<Recorded> {
self.state.requests.lock().unwrap().clone()
}
pub fn requests_to(&self, path: &str) -> Vec<Recorded> {
self.requests()
.into_iter()
.filter(|r| r.path() == path)
.collect()
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn read_request(stream: &mut UnixStream) -> Option<Recorded> {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
if let Some(end) = find(&buf, b"\r\n\r\n") {
break end;
}
match stream.read(&mut chunk) {
Ok(0) | Err(_) => return None,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let mut lines = head.split("\r\n");
let mut request_line = lines.next()?.split(' ');
let method = request_line.next()?.to_string();
let target = request_line.next()?.to_string();
let headers: Vec<String> = lines.map(str::to_string).collect();
let length = headers
.iter()
.filter_map(|h| h.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let mut body = buf[head_end + 4..].to_vec();
while body.len() < length {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => return None,
Ok(n) => body.extend_from_slice(&chunk[..n]),
}
}
Some(Recorded {
method,
target,
headers,
body,
})
}
fn serve(mut stream: UnixStream, state: &State) {
let Some(request) = read_request(&mut stream) else {
return;
};
let path = request.path().to_string();
state.requests.lock().unwrap().push(request);
let reply = {
let mut routes = state.routes.lock().unwrap();
match routes.iter_mut().find(|(p, _)| *p == path) {
Some((_, replies)) if replies.len() > 1 => replies.pop_front(),
Some((_, replies)) => replies.front().cloned(),
None => None,
}
};
let reply =
reply.unwrap_or_else(|| Reply::json(404, r#"{"error":"no route in the fake server"}"#));
thread::sleep(Duration::from_millis(reply.head_delay_ms));
let end = reply
.stop_after
.unwrap_or(reply.bytes.len())
.min(reply.bytes.len());
for (i, piece) in reply.bytes[..end].chunks(reply.piece).enumerate() {
if i > 0 {
thread::sleep(Duration::from_millis(reply.piece_delay_ms));
}
if stream.write_all(piece).is_err() {
return; // the client went away, which some tests do on purpose
}
let _ = stream.flush();
}
thread::sleep(Duration::from_millis(reply.hang_ms));
}
@@ -0,0 +1,307 @@
//! Tests for the registry, dispatch, the denial sentences, the result cap and the fake tools.
//! Do not edit.
mod support;
use loopd::tools::{
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch,
};
use proto::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse};
fn req(tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new("s").unwrap(),
call: CallId(1),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
#[test]
fn the_core_schemas_are_the_fixed_tools_array() {
let names: Vec<String> = Registry::m2b()
.core_schemas()
.into_iter()
.map(|s| s.name)
.collect();
assert_eq!(names, ["clock", "find_tool", "call_tool"]);
let find = &Registry::m2b().core_schemas()[1];
assert_eq!(find.parameters["required"], serde_json::json!(["query"]));
let call = &Registry::m2b().core_schemas()[2];
assert_eq!(
call.parameters["required"],
serde_json::json!(["name", "arguments"])
);
}
#[test]
fn find_matches_name_or_description_case_insensitively() {
let r = Registry::m2b();
let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::<Vec<_>>();
assert_eq!(names("echo"), ["echo"]);
assert_eq!(names("ECHO"), ["echo"]);
assert_eq!(names("unchanged"), ["echo"], "matches the description too");
assert_eq!(names("time"), ["clock"]);
assert!(names("weather").is_empty());
assert!(
names("").is_empty(),
"an empty query matches nothing, not everything"
);
assert!(names(" ").is_empty());
}
#[test]
fn find_tool_is_answered_locally() {
let r = Registry::m2b();
match dispatch(&r, "find_tool", r#"{"query":"echo"}"#) {
Dispatch::Local(text) => {
assert!(text.starts_with("1 tool(s) match:\n"), "{text}");
assert!(text.contains("\"name\":\"echo\""), "{text}");
assert!(
text.contains("\"parameters\""),
"the schema is included: {text}"
);
assert!(text.ends_with("Call it with call_tool."), "{text}");
}
other => panic!("{other:?}"),
}
assert_eq!(
dispatch(&r, "find_tool", r#"{"query":"weather"}"#),
Dispatch::Local("No tool matches.".to_string())
);
assert!(
matches!(dispatch(&r, "find_tool", "not json"), Dispatch::Local(t) if t == "No tool matches.")
);
}
#[test]
fn call_tool_unwraps_a_known_non_core_tool_and_nothing_else() {
let r = Registry::m2b();
let args = r#"{"name":"echo","arguments":{"text": "box"}}"#;
match dispatch(&r, "call_tool", args) {
Dispatch::Port { tool, arguments } => {
assert_eq!(tool, "echo");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arguments).unwrap(),
serde_json::json!({"text": "box"})
);
}
other => panic!("{other:?}"),
}
let cases = [
(
r#"{"name":"weather","arguments":{}}"#,
"No tool named \"weather\"",
),
(r#"{"name":"clock","arguments":{}}"#, "core tool"),
(r#"{"arguments":{}}"#, "No tool named \"\""),
("not json", "needs a JSON object"),
];
for (args, want) in cases {
match dispatch(&r, "call_tool", args) {
Dispatch::Local(text) => assert!(text.contains(want), "{args}: {text}"),
other => panic!("{args}: {other:?}, must never reach the port"),
}
}
}
#[test]
fn any_other_tool_goes_to_the_port_as_it_is() {
let r = Registry::m2b();
// Even one the registry does not know: the port (brokerd) decides, not loopd.
let want = Dispatch::Port {
tool: "read_file".to_string(),
arguments: r#"{"path":"/x"}"#.to_string(),
};
assert_eq!(dispatch(&r, "read_file", r#"{"path":"/x"}"#), want);
let want = Dispatch::Port {
tool: "weather".to_string(),
arguments: "not json".to_string(),
};
assert_eq!(dispatch(&r, "weather", "not json"), want);
}
#[test]
fn the_clock_is_answered_locally_whatever_its_arguments() {
for registry in [Registry::m2b(), Registry::m3a(), Registry::new(vec![])] {
for arguments in ["{}", r#"{"zone":"UTC"}"#, "not json", ""] {
let before = proto::Timestamp::now();
match dispatch(&registry, "clock", arguments) {
Dispatch::Local(text) => {
let time = proto::Timestamp::parse(&text)
.unwrap_or_else(|e| panic!("an RFC 3339 time, got {text:?}: {e:?}"));
assert!(time >= before, "{text}");
assert!(text.ends_with('Z'), "UTC: {text}");
}
other => panic!("{arguments:?}: {other:?}, the clock must not reach the port"),
}
}
}
}
#[test]
fn the_m3a_registry_has_the_same_core_and_the_four_broker_tools() {
let r = Registry::m3a();
assert_eq!(
r.core_schemas(),
Registry::m2b().core_schemas(),
"the tools array is part of the baseline: it must not change"
);
let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::<Vec<_>>();
assert_eq!(names("file"), ["read_file", "write_file"]);
assert_eq!(names("shell"), ["shell"]);
assert_eq!(names("fetch"), ["http_fetch"]);
assert_eq!(names("https"), ["http_fetch"]);
assert!(names("echo").is_empty(), "echo is a test tool only");
// The argument schemas are section 3's table: exactly these properties, all strings.
let table: [(&str, &[&str], &[&str]); 4] = [
("read_file", &["path"], &["path"]),
("write_file", &["content", "path"], &["path", "content"]),
("shell", &["command", "cwd"], &["command"]),
("http_fetch", &["url"], &["url"]),
];
for (name, properties, required) in table {
let entry = r.get(name).unwrap_or_else(|| panic!("{name} is missing"));
assert!(!entry.core, "{name} is found with find_tool, not declared");
let p = &entry.schema.parameters;
assert_eq!(p["type"], "object", "{name}");
let mut got: Vec<&str> = p["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
got.sort_unstable();
assert_eq!(got, properties, "{name}: properties");
for property in properties {
assert_eq!(
p["properties"][property]["type"], "string",
"{name}.{property}"
);
assert!(
p["properties"][property]["description"].is_string(),
"{name}.{property} needs a description"
);
}
assert_eq!(
p["required"],
serde_json::json!(required),
"{name}: required"
);
}
// call_tool lets the four through to the port and nothing else.
match dispatch(
&r,
"call_tool",
r#"{"name":"shell","arguments":{"command":"ls"}}"#,
) {
Dispatch::Port { tool, arguments } => {
assert_eq!(tool, "shell");
assert_eq!(arguments, r#"{"command":"ls"}"#);
}
other => panic!("{other:?}"),
}
assert!(matches!(
dispatch(&r, "call_tool", r#"{"name":"echo","arguments":{}}"#),
Dispatch::Local(t) if t.contains("No tool named \"echo\"")
));
}
#[test]
fn every_deny_reason_has_its_sentence() {
let table = [
(DenyReason::NoGrant, "Denied: no grant allows this call."),
(
DenyReason::GrantExpired,
"Denied: the grant for this call has expired.",
),
(
DenyReason::TaintTooHigh,
"Denied: this session has seen data too sensitive for this call.",
),
(
DenyReason::DeniedByGrant,
"Denied: a grant forbids this call.",
),
(
DenyReason::ApprovalRefused,
"Denied: the owner refused this call.",
),
(
DenyReason::ApprovalExpired,
"Denied: the approval request expired without an answer.",
),
(
DenyReason::InvalidArguments,
"Denied: the arguments are not valid for this tool.",
),
(
DenyReason::GrantsInvalid,
"Denied: the grant files have an error; the owner has been told.",
),
(
DenyReason::AuditUnavailable,
"Denied: the audit log cannot be written; the owner has been told.",
),
(
DenyReason::StateUnreadable,
"Denied: this session's broker state is damaged; the owner has been told.",
),
];
for (reason, want) in table {
assert_eq!(denial_text(reason), want, "{reason:?}");
}
}
#[test]
fn results_are_cut_on_a_character_boundary_and_marked() {
assert_eq!(cap_result("short", 100), ("short".to_string(), false));
assert_eq!(cap_result("exactly", 7), ("exactly".to_string(), false));
let (text, truncated) = cap_result("abcdefghij", 4);
assert_eq!(text, "abcd\n[truncated]");
assert!(truncated);
// 3 ASCII bytes, then 2-byte characters: byte 4 is inside a character.
let (text, truncated) = cap_result("abc\u{e9}\u{e9}\u{e9}", 4);
assert_eq!(text, "abc\n[truncated]");
assert!(truncated);
assert_eq!(cap_result("", 0), (String::new(), false));
assert_eq!(cap_result("x", 0), ("\n[truncated]".to_string(), true));
}
#[test]
fn fake_tools_answer_echo_deny_the_rest_and_record_calls() {
let fake = FakeTools::new();
let mut seen = 0;
let mut on_pending = |_: &Pending| seen += 1;
match fake.call(&req("echo", r#"{"text":"box"}"#), &mut on_pending) {
ToolResponse::Result {
content,
class,
untrusted,
truncated,
} => {
assert_eq!(content, "box");
assert_eq!(class, proto::DataClass::Public);
assert!(!untrusted && !truncated);
}
other => panic!("{other:?}"),
}
assert!(matches!(
fake.call(&req("echo", r#"{"tex":"box"}"#), &mut on_pending),
ToolResponse::Failed { .. }
));
for tool in ["weather", "read_file", "clock"] {
assert_eq!(
fake.call(&req(tool, "{}"), &mut on_pending),
ToolResponse::Denied {
reason: DenyReason::NoGrant
},
"{tool}: the clock is loopd's own now, not the port's"
);
}
assert_eq!(seen, 0, "the fake never asks for approval");
assert_eq!(fake.calls().len(), 5);
assert_eq!(fake.calls()[0].tool, "echo");
}
@@ -0,0 +1,297 @@
//! Tests for one turn: record sequences and tool dispatch. Do not edit.
//! The limits and the append-only property are in `limits.rs`; denials and approvals are in
//! `turn_broker.rs`.
mod support;
#[path = "support/turn.rs"]
mod turn_support;
use loopd::tools::Registry;
use proto::{DataClass, LogRecord, SessionId, ToolResponse, TurnEvent};
use support::{Reply, ok_result};
use turn_support::{setup, types};
const CHAT: &str = "/v1/chat/completions";
#[test]
fn a_plain_turn() {
let s = setup(vec![]);
s.server.route(CHAT, vec![Reply::fixture("plain")]);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "hello");
let outcome = result.unwrap();
assert_eq!(
outcome.content,
support::expected("plain")["content"].as_str().unwrap()
);
assert_eq!(
(outcome.usage.prompt_n, outcome.usage.predicted_n),
(46, 16)
);
assert_eq!(
types(session.records()),
["start", "user", "assistant", "usage"]
);
assert_eq!(
types(&s.home.records("a")),
["start", "user", "assistant", "usage"],
"on disk too"
);
assert!(
events
.iter()
.any(|e| matches!(e, TurnEvent::Content { .. }))
);
assert!(
!events
.iter()
.any(|e| matches!(e, TurnEvent::ToolCallStarted { .. }))
);
assert!(s.port.calls().is_empty());
let sent = s.server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
let body = sent[0].json();
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(
body["messages"][0]["content"],
"You are Boxmaker, a test agent."
);
assert_eq!(
body["messages"][1],
serde_json::json!({"role": "user", "content": "hello"})
);
let names: Vec<&str> = body["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["function"]["name"].as_str().unwrap())
.collect();
assert_eq!(names, ["clock", "find_tool", "call_tool"]);
assert_eq!(body["id_slot"], 0);
}
#[test]
fn a_tool_turn_goes_through_the_port_and_records_everything() {
let s = setup(vec![ok_result("straylight\n")]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "read the hostname");
assert!(result.is_ok(), "{result:?}");
assert_eq!(
types(session.records()),
[
"start",
"user",
"assistant",
"usage",
"tool_result",
"assistant",
"usage"
]
);
let calls = s.port.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].tool, "read_file");
assert_eq!(
calls[0].arguments, r#"{"path":"/etc/hostname"}"#,
"arguments are passed on unparsed"
);
assert_eq!(calls[0].session, SessionId::new("a").unwrap());
assert_eq!(calls[0].call, proto::CallId(1));
match &session.records()[4] {
LogRecord::ToolResult {
call,
tool_call_id,
content,
class,
untrusted,
truncated,
..
} => {
assert_eq!(*call, proto::CallId(1));
assert_eq!(
tool_call_id, "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F",
"the server's id, so the template can pair it"
);
assert_eq!(content, "straylight\n");
assert_eq!(
(*class, *untrusted, *truncated),
(DataClass::Private, true, false)
);
}
other => panic!("{other:?}"),
}
let kinds: Vec<&str> = events
.iter()
.filter_map(|e| match e {
TurnEvent::ToolCallStarted { name } => Some(name.as_str()),
TurnEvent::ToolResult { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
assert_eq!(kinds, ["read_file", "read_file"]);
// The second request extends the first: the tool result sits after the assistant turn.
let sent = s.server.requests_to(CHAT);
let m2 = sent[1].json()["messages"].as_array().unwrap().clone();
assert_eq!(m2[2]["role"], "assistant");
assert_eq!(
m2[2]["tool_calls"][0]["id"],
"wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F"
);
assert_eq!(
m2[3],
serde_json::json!({"role": "tool", "tool_call_id": "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", "content": "straylight\n"})
);
}
#[test]
fn find_tool_and_call_tool_reach_the_port_only_for_the_target() {
let s = setup(vec![ok_result("box")]);
s.server.route(
CHAT,
vec![
Reply::fixture("find_tool"),
Reply::fixture("call_tool"),
Reply::fixture("plain"),
],
);
let mut session = s.session("a");
let (result, _) = s.turn(&mut session, "echo box");
assert!(result.is_ok(), "{result:?}");
assert_eq!(
types(session.records()),
[
"start",
"user",
"assistant",
"usage",
"tool_result",
"assistant",
"usage",
"tool_result",
"assistant",
"usage"
]
);
let calls = s.port.calls();
assert_eq!(
calls.len(),
1,
"find_tool is answered by loopd; only echo reaches the port"
);
assert_eq!(calls[0].tool, "echo");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&calls[0].arguments).unwrap(),
serde_json::json!({"text": "box"})
);
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
..
} => {
assert!(
content.contains("\"name\":\"echo\"")
&& content.ends_with("Call it with call_tool."),
"{content}"
);
assert_eq!((*class, *untrusted), (DataClass::Public, false));
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_call_tool_for_an_unknown_tool_never_reaches_the_port() {
// The recorded call_tool asks for "echo"; with echo removed from the registry it is unknown.
let mut s = setup(vec![]);
s.registry = Registry::new(vec![loopd::tools::Entry {
schema: loopd::tools::clock_schema(),
core: true,
}]);
s.server.route(
CHAT,
vec![Reply::fixture("call_tool"), Reply::fixture("plain")],
);
let mut session = s.session("a");
assert!(s.turn(&mut session, "x").0.is_ok());
assert!(s.port.calls().is_empty());
assert!(
matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content.contains("No tool named \"echo\""))
);
}
#[test]
fn the_result_cap_applies_when_appended() {
let mut s = setup(vec![ok_result(&"x".repeat(100))]);
s.cfg.r#loop.tool_result_cap = 20;
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok());
match &session.records()[4] {
LogRecord::ToolResult {
content, truncated, ..
} => {
assert_eq!(content, &format!("{}\n[truncated]", "x".repeat(20)));
assert!(truncated);
}
other => panic!("{other:?}"),
}
assert!(events.iter().any(|e| matches!(
e,
TurnEvent::ToolResult {
truncated: true,
..
}
)));
let m2 = s.server.requests_to(CHAT)[1].json();
assert_eq!(
m2["messages"][3]["content"].as_str().unwrap().len(),
20 + "\n[truncated]".len(),
"the model sees the capped text"
);
}
#[test]
fn a_tool_failure_becomes_a_result_the_model_can_read() {
let s = setup(vec![ToolResponse::Failed {
message: "disk on fire".to_string(),
}]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok(), "{result:?}");
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
..
} => {
assert_eq!(content, "The tool failed: disk on fire");
assert_eq!((*class, *untrusted), (DataClass::Public, false));
}
other => panic!("{other:?}"),
}
assert!(
!events
.iter()
.any(|e| matches!(e, TurnEvent::ToolDenied { .. })),
"a failure is not a denial"
);
}
@@ -0,0 +1,222 @@
//! Tests for what the turn loop does with the broker's answers: denials, pending approvals, and
//! a port that misbehaves. Do not edit.
mod support;
#[path = "support/turn.rs"]
mod turn_support;
use proto::{DataClass, DenyReason, LogRecord, Timestamp, ToolResponse, TurnEvent};
use support::{Reply, ReturnsPendingPort, ok_result, pending};
use turn_support::{setup, types};
const CHAT: &str = "/v1/chat/completions";
/// The tool events of a turn, in order, as short strings.
fn tool_events(events: &[TurnEvent]) -> Vec<String> {
events
.iter()
.filter_map(|e| match e {
TurnEvent::ToolCallStarted { name } => Some(format!("started {name}")),
TurnEvent::ApprovalPending { approval, tool, .. } => {
Some(format!("pending {approval} {tool}"))
}
TurnEvent::ToolDenied { name, reason } => Some(format!("denied {name} {reason:?}")),
TurnEvent::ToolResult { name, .. } => Some(format!("result {name}")),
_ => None,
})
.collect()
}
#[test]
fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::TaintTooHigh,
}]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(
result.is_ok(),
"the turn goes on after a denial: {result:?}"
);
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
truncated,
..
} => {
assert_eq!(
content,
"Denied: this session has seen data too sensitive for this call."
);
assert_eq!(
(*class, *untrusted, *truncated),
(DataClass::Public, false, false)
);
}
other => panic!("{other:?}"),
}
assert_eq!(
tool_events(&events),
[
"started read_file",
"denied read_file TaintTooHigh",
"result read_file"
],
"the denial comes before the result"
);
// The model reads the sentence in the next request.
let m2 = s.server.requests_to(CHAT)[1].json();
assert_eq!(
m2["messages"][3]["content"],
"Denied: this session has seen data too sensitive for this call."
);
}
#[test]
fn a_denied_call_tool_names_the_target_tool_in_the_denial() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::NoGrant,
}]);
s.server.route(
CHAT,
vec![Reply::fixture("call_tool"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "echo box");
assert!(result.is_ok(), "{result:?}");
assert_eq!(
tool_events(&events),
[
"started call_tool",
"denied echo NoGrant",
"result call_tool"
],
"the owner writes grants for `echo`, not for `call_tool`"
);
assert!(
matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "Denied: no grant allows this call.")
);
}
#[test]
fn a_pending_approval_is_an_event_and_the_answer_after_it_is_the_result() {
let s = setup(vec![
pending(41, "2026-09-18T12:15:00.000Z"),
ok_result("straylight\n"),
]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok(), "{result:?}");
assert_eq!(
tool_events(&events),
[
"started read_file",
"pending 41 read_file",
"result read_file"
]
);
let expires = events.iter().find_map(|e| match e {
TurnEvent::ApprovalPending { expires, .. } => Some(*expires),
_ => None,
});
assert_eq!(
expires,
Some(Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap())
);
assert_eq!(s.port.calls().len(), 1, "one call, however long it waited");
assert_eq!(
types(session.records()),
[
"start",
"user",
"assistant",
"usage",
"tool_result",
"assistant",
"usage"
],
"waiting writes nothing to the log"
);
assert!(
matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "straylight\n")
);
}
#[test]
fn a_pending_approval_that_ends_in_a_refusal() {
let s = setup(vec![
pending(7, "2026-09-18T12:15:00.000Z"),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused,
},
]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok(), "{result:?}");
assert_eq!(
tool_events(&events),
[
"started read_file",
"pending 7 read_file",
"denied read_file ApprovalRefused",
"result read_file"
]
);
assert!(
matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content == "Denied: the owner refused this call.")
);
}
#[test]
fn a_port_that_returns_a_pending_frame_as_its_answer_is_a_failure_not_a_decision() {
let s = setup(vec![]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let runtime = loopd::turn::Runtime {
cfg: &s.cfg,
client: &s.client,
port: &ReturnsPendingPort,
registry: &s.registry,
};
let mut events = Vec::new();
let result =
loopd::turn::run_turn(&mut session, &runtime, "x", &mut |e| events.push(e.clone()));
assert!(result.is_ok(), "{result:?}");
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
..
} => {
assert_eq!(
content,
"The tool failed: the tool broker gave no final answer"
);
assert_eq!((*class, *untrusted), (DataClass::Public, false));
}
other => panic!("{other:?}"),
}
assert_eq!(
tool_events(&events),
["started read_file", "result read_file"],
"neither pending nor denied: the port said neither"
);
}
@@ -0,0 +1,257 @@
//! Tests for the admin messages of `admin.sock` and the two new error codes, against byte-exact
//! fixtures. Do not edit these or the fixtures.
use proto::{
ApprovalList, Approve, ApproveResult, CallId, DataClass, DecisionRecord, DenyReason, Empty,
Envelope, ErrorCode, GrantProblem, GrantsReport, Message, PendingApproval, Refuse, SessionId,
Timestamp, WireError,
};
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
text.trim_end_matches('\n').to_string()
}
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
fn check(name: &str, id: u64, msg: Message) {
let want = Envelope {
v: 1,
id,
r#final: true,
msg,
};
let text = fixture(name);
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(got, want, "{name}: decoded value");
assert_eq!(
serde_json::to_string(&want).unwrap(),
text,
"{name}: encoded bytes"
);
}
fn pending() -> PendingApproval {
PendingApproval {
approval: 41,
session: SessionId::new("chat-1789700000-42").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#
.to_string(),
grant: "shell-scratch".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(),
}
}
#[test]
fn the_three_requests_with_an_empty_body() {
check("approvals.json", 5, Message::Approvals(Empty {}));
check("check_grants.json", 8, Message::CheckGrants(Empty {}));
check("ok.json", 7, Message::Ok(Empty {}));
}
#[test]
fn approval_list() {
let list = ApprovalList {
items: vec![pending()],
};
check("approval_list.json", 5, Message::ApprovalList(list));
let empty = ApprovalList { items: Vec::new() };
check("approval_list_empty.json", 5, Message::ApprovalList(empty));
}
#[test]
fn approve_and_its_result() {
check(
"approve.json",
6,
Message::Approve(Approve { approval: 41 }),
);
check(
"approve_result_allowed.json",
6,
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Allowed {},
}),
);
check(
"approve_result_denied.json",
6,
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Denied {
reason: DenyReason::NoGrant,
},
}),
);
}
#[test]
fn refuse_with_and_without_a_reason() {
check(
"refuse.json",
7,
Message::Refuse(Refuse {
approval: 41,
reason: Some("not while I am away".to_string()),
}),
);
check(
"refuse_no_reason.json",
7,
Message::Refuse(Refuse {
approval: 41,
reason: None,
}),
);
}
#[test]
fn grants_report() {
let report = GrantsReport {
problems: vec![
GrantProblem {
file: "notes-read.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(),
},
],
};
check("grants_report.json", 8, Message::GrantsReport(report));
let ok = GrantsReport {
problems: Vec::new(),
};
check("grants_report_ok.json", 8, Message::GrantsReport(ok));
}
#[test]
fn the_two_new_error_codes() {
check(
"error_forbidden.json",
9,
Message::Error(WireError {
code: ErrorCode::Forbidden,
detail: "approve is not accepted on broker.sock".to_string(),
}),
);
check(
"error_no_such_approval.json",
6,
Message::Error(WireError {
code: ErrorCode::NoSuchApproval,
detail: "41".to_string(),
}),
);
}
/// An empty body is an object with no keys: not `null`, not a missing body, not an object with a
/// key in it.
#[test]
fn an_empty_body_must_be_an_empty_object() {
let good = fixture("approvals.json");
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
let bad = [
good.replacen("\"body\":{}", "\"body\":null", 1),
good.replacen(",\"body\":{}", "", 1),
good.replacen("\"body\":{}", "\"body\":{\"all\":true}", 1),
];
for text in bad {
assert_ne!(text, good);
assert!(
serde_json::from_str::<Envelope>(&text).is_err(),
"accepted {text}"
);
}
}
/// `reason` and `line` may be null but may not be left out: every field is always written, so a
/// reader never has to guess what a missing one means.
#[test]
fn optional_fields_are_null_not_absent() {
let refuse = fixture("refuse_no_reason.json");
let cut = refuse.replacen(",\"reason\":null", "", 1);
assert_ne!(cut, refuse);
assert!(
serde_json::from_str::<Envelope>(&cut).is_ok(),
"serde reads a missing Option as None; this documents it"
);
assert!(
serde_json::to_string(&Refuse {
approval: 1,
reason: None
})
.unwrap()
.contains("\"reason\":null")
);
assert!(
serde_json::to_string(&GrantProblem {
file: "a.toml".to_string(),
line: None,
problem: "x".to_string()
})
.unwrap()
.contains("\"line\":null")
);
}
/// An outcome is strict too. serde does not apply `deny_unknown_fields` to unit variants such as
/// `allowed`, so `DecisionRecord` must not rely on the derive for it.
#[test]
fn an_outcome_rejects_unknown_and_misplaced_fields() {
for good in [
r#"{"outcome":"allowed"}"#,
r#"{"outcome":"ask"}"#,
r#"{"outcome":"denied","reason":"no_grant"}"#,
] {
let value: DecisionRecord = serde_json::from_str(good).unwrap();
assert_eq!(serde_json::to_string(&value).unwrap(), good);
}
for bad in [
r#"{"outcome":"allowed","zz":1}"#,
r#"{"outcome":"ask","zz":1}"#,
r#"{"outcome":"denied","reason":"no_grant","zz":1}"#,
r#"{"outcome":"allowed","reason":"no_grant"}"#,
r#"{"outcome":"ask","reason":null,"zz":1}"#,
r#"{"outcome":"allowed","reason":null}"#,
r#"{"outcome":"denied"}"#,
r#"{"outcome":"approved"}"#,
r#"{"reason":"no_grant"}"#,
] {
assert!(
serde_json::from_str::<DecisionRecord>(bad).is_err(),
"accepted {bad}"
);
}
}
#[test]
fn pending_approvals_reject_bad_values() {
let good = fixture("approval_list.json");
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
let bad = [
// an approval id is a number
good.replacen("\"approval\":41", "\"approval\":\"41\"", 1),
// the session id is validated
good.replacen("chat-1789700000-42", "../etc", 1),
// the taint is one of the three classes
good.replacen("\"taint\":\"private\"", "\"taint\":\"internal\"", 1),
// a field is missing
good.replacen("\"grant\":\"shell-scratch\",", "", 1),
// a field nobody defined
good.replacen("\"grant\":", "\"note\":1,\"grant\":", 1),
];
for text in bad {
assert_ne!(text, good);
assert!(
serde_json::from_str::<Envelope>(&text).is_err(),
"accepted {text}"
);
}
}
@@ -0,0 +1,389 @@
//! The audit chain verifier against the fixture logs in `tests/fixtures/audit/`. Do not edit
//! this file or the fixtures: their hashes are real, and one changed byte changes the verdict.
//!
//! Every fixture is a small audit directory. `good` is an undamaged two-day log; the others are
//! `good` with one thing done to it, named by the directory.
use proto::{AuditRecord, ChainReport, ChainVerifier, Hash32, Location, sha256};
const D1: &str = "2026-09-17.jsonl";
const D2: &str = "2026-09-18.jsonl";
fn dir(case: &str) -> String {
format!("{}/tests/fixtures/audit/{case}", env!("CARGO_MANIFEST_DIR"))
}
/// The `.jsonl` files of a case, in name order, with their bytes.
fn files(case: &str) -> Vec<(String, Vec<u8>)> {
let dir = dir(case);
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("{dir}: {e}"))
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.filter(|name| name.ends_with(".jsonl"))
.collect();
names.sort();
assert!(!names.is_empty(), "{dir}: no files");
names
.into_iter()
.map(|name| {
let bytes = std::fs::read(format!("{dir}/{name}")).unwrap();
(name, bytes)
})
.collect()
}
fn verify(case: &str) -> ChainReport {
let mut verifier = ChainVerifier::new();
for (name, bytes) in files(case) {
verifier.feed(&name, &bytes);
}
verifier.finish()
}
/// Line `line` (1-based) of a file of a case, without its newline.
fn line_of(case: &str, file: &str, line: usize) -> Vec<u8> {
let (_, bytes) = files(case)
.into_iter()
.find(|(name, _)| name == file)
.unwrap();
bytes.split(|b| *b == b'\n').nth(line - 1).unwrap().to_vec()
}
fn hash_of(case: &str, file: &str, line: usize) -> Hash32 {
sha256(&line_of(case, file, line)).unwrap()
}
fn at(file: &str, line: u64) -> Location {
Location {
file: file.to_string(),
line,
}
}
#[test]
fn good_log_verifies() {
let report = verify("good");
assert_eq!(report.failure, None);
assert_eq!(report.records, 10);
assert_eq!(report.next_seq, 10);
assert_eq!(report.head, Some(hash_of("good", D2, 5)));
assert_eq!(
report.abandoned,
vec![6],
"the ask at seq 6 has no approval"
);
assert_eq!(
report.unfinished,
vec![7],
"the allowed call at seq 7 has no result"
);
assert!(report.recoveries.is_empty());
assert!(report.accepted_breaks.is_empty());
assert!(report.clock_warnings.is_empty());
assert_eq!(report.torn_tail, None);
}
/// The tampering suite: each case fails, at this file and line, with this text.
#[test]
fn tampering_is_found_at_the_right_line() {
let parse = "does not parse as an audit record";
let cases = [
// The changed line still parses and chains; the line after it no longer chains from it.
(
"changed-byte",
D1,
4,
"prev is not the hash of the line before",
),
("deleted-line", D1, 3, "seq is 3, expected 2"),
("swapped-lines", D1, 2, "seq is 2, expected 1"),
("seq-gap", D1, 3, "seq is 3, expected 2"),
(
"file-not-chained",
D2,
1,
"does not chain from the last line of the file before",
),
("cut-short", D1, 3, parse),
(
"break-wrong-line",
D1,
4,
"prev is not the hash of the line before",
),
(
"break-wrong-last-good",
D1,
4,
"prev is not the hash of the line before",
),
(
"break-wrong-prev",
D1,
4,
"prev is not the hash of the line before",
),
(
"break-wrong-seq",
D1,
4,
"prev is not the hash of the line before",
),
(
"break-without-failure",
D2,
6,
"an accepted break with no failure before it",
),
("recovery-wrong-hash", D2, 6, parse),
("recovery-wrong-length", D2, 6, parse),
(
"recovery-describes-nothing",
D2,
6,
"a recovery record that does not describe the line before it",
),
("torn-recovery", D2, 6, parse),
];
for (case, file, line, what) in cases {
let failure = verify(case)
.failure
.unwrap_or_else(|| panic!("{case}: verified, but it is damaged"));
assert_eq!(
(failure.file.as_str(), failure.line, failure.what.as_str()),
(file, line, what),
"{case}"
);
}
}
#[test]
fn a_failure_says_what_a_break_record_must_carry() {
let failure = verify("changed-byte").failure.unwrap();
assert_eq!(failure.last_good, hash_of("changed-byte", D1, 3));
assert_eq!(failure.break_prev, hash_of("changed-byte", D2, 5));
// The failing line should have had seq 3; seven lines run from it to the end of the log.
assert_eq!(failure.break_seq, 10);
assert!(!failure.tail_torn);
// A failure at the very first line: nothing verified, so last_good is all zeros.
let mut verifier = ChainVerifier::new();
verifier.file(D1);
verifier.line(b"not json", true);
verifier.line(b"nor this", true);
let failure = verifier.finish().failure.unwrap();
assert_eq!(
(failure.line, failure.last_good, failure.break_seq),
(1, Hash32::ZERO, 2)
);
assert_eq!(failure.break_prev, sha256(b"nor this").unwrap());
let failure = verify("torn-recovery").failure.unwrap();
assert!(failure.tail_torn, "the last line has no newline");
assert_eq!(
failure.break_seq, 12,
"seq 10 for line 6, and two lines to the end"
);
}
#[test]
fn verification_stops_counting_at_a_failure() {
let report = verify("changed-byte");
assert_eq!(report.records, 3);
assert_eq!(report.head, Some(hash_of("changed-byte", D1, 3)));
assert_eq!(report.next_seq, 3);
assert_eq!(report.torn_tail, None);
}
#[test]
fn a_torn_tail_is_not_a_failure() {
// (case, file, line, has_newline, records, recovery_seq, file and line of the record before)
let cases = [
("torn-tail", D2, 6, false, 10, 10, (D2, 5)),
// Complete JSON that lacks only its newline is torn all the same.
("torn-tail-complete-json", D2, 6, false, 10, 10, (D2, 5)),
// A crash between ending a torn line and writing its Recovery.
("torn-unparseable-newline", D2, 6, true, 10, 10, (D2, 5)),
("torn-first-line", D2, 1, false, 5, 5, (D1, 5)),
];
for (case, file, line, has_newline, records, seq, before) in cases {
let report = verify(case);
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.records, records, "{case}");
let torn = report
.torn_tail
.unwrap_or_else(|| panic!("{case}: no torn tail"));
let bytes = line_of(case, file, line as usize);
assert_eq!(torn.at, at(file, line), "{case}");
assert_eq!(torn.has_newline, has_newline, "{case}");
assert_eq!(torn.bytes, bytes.len() as u64, "{case}");
assert_eq!(torn.sha256, sha256(&bytes).unwrap(), "{case}");
assert_eq!(torn.recovery_seq, seq, "{case}");
assert_eq!(
torn.recovery_prev,
hash_of(case, before.0, before.1),
"{case}"
);
assert_eq!(
report.next_seq, seq,
"{case}: the torn line is not a record"
);
}
let whole = line_of("torn-tail-complete-json", D2, 6);
assert!(
serde_json::from_slice::<AuditRecord>(&whole).is_ok(),
"this case must be a line that parses"
);
}
#[test]
fn an_empty_latest_file_is_fine() {
let report = verify("empty-latest");
assert_eq!((report.failure, report.torn_tail), (None, None));
assert_eq!((report.records, report.next_seq), (5, 5));
}
#[test]
fn a_recovered_line_is_not_a_record_and_not_a_failure() {
// (case, where the recovered line is, records, abandoned, unfinished)
let cases = [
("recovered", at(D2, 6), 12, vec![6], vec![]),
// The recovered line is complete JSON with seq 10; the Recovery takes seq 10 again.
("recovered-complete-json", at(D2, 6), 12, vec![6], vec![]),
// Torn on one day, recovered on the next: the Recovery is in the torn line's file.
("recovered-next-day", at(D1, 6), 11, vec![7], vec![8]),
("recovered-first-line", at(D2, 1), 6, vec![], vec![]),
];
for (case, recovered, records, abandoned, unfinished) in cases {
let report = verify(case);
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.torn_tail, None, "{case}");
assert_eq!(report.recoveries, vec![recovered], "{case}");
assert_eq!(report.records, records, "{case}");
assert_eq!(report.abandoned, abandoned, "{case}");
assert_eq!(report.unfinished, unfinished, "{case}");
assert!(report.clock_warnings.is_empty(), "{case}");
}
}
#[test]
fn a_clock_stepped_back_is_a_warning() {
let report = verify("clock-back");
assert_eq!(report.failure, None);
assert_eq!(report.records, 11);
assert_eq!(report.clock_warnings, vec![at(D2, 6)]);
}
#[test]
fn an_accepted_break_clears_the_failure_before_it() {
// (case, where the break record is, records, next_seq)
let cases = [
("accepted-break", at(D1, 6), 5, 7),
("accepted-break-older-file", at(D2, 6), 5, 12),
// A deleted line in day 2 as well: one break covers every failure before it.
("accepted-break-two-failures", at(D2, 5), 4, 10),
// A line in the region claims seq 18446744073709551615. The break's seq is counted
// from lines, so it is 10 all the same.
("accepted-break-max-seq", at(D2, 6), 4, 11),
];
for (case, break_at, records, next_seq) in cases {
let report = verify(case);
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.accepted_breaks, vec![break_at], "{case}");
assert_eq!(report.records, records, "{case}");
assert_eq!(report.next_seq, next_seq, "{case}");
}
// Records inside the region are not vouched for: the approval of seq 2 is in it.
let report = verify("accepted-break");
assert_eq!(report.abandoned, vec![2]);
assert_eq!(report.unfinished, vec![6]);
}
/// A verifier that starts at the latest file cannot judge a break that names an older one. It
/// checks the break's `prev` and goes on; the full verification judges the rest.
#[test]
fn a_resumed_verifier_accepts_a_break_naming_an_earlier_file() {
let case = "accepted-break-older-file";
let last: AuditRecord = serde_json::from_slice(&line_of(case, D1, 5)).unwrap();
let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5));
let (_, day2) = files(case).into_iter().nth(1).unwrap();
verifier.feed(D2, &day2);
let report = verifier.finish();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D2, 6)]);
assert_eq!((report.records, report.next_seq), (7, 12));
// The same break with a wrong prev is not accepted, resumed or not.
let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5));
let (_, day2) = files("break-wrong-prev").into_iter().nth(1).unwrap();
verifier.feed(D2, &day2);
assert!(verifier.finish().failure.is_some());
}
#[test]
fn resume_continues_from_the_file_before() {
let last: AuditRecord = serde_json::from_slice(&line_of("good", D1, 5)).unwrap();
let (_, day2) = files("good").into_iter().nth(1).unwrap();
let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of("good", D1, 5));
verifier.feed(D2, &day2);
let report = verifier.finish();
assert_eq!(report.failure, None);
assert_eq!((report.records, report.next_seq), (5, 10));
// Resumed from the wrong hash, the first line of the file does not chain.
let mut verifier = ChainVerifier::resume(last.seq + 1, Hash32::ZERO);
verifier.feed(D2, &day2);
let failure = verifier.finish().failure.unwrap();
assert_eq!((failure.file.as_str(), failure.line), (D2, 1));
assert_eq!(
failure.what,
"does not chain from the last line of the file before"
);
assert_eq!(
failure.last_good,
Hash32::ZERO,
"the hash it was resumed with"
);
}
/// `feed` is `file` and then `line` for each line; both ways must give the same report.
#[test]
fn feed_is_file_then_lines() {
for case in [
"good",
"torn-tail",
"recovered",
"changed-byte",
"empty-latest",
] {
let mut verifier = ChainVerifier::new();
for (name, bytes) in files(case) {
verifier.file(&name);
let mut rest: &[u8] = &bytes;
while !rest.is_empty() {
match rest.iter().position(|b| *b == b'\n') {
Some(end) => {
verifier.line(&rest[..end], true);
rest = &rest[end + 1..];
}
None => {
verifier.line(rest, false);
rest = &[];
}
}
}
}
assert_eq!(verifier.finish(), verify(case), "{case}");
}
}
#[test]
fn an_empty_log_is_fine() {
let report = ChainVerifier::new().finish();
assert_eq!(
(report.failure, report.torn_tail, report.head),
(None, None, None)
);
assert_eq!((report.records, report.next_seq), (0, 0));
}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":18446744073709551615,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,7 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
{"seq":11,"time":"2026-09-18T09:30:01.000Z","prev":"17662d4b56809a03a432c30d037904fcab478ed8b3d9fa8a1f515b7dbe0a7837","event":{"type":"decision","session":"chat-1","call":9,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
@@ -0,0 +1,7 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":5,"time":"2026-09-17T08:30:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
{"seq":6,"time":"2026-09-17T08:30:01.000Z","prev":"1d166141aa286ccb2b76e4c5b640a397a8af3c7fef0623a145182c812bacc114","event":{"type":"decision","session":"chat-1","call":9,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-18.jsonl","line":5,"last_good":"0000000000000000000000000000000000000000000000000000000000000000"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"0000000000000000000000000000000000000000000000000000000000000000"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":3,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,7 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{}
{"seq":11,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":11,"time":"2026-09-18T09:30:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":4,"last_good":"6707895855712d119b54e67d86f46331122e0f82450a5481c2d770fa7f16f021"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -H\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,6 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-17T23:59:58.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
@@ -0,0 +1,4 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"d5bb0822b4a83babe54392edcdc0b895e9919e118ca0d9c42da179be8a7719a8","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"ee18e126c0bb8dfa719c7c2b0a94d26019017a4c7e983b61668ed81cc135cb94","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"cfccb0f03849b11c17a2a71a0281583052cbfb96cd5b9eb184145142e35db056","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"56ca7170d34f57b158991a3f4798c795fa5d147f8ca44a858197cd94c9c6f14c","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,5 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
@@ -0,0 +1,5 @@
{"seq":0,"time":"2026-09-17T08:00:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"chat-1","call":1,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":1,"time":"2026-09-17T08:00:01.000Z","prev":"d7a63875b77829169f2c9f1bfb54cf00cc11a7efdcc34fa3a3ea1e889ea38eaa","event":{"type":"result","session":"chat-1","call":1,"decision":0,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":2,"time":"2026-09-17T08:00:02.000Z","prev":"dc8b744a6123f275332ab7b538100b9acdd1e4a9e1cc03a47f126700c21aa4ea","event":{"type":"decision","session":"chat-1","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":3,"time":"2026-09-17T08:00:03.000Z","prev":"5125cad73872977d597bb5bae5eda8c7f41fa34eb7f35bb3a3c6d2e34ffe9483","event":{"type":"approval","session":"chat-1","call":2,"decision":2,"answer":"approved","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"allowed"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":4,"time":"2026-09-17T08:00:04.000Z","prev":"e37ad0c18430235141ca1b8242653066983995e239d78cfdea4678111a2d0987","event":{"type":"result","session":"chat-1","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
@@ -0,0 +1,8 @@
{"seq":5,"time":"2026-09-18T09:00:00.000Z","prev":"29ef9d1d28442c8615bf8db10598d38eecc589e469ee14a54085038e2aad9563","event":{"type":"decision","session":"chat-1","call":3,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":6,"time":"2026-09-18T09:00:01.000Z","prev":"451896c692a37d00ea44b95b379c71850f2b6577bbff878591de47df832c9f44","event":{"type":"decision","session":"chat-1","call":4,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":7,"time":"2026-09-18T09:00:02.000Z","prev":"4d04826fa2d19247131f45cb87d242099fa735f77c281e6e07656581abcda2ed","event":{"type":"decision","session":"chat-1","call":5,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"allowed"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":8,"time":"2026-09-18T09:00:03.000Z","prev":"17438f107a2e7bc3dafba3beae1f610155b76ccf87db534aad0841ba5c02c238","event":{"type":"decision","session":"chat-1","call":6,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-scratch","grant_sha256":"3492ad65d05a973fef8c825521eeb41ae64625a672a8aeeeabc696e16d62a020","taint":"private","untrusted":false}}
{"seq":9,"time":"2026-09-18T09:00:04.000Z","prev":"62eb4f6a64789e9401df1009485910d0791ed6ea1f57c172b95c6d58eb2f7c15","event":{"type":"approval","session":"chat-1","call":6,"decision":8,"answer":"refused","by":"bxctl","post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
{"seq":10,"time":"2026-09-18T09:00:05.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}
{"seq":10,"time":"2026-09-18T09:10:00.000Z","prev":"526e63bfcbab56505a872d14ca9a600936ca00a0156160fc46c428986883cb68","event":{"type":"recovery","torn_bytes":368,"torn_sha256":"6627168a838f2beffce7ca0be64c0be17ecaec1184d9915c3c9ae046eeb9fe8c"}}
{"seq":11,"time":"2026-09-18T09:10:01.000Z","prev":"20a555911cfdfa0c514ec07c4162733e64b5ebc26e8bd660804fd1c9a9638c6c","event":{"type":"result","session":"chat-1","call":5,"decision":7,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":25,"sha256":"ed52694265ada1850bdf1a90f9c6b6e1ed9e45fbca44fbe9114639ada3dc8051","taint_after":"private"}}

Some files were not shown because too many files have changed in this diff Show More