Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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());
|
||||
}
|
||||
Reference in New Issue
Block a user