Add bxctl approvals, approve, refuse and grants check
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user