Show and answer approvals in bxctl chat

Implemented-By: Grok 4.6
This commit is contained in:
2026-09-22 20:25:40 -07:00
parent 0a9df81fe4
commit f1f17a171f
5 changed files with 906 additions and 66 deletions
+487
View File
@@ -0,0 +1,487 @@
//! Tests for approvals in `bxctl chat`, against a fake `loopd` and a fake `brokerd`. Do not edit.
//!
//! What the owner is shown comes from `brokerd`, never from `loopd`'s event, and only the
//! approval's id, typed in full, approves.
mod support;
use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, handle_pending, stream_turn};
use proto::{
Approve, DecisionRecord, Empty, ErrorCode, Message, Refuse, SessionId, Timestamp, TurnEvent,
};
use std::io::{BufRead, Cursor, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use support::{FakeBrokerd, brokerd_with, fake_brokerd, fake_loopd, pending, ts, wire_error};
const BACKSLASH: char = '\\';
const NOW: &str = "2026-09-18T12:02:00.000Z";
const PROMPT: &str = "type 41 to approve, anything else refuses: ";
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn approvals_request() -> Message {
Message::Approvals(Empty {})
}
fn approve_request() -> Message {
Message::Approve(Approve { approval: 41 })
}
fn refuse_request() -> Message {
Message::Refuse(Refuse {
approval: 41,
reason: None,
})
}
/// A `brokerd` with approval 41 pending, for `shell`.
fn broker() -> FakeBrokerd {
brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Allowed {},
)
}
/// Runs `handle_pending` for approval 41 with `typed` waiting on stdin. Returns what was
/// printed and what was left unread.
fn handle(socket: &Path, ask: bool, typed: &str) -> (String, String) {
let mut input = Cursor::new(typed.as_bytes().to_vec());
let mut out = Vec::new();
handle_pending(socket, 41, ask, ts(NOW), &mut input, &mut out).unwrap();
let mut left = String::new();
std::io::Read::read_to_string(&mut input, &mut left).unwrap();
(String::from_utf8(out).unwrap(), left)
}
// ---- handle_pending ----
#[test]
fn the_block_then_the_question_and_the_id_approves() {
let fake = broker();
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(
out,
format!(
"\x1b[0m41 2 min ago expires in 13 min session chat-1758… grant shell-scratch \
taint private\n shell {{\"command\":\"ls\"}}\n{PROMPT}approved 41: runs\n"
),
"attributes reset, the block, the question, the outcome"
);
assert_eq!(left, "");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_id_with_a_carriage_return_also_approves() {
let fake = broker();
handle(&fake.socket, true, "41\r\n");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
/// Everything that is not exactly the id refuses: `y`, a near miss, an empty line, the end of
/// the input.
#[test]
fn anything_else_refuses() {
for typed in [
"y\n",
"Y\n",
"yes\n",
"\n",
"",
" 41\n",
"41 \n",
"041\n",
"+41\n",
"4 1\n",
"42\n",
"approve 41\n",
"41",
] {
let fake = broker();
let (out, _) = handle(&fake.socket, true, typed);
if typed == "41" {
// The last line of the input need not end in a newline; it is still the id.
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
continue;
}
assert_eq!(
fake.requests(),
vec![approvals_request(), refuse_request()],
"{typed:?}"
);
assert!(
out.ends_with(&format!("{PROMPT}refused 41\n")),
"{typed:?}: {out:?}"
);
}
}
/// A line typed while the turn ran is already waiting when the question is asked. It is the
/// answer, it is not the id, so it refuses; and only that one line is read.
#[test]
fn a_line_already_waiting_refuses_and_only_one_line_is_read() {
let fake = broker();
let (_, left) = handle(&fake.socket, true, "are you still there?\n41\n");
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(left, "41\n", "the next line is left for the chat");
}
#[test]
fn an_id_that_is_not_listed_is_no_longer_pending_and_nothing_is_asked() {
let fake = brokerd_with(
vec![pending(40, "shell", "{}"), pending(42, "shell", "{}")],
DecisionRecord::Allowed {},
);
let (out, left) = handle(&fake.socket, true, "41\n");
assert_eq!(out, "approval 41 is no longer pending\n");
assert_eq!(left, "41\n", "nothing was read");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn without_ask_the_block_is_shown_and_nothing_is_asked_or_read() {
let fake = broker();
let (out, left) = handle(&fake.socket, false, "41\n");
assert!(out.starts_with("\x1b[0m41 2 min ago "), "{out:?}");
assert!(out.ends_with(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(left, "41\n");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn a_brokerd_that_cannot_be_reached_is_reported_and_nothing_is_asked() {
let missing = support::temp_socket("nobody-listens.sock");
let (out, left) = handle(&missing, true, "41\n");
assert!(
out.starts_with("approval 41: cannot ask brokerd: cannot reach brokerd at "),
"{out:?}"
);
assert!(out.ends_with('\n') && out.lines().count() == 1, "{out:?}");
assert_eq!(left, "41\n");
}
/// The approval can expire between the list and the answer.
#[test]
fn an_answer_that_comes_too_late_is_reported() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::NoSuchApproval, "no such approval"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
let (out, _) = handle(&fake.socket, true, "no\n");
assert!(
out.ends_with(&format!(
"{PROMPT}41: no such approval (already answered or expired)\n"
)),
"{out:?}"
);
}
/// Any other failure of the answer is reported on one line, and the turn goes on: the call is
/// still pending at `brokerd`, and `bxctl approve` from another terminal can answer it.
#[test]
fn an_answer_that_fails_is_reported_and_is_not_an_error() {
let fake = fake_brokerd(|msg| match msg {
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
items: vec![pending(41, "shell", "{}")],
}),
_ => wire_error(ErrorCode::Internal, "boom"),
});
let (out, _) = handle(&fake.socket, true, "41\n");
assert!(
out.ends_with(&format!("{PROMPT}approval 41: internal: boom\n")),
"{out:?}"
);
}
/// What `brokerd` sends is printed as data here too.
#[test]
fn the_block_in_chat_is_escaped() {
let rlo = char::from_u32(0x202e).unwrap();
let arguments = format!("{{\"path\":\"/home/kyle/{rlo}dm\x1b[8m\"}}");
let fake = brokerd_with(
vec![pending(41, "read_file", &arguments)],
DecisionRecord::Allowed {},
);
let (out, _) = handle(&fake.socket, false, "");
assert_eq!(out.matches('\x1b').count(), 1, "only the reset: {out:?}");
assert!(!out.contains(rlo), "{out:?}");
assert!(
out.contains(&format!("/home/kyle/{}dm{}[8m", esc(0x202e), esc(0x1b))),
"{out:?}"
);
}
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("the terminal went away"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("the terminal went away"))
}
}
/// Every write can fail, and none is ignored: not the "no longer pending" line, not the block,
/// not the question. Nothing is approved for an owner who was shown nothing.
#[test]
fn a_failed_write_is_an_error_and_nothing_is_answered() {
let fake = broker();
let mut input = Cursor::new(b"41\n".to_vec());
assert!(handle_pending(&fake.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
assert_eq!(fake.requests(), vec![approvals_request()]);
let none = brokerd_with(Vec::new(), DecisionRecord::Allowed {});
let mut input = Cursor::new(Vec::new());
assert!(handle_pending(&none.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
}
// ---- stream_turn ----
fn pending_event(tool: &str) -> TurnEvent {
TurnEvent::ApprovalPending {
approval: 41,
tool: tool.to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
}
}
/// Runs one turn against a fake `loopd` that sends `events`. Returns what was printed.
fn turn(
events: Vec<TurnEvent>,
admin_socket: &Path,
on_pending: OnPending,
json: bool,
typed: &str,
) -> String {
let loopd = fake_loopd(events, "done");
let mut printer = Printer::new(true, json);
let mut input: Box<dyn BufRead> = Box::new(Cursor::new(typed.as_bytes().to_vec()));
let mut out = Vec::new();
let approvals = Approvals {
admin_socket,
on_pending,
};
let mut io = TurnIo {
printer: &mut printer,
input: &mut *input,
out: &mut out,
};
let done = stream_turn(
&loopd.socket,
&SessionId::new("s1").unwrap(),
"go",
false,
&approvals,
&mut io,
)
.unwrap()
.unwrap();
assert_eq!(done.content, "done");
String::from_utf8(out).unwrap()
}
/// A compromised `loopd` must not choose what the owner sees: the event says `read_file`, the
/// broker's entry says `shell`, and the block says `shell`.
#[test]
fn the_block_comes_from_brokerd_not_from_the_event() {
let fake = broker();
let out = turn(
vec![pending_event("read_file")],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("read_file"), "{out:?}");
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
}
#[test]
fn the_turn_goes_on_after_the_answer() {
let fake = broker();
let out = turn(
vec![
TurnEvent::Reasoning {
text: "hm".to_string(),
},
pending_event("shell"),
TurnEvent::Content {
text: "It ran.".to_string(),
},
],
&fake.socket,
OnPending::Ask,
false,
"41\n",
);
assert!(
out.starts_with("\x1b[2mhm\x1b[0m\n\x1b[0m41 "),
"reasoning is ended before the block: {out:?}"
);
assert!(out.ends_with("approved 41: runs\nIt ran."), "{out:?}");
}
#[test]
fn show_prints_the_block_and_answers_nothing() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::Show,
false,
"41\n",
);
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
assert!(!out.contains("to approve"), "{out:?}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn event_only_prints_the_json_line_and_never_asks_brokerd() {
let fake = broker();
let out = turn(
vec![pending_event("shell")],
&fake.socket,
OnPending::EventOnly,
true,
"41\n",
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1, "{out:?}");
let event: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(event["event"], "approval_pending");
assert_eq!(event["approval"], 41);
assert_eq!(fake.requests(), Vec::<Message>::new());
}
// ---- the binary ----
fn chat(loopd: &Path, brokerd: &Path, extra: &[&str], typed: &str) -> std::process::Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(loopd)
.arg("--admin-socket")
.arg(brokerd)
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(typed.as_bytes()).unwrap();
}
child.wait_with_output().unwrap()
}
/// With a pipe, everything typed is in the reader's buffer before the first turn is sent. The
/// approval's answer must come from that same reader: a second reader on stdin would see the
/// end of the input, and refuse.
#[test]
fn interactive_mode_reads_the_answer_from_the_same_input_as_the_chat() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\n41\n/quit\n");
assert!(output.status.success());
assert_eq!(
fake.requests(),
vec![approvals_request(), approve_request()]
);
assert_eq!(
*loopd.turns.lock().unwrap(),
vec!["go".to_string()],
"the answer was not sent to the model as a message"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(PROMPT), "{stderr}");
assert!(stderr.contains("approved 41: runs"), "{stderr}");
}
#[test]
fn interactive_mode_refuses_on_a_stray_line() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &[], "go\ny\n/quit\n");
assert!(output.status.success());
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
assert_eq!(*loopd.turns.lock().unwrap(), vec!["go".to_string()]);
}
#[test]
fn say_shows_the_block_and_answers_nothing() {
let loopd = fake_loopd(vec![pending_event("read_file")], "ok");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "41\n");
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(" shell {\"command\":\"ls\"}\n"),
"{stderr}"
);
assert!(!stderr.contains("to approve"), "{stderr}");
assert_eq!(fake.requests(), vec![approvals_request()]);
}
#[test]
fn json_prints_only_json_and_never_asks_brokerd() {
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
let fake = broker();
let output = chat(
&loopd.socket,
&fake.socket,
&["--json", "--say", "go"],
"41\n",
);
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"not JSON: {line}"
);
}
assert_eq!(stderr.lines().count(), 2, "the event and the done frame");
assert_eq!(fake.requests(), Vec::<Message>::new());
}
/// The answer on stdout is the model's text too.
#[test]
fn the_answer_on_stdout_is_printed_as_data() {
let loopd = fake_loopd(Vec::new(), "a\x1b[8mb\n\tc");
let fake = broker();
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "");
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!("a{}[8mb\n\tc\n", esc(0x1b))
);
}
+224
View File
@@ -0,0 +1,224 @@
//! Tests for how `bxctl chat` prints what the model wrote and what `brokerd` denied. Do not edit.
use bxctl::chat::Printer;
use proto::{DataClass, DenyReason, Timestamp, TurnEvent};
const BACKSLASH: char = '\\';
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn print(printer: &mut Printer, events: &[TurnEvent]) -> String {
let mut out = Vec::new();
for e in events {
printer.event(&mut out, e).unwrap();
}
printer.end_reasoning(&mut out).unwrap();
String::from_utf8(out).unwrap()
}
fn denied(name: &str, reason: DenyReason) -> TurnEvent {
TurnEvent::ToolDenied {
name: name.to_string(),
reason,
}
}
#[test]
fn a_denial_shows_its_reason_by_its_wire_name() {
let mut p = Printer::new(true, false);
assert_eq!(
print(&mut p, &[denied("read_file", DenyReason::NoGrant)]),
"[denied read_file: no_grant]\n"
);
}
/// Walks all ten reasons: the three that mean the harness is refusing to work carry their
/// runbook entry on the next line, and the other seven carry nothing.
#[test]
fn the_three_fail_closed_denials_point_to_the_runbook_and_no_other_does() {
let cases = [
(DenyReason::NoGrant, "no_grant", None),
(DenyReason::GrantExpired, "grant_expired", None),
(DenyReason::TaintTooHigh, "taint_too_high", None),
(DenyReason::DeniedByGrant, "denied_by_grant", None),
(DenyReason::ApprovalRefused, "approval_refused", None),
(DenyReason::ApprovalExpired, "approval_expired", None),
(DenyReason::InvalidArguments, "invalid_arguments", None),
(
DenyReason::GrantsInvalid,
"grants_invalid",
Some("see docs/runbook.md#grants-invalid"),
),
(
DenyReason::AuditUnavailable,
"audit_unavailable",
Some("see docs/runbook.md#audit-unavailable"),
),
(
DenyReason::StateUnreadable,
"state_unreadable",
Some("see docs/runbook.md#broker-state-damaged"),
),
];
for (reason, name, pointer) in cases {
let mut p = Printer::new(true, false);
let got = print(&mut p, &[denied("shell", reason)]);
let want = match pointer {
Some(pointer) => format!("[denied shell: {name}]\n{pointer}\n"),
None => format!("[denied shell: {name}]\n"),
};
assert_eq!(got, want);
}
}
#[test]
fn a_denial_ends_an_open_reasoning_block_first() {
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: "hm".to_string(),
},
denied("shell", DenyReason::NoGrant),
],
);
assert_eq!(got, "\x1b[2mhm\x1b[0m\n[denied shell: no_grant]\n");
}
#[test]
fn reasoning_and_content_are_printed_as_data() {
let hostile = "a\x1b[8mb\x07c\rd";
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: hostile.to_string(),
},
TurnEvent::Content {
text: hostile.to_string(),
},
],
);
let shown = format!("a{}[8mb{}c{}d", esc(0x1b), esc(0x07), esc(0x0d));
// The only escape sequences left are the printer's own: dim on, dim off.
assert_eq!(got, format!("\x1b[2m{shown}\x1b[0m\n{shown}"));
}
#[test]
fn invisible_and_direction_changing_characters_are_shown() {
let rlo = char::from_u32(0x202e).unwrap();
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[TurnEvent::Content {
text: format!("see {rlo}txt.exe"),
}],
);
assert_eq!(got, format!("see {}txt.exe", esc(0x202e)));
}
#[test]
fn newlines_and_tabs_in_model_text_pass_through() {
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[TurnEvent::Content {
text: "one\n\ttwo\n".to_string(),
}],
);
assert_eq!(got, "one\n\ttwo\n");
}
/// The model chooses tool names too: every place a name is printed escapes it.
#[test]
fn tool_names_are_printed_as_data_everywhere() {
let name = "sh\x1b[2Jell";
let shown = format!("sh{}[2Jell", esc(0x1b));
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::ToolCallStarted {
name: name.to_string(),
},
TurnEvent::ToolResult {
name: name.to_string(),
class: DataClass::Public,
truncated: false,
},
TurnEvent::ToolResult {
name: name.to_string(),
class: DataClass::Private,
truncated: true,
},
denied(name, DenyReason::NoGrant),
],
);
assert_eq!(
got,
format!(
"[tool {shown}]\n[{shown}: Public]\n[{shown}: Private, truncated]\n\
[denied {shown}: no_grant]\n"
)
);
assert!(!got.contains('\x1b'));
}
/// The printer shows nothing for a pending approval: the block comes from `brokerd`, through
/// `handle_pending`. It still ends an open reasoning block, so the block is not dimmed.
#[test]
fn a_pending_approval_prints_nothing_itself_but_ends_the_reasoning_block() {
let pending = TurnEvent::ApprovalPending {
approval: 41,
tool: "read_file".to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
};
let mut p = Printer::new(true, false);
assert_eq!(print(&mut p, std::slice::from_ref(&pending)), "");
let mut p = Printer::new(true, false);
let got = print(
&mut p,
&[
TurnEvent::Reasoning {
text: "hm".to_string(),
},
pending,
],
);
assert_eq!(got, "\x1b[2mhm\x1b[0m\n");
}
#[test]
fn json_mode_prints_the_new_events_as_json_lines() {
let mut p = Printer::new(true, true);
let got = print(
&mut p,
&[
TurnEvent::ApprovalPending {
approval: 41,
tool: "read_file".to_string(),
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
},
denied("shell", DenyReason::GrantsInvalid),
],
);
let lines: Vec<serde_json::Value> = got
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(
lines,
vec![
serde_json::json!({"event": "approval_pending", "approval": 41,
"tool": "read_file", "expires": "2026-09-18T12:15:00.000Z"}),
serde_json::json!({"event": "tool_denied", "name": "shell",
"reason": "grants_invalid"}),
]
);
assert!(!got.contains("runbook"), "json mode adds no prose");
}