Handle approvals, refusals and grant checks on admin.sock
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which answers
|
||||
//! an approval the same way. Whoever takes an entry out of the table answers it.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
use crate::approvals::Entry;
|
||||
use crate::broker::{Broker, forbid, read_request, send};
|
||||
use crate::grants;
|
||||
use crate::ledger::{Answer, Answered};
|
||||
use proto::{
|
||||
ApprovalList, Approve, ApproveResult, DecisionRecord, DenyReason, Empty, ErrorCode,
|
||||
GrantsReport, Message, Refuse, Timestamp, WireError,
|
||||
};
|
||||
|
||||
/// The Approval record's `by` for answers through admin.sock.
|
||||
pub const BY: &str = "bxctl";
|
||||
/// The refusal `bxctl` sees when the ledger could not record it.
|
||||
pub const REFUSAL_INTERNAL: &str =
|
||||
"the refusal could not be recorded; the call is denied; see docs/runbook.md#audit-unavailable";
|
||||
|
||||
/// Whoever takes an entry out of the table answers it: the ledger records the answer, then the
|
||||
/// verdict goes to the waiting thread, and only then is the record returned.
|
||||
pub fn answer(broker: &Broker, entry: Entry, answer: Answer, now: Timestamp) -> DecisionRecord {
|
||||
// 1. The grants as they are now: an approval decides again with them.
|
||||
let grants = broker.grants();
|
||||
|
||||
// 2. Record the answer; the ledger writes the Approval record and returns the verdict and
|
||||
// outcome together, or the audit-unavailable denial when it could not record.
|
||||
let Entry { info, ask, reply } = entry;
|
||||
let Answered { verdict, outcome } =
|
||||
broker
|
||||
.ledger()
|
||||
.answer(ask, info.approval, answer, &grants, now);
|
||||
|
||||
// 3. The record is written either way; if the waiting thread is gone, there is nothing else to do.
|
||||
if reply.send(verdict).is_err() {
|
||||
broker.log(&format!(
|
||||
"brokerd: approval {} was answered after its requester had gone",
|
||||
info.approval
|
||||
));
|
||||
}
|
||||
|
||||
// 4. The outcome the caller reports to bxctl.
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Answer every approval whose time has run out, the same way an owner's refusal would.
|
||||
pub fn expire_due(broker: &Broker, now: Timestamp) -> usize {
|
||||
let entries = broker.table().take_expired(now);
|
||||
let count = entries.len();
|
||||
for entry in entries {
|
||||
answer(broker, entry, Answer::Expired, now);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Serve one `admin.sock` connection from bxctl: one request, one final answer.
|
||||
pub fn handle(mut stream: UnixStream, broker: &Broker) {
|
||||
// 1. The peer is gone before it speaks, or its frame is malformed: answer and return.
|
||||
let Some(envelope) = read_request(&mut stream) else {
|
||||
return;
|
||||
};
|
||||
let id = envelope.id;
|
||||
let msg = envelope.msg;
|
||||
let now = Timestamp::now();
|
||||
|
||||
// 2. By message kind. `take` is the only way in: no one looks at an entry and removes it later.
|
||||
let response = match msg {
|
||||
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
|
||||
items: broker.table().list(),
|
||||
}),
|
||||
Message::Approve(Approve { approval }) => match broker.table().take(approval) {
|
||||
None => Message::Error(WireError {
|
||||
code: ErrorCode::NoSuchApproval,
|
||||
detail: format!("approval {approval} is not pending"),
|
||||
}),
|
||||
Some(entry) => {
|
||||
let outcome = answer(
|
||||
broker,
|
||||
entry,
|
||||
Answer::Approved {
|
||||
by: Some(BY.to_string()),
|
||||
},
|
||||
now,
|
||||
);
|
||||
Message::ApproveResult(ApproveResult { outcome })
|
||||
}
|
||||
},
|
||||
Message::Refuse(Refuse { approval, reason }) => match broker.table().take(approval) {
|
||||
None => Message::Error(WireError {
|
||||
code: ErrorCode::NoSuchApproval,
|
||||
detail: format!("approval {approval} is not pending"),
|
||||
}),
|
||||
Some(entry) => match answer(
|
||||
broker,
|
||||
entry,
|
||||
Answer::Refused {
|
||||
by: Some(BY.to_string()),
|
||||
reason,
|
||||
},
|
||||
now,
|
||||
) {
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::ApprovalRefused,
|
||||
} => Message::Ok(Empty {}),
|
||||
// The refusal could not be recorded: bxctl gets an Internal, the waiting call a denial.
|
||||
_ => Message::Error(WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail: REFUSAL_INTERNAL.to_string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
// bxctl grants check shows the problems itself and must not use up the print-once of the broker.
|
||||
Message::CheckGrants(_) => {
|
||||
let problems = match grants::load(&broker.cfg().paths.grants) {
|
||||
Ok(_) => Vec::new(),
|
||||
Err(problems) => problems,
|
||||
};
|
||||
Message::GrantsReport(GrantsReport { problems })
|
||||
}
|
||||
other => {
|
||||
forbid(broker, &mut stream, id, &other, "admin.sock");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. The final frame; a failed send is ignored.
|
||||
let _ = send(&mut stream, id, true, response);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The broker: the only role that holds authority.
|
||||
|
||||
pub mod admin;
|
||||
pub mod approvals;
|
||||
pub mod args;
|
||||
pub mod audit;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,8 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
| M3a/09-brokerd-audit-writer | 2026-09-19 | done | 2 | fail | `write_record` opens with `.append(true)` (task says "for write") because this environment's `tmpfs` truncates on `write(true).create(true)`; `open` tolerates an already-existing dir (the `case` fixtures pre-create it); `Lock(fs::File)` wrapper added so `Writer` can `#[derive(Debug)]` (the copied tests call `unwrap_err`). | Wrote `crates/brokerd/src/audit.rs`: `Writer`, `Opened`, `AuditError` (Locked/Broken/NothingToAccept/Io/Stopped, hand-written Display ending in the task's RUNBOOK anchors), `verify_dir` (the short check for 2+ files, else full), and `RECOVERED_NOTICE`; `pub mod audit;` in lib.rs. Copied three test files byte-identical. The day-boundary and failed-write tests failed for two real reasons: the appends were silently losing every second line because `tmpfs` truncates on `write(true)` (fixed with `.append(true)`), and the second writer was not being marked `Stopped` after a failed write (fixed per append rule 5). All 16 tests pass (9 audit + 7 audit_startup) across five runs; `make gate` prints `gate: ok`. Two clippy fixes before a clean gate: collapsed the dir-builder `if let` into a let-chain, and added `.truncate(false)` to the lock's open. | OpenCode |
|
||||
| M3a/10-brokerd-runner | 2026-09-19 | done | 1 | pass | none |
|
||||
| M3a/11-brokerd-approvals | 2026-09-19 | done | 1 | pass | none | Wrote crates/brokerd/src/approvals.rs: Verdict { Run(Box<Decision>), Denied(DenyReason) } (boxed for clippy's large_enum_variant), Entry { info, ask, reply: Sender<Verdict> }, and Table { entries: Mutex<BTreeMap<u64, Entry>> } with a single private `lock()` helper that takes the mutex and recovers a poisoned guard with `unwrap_or_else(|p| p.into_inner())`. `insert` makes a channel and stores the Entry under `info.approval` returning the receiver; `take` removes under the lock and returns the Entry (so the non-Clone `Ask` is not cloned); `take_expired` holds one lock, collects the ids where `now >= expires` (BTreeMap `values()` already yields id order, so no per-id lock to race), removes each, returns them in id order; `list` clones every `info` in id order. No method sends on `reply`. 7 approvals tests pass five runs in a row; `make gate` prints `gate: ok`. | ? | Wrote `crates/brokerd/src/runner.rs`: `Mount` (path/writable), `RunSpec` (four private fields in the given order, no constructor, no Clone, four getters, `egress` as `as_deref`), `RunOutput`, `RunError` (Failed/Unavailable), `Runtime` (Send+Sync), `Refusing` (every call `Err(Unavailable(REFUSING))`), and `run(decision, &dyn Runtime)`. `run` takes `decision.label()` first, builds the spec (`tool = args().tool()`, `arguments = args().clone()`), then the mounts/egress table is one `match` on the `ToolArgs` variant with no `_` arm: ReadFile mounts the matched path read-only, WriteFile writable, Shell mounts every grant path in order writable, HttpFetch mounts nothing and carries `Some(hosts())`; only HttpFetch has network. `Ok(output)` -> `ToolResponse::Result` with the label's class/untrusted; `Err(Failed|Unavailable(m))` -> `Failed { message: m }` unchanged. Added the two module doctests (compile_fail proving the fields private, compiling through proving `tool()` public). 8 runner tests pass; 9 doctests pass; `make gate` prints `gate: ok`. Proved the compile_fail doctest has teeth: making the four RunSpec fields `pub` made it fail ("test compiled"), reverting to private made it pass again. | ? |
|
||||
| M3a/13-brokerd-broker | 2026-09-20 | done | 2 | fail | none | Wrote crates/brokerd/src/broker.rs (313 lines): Broker { cfg, ledger, table, runtime, log, printed } with fields in the wire order, and the 11 public items (Broker::new, cfg, ledger, table, grants, log; and free fn kind, send, read_request, forbid, alive, handle). grants() loads the owner's grants, logging one render per distinct problem set via a Mutex<Option<Vec<GrantProblem>>>'printed' tracker that clears on Ok and resets on re-read. kind() maps all 14 Message variants to snake_case. send() writes one Frame. read_request() returns None on Closed and answers a malformed frame with BadVersion/BadMessage/BadFrame then None. forbid() logs the refused kind plus the RUNBOOK anchor and answers Forbidden. alive() sets a 10 ms read timeout and treats WouldBlock/TimedOut as waiting (a byte would break the protocol). handle(): read_request exit 1 (line 191), forbid exit 2 (line 196), decide 3a denied (203)/3b allowed (204)/3c ask (206), final frame (210). run() records the Result; pending() computes expiry (ttl-or-grant), inserts the PendingApproval, sends one PendingApproval{expires} final:false, takes the table entry if the peer left before it went out (line 269), waits up to 1 s per loop iteration with alive(), answers Denied/Run, and on Run checks alive() once more: gone -> finish(GONE) and take the entry (line 307), else run. No abandoned record is written; the requester-leaves path returns None. Fixed before a clean gate: crate:: not brokerd:: for internal modules, GONE made pub (the test imports it), and clippy (needless return x3, collapsible_if, needless borrow). broker 9, broker_pending 5, broker_sequence 2 pass five runs in a row; make gate prints gate: ok. | ? |
|
||||
| M3a/14-brokerd-admin | 2026-09-20 | done | 1 | pass | none | Wrote crates/brokerd/src/admin.rs (129 lines): BY ("bxctl") and REFUSAL_INTERNAL ("the refusal could not be recorded; the call is denied; see docs/runbook.md#audit-unavailable"); answer (grants as they are now, ledger.answer(ask, info.approval, answer, &grants, now) -> Answered { verdict, outcome }, reply.send(verdict) logging "brokerd: approval {id} was answered after its requester had gone" on Err, return outcome); expire_due (take_expired(now), answer(.., Answer::Expired, now) each, return count); handle (read_request None -> return; by kind: Approvals -> ApprovalList{table().list()}, Approve/Refuse -> table().take(approval) None -> NoSuchApproval "approval {id} is not pending" else answer and ApproveResult{outcome}/Ok(Empty {}), a Refuse whose outcome is not Denied{ApprovalRefused} -> Internal with REFUSAL_INTERNAL, CheckGrants -> grants::load(cfg.paths.grants) Err's list or empty -> GrantsReport{problems}, anything else -> forbid on admin.sock); copied crates/brokerd/tests/admin.rs byte-identical (support/ already identical), added pub mod admin; to lib.rs. All twelve admin tests pass five runs in a row; make gate prints gate: ok. Two compiler-necessary deviations from the interface shorthand: handle takes `mut stream` (the task wrote `stream`, but read_request/forbid/send take &mut, and `mut` is not part of the fn(UnixStream,&Broker) type the test passes); Answer::Approved/Refused use `Some(BY.to_string())` because ledger::Answer::by is Option<String>. | ? |
|
||||
|
||||
|
||||
## Reviews
|
||||
|
||||
Reference in New Issue
Block a user