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>
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 fromloopd's event. A compromisedloopdmust 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:
-
ReasoningandContent: writeescape_model_text(text), nottext. -
Every tool name goes through
escape_model_text: inToolCallStarted, in both forms ofToolResult(truncated or not), and inToolDenied. The model chooses tool names too. -
ToolDenied { name, reason }: end an open reasoning block (close_dimmed), then write[denied {name}: {reason}]withadmin::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 withformat!):Reason Next line GrantsInvalidsee docs/runbook.md#grants-invalidAuditUnavailablesee docs/runbook.md#audit-unavailableStateUnreadablesee docs/runbook.md#broker-state-damagedUse a
matchwith all ten reasons and no_arm. -
ApprovalPending: end an open reasoning block and print nothing. The block ishandle_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.
admin::list(admin_socket).Err(e): reportapproval 41: cannot ask brokerd: {e}.- Find the item whose
approvalis the id. None: reportapproval 41 is no longer pending. Nothing is read frominput. - Write
\x1b[0m(no newline), thenadmin::write_block(out, item, now). Text printed before may have left the terminal dimmed or worse; the reset comes first. askis false: returnOk(()). Nothing is read frominput.- Write
type 41 to approve, anything else refuses:(no newline) and flush. input.read_line(&mut line)?, once. Remove one trailing\n, then one trailing\r. Nothing else is trimmed.- The line equals
approval.to_string():admin::cmd_approve(admin_socket, approval, out). Anything else, includingy,41,041, an empty line and the end of the input:admin::cmd_refuse(admin_socket, approval, None, out). Ok(_): returnOk(()); the command has printed its line (approved 41: runs,refused 41, or the "no such approval" line).Err(AdminError::Io(e)): returnErr(e). Any otherErr(e): reportapproval 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
run_chatmakes oneBufReaderon locked stdin and passes it, as&mut dyn BufRead, to both modes. The chat loop reads its lines from it, and every turn'sTurnIo.inputis that same reader. A second reader on stdin would lose what the first has buffered; the testinteractive_mode_reads_the_answer_from_the_same_input_as_the_chatcatches that.on_pending:--jsongivesEventOnly; otherwise--saygivesShow; otherwiseAsk.admin_socketisopts.admin_socket.outis locked stderr.write_answerwritesescape_model_text(&done.content)to stdout. The JSON line is unchanged: JSON escapes for itself.
Steps
- 1. Copy.
git switch m3a, thencp 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:admin21,chat12,chat_approvals20,chat_print9,cli12,escape8 passed. Run it five times. - 5. Check every exit. Go through the eight numbered exits of
handle_pendingabove and find each one in your code. Then find everywritetoout: each must end in?. - 6. Run the gate.
cargo fmt --all, thenmake 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 bxctlreports the six counts of step 4, five runs in a row;make gateprintsgate: ok;chat.rsandmain.rsare 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
ApprovalPendingevent. bxctl::adminlackslist,write_block,cmd_approveorcmd_refuse: task 18 has not been done.