Add bxctl approvals, approve, refuse and grants check

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 23:25:05 -07:00
parent 469be2c0a1
commit 2d94067b52
12 changed files with 1780 additions and 79 deletions
+256
View File
@@ -0,0 +1,256 @@
//! The owner's admin commands over admin.sock: approvals, approve, refuse, grants check.
use std::io::{self, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use proto::{
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, FrameError, Message,
PROTOCOL_VERSION, PendingApproval, Refuse, Timestamp, WireError, read_frame, write_frame,
};
use crate::chat::code_name;
use crate::escape::escape_json_text;
// Ask brokerd for one message and read one answer.
pub fn request(socket: &Path, msg: Message) -> Result<Message, AdminError> {
let mut stream =
UnixStream::connect(socket).map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?;
let env = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
};
write_frame(&mut stream, &env).map_err(AdminError::Frame)?;
let answer = read_frame(&mut stream).map_err(AdminError::Frame)?;
if answer.id != 1 || !answer.r#final {
return Err(AdminError::Protocol(
"expected an answer for request 1".to_string(),
));
}
match answer.msg {
Message::Error(w) => Err(AdminError::Refused(w)),
other => Ok(other),
}
}
// Ask brokerd for the list of approvals waiting, or report an answer of the wrong kind.
pub fn list(socket: &Path) -> Result<Vec<PendingApproval>, AdminError> {
match request(socket, Message::Approvals(Empty {}))? {
Message::ApprovalList(list) => Ok(list.items),
_ => Err(AdminError::Protocol(
"expected an approval list".to_string(),
)),
}
}
#[derive(Debug)]
pub enum AdminError {
Connect(PathBuf, std::io::Error),
Frame(FrameError),
Refused(WireError),
Protocol(String),
}
impl std::fmt::Display for AdminError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdminError::Connect(p, e) => write!(f, "cannot reach brokerd at {}: {e}", p.display()),
AdminError::Frame(e) => write!(f, "{e}"),
AdminError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
AdminError::Protocol(s) => write!(f, "{s}"),
}
}
}
// An error writing the output is reported like any other failure, with the message preserved.
impl From<std::io::Error> for AdminError {
fn from(e: std::io::Error) -> Self {
AdminError::Protocol(e.to_string())
}
}
impl std::error::Error for AdminError {}
pub fn cmd_approvals(
socket: &Path,
now: Timestamp,
out: &mut dyn Write,
) -> Result<bool, AdminError> {
let items = list(socket)?;
if items.is_empty() {
writeln!(out, "no pending approvals")?;
} else {
for item in &items {
write_block(out, item, now)?;
}
}
Ok(true)
}
pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result<bool, AdminError> {
let msg = match request(socket, Message::Approve(Approve { approval })) {
Ok(m) => m,
Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => {
writeln!(
out,
"{approval}: no such approval (already answered or expired)"
)?;
return Ok(false);
}
Err(e) => return Err(e),
};
match msg {
Message::ApproveResult(result) => match result.outcome {
DecisionRecord::Allowed {} | DecisionRecord::Ask {} => {
writeln!(out, "approved {approval}: runs")?;
Ok(true)
}
DecisionRecord::Denied { reason } => {
writeln!(out, "approved {approval}: denied ({})", reason_name(reason))?;
Ok(false)
}
},
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn cmd_refuse(
socket: &Path,
approval: u64,
reason: Option<&str>,
out: &mut dyn Write,
) -> Result<bool, AdminError> {
let msg = match request(
socket,
Message::Refuse(Refuse {
approval,
reason: reason.map(str::to_string),
}),
) {
Ok(m) => m,
Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => {
writeln!(
out,
"{approval}: no such approval (already answered or expired)"
)?;
return Ok(false);
}
Err(e) => return Err(e),
};
match msg {
Message::Ok(_) => {
writeln!(out, "refused {approval}")?;
Ok(true)
}
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result<bool, AdminError> {
let msg = request(socket, Message::CheckGrants(Empty {}))?;
match msg {
Message::GrantsReport(report) => {
if report.problems.is_empty() {
writeln!(out, "grants: ok")?;
Ok(true)
} else {
for problem in &report.problems {
writeln!(
out,
"{}{}: {}",
escape_json_text(&problem.file),
match problem.line {
Some(line) => format!(":{line}"),
None => String::new(),
},
escape_json_text(&problem.problem)
)?;
}
Ok(false)
}
}
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn reason_name(reason: DenyReason) -> &'static str {
match reason {
DenyReason::NoGrant => "no_grant",
DenyReason::GrantExpired => "grant_expired",
DenyReason::TaintTooHigh => "taint_too_high",
DenyReason::DeniedByGrant => "denied_by_grant",
DenyReason::ApprovalRefused => "approval_refused",
DenyReason::ApprovalExpired => "approval_expired",
DenyReason::GrantsInvalid => "grants_invalid",
DenyReason::AuditUnavailable => "audit_unavailable",
DenyReason::InvalidArguments => "invalid_arguments",
DenyReason::StateUnreadable => "state_unreadable",
}
}
// The approvals table is one block per item: a summary line, then the tool and arguments.
pub fn write_block(out: &mut dyn Write, item: &PendingApproval, now: Timestamp) -> io::Result<()> {
let age = now.unix_millis().saturating_sub(item.created.unix_millis());
let until = item.expires.unix_millis().saturating_sub(now.unix_millis());
writeln!(
out,
"{} {} ago {} session {} grant {} taint {}",
item.approval,
span(age),
expiry(until, now >= item.expires),
session_shown(item.session.as_str()),
escape_json_text(&item.grant),
taint_name(item.taint),
)?;
writeln!(
out,
" {} {}",
escape_json_text(&item.tool),
escape_json_text(&item.arguments)
)?;
Ok(())
}
// A span of whole seconds, minutes, or hours, rounded down.
fn span(ms: u64) -> String {
let secs = ms / 1000;
let mins = secs / 60;
let hours = mins / 60;
if mins == 0 {
format!("{secs} s")
} else if hours == 0 {
format!("{mins} min")
} else {
format!("{hours} h")
}
}
// Whether the approval has expired, or how long until it does.
fn expiry(until: u64, expired: bool) -> String {
if expired {
"expired".to_string()
} else {
format!("expires in {}", span(until))
}
}
// A session id longer than ten characters is cut to nine and an ellipsis.
fn session_shown(session: &str) -> String {
if session.chars().count() > 10 {
let head: String = session.chars().take(9).collect();
format!("{head}")
} else {
session.to_string()
}
}
// The taint of a decision is its wire name.
fn taint_name(taint: proto::DataClass) -> &'static str {
match taint {
proto::DataClass::Public => "public",
proto::DataClass::Private => "private",
proto::DataClass::Secret => "secret",
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ impl std::error::Error for ChatError {
}
// The ErrorCode name in snake_case, spelled out in words for the owner reading a failure.
fn code_name(code: ErrorCode) -> &'static str {
pub fn code_name(code: ErrorCode) -> &'static str {
match code {
ErrorCode::BadFrame => "bad frame",
ErrorCode::BadVersion => "bad version",
+290
View File
@@ -0,0 +1,290 @@
//! `bxctl` command-line parsing: the subcommands, the shared `USAGE` and `ChatOptions`.
use std::path::{Path, PathBuf};
use proto::SessionId;
// Shown on stderr for any usage error. It names every subcommand and the flags they share, so a
// single line tells the owner what they can type.
pub const USAGE: &str = "\
usage: bxctl <command> [options]
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
Talk to loopd.
bxctl approvals [--admin-socket PATH]
List the approvals waiting for a decision.
bxctl approve <id> [--admin-socket PATH]
Approve approval <id>.
bxctl refuse <id> [--reason TEXT] [--admin-socket PATH]
Refuse approval <id>, recording a reason.
bxctl grants check [--admin-socket PATH]
Check that the grants load.
bxctl audit verify [--home DIR]
Verify the audit log against the grants.";
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatOptions {
pub socket: PathBuf,
pub admin_socket: PathBuf,
pub session: Option<SessionId>,
pub show_thinking: bool,
pub say: Option<String>,
pub json: bool,
}
impl ChatOptions {
pub fn default(home: &Path) -> Self {
ChatOptions {
socket: home.join("run").join("loop").join("loop.sock"),
admin_socket: default_admin_socket(home),
session: None,
show_thinking: true,
say: None,
json: false,
}
}
}
// Why `parse` rejected the arguments. There is only one kind: the arguments were wrong.
#[derive(Debug, PartialEq, Eq)]
pub struct UsageError;
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid arguments")
}
}
impl std::error::Error for UsageError {}
// What the owner asked for. `Chat` carries its options; every admin subcommand carries the admin
// socket to use (defaulted from `$BOXMAKER_HOME`) plus whatever only it needs.
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Chat(ChatOptions),
Approvals {
admin_socket: PathBuf,
},
Approve {
admin_socket: PathBuf,
approval: u64,
},
Refuse {
admin_socket: PathBuf,
approval: u64,
reason: Option<String>,
},
GrantsCheck {
admin_socket: PathBuf,
},
AuditVerify {
home: PathBuf,
},
}
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (command, rest) = match args.split_first() {
Some((c, r)) => (c.as_str(), r),
None => return Err(UsageError),
};
match command {
"chat" => parse_chat(rest, home),
"approvals" => parse_approvals(rest, home),
"approve" => parse_approve(rest, home),
"refuse" => parse_refuse(rest, home),
"grants" => parse_grants(rest, home),
"audit" => parse_audit(rest, home),
// A flag before the command, or any unknown command, is not valid.
_ => Err(UsageError),
}
}
fn default_admin_socket(home: &Path) -> PathBuf {
home.join("run").join("owner-broker").join("admin.sock")
}
fn parse_chat(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut opts = ChatOptions::default(home);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => opts.socket = PathBuf::from(value_at(args, i)?),
"--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?),
"--session" => opts.session = Some(parse_session(&value_at(args, i)?)?),
"--say" => opts.say = Some(value_at(args, i)?),
"--no-thinking" => {
opts.show_thinking = false;
i += 1;
continue;
}
"--json" => {
opts.json = true;
i += 1;
continue;
}
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::Chat(opts))
}
fn parse_approvals(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => admin_socket = PathBuf::from(value_at(args, i)?),
// Anything else is a stray argument or an unknown flag.
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::Approvals { admin_socket })
}
fn parse_approve(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut approval = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => {
admin_socket = PathBuf::from(value_at(args, i)?);
i += 2;
continue;
}
other => {
if approval.is_some() {
return Err(UsageError);
}
approval = Some(parse_id(other)?);
i += 1;
}
}
}
match approval {
Some(approval) => Ok(Command::Approve {
admin_socket,
approval,
}),
None => Err(UsageError),
}
}
fn parse_refuse(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut approval = None;
let mut reason = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => {
admin_socket = PathBuf::from(value_at(args, i)?);
i += 2;
continue;
}
"--reason" => {
if reason.is_some() {
return Err(UsageError);
}
reason = Some(verify_reason(&value_at(args, i)?)?);
i += 2;
continue;
}
other => {
if approval.is_some() {
return Err(UsageError);
}
approval = Some(parse_id(other)?);
i += 1;
}
}
}
match approval {
Some(approval) => Ok(Command::Refuse {
admin_socket,
approval,
reason,
}),
None => Err(UsageError),
}
}
fn parse_grants(args: &[String], home: &Path) -> Result<Command, UsageError> {
let rest = match args.first().map(String::as_str) {
Some("check") => &args[1..],
_ => return Err(UsageError),
};
let mut admin_socket = default_admin_socket(home);
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--admin-socket" => admin_socket = PathBuf::from(value_at(rest, i)?),
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::GrantsCheck { admin_socket })
}
fn parse_audit(args: &[String], home: &Path) -> Result<Command, UsageError> {
let rest = match args.first().map(String::as_str) {
Some("verify") => &args[1..],
_ => return Err(UsageError),
};
let mut home = home.to_path_buf();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--home" => home = PathBuf::from(value_at(rest, i)?),
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::AuditVerify { home })
}
// The argument after the flag at `i`, taken as the value even when it looks like a flag, so a value
// is a value.
fn value_at(args: &[String], i: usize) -> Result<String, UsageError> {
args.get(i + 1).cloned().ok_or(UsageError)
}
// An approval id is ASCII digits that fit in u64 and nothing else; `str::parse::<u64>` alone would
// accept `+41`, so the digit check comes first.
fn parse_id(value: &str) -> Result<u64, UsageError> {
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return Err(UsageError);
}
value.parse::<u64>().map_err(|_| UsageError)
}
// A session id is a valid id, and one that is purely numeric must fit in u64 so it is never
// mistaken for an approval id.
fn parse_session(value: &str) -> Result<SessionId, UsageError> {
match SessionId::new(value) {
Ok(id) => {
if value.bytes().all(|b| b.is_ascii_digit()) && value.parse::<u64>().is_err() {
return Err(UsageError);
}
Ok(id)
}
Err(_) => Err(UsageError),
}
}
// A refuse reason must be present; its content is unvalidated on the client.
fn verify_reason(reason: &str) -> Result<String, UsageError> {
if reason.is_empty() {
return Err(UsageError);
}
Ok(reason.to_string())
}
+43
View File
@@ -0,0 +1,43 @@
//! The two text-escape helpers the owner's client needs.
// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code
// point in any of these ranges must never reach the terminal raw.
const ESCAPE_RANGES: &[(u32, u32)] = &[
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
pub fn escape_json_text(s: &str) -> String {
escape(s, false)
}
pub fn escape_model_text(s: &str) -> String {
escape(s, true)
}
fn escape(s: &str, model_text: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let cp = u32::from(c);
if should_escape(c, model_text) {
out.push_str(&format!("\\u{:04x}", cp));
} else {
out.push(c);
}
}
out
}
// A control point is escaped, except that model text keeps \n and \t.
fn should_escape(c: char, model_text: bool) -> bool {
let cp = u32::from(c);
if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) {
!(model_text && (cp == 0x09 || cp == 0x0a))
} else {
false
}
}
+4
View File
@@ -1,3 +1,7 @@
//! The owner's command-line tool.
pub mod admin;
pub mod chat;
pub mod cli;
pub mod escape;
pub mod verify;
+68 -78
View File
@@ -4,85 +4,75 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bxctl::admin::{self, AdminError};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent};
const USAGE: &str =
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]";
struct Options {
socket: PathBuf,
session: Option<SessionId>,
show_thinking: bool,
say: Option<String>,
json: bool,
}
use bxctl::cli::{self, ChatOptions, Command, USAGE};
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) != Some("chat") {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
match parse_chat(&args[1..]) {
Ok(opts) => run(&opts),
Err(()) => {
eprintln!("{USAGE}");
ExitCode::from(2)
}
}
}
fn parse_chat(args: &[String]) -> Result<Options, ()> {
let mut opts = Options {
socket: default_socket(),
session: None,
show_thinking: true,
say: None,
json: false,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => {
i += 1;
match args.get(i) {
Some(p) => opts.socket = PathBuf::from(p),
None => return Err(()),
}
}
"--session" => {
i += 1;
match args.get(i) {
Some(s) => match SessionId::new(s) {
Ok(id) => opts.session = Some(id),
Err(_) => return Err(()),
},
None => return Err(()),
}
}
"--say" => {
i += 1;
match args.get(i) {
Some(t) => opts.say = Some(t.to_string()),
None => return Err(()),
}
}
"--no-thinking" => opts.show_thinking = false,
"--json" => opts.json = true,
_ => return Err(()),
}
i += 1;
}
Ok(opts)
}
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
fn default_socket() -> PathBuf {
let base = std::env::var_os("BOXMAKER_HOME")
// $BOXMAKER_HOME defaults to /var/lib/boxmaker; defaults for the sockets are read from it.
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock")
// Parse before connecting, so a usage error exits 2 even without a broker.
let command = match cli::parse(&args, &home) {
Ok(command) => command,
Err(_) => {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
};
match command {
Command::Chat(opts) => run(&opts),
Command::Approvals { admin_socket } => exit(|| cmd_approvals(&admin_socket)),
Command::Approve {
admin_socket,
approval,
} => exit(|| cmd_approve(&admin_socket, approval)),
Command::Refuse {
admin_socket,
approval,
reason,
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
Command::AuditVerify { home: _ } => {
eprintln!("bxctl: audit verify is not implemented yet");
ExitCode::from(1)
}
}
}
// Run a command, mapping its result to an exit code: success for true, 1 for false or an error.
fn exit<F: FnOnce() -> Result<bool, AdminError>>(f: F) -> ExitCode {
match f() {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
}
}
fn cmd_approvals(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approvals(admin, Timestamp::now(), &mut out)
}
fn cmd_approve(admin: &Path, id: u64) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approve(admin, id, &mut out)
}
fn cmd_refuse(admin: &Path, id: u64, reason: Option<&str>) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_refuse(admin, id, reason, &mut out)
}
fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_grants_check(admin, &mut out)
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
@@ -123,14 +113,14 @@ fn stream_turn(
}
}
fn run(opts: &Options) -> ExitCode {
fn run(opts: &ChatOptions) -> ExitCode {
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
}
}
fn run_say(opts: &Options, text: &str) -> ExitCode {
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
@@ -152,7 +142,7 @@ fn run_say(opts: &Options, text: &str) -> ExitCode {
// Runs a --say turn, retrying once when the named session does not exist.
fn run_turn_twice(
opts: &Options,
opts: &ChatOptions,
session: &SessionId,
text: &str,
resume: bool,
@@ -189,7 +179,7 @@ fn run_turn_twice(
}
}
fn run_interactive(opts: &Options) -> ExitCode {
fn run_interactive(opts: &ChatOptions) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
@@ -255,7 +245,7 @@ fn run_interactive(opts: &Options) -> ExitCode {
}
// The plain answer goes to stdout; in --json mode the TurnDone goes to stderr after the events.
fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
if opts.json {
let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
let mut err = std::io::stderr().lock();
+1
View File
@@ -0,0 +1 @@
//! Placeholder for the audit-log verifier (task 19).