Files
boxmaker/docs/plans/M3a/20-bxctl-chat-approvals.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

143 lines
7.2 KiB
Markdown

# M3a task 20: approvals in `bxctl chat`
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Show and answer approvals in bxctl chat`
## Goal
When `loopd` reports that a tool call is waiting for approval, `bxctl chat` fetches the approval
from `brokerd`, shows it, and in interactive mode asks the owner. It shows why a call was denied.
Everything the model wrote is printed as data.
Two rules come from the threat model, and the tests hold you to both:
- **What the owner is shown comes from `brokerd`, never from `loopd`'s event.** A compromised
`loopd` must not choose what the owner approves. The event gives the id and nothing else.
- **Only the id, typed in full, approves.** Lines typed while the turn ran are already waiting in
stdin. A stray line must refuse, never approve.
## Files
- Copy: `crates/bxctl/tests/chat_print.rs`, `crates/bxctl/tests/chat_approvals.rs`
- Modify: `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs`, `docs/implementer-log.md`
`crates/bxctl/tests/chat.rs` and `tests/support/mod.rs` stay as they are. No new dependency.
## Interfaces
```rust
// chat.rs, added
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnPending { EventOnly, Show, Ask } // --json | --say | interactive
pub struct Approvals<'a> { pub admin_socket: &'a Path, pub on_pending: OnPending }
pub struct TurnIo<'a> {
pub printer: &'a mut Printer,
pub input: &'a mut dyn BufRead, // the reader the chat lines come from
pub out: &'a mut dyn Write, // stderr
}
pub fn handle_pending(admin_socket: &Path, approval: u64, ask: bool, now: Timestamp,
input: &mut dyn BufRead, out: &mut dyn Write) -> std::io::Result<()>;
/// The outer error is a failed write to `io.out`; the inner one is the turn's.
pub fn stream_turn(socket: &Path, session: &SessionId, text: &str, resume: bool,
approvals: &Approvals<'_>, io: &mut TurnIo<'_>)
-> std::io::Result<Result<TurnDone, ChatError>>;
```
`stream_turn` is `main.rs`'s function of that name, moved into the library so tests can drive it.
`Sink` moves with it, or becomes an `Option<std::io::Error>` local to `stream_turn`.
## What `Printer::event` does now
JSON mode is unchanged: one JSON line for every event, the two new ones included, nothing else.
Otherwise:
1. `Reasoning` and `Content`: write `escape_model_text(text)`, not `text`.
2. **Every tool name** goes through `escape_model_text`: in `ToolCallStarted`, in both forms of
`ToolResult` (truncated or not), and in `ToolDenied`. The model chooses tool names too.
3. `ToolDenied { name, reason }`: end an open reasoning block (`close_dimmed`), then write
`[denied {name}: {reason}]` with `admin::reason_name(reason)`. Then, for three reasons only,
one more line, written out in full in the source (the gate script reads these pointers, and
cannot read one built with `format!`):
| Reason | Next line |
|---|---|
| `GrantsInvalid` | `see docs/runbook.md#grants-invalid` |
| `AuditUnavailable` | `see docs/runbook.md#audit-unavailable` |
| `StateUnreadable` | `see docs/runbook.md#broker-state-damaged` |
Use a `match` with all ten reasons and no `_` arm.
4. `ApprovalPending`: end an open reasoning block and print nothing. The block is
`handle_pending`'s job.
## What `handle_pending` does
Every exit is listed. "Report" means write one line to `out` and return `Ok(())`: the turn goes
on, and the call is still pending at `brokerd`. **Every write to `out` uses `?`**; a failed write
returns `Err` at once and nothing more is sent to `brokerd`.
1. `admin::list(admin_socket)`. `Err(e)`: report `approval 41: cannot ask brokerd: {e}`.
2. Find the item whose `approval` is the id. None: report `approval 41 is no longer pending`.
Nothing is read from `input`.
3. Write `\x1b[0m` (no newline), then `admin::write_block(out, item, now)`. Text printed before
may have left the terminal dimmed or worse; the reset comes first.
4. `ask` is false: return `Ok(())`. Nothing is read from `input`.
5. Write `type 41 to approve, anything else refuses: ` (no newline) and flush.
6. `input.read_line(&mut line)?`, once. Remove one trailing `\n`, then one trailing `\r`. Nothing
else is trimmed.
7. The line equals `approval.to_string()`: `admin::cmd_approve(admin_socket, approval, out)`.
**Anything else**, including `y`, ` 41`, `041`, an empty line and the end of the input:
`admin::cmd_refuse(admin_socket, approval, None, out)`.
8. `Ok(_)`: return `Ok(())`; the command has printed its line (`approved 41: runs`,
`refused 41`, or the "no such approval" line). `Err(AdminError::Io(e))`: return `Err(e)`. Any
other `Err(e)`: report `approval 41: {e}`.
## What `stream_turn` does
For each event, in this order: `io.printer.event(io.out, event)`; then, if the event is
`ApprovalPending { approval, .. }` and `on_pending` is not `EventOnly`, `handle_pending` with
`ask = (on_pending == OnPending::Ask)`, `Timestamp::now()`, `io.input` and `io.out`. After the
first failed write nothing more is written, and that error is returned once `run_turn` ends. If
there was none, `io.printer.end_reasoning(io.out)?` and return `Ok(outcome)`.
## `main.rs`
1. `run_chat` makes **one** `BufReader` on locked stdin and passes it, as `&mut dyn BufRead`, to
both modes. The chat loop reads its lines from it, and every turn's `TurnIo.input` is that same
reader. A second reader on stdin would lose what the first has buffered; the test
`interactive_mode_reads_the_answer_from_the_same_input_as_the_chat` catches that.
2. `on_pending`: `--json` gives `EventOnly`; otherwise `--say` gives `Show`; otherwise `Ask`.
`admin_socket` is `opts.admin_socket`. `out` is locked stderr.
3. `write_answer` writes `escape_model_text(&done.content)` to stdout. The JSON line is unchanged:
JSON escapes for itself.
## Steps
- [ ] **1. Copy.** `git switch m3a`, then
`cp docs/plans/M3a/files/crates/bxctl/tests/chat_print.rs docs/plans/M3a/files/crates/bxctl/tests/chat_approvals.rs crates/bxctl/tests/`
- [ ] **2. See the tests fail.** `cargo test -p bxctl --test chat_approvals`. Expected: it does not
compile.
- [ ] **3. Change `chat.rs`:** `Printer::event`, the three new types, `handle_pending`,
`stream_turn`. `cargo build -p bxctl --lib`. Expected: it compiles.
- [ ] **4. Change `main.rs`.** `cargo test -p bxctl`. Expected: `admin` 21, `chat` 12,
`chat_approvals` 20, `chat_print` 9, `cli` 12, `escape` 8 passed. Run it five times.
- [ ] **5. Check every exit.** Go through the eight numbered exits of `handle_pending` above and
find each one in your code. Then find every `write` to `out`: each must end in `?`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **7. Log and commit.** `git add crates/bxctl docs/implementer-log.md && git commit`
## Done when
- `cargo test -p bxctl` reports the six counts of step 4, five runs in a row; `make gate` prints
`gate: ok`; `chat.rs` and `main.rs` are each under 500 lines.
## Stop and report if
- A test can only pass by taking the tool name, the arguments or the grant from the
`ApprovalPending` event.
- `bxctl::admin` lacks `list`, `write_block`, `cmd_approve` or `cmd_refuse`: task 18 has not been
done.