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",
}
}