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>
185 lines
9.8 KiB
Markdown
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.
|