# 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>; ``` `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` 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.