Files
boxmaker/docs/plans/M3a/18-bxctl-admin.md
kyleandClaude Opus 5 e3f37da232 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>
2026-09-18 23:45:43 -07:00

185 lines
9.8 KiB
Markdown

# 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 <path>] [--admin-socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]
bxctl approvals [--admin-socket <path>]
bxctl approve <id> [--admin-socket <path>]
bxctl refuse <id> [--reason <text>] [--admin-socket <path>]
bxctl grants check [--admin-socket <path>]
bxctl audit verify [--home <path>]";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatOptions { pub socket: PathBuf, pub admin_socket: PathBuf, pub session: Option<SessionId>,
pub show_thinking: bool, pub say: Option<String>, 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<String> },
GrantsCheck { admin_socket: PathBuf },
AuditVerify { home: PathBuf },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UsageError;
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError>;
// 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<Message, AdminError>;
pub fn list(socket: &Path) -> Result<Vec<PendingApproval>, 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<bool, AdminError>;
pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result<bool, AdminError>;
pub fn cmd_refuse(socket: &Path, approval: u64, reason: Option<&str>, out: &mut dyn Write) -> Result<bool, AdminError>;
pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result<bool, AdminError>;
// verify.rs: a placeholder. Task 19 replaces the body.
pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result<bool> {
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:
`<home>/run/loop/loop.sock`, `<home>/run/owner-broker/admin.sock`, and `<home>` 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::<u64>()` 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.