From d250678355241aed513c06a905a5f9ac7f29e1cb Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 20 Sep 2026 13:54:01 -0700 Subject: [PATCH] Handle approvals, refusals and grant checks on admin.sock Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/brokerd/src/admin.rs | 129 ++++++++++++ crates/brokerd/src/lib.rs | 1 + crates/brokerd/tests/admin.rs | 385 ++++++++++++++++++++++++++++++++++ docs/implementer-log.md | 2 + 4 files changed, 517 insertions(+) create mode 100644 crates/brokerd/src/admin.rs create mode 100644 crates/brokerd/tests/admin.rs diff --git a/crates/brokerd/src/admin.rs b/crates/brokerd/src/admin.rs new file mode 100644 index 0000000..e13b842 --- /dev/null +++ b/crates/brokerd/src/admin.rs @@ -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); +} diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index 6edac64..4665608 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -1,5 +1,6 @@ //! The broker: the only role that holds authority. +pub mod admin; pub mod approvals; pub mod args; pub mod audit; diff --git a/crates/brokerd/tests/admin.rs b/crates/brokerd/tests/admin.rs new file mode 100644 index 0000000..bf59046 --- /dev/null +++ b/crates/brokerd/tests/admin.rs @@ -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, 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, 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, approval: u64) -> Message { + admin(broker, Message::Approve(Approve { approval })) +} + +fn refuse(broker: &Arc, 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 { + 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 = 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" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 0abec73..c8029d1 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -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), Denied(DenyReason) } (boxed for clippy's large_enum_variant), Entry { info, ask, reply: Sender }, and Table { entries: Mutex> } 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>>'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. | ? | ## Reviews