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:
2026-09-20 13:54:01 -07:00
parent e68e626cd7
commit d250678355
4 changed files with 517 additions and 0 deletions
+2
View File
@@ -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