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>
325 lines
10 KiB
Rust
325 lines
10 KiB
Rust
//! 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}");
|
|
}
|