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>
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.
- The first word, or first two (
grants check,audit verify), pick the command. Anything else, including no words and a flag before the command, isUsageError. - 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-sockethas the value--admin-socket).chatkeeps its two flags without values,--no-thinkingand--json. UsageErrorfor each of: a flag the command does not take; a flag with no word after it; a flag given twice (chatexcepted); a positional word where none is taken; no id, or more than one; an invalid--session.- An id is one or more ASCII digits that fit a
u64. Check the digits beforestr::parse:"+41".parse::<u64>()succeeds, and+41is not an id.
admin.rs, request. Every exit:
UnixStream::connectfails:Connect(socket.to_path_buf(), e).- Write one frame:
v: PROTOCOL_VERSION,id: 1,final: true. Failure:Frame. - Read one frame. Failure, including a closed connection:
Frame. - Its
idis not 1, orfinalis false:Protocol. - 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. spanof a number of milliseconds: under 60 s,{n} s; under 60 min,{n} min; otherwise{n} h; always rounded down. "ago" isnow - created, and 0 ifcreatedis afternow(saturating_sub).expiryisexpiredwhennow >= expires, otherwiseexpires in {span}.- A session id longer than 10 characters is shown as its first 9 and
…. taintispublic,privateorsecret.tool,grantandargumentseach go throughescape_json_text. Nothing else is changed: the arguments are printed asbrokerdsent 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, thenmkdir -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 fourpub modlines tolib.rs, write theverify.rsplaceholder.cargo test -p bxctl --test escape. Expected:8 passed. - 4. Check the bytes.
grep -c 'u{:04x}' crates/bxctl/src/escape.rsprints 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 newmain.rs. - 6. Write
admin.rs, then changemain.rs.cargo test -p bxctl. Expected:admin21 passed,chat12 passed,cli12 passed,escape8 passed. - 7. Run the gate.
cargo fmt --all, thenmake 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 bxctlreports 21, 12, 12 and 8 passed foradmin,chat,cliandescape;make gateprintsgate: ok;main.rsis under 500 lines.
Stop and report if
- A test needs
bxctlto read a grant file or an audit file itself in this task. protolacksMessage::Approvals,PendingApproval,GrantProblemorErrorCode::NoSuchApproval: task 02 has not been done.