# M3a task 18: `bxctl` admin commands and printing text as data **Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add bxctl approvals, approve, refuse and grants check` ## Goal `bxctl` gains four commands that talk to `brokerd` over `admin.sock`, a command-line parser that tests can drive, and a module that prints model-written text as data. `bxctl chat` keeps working exactly as it does; task 20 changes it. ## Files - Copy: `crates/bxctl/tests/escape.rs`, `cli.rs`, `admin.rs`, `support/mod.rs` - Create: `crates/bxctl/src/escape.rs`, `cli.rs`, `admin.rs`, `verify.rs` - Modify: `crates/bxctl/src/lib.rs`, `main.rs`, `chat.rs` (one word), `docs/implementer-log.md` No new dependency. The wire messages are our format: `proto` already rejects unknown fields. ## Interfaces ```rust // escape.rs pub fn escape_json_text(text: &str) -> String; pub fn escape_model_text(text: &str) -> String; // cli.rs pub const USAGE: &str = "usage: bxctl chat [--socket ] [--admin-socket ] [--session ] [--no-thinking] [--say ] [--json] bxctl approvals [--admin-socket ] bxctl approve [--admin-socket ] bxctl refuse [--reason ] [--admin-socket ] bxctl grants check [--admin-socket ] bxctl audit verify [--home ]"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChatOptions { pub socket: PathBuf, pub admin_socket: PathBuf, pub session: Option, pub show_thinking: bool, pub say: Option, pub json: bool } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { Chat(ChatOptions), Approvals { admin_socket: PathBuf }, Approve { admin_socket: PathBuf, approval: u64 }, Refuse { admin_socket: PathBuf, approval: u64, reason: Option }, GrantsCheck { admin_socket: PathBuf }, AuditVerify { home: PathBuf }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UsageError; pub fn parse(args: &[String], home: &Path) -> Result; // admin.rs #[derive(Debug)] pub enum AdminError { Connect(PathBuf, std::io::Error), Frame(proto::FrameError), Refused(proto::WireError), Protocol(String), Io(std::io::Error) } pub fn reason_name(reason: DenyReason) -> &'static str; pub fn request(socket: &Path, msg: Message) -> Result; pub fn list(socket: &Path) -> Result, AdminError>; pub fn write_block(out: &mut dyn Write, item: &PendingApproval, now: Timestamp) -> std::io::Result<()>; pub fn cmd_approvals(socket: &Path, now: Timestamp, out: &mut dyn Write) -> Result; pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result; pub fn cmd_refuse(socket: &Path, approval: u64, reason: Option<&str>, out: &mut dyn Write) -> Result; pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result; // verify.rs: a placeholder. Task 19 replaces the body. pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result { let _ = home; writeln!(out, "audit verify: not built yet")?; Ok(false) } ``` `AdminError` implements `Display` and `std::error::Error`. Every `cmd_` returns `Ok(true)` for exit status 0 and `Ok(false)` for exit status 1. ## Rules **`escape.rs`.** These code points are printed as `\uXXXX`: a backslash, `u`, and four lowercase hex digits (`format!("\\u{:04x}", u32::from(c))`). Everything else is copied unchanged. | From | To | | From | To | |---|---|---|---|---| | U+0000 | U+001F | | U+2028 | U+202E | | U+007F | U+009F | | U+2060 | U+2069 | | U+200B | U+200F | | U+FEFF | U+FEFF | `escape_model_text` is the same, except that newline (U+000A) and tab (U+0009) are copied unchanged. A carriage return is still escaped. The test walks every code point; do not sample. **`cli.rs`.** `parse` reads no environment and prints nothing. `home` gives the defaults: `/run/loop/loop.sock`, `/run/owner-broker/admin.sock`, and `` for `--home`. 1. The first word, or first two (`grants check`, `audit verify`), pick the command. Anything else, including no words and a flag before the command, is `UsageError`. 2. After the command, a word that starts with `--` is a flag and the next word is its value, whatever that word looks like (`--reason --admin-socket` has the value `--admin-socket`). `chat` keeps its two flags without values, `--no-thinking` and `--json`. 3. `UsageError` for each of: a flag the command does not take; a flag with no word after it; a flag given twice (`chat` excepted); a positional word where none is taken; no id, or more than one; an invalid `--session`. 4. An id is one or more ASCII digits that fit a `u64`. Check the digits before `str::parse`: `"+41".parse::()` succeeds, and `+41` is not an id. **`admin.rs`, `request`.** Every exit: 1. `UnixStream::connect` fails: `Connect(socket.to_path_buf(), e)`. 2. Write one frame: `v: PROTOCOL_VERSION`, `id: 1`, `final: true`. Failure: `Frame`. 3. Read one frame. Failure, including a closed connection: `Frame`. 4. Its `id` is not 1, or `final` is false: `Protocol`. 5. It is `Message::Error(w)`: `Refused(w)`. Otherwise return the message. `Display`: `Connect(p, e)` is `cannot reach brokerd at {p}: {e}` (`p.display()`); `Refused(w)` is `{code}: {detail}` with `chat::code_name` (make that function `pub`); the rest print their inner value. `list` sends `Message::Approvals(Empty {})` and expects `ApprovalList`; any other kind is `Protocol`. `reason_name` is a `match` with all ten reasons and no `_` arm, giving the snake_case wire name (`DenyReason::NoGrant` is `"no_grant"`). **`write_block`** writes exactly two lines: ``` 41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private shell {"command":"ls"} ``` - Line 1: `{approval} {span} ago {expiry} session {session} grant {grant} taint {taint}`, two spaces between parts. Line 2: four spaces, the tool, one space, the arguments. - `span` of a number of milliseconds: under 60 s, `{n} s`; under 60 min, `{n} min`; otherwise `{n} h`; always rounded down. "ago" is `now - created`, and 0 if `created` is after `now` (`saturating_sub`). `expiry` is `expired` when `now >= expires`, otherwise `expires in {span}`. - A session id longer than 10 characters is shown as its first 9 and `…`. - `taint` is `public`, `private` or `secret`. - `tool`, `grant` and `arguments` each go through `escape_json_text`. Nothing else is changed: the arguments are printed as `brokerd` sent them. **The commands.** Each makes one `request`. `Refused` with `ErrorCode::NoSuchApproval` is handled as below; every other `Err` from `request`, and every answer of the wrong kind (`Protocol`), is returned and nothing is printed. A failed write to `out` is `Io`. | Command | Sends | Answer | Prints | Returns | |---|---|---|---|---| | `cmd_approvals` | `Approvals` | `ApprovalList`, empty | `no pending approvals` | `true` | | | | `ApprovalList` | one block per item, in order | `true` | | `cmd_approve` | `Approve` | `ApproveResult`, `Allowed {}` or `Ask {}` | `approved 41: runs` | `true` | | | | `ApproveResult`, `Denied { reason }` | `approved 41: denied (no_grant)` | `false` | | `cmd_refuse` | `Refuse` | `Ok` | `refused 41` | `true` | | both of those | | error `no_such_approval` | `41: no such approval (already answered or expired)` | `false` | | `cmd_grants_check` | `CheckGrants` | `GrantsReport`, empty | `grants: ok` | `true` | | | | `GrantsReport` | every problem, one line each | `false` | A problem is `{file}:{line}: {problem}`, or `{file}: {problem}` when `line` is `None`; `file` and `problem` go through `escape_json_text`, so a problem is always one line. **`main.rs`.** Read `BOXMAKER_HOME` (default `/var/lib/boxmaker`) and call `cli::parse`. On `UsageError` print `USAGE` to stderr and exit 2. Delete `Options`, `parse_chat` and `default_socket`; the chat functions stay as they are and take `&ChatOptions`. The four commands write to locked stdout with `Timestamp::now()`; `audit verify` calls `bxctl::verify::run`. For all five: `Ok(true)` exits 0, `Ok(false)` exits 1, and `Err(e)` prints to stderr and exits 1. The four commands print `bxctl: {e}`; `audit verify` prints `bxctl: cannot read the audit log under {home}: {e}` (`home.display()`). ## Steps - [ ] **1. Copy.** `git switch m3a`, then `mkdir -p crates/bxctl/tests/support && cp docs/plans/M3a/files/crates/bxctl/tests/{escape,cli,admin}.rs crates/bxctl/tests/ && cp docs/plans/M3a/files/crates/bxctl/tests/support/mod.rs crates/bxctl/tests/support/` - [ ] **2. See the tests fail.** `cargo test -p bxctl --test escape`. Expected: it does not compile. - [ ] **3. Write `escape.rs`**, add the four `pub mod` lines to `lib.rs`, write the `verify.rs` placeholder. `cargo test -p bxctl --test escape`. Expected: `8 passed`. - [ ] **4. Check the bytes.** `grep -c 'u{:04x}' crates/bxctl/src/escape.rs` prints at least 1. If your editor turned an escape in a file into the character it names, fix the file. - [ ] **5. Write `cli.rs`.** `cargo build -p bxctl`. Expected: it compiles. Its tests run in step 6, because one of them needs the new `main.rs`. - [ ] **6. Write `admin.rs`, then change `main.rs`.** `cargo test -p bxctl`. Expected: `admin` 21 passed, `chat` 12 passed, `cli` 12 passed, `escape` 8 passed. - [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`. - [ ] **8. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit` ## Done when - `cargo test -p bxctl` reports 21, 12, 12 and 8 passed for `admin`, `chat`, `cli` and `escape`; `make gate` prints `gate: ok`; `main.rs` is under 500 lines. ## Stop and report if - A test needs `bxctl` to read a grant file or an audit file itself in this task. - `proto` lacks `Message::Approvals`, `PendingApproval`, `GrantProblem` or `ErrorCode::NoSuchApproval`: task 02 has not been done.