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

7.2 KiB

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

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