Files
boxmaker/docs/plans/M3a/18-bxctl-admin.md
T
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

9.8 KiB

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

// 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.