Merge m3a: brokerd's decision path (M3a)

Tasks 01 to 22, the review, and task 23 (the review fixes, with a second,
independent review of them). The one conflict, docs/implementer-lessons.md,
had the same T18 and T19 on both sides; m3a's T20 to T22 follow them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:55:11 -07:00
co-authored by Claude Opus 5.5
212 changed files with 18352 additions and 378 deletions
+4
View File
@@ -69,6 +69,10 @@ These come from defects found in review. The evidence is in `docs/implementer-le
- A rule about one path applies to every path that does the same thing, including early returns
and error paths the task did not walk through.
- A file that exists but cannot be read is an error. Only a missing file may count as absent.
- A file your own program can create before it writes anything must load like an empty one. Test
the state a crash leaves behind, not only the states you write on purpose.
- When two programs must agree about a set of things (which files are a log, which names are ids),
one of them deciding alone is a bug. Write the case that walks both.
## The gate
Generated
+3
View File
@@ -7,6 +7,9 @@ name = "brokerd"
version = "0.1.0"
dependencies = [
"proto",
"serde",
"serde_json",
"toml",
]
[[package]]
+4
View File
@@ -6,10 +6,14 @@ gate:
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
cargo test --workspace --locked --offline
cargo build --workspace --locked --offline
BOXMAKER_BROKERD=$(CURDIR)/target/debug/brokerd \
cargo test -p loopd --test end_to_end --locked --offline -- --ignored
cargo deny --offline check bans licenses sources
sh scripts/check-lines.sh
sh scripts/check-crate-deps.sh
sh scripts/check-dep-docs.sh
sh scripts/check-runbook.sh
sh scripts/test-gate-scripts.sh
@echo "gate: ok"
+3
View File
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
proto.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
+129
View File
@@ -0,0 +1,129 @@
//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which answers
//! an approval the same way. Whoever takes an entry out of the table answers it.
use std::os::unix::net::UnixStream;
use crate::approvals::Entry;
use crate::broker::{Broker, forbid, read_request, send};
use crate::grants;
use crate::ledger::{Answer, Answered};
use proto::{
ApprovalList, Approve, ApproveResult, DecisionRecord, DenyReason, Empty, ErrorCode,
GrantsReport, Message, Refuse, Timestamp, WireError,
};
/// The Approval record's `by` for answers through admin.sock.
pub const BY: &str = "bxctl";
/// The refusal `bxctl` sees when the ledger could not record it.
pub const REFUSAL_INTERNAL: &str =
"the refusal could not be recorded; the call is denied; see docs/runbook.md#audit-unavailable";
/// Whoever takes an entry out of the table answers it: the ledger records the answer, then the
/// verdict goes to the waiting thread, and only then is the record returned.
pub fn answer(broker: &Broker, entry: Entry, answer: Answer, now: Timestamp) -> DecisionRecord {
// 1. The grants as they are now: an approval decides again with them.
let grants = broker.grants();
// 2. Record the answer; the ledger writes the Approval record and returns the verdict and
// outcome together, or the audit-unavailable denial when it could not record.
let Entry { info, ask, reply } = entry;
let Answered { verdict, outcome } =
broker
.ledger()
.answer(ask, info.approval, answer, &grants, now);
// 3. The record is written either way; if the waiting thread is gone, there is nothing else to do.
if reply.send(verdict).is_err() {
broker.log(&format!(
"brokerd: approval {} was answered after its requester had gone",
info.approval
));
}
// 4. The outcome the caller reports to bxctl.
outcome
}
/// Answer every approval whose time has run out, the same way an owner's refusal would.
pub fn expire_due(broker: &Broker, now: Timestamp) -> usize {
let entries = broker.table().take_expired(now);
let count = entries.len();
for entry in entries {
answer(broker, entry, Answer::Expired, now);
}
count
}
/// Serve one `admin.sock` connection from bxctl: one request, one final answer.
pub fn handle(mut stream: UnixStream, broker: &Broker) {
// 1. The peer is gone before it speaks, or its frame is malformed: answer and return.
let Some(envelope) = read_request(&mut stream) else {
return;
};
let id = envelope.id;
let msg = envelope.msg;
let now = Timestamp::now();
// 2. By message kind. `take` is the only way in: no one looks at an entry and removes it later.
let response = match msg {
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
items: broker.table().list(),
}),
Message::Approve(Approve { approval }) => match broker.table().take(approval) {
None => Message::Error(WireError {
code: ErrorCode::NoSuchApproval,
detail: format!("approval {approval} is not pending"),
}),
Some(entry) => {
let outcome = answer(
broker,
entry,
Answer::Approved {
by: Some(BY.to_string()),
},
now,
);
Message::ApproveResult(ApproveResult { outcome })
}
},
Message::Refuse(Refuse { approval, reason }) => match broker.table().take(approval) {
None => Message::Error(WireError {
code: ErrorCode::NoSuchApproval,
detail: format!("approval {approval} is not pending"),
}),
Some(entry) => match answer(
broker,
entry,
Answer::Refused {
by: Some(BY.to_string()),
reason,
},
now,
) {
DecisionRecord::Denied {
reason: DenyReason::ApprovalRefused,
} => Message::Ok(Empty {}),
// The refusal could not be recorded: bxctl gets an Internal, the waiting call a denial.
_ => Message::Error(WireError {
code: ErrorCode::Internal,
detail: REFUSAL_INTERNAL.to_string(),
}),
},
},
// bxctl grants check shows the problems itself and must not use up the print-once of the broker.
Message::CheckGrants(_) => {
let problems = match grants::load(&broker.cfg().paths.grants) {
Ok(_) => Vec::new(),
Err(problems) => problems,
};
Message::GrantsReport(GrantsReport { problems })
}
other => {
forbid(broker, &mut stream, id, &other, "admin.sock");
return;
}
};
// 3. The final frame; a failed send is ignored.
let _ = send(&mut stream, id, true, response);
}
+76
View File
@@ -0,0 +1,76 @@
//! The pending-approval table: whoever takes an entry out answers it, and everyone else finds it
//! gone. In memory only.
use crate::policy::{Ask, Decision};
use proto::{DenyReason, PendingApproval, Timestamp};
use std::collections::BTreeMap;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Mutex, MutexGuard};
/// What the waiting thread is told. Boxed: clippy's `large_enum_variant` rejects it unboxed.
#[derive(Debug)]
pub enum Verdict {
Run(Box<Decision>),
Denied(DenyReason),
}
#[derive(Debug)]
pub struct Entry {
pub info: PendingApproval,
pub ask: Ask,
pub reply: Sender<Verdict>,
}
#[derive(Debug, Default)]
pub struct Table {
entries: Mutex<BTreeMap<u64, Entry>>,
}
impl Table {
pub fn new() -> Table {
Table {
entries: Mutex::new(BTreeMap::new()),
}
}
fn lock(&self) -> MutexGuard<'_, BTreeMap<u64, Entry>> {
self.entries
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn insert(&self, info: PendingApproval, ask: Ask) -> Receiver<Verdict> {
let (sender, receiver) = channel();
self.lock().insert(
info.approval,
Entry {
info,
ask,
reply: sender,
},
);
receiver
}
pub fn take(&self, id: u64) -> Option<Entry> {
self.lock().remove(&id)
}
pub fn take_expired(&self, now: Timestamp) -> Vec<Entry> {
// One lock: taking it once per id would let an approve slip in between.
let mut entries = self.lock();
let ids: Vec<u64> = entries
.values()
.filter(|entry| now >= entry.info.expires)
.map(|entry| entry.info.approval)
.collect();
ids.iter().filter_map(|id| entries.remove(id)).collect()
}
pub fn list(&self) -> Vec<PendingApproval> {
self.lock()
.values()
.map(|entry| entry.info.clone())
.collect()
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Tool arguments and the form checks for paths, hosts and URLs.
//!
//! `parse` turns the model's argument string into a typed value; the free functions say whether a
//! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads
//! was written by the model, so it is treated as hostile.
use serde::{Deserialize, Serialize};
use std::fmt;
/// Maximum length, in bytes, of a path.
pub const MAX_PATH: usize = 4096;
/// Maximum length, in bytes, of a URL.
pub const MAX_URL: usize = 2048;
/// The four tools the broker can dispatch to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolName {
ReadFile,
WriteFile,
Shell,
HttpFetch,
}
impl ToolName {
/// The four tools, in the order listed in the interface.
pub const ALL: [ToolName; 4] = [
ToolName::ReadFile,
ToolName::WriteFile,
ToolName::Shell,
ToolName::HttpFetch,
];
/// Parse a tool name as written by the model.
pub fn parse(name: &str) -> Option<ToolName> {
match name {
"read_file" => Some(ToolName::ReadFile),
"write_file" => Some(ToolName::WriteFile),
"shell" => Some(ToolName::Shell),
"http_fetch" => Some(ToolName::HttpFetch),
_ => None,
}
}
/// The name as written by the model.
pub fn as_str(self) -> &'static str {
match self {
ToolName::ReadFile => "read_file",
ToolName::WriteFile => "write_file",
ToolName::Shell => "shell",
ToolName::HttpFetch => "http_fetch",
}
}
}
/// A parsed set of tool arguments, one variant per tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolArgs {
ReadFile {
path: String,
},
WriteFile {
path: String,
content: String,
},
Shell {
command: String,
cwd: Option<String>,
},
HttpFetch {
url: String,
host: String,
},
}
impl ToolArgs {
/// The tool these arguments belong to.
pub fn tool(&self) -> ToolName {
match self {
ToolArgs::ReadFile { .. } => ToolName::ReadFile,
ToolArgs::WriteFile { .. } => ToolName::WriteFile,
ToolArgs::Shell { .. } => ToolName::Shell,
ToolArgs::HttpFetch { .. } => ToolName::HttpFetch,
}
}
/// The arguments written out again, so two spellings of one value look the same.
///
/// Fields come out in the table's order, an absent `cwd` is left out, and `host` is never
/// written because it is not an argument. A serialization failure yields `"{}"`.
pub fn canonical_json(&self) -> String {
match self {
ToolArgs::ReadFile { path } => {
let value = ReadFileArgs { path: path.clone() };
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
ToolArgs::WriteFile { path, content } => {
let value = WriteFileArgs {
path: path.clone(),
content: content.clone(),
};
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
ToolArgs::Shell { command, cwd } => {
let value = ShellArgs {
command: command.clone(),
cwd: cwd.clone(),
};
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
ToolArgs::HttpFetch { url, .. } => {
let value = HttpFetchArgs { url: url.clone() };
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
}
}
}
}
/// Why `parse` refused a set of arguments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArgsError {
/// The arguments were not the right shape for the tool.
Shape(String),
/// A path was not well formed.
Path(String),
/// A URL gave no valid host.
Url(String),
}
impl fmt::Display for ArgsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArgsError::Shape(text) => write!(f, "invalid argument shape: {text}"),
ArgsError::Path(text) => write!(f, "invalid path: {text}"),
ArgsError::Url(text) => write!(f, "invalid url: {text}"),
}
}
}
impl std::error::Error for ArgsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
/// The argument shape for one tool, decoded then re-serialised.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadFileArgs {
path: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteFileArgs {
path: String,
content: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ShellArgs {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct HttpFetchArgs {
url: String,
}
/// Decode `arguments` for `tool` into a typed value.
pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError> {
match tool {
ToolName::ReadFile => {
let value: ReadFileArgs = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(error) => return Err(ArgsError::Shape(error.to_string())),
};
if !valid_path(&value.path) {
return Err(ArgsError::Path(value.path));
}
Ok(ToolArgs::ReadFile { path: value.path })
}
ToolName::WriteFile => {
let value: WriteFileArgs = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(error) => return Err(ArgsError::Shape(error.to_string())),
};
if !valid_path(&value.path) {
return Err(ArgsError::Path(value.path));
}
Ok(ToolArgs::WriteFile {
path: value.path,
content: value.content,
})
}
ToolName::Shell => {
let value: ShellArgs = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(error) => return Err(ArgsError::Shape(error.to_string())),
};
if let Some(cwd) = &value.cwd
&& !valid_path(cwd)
{
return Err(ArgsError::Path(cwd.clone()));
}
Ok(ToolArgs::Shell {
command: value.command,
cwd: value.cwd,
})
}
ToolName::HttpFetch => {
let value: HttpFetchArgs = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(error) => return Err(ArgsError::Shape(error.to_string())),
};
let host = match url_host(&value.url) {
Some(host) => host.to_string(),
None => return Err(ArgsError::Url(value.url)),
};
Ok(ToolArgs::HttpFetch {
url: value.url,
host,
})
}
}
}
/// A path is well formed if it is at most `MAX_PATH` bytes, has no NUL, starts with `/`, and every
/// component is a whole, non-dot name. The root `/` alone is valid.
pub fn valid_path(path: &str) -> bool {
if !(1..=MAX_PATH).contains(&path.len()) || path.contains('\0') {
return false;
}
let rest = match path.strip_prefix('/') {
Some(rest) => rest,
None => return false,
};
if rest.is_empty() {
return true;
}
for component in rest.split('/') {
if component.is_empty() || component == "." || component == ".." {
return false;
}
}
true
}
/// Is `path` inside `grant_path`, by whole components rather than by bytes?
pub fn inside(grant_path: &str, path: &str) -> bool {
match path.strip_prefix(grant_path) {
Some(rest) => rest.is_empty() || rest.starts_with('/') || grant_path == "/",
None => false,
}
}
/// A host label is 1 to 63 bytes of `a-z`, `0-9` or `-`, and neither starts nor ends with `-`.
fn valid_label(label: &str) -> bool {
if !(1..=63).contains(&label.len()) {
return false;
}
let bytes = label.as_bytes();
if bytes.iter().next() == Some(&b'-') || bytes.iter().last() == Some(&b'-') {
return false;
}
bytes
.iter()
.all(|&byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
}
/// A host is 1 to 253 bytes of dot-separated labels, each a whole non-dot name, and the last label
/// starts with a letter. That last rule keeps out every spelling of an IPv4 address.
pub fn valid_host(host: &str) -> bool {
if !(1..=253).contains(&host.len()) {
return false;
}
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return false;
}
for label in &labels {
if !valid_label(label) {
return false;
}
}
match labels.iter().last() {
Some(last) => matches!(last.bytes().next(), Some(byte) if byte.is_ascii_lowercase()),
None => false,
}
}
/// A host, or `*.` followed by a host. Nothing else.
pub fn valid_host_pattern(pattern: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(base) => valid_host(base),
None => valid_host(pattern),
}
}
/// Does `pattern` match `host`? Without `*.` the strings must be equal; with `*.base` the host must
/// end in `.base` with something before the dot.
pub fn host_matches(pattern: &str, host: &str) -> bool {
let base = match pattern.strip_prefix("*.") {
Some(base) => base,
None => return pattern == host,
};
match host.strip_suffix(base) {
Some(before) => match before.strip_suffix('.') {
Some(prefix) => !prefix.is_empty(),
None => false,
},
None => false,
}
}
/// A character that may appear in a host name.
fn host_char(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '.' | '-')
}
/// The host of a URL, if the URL is well formed. See the task's `url_host` rules for the order.
pub fn url_host(url: &str) -> Option<&str> {
if url.len() > MAX_URL {
return None;
}
let without_scheme = url.strip_prefix("https://")?;
// Uppercase is not allowed anywhere in a valid URL, so the host is read as written.
let end = match without_scheme.find(|c: char| !host_char(c)) {
Some(end) => end,
None => without_scheme.len(),
};
let (host, rest) = without_scheme.split_at_checked(end)?;
if !valid_host(host) {
return None;
}
// A trailing `:443` is allowed; any other port, or anything else, must be the end or a path.
let after = match rest.strip_prefix(":443") {
Some(after) => after,
None => rest,
};
let remainder = if after.is_empty() {
after
} else {
after.strip_prefix('/')?
};
if remainder.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
Some(host)
} else {
None
}
}
+421
View File
@@ -0,0 +1,421 @@
//! The audit writer: a hash-chained log of decisions, approvals and results, one JSON record per
//! line. The chain is the log's integrity: every record carries the hash of the line before it, so
//! any change is seen. Opening the log verifies it (a short check for an ordinary start, the whole
//! log when a break is to be accepted), recovers a torn tail, or accepts a break in an older file
//! when told to, and only then hands back a writer that appends the next record.
use std::fs::{self, TryLockError};
use std::io;
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use proto::{
AuditEvent, AuditRecord, ChainFailure, ChainReport, ChainVerifier, Hash32, Timestamp,
is_audit_log_name, sha256,
};
/// The notice appended to the log when a torn final line is recovered, as the RUNBOOK entry names.
pub const RECOVERED_NOTICE: &str =
"audit: recovered a torn final line\nsee docs/runbook.md#audit-recovered";
/// Where the owner looks when the audit log cannot be read, written or locked.
const RUNBOOK_UNAVAILABLE: &str = "see docs/runbook.md#audit-unavailable";
/// The RUNBOOK entry a second writer points to.
const RUNBOOK_ALREADY_RUNNING: &str = "see docs/runbook.md#brokerd-already-running";
/// The RUNBOOK entry a broken chain points to.
const RUNBOOK_BROKEN: &str = "see docs/runbook.md#audit-chain-broken";
/// Why opening or writing the audit log can fail.
#[derive(Debug)]
pub enum AuditError {
/// The log is already locked by another running brokerd.
Locked,
/// A record in the log does not chain: the log is damaged.
Broken(Box<ChainFailure>),
/// Asked to accept a break, but the log verifies: there is none.
NothingToAccept,
/// The log could not be read, written or locked.
Io { what: String, source: io::Error },
/// A write failed; the writer has stopped and must be restarted.
Stopped,
}
impl std::fmt::Display for AuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditError::Locked => {
write!(f, "brokerd is already running\n{RUNBOOK_ALREADY_RUNNING}")
}
AuditError::Broken(failure) => write!(
f,
"{}:{}: {}\n{RUNBOOK_BROKEN}",
failure.file, failure.line, failure.what
),
AuditError::NothingToAccept => {
write!(f, "nothing to accept: the audit log verifies")
}
AuditError::Io { what, source } => {
write!(f, "audit: {what}: {source}\n{RUNBOOK_UNAVAILABLE}")
}
AuditError::Stopped => write!(
f,
"audit: an earlier write failed; restart brokerd\n{RUNBOOK_UNAVAILABLE}"
),
}
}
}
impl std::error::Error for AuditError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AuditError::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl From<io::Error> for AuditError {
fn from(err: io::Error) -> Self {
AuditError::Io {
what: "audit log".to_string(),
source: err,
}
}
}
/// The log file name for a record's time: the day of the timestamp, `.jsonl`.
fn day_name(time: Timestamp) -> String {
let when = time.to_rfc3339();
format!("{}.jsonl", &when[..10])
}
/// An I/O error as an audit error, the path in what.
fn io(what: &str, path: impl AsRef<Path>, source: io::Error) -> AuditError {
let path = path.as_ref();
AuditError::Io {
what: format!("{what} {}", path.display()),
source,
}
}
/// The bytes after the last newline, once one trailing newline is removed. None if nothing is left.
fn last_line(bytes: &[u8]) -> Option<&[u8]> {
let bytes = match bytes.last() {
Some(b'\n') => &bytes[..bytes.len() - 1],
_ => bytes,
};
let line = match bytes.iter().rposition(|b| *b == b'\n') {
Some(pos) => &bytes[pos + 1..],
None => bytes,
};
if line.is_empty() { None } else { Some(line) }
}
/// Every log file in `dir`, by name. An unreadable directory or entry is an I/O error.
fn log_files(dir: &Path) -> Result<Vec<String>, AuditError> {
let mut names: Vec<String> = Vec::new();
let mut entries = fs::read_dir(dir).map_err(|e| io("read audit log", dir, e))?;
loop {
let entry = match entries.next() {
Some(entry) => entry,
None => break,
};
let entry = entry.map_err(|e| io("read audit log", dir, e))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if is_audit_log_name(&name) {
names.push(name.into_owned());
}
}
names.sort();
Ok(names)
}
/// Verify the whole log, from the first record.
pub fn verify_dir(dir: &Path) -> Result<ChainReport, AuditError> {
let files = log_files(dir)?;
let mut verifier = ChainVerifier::new();
for file in files {
let path = dir.join(&file);
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
verifier.feed(&file, &bytes);
}
Ok(verifier.finish())
}
/// Verify the latest file only, resuming from the last line of the file before it, when that line
/// is a record. Otherwise the whole log is verified.
fn short_check(dir: &Path) -> Result<ChainReport, AuditError> {
let files = log_files(dir)?;
let (Some(latest), Some(before)) = (files.last(), files.iter().rev().nth(1)) else {
return verify_dir(dir);
};
let path_before = dir.join(before);
let bytes = fs::read(&path_before).map_err(|e| io("read audit log", &path_before, e))?;
let line = match last_line(&bytes) {
Some(line) => line,
None => return verify_dir(dir),
};
let record: AuditRecord = match serde_json::from_slice(line) {
Ok(record) => record,
Err(_) => return verify_dir(dir),
};
let next_seq = match record.seq.checked_add(1) {
Some(seq) => seq,
None => return verify_dir(dir),
};
let prev = match sha256(line) {
Ok(hash) => hash,
Err(_) => return verify_dir(dir),
};
let mut verifier = ChainVerifier::resume(next_seq, prev);
let path_latest = dir.join(latest);
let latest_bytes = fs::read(&path_latest).map_err(|e| io("read audit log", &path_latest, e))?;
verifier.feed(latest, &latest_bytes);
Ok(verifier.finish())
}
/// The hash of the last line of the file before the latest, or zero if there is no such file or
/// it has no line.
fn tail_of_file_before_latest(dir: &Path) -> Result<Hash32, AuditError> {
let files = log_files(dir)?;
let Some(before) = files.iter().rev().nth(1) else {
return Ok(Hash32::ZERO);
};
let path = dir.join(before);
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
match last_line(&bytes) {
Some(line) => Ok(sha256(line).map_err(io::Error::other)?),
None => Ok(Hash32::ZERO),
}
}
/// End the last line of the log with a newline, in the file that holds it: the last log file that
/// is not empty. A torn line is always there, even when an empty later file exists, and it must be
/// ended in place, or the next record would join it on one line.
fn end_last_line(dir: &Path) -> Result<(), AuditError> {
for name in log_files(dir)?.iter().rev() {
let path = dir.join(name);
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
match bytes.last() {
None => continue,
Some(b'\n') => return Ok(()),
Some(_) => {
let mut file = fs::OpenOptions::new()
.append(true)
.open(&path)
.map_err(|e| io("open audit log", &path, e))?;
file.write_all(b"\n").map_err(io::Error::other)?;
file.sync_all().map_err(io::Error::other)?;
return Ok(());
}
}
}
Ok(())
}
/// Append one record: serialise it, open the target file, write the line, and return the seq and
/// the hash of the line it wrote. A fresh file is created mode 0600 and the directory synced.
fn write_record(
dir: &Path,
target: &str,
seq: u64,
prev: Hash32,
event: AuditEvent,
time: Timestamp,
) -> Result<(u64, Hash32), AuditError> {
let record = AuditRecord {
seq,
time,
prev,
event,
};
let json = serde_json::to_string(&record).map_err(io::Error::other)?;
let path = dir.join(target);
let mut file = fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(&path)
.map_err(|e| io("open audit log", &path, e))?;
file.write_all(json.as_bytes()).map_err(io::Error::other)?;
file.write_all(b"\n").map_err(io::Error::other)?;
file.sync_all().map_err(io::Error::other)?;
fs::File::open(dir).map_err(io::Error::other)?.sync_all()?;
let hash = sha256(json.as_bytes()).map_err(io::Error::other)?;
Ok((seq, hash))
}
/// The last record's file name: today's, or the latest if that is already current.
fn target_for(latest: &Option<String>, time: Timestamp) -> String {
let candidate = day_name(time);
match latest {
Some(current) if candidate <= *current => current.clone(),
_ => candidate,
}
}
/// A held directory lock: the open `.lock` file, released when it drops. Kept in the `Writer` so
/// the lock lasts as long as the writer; `fs::File` is not `Debug`, so this wraps it.
struct Lock(fs::File);
impl std::fmt::Debug for Lock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Lock").field(&"open").finish()
}
}
/// The audit log's writer. Holds the directory lock for its whole life, so a second writer is
/// refused.
#[derive(Debug)]
pub struct Writer {
/// Held, never read: closing the file is what releases the lock. The file itself stays.
_lock: Lock,
path: PathBuf,
latest: Option<String>,
prev: Hash32,
next_seq: u64,
stopped: bool,
}
impl Writer {
/// Open the log at `dir`: create it, lock it, verify it, and recover or accept as needed.
pub fn open(dir: &Path, accept_break: bool) -> Result<Opened, AuditError> {
// 1. The directory, mode 0700. Create it if missing; if it already exists, that is fine.
if let Err(e) = fs::DirBuilder::new().mode(0o700).create(dir)
&& e.kind() != io::ErrorKind::AlreadyExists
{
return Err(io("create audit log", dir, e));
}
// 2. The lock, mode 0600: try_lock refuses a second writer while this one lives.
let lock = Lock(
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(dir.join(".lock"))
.map_err(|e| io("open audit lock", dir.join(".lock"), e))?,
);
match lock.0.try_lock() {
Ok(()) => (),
Err(TryLockError::WouldBlock) => return Err(AuditError::Locked),
Err(TryLockError::Error(e)) => return Err(io("open audit lock", dir.join(".lock"), e)),
}
// 3. Verify the log.
let report = if accept_break {
verify_dir(dir)?
} else {
short_check(dir)?
};
// The latest log file before any recovery or break, for the target of either.
let latest = log_files(dir)
.ok()
.and_then(|files| files.into_iter().last());
let now = Timestamp::now();
let recovered = report.torn_tail.is_some();
// 4-9. A broken chain, nothing to accept, or a torn tail: write the recovery record, if any,
// and set the chain state for the next append.
let (prev, next_seq) = if let Some(failure) = report.failure.as_ref() {
if !accept_break {
return Err(AuditError::Broken(Box::new(failure.clone())));
}
if failure.tail_torn {
end_last_line(dir)?;
}
let (seq, hash) = write_record(
dir,
latest.as_deref().unwrap_or(&day_name(now)),
failure.break_seq,
failure.break_prev,
AuditEvent::AcceptedBreak {
file: failure.file.clone(),
line: failure.line,
last_good: failure.last_good,
},
now,
)?;
(hash, seq.checked_add(1).unwrap_or(report.next_seq))
} else if accept_break {
return Err(AuditError::NothingToAccept);
} else if let Some(torn) = report.torn_tail.as_ref() {
if !torn.has_newline {
end_last_line(dir)?;
}
let (seq, hash) = write_record(
dir,
latest.as_deref().unwrap_or(&day_name(now)),
torn.recovery_seq,
torn.recovery_prev,
AuditEvent::Recovery {
torn_bytes: torn.bytes,
torn_sha256: torn.sha256,
},
now,
)?;
(hash, seq.checked_add(1).unwrap_or(report.next_seq))
} else {
// No record verified: the chain goes on from the last line of the file before the
// latest, or starts at zero when there is none (a log whose only file is empty).
let prev = match report.head {
Some(head) => head,
None => tail_of_file_before_latest(dir)?,
};
(prev, report.next_seq)
};
// 10. Hand back the writer.
Ok(Opened {
writer: Writer {
_lock: lock,
path: dir.to_path_buf(),
latest,
prev,
next_seq,
stopped: false,
},
recovered,
accepted: report.failure.map(Box::new),
})
}
/// Append a record at `time`, in the right day's file. The seq it writes is returned.
pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
if self.stopped {
return Err(AuditError::Stopped);
}
let target = target_for(&self.latest, time);
let (seq, hash) =
match write_record(&self.path, &target, self.next_seq, self.prev, event, time) {
Ok(r) => r,
Err(e) => {
self.stopped = true;
return Err(e);
}
};
self.prev = hash;
self.next_seq = seq.checked_add(1).unwrap_or(self.next_seq);
if self.latest.as_deref() != Some(target.as_str()) {
self.latest = Some(target);
}
Ok(seq)
}
/// The seq the next append writes.
pub fn next_seq(&self) -> u64 {
self.next_seq
}
}
/// What `open` hands back: the writer plus what it recovered or accepted.
#[derive(Debug)]
pub struct Opened {
pub writer: Writer,
pub recovered: bool,
pub accepted: Option<Box<ChainFailure>>,
}
+313
View File
@@ -0,0 +1,313 @@
//! Serving one `broker.sock` connection: decide, record, run if allowed, record the result, and
//! answer with one final frame carrying the request's id. An `ask` call first sends one pending
//! frame and waits for whoever takes the table entry to send the verdict. Every record goes
//! through the ledger.
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
use std::sync::mpsc::RecvTimeoutError;
use std::time::Duration;
use proto::{
DenyReason, Envelope, ErrorCode, FrameError, Message, PROTOCOL_VERSION, PendingApproval,
Timestamp, ToolResponse, WireError, read_frame, write_frame,
};
use crate::approvals::{Table, Verdict};
use crate::config::Config;
use crate::grants;
use crate::ledger::{Call, Decided, Grants, Ledger};
use crate::policy::{Ask, SessionState};
use crate::runner;
use crate::runner::Runtime;
/// The failure message sent when a peer leaves at the last look, after the table entry is taken.
pub const GONE: &str = "the requester went away";
/// A line printer: one line per call, owned by the broker.
pub type Log = Box<dyn Fn(&str) + Send + Sync>;
/// The only role that holds authority: one runtime, the ledger, the approval table, and the grants
/// the owner can re-read.
pub struct Broker {
cfg: Config,
ledger: Ledger,
table: Table,
runtime: Box<dyn Runtime>,
log: Log,
printed: Mutex<Option<Vec<proto::GrantProblem>>>,
}
impl Broker {
pub fn new(cfg: Config, ledger: Ledger, runtime: Box<dyn Runtime>, log: Log) -> Broker {
Broker {
cfg,
ledger,
table: Table::new(),
runtime,
log,
printed: Mutex::new(None),
}
}
pub fn cfg(&self) -> &Config {
&self.cfg
}
pub fn ledger(&self) -> &Ledger {
&self.ledger
}
pub fn table(&self) -> &Table {
&self.table
}
pub fn log(&self, line: &str) {
(self.log)(line)
}
/// The owner's grants, printed once per distinct set of problems.
pub fn grants(&self) -> Grants {
match grants::load(&self.cfg.paths.grants) {
Ok(set) => {
let mut printed = self.printed.lock().unwrap_or_else(|p| p.into_inner());
*printed = None;
Ok(set)
}
Err(problems) => {
let mut printed = self.printed.lock().unwrap_or_else(|p| p.into_inner());
if printed.as_deref() != Some(problems.as_slice()) {
self.log(grants::render(&problems).trim_end());
}
*printed = Some(problems.clone());
Err(problems)
}
}
}
}
/// The snake_case wire name of a message: all fourteen kinds, none with a leading underscore.
pub fn kind(msg: &Message) -> &'static str {
match msg {
Message::ToolRequest(_) => "tool_request",
Message::ToolResponse(_) => "tool_response",
Message::Error(_) => "error",
Message::Turn(_) => "turn",
Message::TurnEvent(_) => "turn_event",
Message::TurnDone(_) => "turn_done",
Message::Approvals(_) => "approvals",
Message::ApprovalList(_) => "approval_list",
Message::Approve(_) => "approve",
Message::ApproveResult(_) => "approve_result",
Message::Refuse(_) => "refuse",
Message::Ok(_) => "ok",
Message::CheckGrants(_) => "check_grants",
Message::GrantsReport(_) => "grants_report",
}
}
/// One frame on the wire: the version, the request's id, whether it is the last, and the message.
/// True when the frame reached the peer.
pub fn send(stream: &mut UnixStream, id: u64, last: bool, msg: Message) -> bool {
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
};
write_frame(stream, &env).is_ok()
}
/// The next frame, or `None` when the peer is gone. A malformed frame is answered with an error
/// frame and then `None`.
pub fn read_request(stream: &mut UnixStream) -> Option<Envelope> {
match read_frame(stream) {
Ok(env) => Some(env),
Err(FrameError::Closed) => None,
Err(frame_error) => {
let code = match &frame_error {
FrameError::BadVersion(_) => ErrorCode::BadVersion,
FrameError::Json(_) => ErrorCode::BadMessage,
_ => ErrorCode::BadFrame,
};
let _ = send(
stream,
0,
true,
Message::Error(WireError {
code,
detail: frame_error.to_string(),
}),
);
None
}
}
}
/// Refuse a message that does not belong on this socket: log it, then answer with `Forbidden`.
pub fn forbid(broker: &Broker, stream: &mut UnixStream, id: u64, msg: &Message, socket: &str) {
let kind_name = kind(msg);
broker.log(&format!(
"brokerd: refused the message kind {kind_name} on {socket}\nsee docs/runbook.md#socket-forbidden"
));
let _ = send(
stream,
id,
true,
Message::Error(WireError {
code: ErrorCode::Forbidden,
detail: format!("{kind_name} is not accepted on {socket}"),
}),
);
}
/// Whether the peer is still there: it sends nothing more and never half-closes, so a byte would
/// break the protocol and a timeout means it is waiting.
pub fn alive(stream: &UnixStream) -> bool {
let mut stream = stream;
if stream
.set_read_timeout(Some(Duration::from_millis(10)))
.is_err()
{
return false;
}
let mut byte = [0u8; 1];
match stream.read(&mut byte) {
Ok(_) => false,
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
true
}
Err(_) => false,
}
}
/// Answer one call: decide, and either answer it or wait for an approval.
pub fn handle(mut stream: UnixStream, broker: &Broker) {
let Some(envelope) = read_request(&mut stream) else {
return; // 1. the peer is gone before it speaks
};
let id = envelope.id;
let msg = envelope.msg;
let Message::ToolRequest(request) = msg else {
forbid(broker, &mut stream, id, &msg, "broker.sock"); // 2. not a tool request
return;
};
let now = Timestamp::now();
let grants = broker.grants();
let answer = match broker.ledger().decide(request, &grants, now) {
Decided::Denied(reason) => Some(ToolResponse::Denied { reason }), // 3a. denied
Decided::Allowed { decision, seq } => Some(run(broker, decision, seq)), // 3b. allowed
Decided::Ask { ask, seq, state } => {
pending(&mut stream, broker, id, ask, seq, state, now) // 3c. ask
}
};
if let Some(answer) = answer {
let _ = send(&mut stream, id, true, Message::ToolResponse(answer)); // 4. the final frame
}
}
/// Run an allowed call and record its result.
fn run(broker: &Broker, decision: crate::policy::Decision, seq: u64) -> ToolResponse {
let call = Call::of(&decision, seq);
let response = runner::run(decision, broker.runtime.as_ref());
broker.ledger().finish(&call, response, Timestamp::now())
}
/// The `ask` path: send one pending frame, wait for the verdict, then answer. `None` means send
/// nothing more: the requester left while it was pending.
fn pending(
stream: &mut UnixStream,
broker: &Broker,
id: u64,
ask: Ask,
seq: u64,
state: SessionState,
now: Timestamp,
) -> Option<ToolResponse> {
// 1. The expiry: now plus the ttl, or the grant's own expiry if that is earlier.
let ttl = broker.cfg().approvals.ttl_ms;
let by_ttl = match Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl)) {
Ok(expires) => expires,
Err(_) => Timestamp::MAX,
};
let expires = match ask.expires() {
Some(expiry) if expiry < by_ttl => expiry,
_ => by_ttl,
};
// 2. Record the pending call in the table.
let info = PendingApproval {
approval: seq,
session: ask.request().session.clone(),
call: ask.request().call,
tool: ask.request().tool.clone(),
arguments: ask.args().canonical_json(),
grant: ask.grant().to_string(),
taint: state.taint,
created: now,
expires,
};
let verdict = broker.table().insert(info, ask);
// 3. Show the caller the pending call. If the frame does not reach the peer, the entry is
// still ours to take; if it is gone, someone is already answering it.
if !send(
stream,
id,
false,
Message::ToolResponse(ToolResponse::PendingApproval {
approval: seq,
expires,
}),
) && broker.table().take(seq).is_some()
{
return None; // 3. the peer left before the frame went out
}
// 4. Wait for the verdict, or for the peer to leave.
let outcome = loop {
match verdict.recv_timeout(Duration::from_secs(1)) {
Ok(verdict) => break Ok(verdict),
Err(RecvTimeoutError::Timeout) => {
if alive(stream) {
continue;
}
match broker.table().take(seq) {
Some(_) => return None, // 4. the peer left while we waited
None => match verdict.recv() {
Ok(verdict) => break Ok(verdict),
Err(_) => break Err(DenyReason::AuditUnavailable),
},
}
}
Err(RecvTimeoutError::Disconnected) => break Err(DenyReason::AuditUnavailable), // 4. the taker dropped it unanswered
}
};
// 5. Answer from the verdict.
match outcome {
Ok(verdict) => match verdict {
Verdict::Denied(reason) => Some(ToolResponse::Denied { reason }),
Verdict::Run(decision) => {
let decision = *decision;
if !alive(stream) {
let _ = broker.ledger().finish(
&Call::of(&decision, seq),
ToolResponse::Failed {
message: GONE.to_string(),
},
Timestamp::now(), // now, not when it was decided: records keep time order
);
return None; // 5. the peer left at the last look; nothing ran
}
Some(run(broker, decision, seq))
}
},
Err(reason) => Some(ToolResponse::Denied { reason }),
}
}
+124
View File
@@ -0,0 +1,124 @@
//! `brokerd` configuration: read `brokerd.toml` into a typed `Config`.
//!
//! This is our own format, so unknown keys are errors in every table: a
//! misspelt key that silently fell back to its default would be a setting the
//! owner believes is set and is not.
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Paths {
pub home: PathBuf,
pub grants: PathBuf,
}
impl Default for Paths {
fn default() -> Self {
Self {
home: std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
grants: PathBuf::from("/etc/boxmaker/grants"),
}
}
}
/// An empty path means "the default under `home`".
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct Sockets {
pub broker: PathBuf,
pub admin: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Approvals {
pub ttl_ms: u64,
}
impl Default for Approvals {
fn default() -> Self {
Self { ttl_ms: 900_000 }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub paths: Paths,
#[serde(default)]
pub sockets: Sockets,
#[serde(default)]
pub approvals: Approvals,
}
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
pub const MAX_TTL_MS: u64 = 86_400_000;
#[derive(Debug)]
pub enum ConfigError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::Error),
/// The file parses, but a value is outside what brokerd accepts.
Invalid(PathBuf, String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()),
ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
ConfigError::Invalid(path, why) => write!(f, "{}: {why}", path.display()),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
toml::from_str(text)
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let text =
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
let config: Config =
toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
// `loopd` waits at most a day after a pending frame, so a longer approval would be given up
// by the caller while `bxctl approvals` still listed it.
if !(1..=MAX_TTL_MS).contains(&config.approvals.ttl_ms) {
return Err(ConfigError::Invalid(
path.to_path_buf(),
format!(
"[approvals] ttl_ms is {}; it must be from 1 to {MAX_TTL_MS} (a day)",
config.approvals.ttl_ms
),
));
}
Ok(config)
}
pub fn broker_socket(&self) -> PathBuf {
if self.sockets.broker.as_os_str().is_empty() {
self.paths.home.join("run/loop-broker/broker.sock")
} else {
self.sockets.broker.clone()
}
}
pub fn admin_socket(&self) -> PathBuf {
if self.sockets.admin.as_os_str().is_empty() {
self.paths.home.join("run/owner-broker/admin.sock")
} else {
self.sockets.admin.clone()
}
}
pub fn audit_dir(&self) -> PathBuf {
self.paths.home.join("audit")
}
pub fn state_dir(&self) -> PathBuf {
self.paths.home.join("broker/sessions")
}
}
+373
View File
@@ -0,0 +1,373 @@
//! Loading the owner's grant files into a `GrantSet`, failing closed.
//!
//! A mistyped `deny` grant that was skipped would silently become an allow wherever another grant
//! matches, so if any file is invalid the whole set is invalid. `load` therefore returns either a
//! complete valid set or the full list of problems, never a partial one, and reports every problem
//! in every file rather than only the first.
use std::path::Path;
use crate::args::{ToolName, valid_host_pattern, valid_path};
use proto::{DataClass, Grant, GrantProblem, Mode, sha256};
/// Where the owner reads the rules behind a rejected set.
pub const RUNBOOK: &str = "see docs/runbook.md#grants-invalid";
/// A grant read from one file: the id (the file stem), the parsed grant, and the file's hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedGrant {
pub id: String, // the file stem
pub grant: proto::Grant,
pub sha256: proto::Hash32, // of the file's bytes as read
}
/// A set that passed every rule, in id order. Its field is private: `from_grants` and `load` are
/// the only ways to make one that is not empty.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GrantSet {
grants: Vec<LoadedGrant>,
}
impl GrantSet {
/// Build a set, checking every grant and refusing the whole set if any grant is invalid.
pub fn from_grants(grants: Vec<LoadedGrant>) -> Result<GrantSet, Vec<GrantProblem>> {
let mut grants = grants;
grants.sort_by(|a, b| a.id.cmp(&b.id));
let mut problems: Vec<GrantProblem> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for grant in &grants {
if seen.iter().any(|id| id == &grant.id) {
push(
&mut problems,
format!("{}.toml", grant.id),
None,
"two grants have this id",
);
}
seen.push(grant.id.clone());
check_grant(grant, &mut problems);
}
if problems.is_empty() {
Ok(GrantSet { grants })
} else {
Err(problems)
}
}
/// The grants in id order.
pub fn grants(&self) -> &[LoadedGrant] {
&self.grants
}
}
/// A file name is a valid grant id when it is 1 to 64 characters of `a-z`, `0-9` and `-`.
pub fn valid_id(id: &str) -> bool {
if !(1..=64).contains(&id.len()) {
return false;
}
id.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
}
/// Read every `*.toml` in `dir` into a `GrantSet`, or report every problem found.
pub fn load(dir: &Path) -> Result<GrantSet, Vec<GrantProblem>> {
// Step 1: the directory itself must be readable. A missing directory is not an empty set.
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) => {
return Err(vec![GrantProblem {
file: dir.to_string_lossy().to_string(),
line: None,
problem: format!("the grants directory cannot be read: {}", error),
}]);
}
};
// Step 2: collect and sort the file names, so problems come out in the same order every time.
let mut names: Vec<String> = Vec::new();
for entry in entries {
match entry {
Ok(entry) => names.push(entry.file_name().to_string_lossy().to_string()),
Err(error) => {
// An entry that cannot be read is the same problem as the directory.
return Err(vec![GrantProblem {
file: dir.to_string_lossy().to_string(),
line: None,
problem: format!("the grants directory cannot be read: {}", error),
}]);
}
}
}
names.sort();
// Steps 3-7: read each file, keeping the valid grants and recording every other problem.
let mut kept: Vec<LoadedGrant> = Vec::new();
let mut problems: Vec<GrantProblem> = Vec::new();
for name in &names {
if !name.ends_with(".toml") {
continue;
}
let id = &name[..name.len() - ".toml".len()];
let path = dir.join(name);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) => {
// Never treat an unreadable file as absent.
problems.push(GrantProblem {
file: name.clone(),
line: None,
problem: format!("cannot be read: {}", error),
});
continue;
}
};
let text = match std::str::from_utf8(&bytes) {
Ok(text) => text,
Err(_) => {
problems.push(GrantProblem {
file: name.clone(),
line: None,
problem: "is not UTF-8".to_string(),
});
continue;
}
};
match toml::from_str::<Grant>(text) {
Ok(grant) => {
let sha256 = match sha256(&bytes) {
Ok(sha256) => sha256,
Err(error) => {
problems.push(GrantProblem {
file: name.clone(),
line: None,
problem: error.to_string(),
});
continue;
}
};
kept.push(LoadedGrant {
id: id.to_string(),
grant,
sha256,
});
}
Err(error) => {
problems.push(GrantProblem {
file: name.clone(),
line: Some(span_line(text, &error)),
problem: error.message().to_string(),
});
}
}
}
// Step 8: even when the steps above found problems, still check the valid grants so the owner
// sees everything at once. Return Err if anything at all was reported.
let mut all = problems;
let set = match GrantSet::from_grants(kept) {
Ok(set) => Some(set),
Err(value_problems) => {
all.extend(value_problems);
None
}
};
all.sort_by(|a, b| a.file.cmp(&b.file));
match set {
Some(set) => {
if all.is_empty() {
Ok(set)
} else {
Err(all)
}
}
None => Err(all),
}
}
/// The 1-based line of a parse error's start: the number of newlines before it, plus one.
fn span_line(text: &str, error: &toml::de::Error) -> u64 {
match error.span() {
Some(range) => {
let before = match text.get(..range.start) {
Some(before) => before,
None => text,
};
let newlines = before.bytes().filter(|&b| b == b'\n').count();
u64::try_from(newlines).map_or(u64::MAX, |n| n.saturating_add(1))
}
None => 1,
}
}
/// Check one grant and push every problem it has. `file` is `"<id>.toml"`, `line` is `None`.
fn check_grant(grant: &LoadedGrant, problems: &mut Vec<GrantProblem>) {
let id = &grant.id;
let inner = &grant.grant;
let file = format!("{}.toml", id);
// Rule 2: the id must be a valid grant id.
if !valid_id(id) {
push(
problems,
file.clone(),
None,
"the file name is not a valid grant id",
);
}
// Rule 3: the tool must be one of the four; an unknown tool skips rule 6 but not the others.
match ToolName::parse(&grant.grant.tool) {
None => push(
problems,
file.clone(),
None,
"unknown tool; only read_file, write_file, shell and http_fetch",
),
Some(tool) => check_tool_constraints(tool, &file, inner, problems),
}
// Rule 4: secrets are not supported until M4.
if inner.secret.is_some() {
push(
problems,
file.clone(),
None,
"secrets are not supported until M4",
);
}
// Rule 5: patterns are not supported.
if !inner.constraints.patterns.is_empty() {
push(problems, file.clone(), None, "patterns are not supported");
}
// Rule 7: no path may be the whole file system or an invalid absolute path.
for path in &inner.constraints.paths {
if path == "/" {
push(
problems,
file.clone(),
None,
"a grant of the whole file system is not supported",
);
} else if !valid_path(path) {
push(
problems,
file.clone(),
None,
format!("{:?} is not a valid absolute path", path),
);
}
}
// Rule 8: every host must be a valid host pattern.
for host in &inner.constraints.hosts {
if !valid_host_pattern(host) {
push(
problems,
file.clone(),
None,
format!("{:?} is not a valid host pattern", host),
);
}
}
// Rule 9: a deny grant must apply at every taint.
if inner.mode == Mode::Deny && inner.max_taint != DataClass::Secret {
push(
problems,
file,
None,
"a deny grant must apply at every taint",
);
}
}
/// Rule 6: the per-tool checks on paths and hosts. `file` is `"<id>.toml"`.
fn check_tool_constraints(
tool: ToolName,
file: &str,
grant: &Grant,
problems: &mut Vec<GrantProblem>,
) {
let tool = tool.as_str();
match tool {
"read_file" | "write_file" => {
if grant.constraints.paths.is_empty() {
push(
problems,
file.to_string(),
None,
format!("{} needs at least one path", tool),
);
}
if !grant.constraints.hosts.is_empty() {
push(
problems,
file.to_string(),
None,
format!("{} does not take hosts", tool),
);
}
}
"shell" if !grant.constraints.hosts.is_empty() => {
push(
problems,
file.to_string(),
None,
format!("{} does not take hosts", tool),
);
}
"http_fetch" => {
if grant.constraints.hosts.is_empty() {
push(
problems,
file.to_string(),
None,
"http_fetch needs at least one host",
);
}
if !grant.constraints.paths.is_empty() {
push(
problems,
file.to_string(),
None,
"http_fetch does not take paths",
);
}
}
_ => {}
}
}
/// One line per problem, then `RUNBOOK` on its own line; every line ends with `\n`.
pub fn render(problems: &[GrantProblem]) -> String {
let mut out = String::new();
for problem in problems {
match problem.line {
Some(line) => {
out.push_str(&format!("{}:{}: {}\n", problem.file, line, problem.problem));
}
None => {
out.push_str(&format!("{}: {}\n", problem.file, problem.problem));
}
}
}
out.push_str(RUNBOOK);
out.push('\n');
out
}
fn push(
problems: &mut Vec<GrantProblem>,
file: String,
line: Option<u64>,
problem: impl Into<String>,
) {
problems.push(GrantProblem {
file,
line,
problem: problem.into(),
});
}
+499
View File
@@ -0,0 +1,499 @@
//! The ledger: the audit writer and every session's state behind one lock, with the three steps
//! that hold it. Without the one lock, two results for one session could each read `private` and
//! the second write would put the taint back down; after any failed append or panic while the lock was held, the ledger refuses every later step, and only the next start's check puts a partial line on disk right.
use std::sync::{Mutex, MutexGuard};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode,
SessionId, Timestamp, ToolRequest, ToolResponse,
};
use crate::approvals::Verdict;
use crate::audit::{AuditError, Writer};
use crate::grants::GrantSet;
use crate::policy::{Ask, Denial, Label, SessionState};
use crate::state::StateStore;
/// The message `finish` returns when the raised taint and the record are not both on disk.
pub const NOT_RECORDED: &str = "the result could not be recorded";
/// After a panic while the lock was held: every call is denied until brokerd is restarted.
pub const POISONED: &str = "brokerd: a thread panicked while holding the ledger; every call is denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable";
/// After a failed append: every call is denied until brokerd is restarted.
pub const STOPPED: &str = "brokerd: an earlier audit write failed; every call is denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable";
/// What `grants::load` returns: a valid set, or the list of problems that made it invalid.
pub type Grants = Result<GrantSet, Vec<GrantProblem>>;
/// The audit writer behind one lock, so a step that fails to record refuses every later step.
pub trait AuditSink: Send {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError>;
}
impl AuditSink for Writer {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
Writer::append(self, time, event)
}
}
/// A call the broker is finishing: the session, the call, the decision it was approved under, and
/// the label the result carries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Call {
pub session: SessionId,
pub call: CallId,
pub decision: u64,
pub label: Label,
}
impl Call {
/// The call and label of an approved decision, with the seq it was approved under.
pub fn of(decision: &crate::policy::Decision, seq: u64) -> Call {
let request = decision.request();
Call {
session: request.session.clone(),
call: request.call,
decision: seq,
label: decision.label(),
}
}
}
/// How a call was decided.
#[derive(Debug)]
pub enum Decided {
Allowed {
decision: crate::policy::Decision,
seq: u64,
},
Ask {
ask: Ask,
seq: u64,
state: SessionState,
},
Denied(DenyReason),
}
/// The value a decided call carries past the record, or the denial reason when there is none.
enum RecordPayload {
Allowed(crate::policy::Decision),
Ask(Ask),
Denial(Denial),
Simple(DenyReason),
}
/// An owner's answer to a pending call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Approved {
by: Option<String>,
},
Refused {
by: Option<String>,
reason: Option<String>,
},
Expired,
}
/// What an approval leaves: the verdict (run or deny) and the record written.
#[derive(Debug)]
pub struct Answered {
pub verdict: Verdict,
pub outcome: DecisionRecord,
}
struct Inner {
audit: Box<dyn AuditSink>,
state: StateStore,
stopped: bool,
}
/// The audit writer and every session's state behind one lock.
pub struct Ledger {
inner: Mutex<Inner>,
log: Box<dyn Fn(&str) + Send + Sync>,
}
impl Ledger {
/// A ledger over the audit writer, the session store, and a sink for its notices.
pub fn new(
audit: Box<dyn AuditSink>,
state: StateStore,
log: Box<dyn Fn(&str) + Send + Sync>,
) -> Ledger {
Ledger {
inner: Mutex::new(Inner {
audit,
state,
stopped: false,
}),
log,
}
}
/// The lock, `None` when the step cannot proceed: poisoned → `POISONED`, stopped → `STOPPED`.
fn lock(&self) -> Option<MutexGuard<'_, Inner>> {
let guard = match self.inner.lock() {
Ok(guard) => guard,
Err(_) => {
(self.log)(POISONED);
return None;
}
};
if guard.stopped {
(self.log)(STOPPED);
return None;
}
Some(guard)
}
/// Read a session's state, logging and returning "unknown" when it cannot be read.
fn state_read(
&self,
guard: &MutexGuard<Inner>,
session: &SessionId,
) -> Result<SessionState, ()> {
match guard.state.read(session) {
Ok(state) => Ok(state),
Err(e) => {
(self.log)(&format!("brokerd: {e}"));
Err(())
}
}
}
/// Append a record, stopping the ledger on failure and logging why. `Ok(seq)` or `Err`.
fn append(
&self,
guard: &mut MutexGuard<Inner>,
now: Timestamp,
event: AuditEvent,
) -> Result<u64, ()> {
match guard.audit.append(now, event) {
Ok(seq) => Ok(seq),
Err(e) => {
guard.stopped = true;
(self.log)(&format!("brokerd: {e}"));
Err(())
}
}
}
/// The refusal `finish` returns when the result could not be recorded.
fn not_recorded() -> ToolResponse {
ToolResponse::Failed {
message: NOT_RECORDED.to_string(),
}
}
/// The answer when the audit is unavailable: deny the verdict and record the denial.
fn audit_unavailable() -> Answered {
Answered {
verdict: Verdict::Denied(DenyReason::AuditUnavailable),
outcome: DecisionRecord::Denied {
reason: DenyReason::AuditUnavailable,
},
}
}
/// A denial verdict and its matching record, for one reason.
fn denied(reason: DenyReason) -> (Verdict, DecisionRecord) {
(Verdict::Denied(reason), DecisionRecord::Denied { reason })
}
/// The outcome of an approved re-decision: the named grant's mode, else allowed.
fn outcome_for(&self, decision: &crate::policy::Decision, grants: &Grants) -> DecisionRecord {
let mode = match grants {
Ok(set) => set
.grants()
.iter()
.find(|g| g.id == decision.grant())
.map(|g| g.grant.mode),
Err(_) => None,
};
match mode {
Some(Mode::Ask) => DecisionRecord::Ask {},
_ => DecisionRecord::Allowed {},
}
}
/// Decide and record a call.
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided {
let session = request.session.clone();
let call = request.call;
let tool = request.tool.clone();
let arguments = request.arguments.clone();
// 1. Lock fails → denied, nothing written.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Decided::Denied(DenyReason::AuditUnavailable),
};
// 2. Read the state, keeping the Result. The recorded state is "unknown" when unreadable.
let state = self.state_read(&guard, &session);
let recorded = match &state {
Ok(state) => *state,
Err(()) => SessionState {
taint: DataClass::Secret,
untrusted: true,
},
};
// 3. The outcome, first that applies.
let (outcome, grant, grant_sha256, payload) = match grants {
Err(_) => (
DecisionRecord::Denied {
reason: DenyReason::GrantsInvalid,
},
None,
None,
RecordPayload::Simple(DenyReason::GrantsInvalid),
),
Ok(set) => match state {
Err(()) => (
DecisionRecord::Denied {
reason: DenyReason::StateUnreadable,
},
None,
None,
RecordPayload::Simple(DenyReason::StateUnreadable),
),
Ok(state) => match crate::policy::decide(request, set, state, now) {
crate::policy::Outcome::Allowed(decision) => (
DecisionRecord::Allowed {},
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
RecordPayload::Allowed(decision),
),
crate::policy::Outcome::Ask(ask) => (
DecisionRecord::Ask {},
Some(ask.grant().to_string()),
Some(ask.grant_sha256()),
RecordPayload::Ask(ask),
),
crate::policy::Outcome::Denied(denial) => (
DecisionRecord::Denied {
reason: denial.reason,
},
denial.grant.clone(),
denial.grant_sha256,
RecordPayload::Denial(denial),
),
},
},
};
// 4. Write the record, then return the verdict.
let event = AuditEvent::Decision {
session,
call,
tool,
arguments,
outcome,
grant,
grant_sha256,
taint: recorded.taint,
untrusted: recorded.untrusted,
};
match self.append(&mut guard, now, event) {
Ok(seq) => match payload {
RecordPayload::Simple(reason) => Decided::Denied(reason),
RecordPayload::Allowed(decision) => Decided::Allowed { decision, seq },
RecordPayload::Ask(ask) => Decided::Ask {
ask,
seq,
state: recorded,
},
RecordPayload::Denial(denial) => Decided::Denied(denial.reason),
},
Err(()) => Decided::Denied(DenyReason::AuditUnavailable),
}
}
/// Re-decide an approval and record it.
pub fn answer(
&self,
ask: Ask,
decision: u64,
answer: Answer,
grants: &Grants,
now: Timestamp,
) -> Answered {
// 1. Lock fails → step fails.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::audit_unavailable(),
};
// 2. Read the state of the ask's session.
let session = ask.request().session.clone();
let call = ask.request().call;
let state = self.state_read(&guard, &session);
let recorded = match &state {
Ok(state) => *state,
Err(()) => SessionState {
taint: DataClass::Secret,
untrusted: true,
},
};
// 3. The answer and its fields.
let (answer, by, reason) = match &answer {
Answer::Approved { by } => (ApprovalAnswer::Approved, by.clone(), None),
Answer::Refused { by, reason } => (ApprovalAnswer::Refused, by.clone(), reason.clone()),
Answer::Expired => (ApprovalAnswer::Expired, None, None),
};
// 4. The re-decision (approved only); every other answer is a denial.
let redecide = match (answer, grants) {
(ApprovalAnswer::Approved, Ok(set)) => match &state {
Ok(state) => crate::policy::redecide(ask, set, *state, now),
Err(()) => Err(Denial::new(DenyReason::StateUnreadable)),
},
_ => Err(Denial::new(DenyReason::GrantsInvalid)),
};
// 5. The grant fields, then the verdict and outcome together.
let (grant, grant_sha256) = match &redecide {
Ok(decision) => (
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
),
Err(_) => (None, None),
};
let (verdict, outcome) = match (answer, redecide) {
(ApprovalAnswer::Approved, Ok(decision)) => {
let outcome = self.outcome_for(&decision, grants);
(Verdict::Run(Box::new(decision)), outcome)
}
(ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason),
(ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused),
(ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired),
};
// 6. Record the approval, or step fails.
match self.append(
&mut guard,
now,
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post: None,
reason,
outcome: outcome.clone(),
grant,
grant_sha256,
taint: recorded.taint,
untrusted: recorded.untrusted,
},
) {
Ok(_) => Answered { verdict, outcome },
Err(()) => Self::audit_unavailable(),
}
}
/// Raise the state, hash the response, and record a result, or stop the ledger on failure.
fn record_result(
&self,
guard: &mut MutexGuard<'_, Inner>,
call: &Call,
now: Timestamp,
status: proto::ResultStatus,
taint_after: DataClass,
response: &ToolResponse,
) -> ToolResponse {
let (sha256, bytes) = match response {
ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) {
Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) {
Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)),
Err(_) => return Self::not_recorded(),
},
_ => return Self::not_recorded(),
};
let (class, untrusted, truncated) = match response {
ToolResponse::Result {
class,
untrusted,
truncated,
..
} => (*class, *untrusted, *truncated),
ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false),
_ => return Self::not_recorded(),
};
let event = AuditEvent::Result {
session: call.session.clone(),
call: call.call,
decision: call.decision,
status,
class,
untrusted,
truncated,
bytes,
sha256,
taint_after,
};
match self.append(guard, now, event) {
Ok(_) => response.clone(),
Err(()) => Self::not_recorded(),
}
}
/// Raise the state and record a result.
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse {
// 1. Lock fails → cannot record.
let mut guard = match self.lock() {
Some(guard) => guard,
None => return Self::not_recorded(),
};
match &response {
// 3. A result raises the state, then is recorded.
ToolResponse::Result { .. } => {
let raised = match self.state_read(&guard, &call.session) {
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
Ok(raised) => raised,
Err(e) => {
(self.log)(&format!("brokerd: {e}"));
return Self::not_recorded();
}
},
Err(()) => return Self::not_recorded(),
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Result,
raised.taint,
&response,
)
}
// 4. A failure changes no state, recorded by its message.
ToolResponse::Failed { .. } => {
let taint_after = match self.state_read(&guard, &call.session) {
Ok(state) => state.taint,
Err(()) => DataClass::Secret,
};
self.record_result(
&mut guard,
call,
now,
proto::ResultStatus::Failed,
taint_after,
&response,
)
}
// 5. The runner never returns these; nothing to record.
ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => {
Self::not_recorded()
}
}
}
}
+10
View File
@@ -1,4 +1,14 @@
//! The broker: the only role that holds authority.
pub mod admin;
pub mod approvals;
pub mod args;
pub mod audit;
pub mod broker;
pub mod config;
pub mod grants;
pub mod ledger;
pub mod policy;
pub mod runner;
pub mod serve;
pub mod state;
+130 -3
View File
@@ -1,4 +1,131 @@
fn main() {
eprintln!("brokerd: not implemented until M3");
std::process::exit(2);
//! `brokerd`: the broker role. `serve` takes the audit lock, binds both sockets, and serves.
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use brokerd::audit::{AuditError, RECOVERED_NOTICE};
use brokerd::config::Config;
use brokerd::runner::Refusing;
use brokerd::serve::{self, ServeError};
const USAGE: &str = "usage: brokerd serve --config <path> [--accept-break]";
/// Where the owner looks when brokerd cannot start: its config, a directory or a socket.
const START_FAILED: &str = "see docs/runbook.md#brokerd-start-failed";
/// Where the owner looks when brokerd stops serving after it started.
const LISTENER_LOST: &str = "see docs/runbook.md#brokerd-listener-lost";
fn main() -> ExitCode {
// The first argument must be `serve`.
// `args_os`: a path need not be UTF-8, and `args` would panic on one that is not.
let mut args = std::env::args_os().skip(1);
match args.next() {
Some(command) if command == "serve" => {}
_ => return usage(),
}
// `--config <path>` exactly once and `--accept-break` at most once, in any order.
let (config_path, accept_break) = match parse_serve(args) {
Ok(pair) => pair,
Err(()) => return usage(),
};
// Read the config before touching the filesystem: a bad config must make nothing.
let cfg = match Config::load(&config_path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("brokerd: {e}\n{START_FAILED}");
return ExitCode::from(1);
}
};
// Keep the socket paths for the serving notice, before `cfg` is moved into `start`.
let broker_path = cfg.broker_socket();
let admin_path = cfg.admin_socket();
let started = match serve::start(
cfg,
accept_break,
Box::new(Refusing),
Arc::new(|line: &str| eprintln!("{line}")),
) {
Ok(started) => started,
Err(ServeError::Audit(e @ AuditError::NothingToAccept)) => {
eprintln!("brokerd: {e}");
return ExitCode::from(2);
}
// The audit errors carry their own entry; the rest are this one.
Err(ServeError::Audit(e)) => {
eprintln!("brokerd: {e}");
return ExitCode::from(1);
}
Err(e) => {
eprintln!("brokerd: {e}\n{START_FAILED}");
return ExitCode::from(1);
}
};
if started.recovered {
eprintln!("{RECOVERED_NOTICE}");
}
if let Some(failure) = &started.accepted {
eprintln!(
"audit: accepted the break at {}:{}: {}",
failure.file, failure.line, failure.what
);
}
eprintln!(
"brokerd: serving tools on {} and approvals on {}",
broker_path.display(),
admin_path.display()
);
match started.run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("brokerd: stopped serving: {e}\n{LISTENER_LOST}");
ExitCode::from(1)
}
}
}
/// The argument list was not `serve --config <path> [--accept-break]`.
fn usage() -> ExitCode {
eprintln!("{USAGE}");
ExitCode::from(2)
}
/// The `--config` and `--accept-break` flags after `serve`, or `Err(())` for a bad list.
/// Flags are compared as `OsStr`, so one that is not UTF-8 is simply unknown; the path is kept as
/// given.
fn parse_serve<I: Iterator<Item = OsString>>(mut args: I) -> Result<(PathBuf, bool), ()> {
let mut config: Option<OsString> = None;
let mut accept_break = false;
loop {
match args.next() {
None => {
return match config {
Some(path) => Ok((PathBuf::from(path), accept_break)),
None => Err(()),
};
}
Some(arg) if arg == "--config" => match args.next() {
Some(path) => {
if config.is_some() {
return Err(());
}
config = Some(path);
}
None => return Err(()),
},
Some(arg) if arg == "--accept-break" => {
if accept_break {
return Err(());
}
accept_break = true;
}
Some(_) => return Err(()),
}
}
}
+451 -48
View File
@@ -1,95 +1,498 @@
//! Policy decisions. `Decision` can only be constructed in this module.
//! Policy decisions. `decide` answers a tool call against the grants, the session's state and the
//! time it is given; `redecide` is the only thing that turns an approval back into a `Decision`.
//! Both are pure: they do no I/O and read no clock.
//!
//! Code outside this module cannot build a `Decision` with a struct literal, because its fields
//! are private:
//! A `Decision` can only be built here, in `decide` and `redecide`: its fields are private, and no
//! constructor is exposed.
//!
//! ```compile_fail
//! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1),
//! tool: "read_file".to_string(),
//! arguments: "{}".to_string(),
//! tool: "shell".to_string(),
//! arguments: r#"{"command":"ls"}"#.to_string(),
//! };
//! let _ = brokerd::policy::Decision { request, grant: "g".to_string() };
//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap();
//! let _ = brokerd::policy::Decision { request, args, matched: todo!() };
//! ```
//!
//! Nor with the constructor, because it is private to this module:
//! Nor with a constructor that needs `Clone`:
//!
//! ```compile_fail
//! fn needs_clone<T: Clone>() {}
//! needs_clone::<brokerd::policy::Decision>();
//! ```
//!
//! Nor one that needs to be decoded:
//!
//! ```compile_fail
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_decoding::<brokerd::policy::Decision>();
//! ```
//!
//! The same three hold for `Ask`.
//!
//! ```compile_fail
//! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1),
//! tool: "read_file".to_string(),
//! arguments: "{}".to_string(),
//! tool: "shell".to_string(),
//! arguments: r#"{"command":"ls"}"#.to_string(),
//! };
//! let _ = brokerd::policy::Decision::new(request, "g".to_string());
//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap();
//! let _ = brokerd::policy::Ask { request, args, matched: todo!() };
//! ```
//!
//! The same setup compiles when it goes through `decide`, which proves the two examples above
//! fail because of `Decision` and not because of a mistake in the setup:
//! ```compile_fail
//! fn needs_clone<T: Clone>() {}
//! needs_clone::<brokerd::policy::Ask>();
//! ```
//!
//! ```compile_fail
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_decoding::<brokerd::policy::Ask>();
//! ```
//!
//! The next example shows they fail because of `Decision` and `Ask`,
//! not because of the setup: the very same request goes through `decide`, which succeeds and, with
//! no grants, is denied with `NoGrant`.
//!
//! ```
//! fn needs_clone<T: Clone>() {}
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_clone::<proto::ToolRequest>();
//! needs_decoding::<proto::ToolRequest>();
//! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1),
//! tool: "read_file".to_string(),
//! arguments: "{}".to_string(),
//! tool: "shell".to_string(),
//! arguments: r#"{"command":"ls"}"#.to_string(),
//! };
//! assert_eq!(brokerd::policy::decide(request).unwrap_err(), proto::DenyReason::NoGrant);
//! let now = proto::Timestamp::from_unix_millis(0).unwrap();
//! match brokerd::policy::decide(
//! request,
//! &brokerd::grants::GrantSet::default(),
//! brokerd::policy::SessionState::default(),
//! now,
//! ) {
//! brokerd::policy::Outcome::Denied(denial) => {
//! assert_eq!(denial.reason, proto::DenyReason::NoGrant);
//! }
//! other => panic!("expected a denial, got {other:?}"),
//! }
//! ```
use proto::{DenyReason, ToolRequest};
use crate::args::{self, ToolArgs, ToolName, host_matches, inside};
use crate::grants::GrantSet;
use proto::{DataClass, DenyReason, Grant, Hash32, Mode, Timestamp, ToolRequest};
/// The session's taint, and whether its results are untrusted. Only `taint` shapes a decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionState {
pub taint: DataClass,
pub untrusted: bool,
}
impl Default for SessionState {
fn default() -> Self {
SessionState {
taint: DataClass::Private,
untrusted: false,
}
}
}
/// How a result is labelled: its data class and whether it is untrusted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Label {
pub class: DataClass,
pub untrusted: bool,
}
/// Why a call was denied, and (for a deny grant) which one denied it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Denial {
pub reason: DenyReason,
/// `Some` only for `DeniedByGrant`: the deny grant's id.
pub grant: Option<String>,
/// `Some` only for `DeniedByGrant`: the deny grant's file hash.
pub grant_sha256: Option<Hash32>,
}
impl Denial {
pub fn new(reason: DenyReason) -> Denial {
Denial {
reason,
grant: None,
grant_sha256: None,
}
}
}
/// The winning grant and what it matched: the paths and hosts the runner mounts, and the expiry
/// and label the caller reads.
#[derive(Debug)]
struct Matched {
grant: String,
grant_sha256: Hash32,
matched_path: Option<String>,
paths: Vec<String>,
hosts: Vec<String>,
expires: Option<Timestamp>,
label: Label,
}
/// A call the broker has decided to run: built only in this module, read by the runner.
#[derive(Debug)]
pub struct Decision {
request: ToolRequest,
grant: String,
args: ToolArgs,
matched: Matched,
}
/// A call the broker left for the owner: built only in this module, re-decided by `redecide`.
#[derive(Debug)]
pub struct Ask {
request: ToolRequest,
args: ToolArgs,
matched: Matched,
}
impl Decision {
#[cfg_attr(not(test), expect(dead_code, reason = "grant matching arrives in M3"))]
fn new(request: ToolRequest, grant: String) -> Self {
Decision { request, grant }
}
pub fn request(&self) -> &ToolRequest {
&self.request
}
pub fn args(&self) -> &ToolArgs {
&self.args
}
pub fn grant(&self) -> &str {
&self.grant
&self.matched.grant
}
pub fn grant_sha256(&self) -> Hash32 {
self.matched.grant_sha256
}
pub fn matched_path(&self) -> Option<&str> {
self.matched.matched_path.as_deref()
}
pub fn paths(&self) -> &[String] {
&self.matched.paths
}
pub fn hosts(&self) -> &[String] {
&self.matched.hosts
}
pub fn expires(&self) -> Option<Timestamp> {
self.matched.expires
}
pub fn label(&self) -> Label {
self.matched.label
}
}
/// Until M3 there are no grants, so every request is denied with DenyReason::NoGrant.
pub fn decide(request: ToolRequest) -> Result<Decision, DenyReason> {
let _ = request;
Err(DenyReason::NoGrant)
impl Ask {
pub fn request(&self) -> &ToolRequest {
&self.request
}
#[cfg(test)]
mod tests {
use super::*;
use proto::{CallId, SessionId};
fn request() -> ToolRequest {
ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments: "{}".to_string(),
pub fn args(&self) -> &ToolArgs {
&self.args
}
pub fn grant(&self) -> &str {
&self.matched.grant
}
pub fn grant_sha256(&self) -> Hash32 {
self.matched.grant_sha256
}
pub fn matched_path(&self) -> Option<&str> {
self.matched.matched_path.as_deref()
}
pub fn paths(&self) -> &[String] {
&self.matched.paths
}
pub fn hosts(&self) -> &[String] {
&self.matched.hosts
}
pub fn expires(&self) -> Option<Timestamp> {
self.matched.expires
}
pub fn label(&self) -> Label {
self.matched.label
}
}
#[test]
fn no_grants_means_deny() {
assert_eq!(decide(request()).unwrap_err(), DenyReason::NoGrant);
/// The result of deciding a call: run it, ask, or deny it.
#[derive(Debug)]
pub enum Outcome {
Allowed(Decision),
Ask(Ask),
Denied(Denial),
}
#[test]
fn decision_exposes_request_and_grant() {
let d = Decision::new(request(), "g1".to_string());
assert_eq!(d.request().tool, "read_file");
assert_eq!(d.grant(), "g1");
pub fn decide(
request: ToolRequest,
grants: &GrantSet,
state: SessionState,
now: Timestamp,
) -> Outcome {
// 1. An unknown tool is denied before its arguments are even parsed.
let Some(tool) = ToolName::parse(&request.tool) else {
return Outcome::Denied(Denial::new(DenyReason::NoGrant));
};
// 2. Malformed arguments are refused before matching, whatever the grants say.
let Ok(args) = args::parse(tool, &request.arguments) else {
return Outcome::Denied(Denial::new(DenyReason::InvalidArguments));
};
let MatchResult {
left,
expired,
tainted,
} = match_grants(&args, grants, state, now);
// 3. No grant left: the reason is whatever the ruled-out grants reminded us of.
let Some(winner) = winner(&left) else {
let reason = match (expired, tainted) {
(true, _) => DenyReason::GrantExpired,
(_, true) => DenyReason::TaintTooHigh,
(_, _) => DenyReason::NoGrant,
};
return Outcome::Denied(Denial::new(reason));
};
// 4. The label is over every grant left, not the winner alone.
let matched = build_matched(&left, &winner);
match winner.mode {
Mode::Deny => Outcome::Denied(Denial {
reason: DenyReason::DeniedByGrant,
grant: Some(winner.id.clone()),
grant_sha256: Some(winner.grant_sha256),
}),
Mode::Ask => Outcome::Ask(Ask {
request,
args,
matched,
}),
Mode::Auto => Outcome::Allowed(Decision {
request,
args,
matched,
}),
}
}
pub fn redecide(
ask: Ask,
grants: &GrantSet,
state: SessionState,
now: Timestamp,
) -> Result<Decision, Denial> {
let MatchResult {
left,
expired,
tainted,
} = match_grants(ask.args(), grants, state, now);
let Some(winner) = winner(&left) else {
let reason = match (expired, tainted) {
(true, _) => DenyReason::GrantExpired,
(_, true) => DenyReason::TaintTooHigh,
(_, _) => DenyReason::NoGrant,
};
return Err(Denial::new(reason));
};
let matched = build_matched(&left, &winner);
match winner.mode {
Mode::Deny => Err(Denial {
reason: DenyReason::DeniedByGrant,
grant: Some(winner.id.clone()),
grant_sha256: Some(winner.grant_sha256),
}),
Mode::Ask | Mode::Auto => Ok(Decision {
request: ask.request().clone(),
args: ask.args().clone(),
matched,
}),
}
}
/// The grants left after matching, and the two flags that pick the denial reason when none are
/// left.
struct MatchResult {
left: Vec<Candidate>,
expired: bool,
tainted: bool,
}
#[derive(Clone)]
struct Candidate {
id: String,
grant_sha256: Hash32,
mode: Mode,
matched_path: Option<String>,
paths: Vec<String>,
hosts: Vec<String>,
expires: Option<Timestamp>,
result_class: DataClass,
untrusted: bool,
}
/// Match a call against every grant, in id order, keeping the ones that still stand.
fn match_grants(
args: &ToolArgs,
grants: &GrantSet,
state: SessionState,
now: Timestamp,
) -> MatchResult {
let mut left: Vec<Candidate> = Vec::new();
let mut expired = false;
let mut tainted = false;
for loaded in grants.grants() {
// M1: only grants for this tool play a part.
if loaded.grant.tool != args.tool().as_str() {
continue;
}
// M2: does this grant cover the arguments, and with which path (if any)?
let Some(matched_path) = covers(args, &loaded.grant) else {
continue;
};
// M3: a grant that would match but is past its expiry or beyond the session's taint does
// not stand. Remember each reason, so the denial can name the right one.
let expired_at = loaded.grant.expires.is_some_and(|at| now >= at);
let too_tainted = state.taint > loaded.grant.max_taint;
if expired_at && !too_tainted {
expired = true;
}
if too_tainted && !expired_at {
tainted = true;
}
if !expired_at && !too_tainted {
left.push(Candidate {
id: loaded.id.clone(),
grant_sha256: loaded.sha256,
mode: loaded.grant.mode,
matched_path,
paths: loaded.grant.constraints.paths.clone(),
hosts: loaded.grant.constraints.hosts.clone(),
expires: loaded.grant.expires,
result_class: loaded.grant.result_class,
untrusted: loaded.grant.untrusted,
});
}
}
MatchResult {
left,
expired,
tainted,
}
}
/// Whether the grant covers the call, and the longest of its paths that holds the call. `None`
/// means the grant does not cover the call at all.
fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
match args {
ToolArgs::ReadFile { path } => best_path(grant, path, true),
ToolArgs::WriteFile { path, .. } => best_path(grant, path, false),
ToolArgs::Shell { cwd: None, .. } => {
if grant.constraints.paths.is_empty() {
Some(None)
} else {
None
}
}
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
ToolArgs::HttpFetch { host, .. } => {
if grant
.constraints
.hosts
.iter()
.any(|pattern| host_matches(pattern, host))
{
Some(None)
} else {
None
}
}
}
}
/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the
/// argument does not count.
fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option<Option<String>> {
let mut best: Option<String> = None;
for candidate in &grant.constraints.paths {
if !inside(candidate, target) {
continue;
}
if !itself_counts && candidate == target {
continue;
}
if best
.as_ref()
.is_none_or(|found| candidate.len() > found.len())
{
best = Some(candidate.clone());
}
}
// `None` means no path holds, so the grant does not cover the call; a held path becomes
// `Some(Some(p))` so `covers` can tell it apart from a covered call with no path.
best.map(Some)
}
/// Build the winning grant's `Matched`, with the label over every standing grant.
fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched {
Matched {
grant: winner.id.clone(),
grant_sha256: winner.grant_sha256,
matched_path: winner.matched_path.clone(),
paths: winner.paths.clone(),
hosts: winner.hosts.clone(),
expires: winner.expires,
label: combined_label(left),
}
}
/// The most restrictive standing grant, then the longest matched path, then the lowest id.
fn winner(candidates: &[Candidate]) -> Option<Candidate> {
for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() {
let mut best: Option<&Candidate> = None;
for candidate in candidates.iter().filter(|c| c.mode == mode) {
best = Some(match best {
Some(found) if !better(candidate, found) => found,
_ => candidate,
});
}
if let Some(found) = best {
return Some(found.clone());
}
}
None
}
/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id.
fn better(a: &Candidate, b: &Candidate) -> bool {
let (la, lb) = (
a.matched_path.as_ref().map_or(0, String::len),
b.matched_path.as_ref().map_or(0, String::len),
);
match la.cmp(&lb) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => a.id < b.id,
}
}
/// The label over every standing grant: the highest data class, and untrusted if any says so.
fn combined_label(candidates: &[Candidate]) -> Label {
let mut class = DataClass::Public;
for candidate in candidates {
if candidate.result_class > class {
class = candidate.result_class;
}
}
let untrusted = candidates.iter().any(|c| c.untrusted);
Label { class, untrusted }
}
+132 -6
View File
@@ -1,12 +1,138 @@
//! Tool runner stub. Real tool execution arrives in M3.
//! The runner seam: an allowed call becomes a `RunSpec` and is handed to a `Runtime`.
//!
//! A `RunSpec` can only be built here, from a `Decision`, so a runtime never sees a call policy
//! did not allow.
//!
//! ```compile_fail
//! let _ = brokerd::runner::RunSpec {
//! tool: brokerd::args::ToolName::Shell,
//! arguments: todo!(),
//! mounts: Vec::new(),
//! egress: None,
//! };
//! ```
//!
//! ```
//! fn tool_of(spec: &brokerd::runner::RunSpec) -> brokerd::args::ToolName {
//! spec.tool()
//! }
//! ```
use crate::args::{ToolArgs, ToolName};
use crate::policy::Decision;
use proto::ToolResponse;
/// Takes the Decision by value, so one decision cannot run a tool twice.
pub fn run(decision: Decision) -> ToolResponse {
let _ = decision;
ToolResponse::Failed {
message: "no tool runner until M3".to_string(),
/// A directory mounted for one call: a path and whether the runtime may write to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub path: String,
pub writable: bool,
}
/// What one call was turned into before it reached a `Runtime`: the tool, its arguments, the
/// directories mounted and the hosts it may reach. Built only here, from a `Decision`.
#[derive(Debug)]
pub struct RunSpec {
tool: ToolName,
arguments: ToolArgs,
mounts: Vec<Mount>,
egress: Option<Vec<String>>,
}
impl RunSpec {
pub fn tool(&self) -> ToolName {
self.tool
}
pub fn arguments(&self) -> &ToolArgs {
&self.arguments
}
pub fn mounts(&self) -> &[Mount] {
&self.mounts
}
pub fn egress(&self) -> Option<&[String]> {
self.egress.as_deref()
}
}
/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled
/// public and trusted, so a runtime may only put a fixed sentence in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunError {
Failed(String),
Unavailable(String),
}
/// What a `Runtime` produced for one call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOutput {
pub content: String,
pub truncated: bool,
}
/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once.
pub trait Runtime: Send + Sync {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
}
/// The production runtime for M3a. It runs nothing; every call is `Unavailable`.
pub struct Refusing;
impl Runtime for Refusing {
fn run(&self, _spec: &RunSpec) -> Result<RunOutput, RunError> {
Err(RunError::Unavailable(REFUSING.to_string()))
}
}
/// The sentence `Refusing` puts in every `Unavailable`: fixed, because it reaches the model.
pub const REFUSING: &str = "the runner arrives in M3b";
/// Turn an allowed call into a `RunSpec`, run it, and answer. A `RunError`'s text reaches the
/// model unchanged, so a runtime may only put a fixed sentence in it.
pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse {
let label = decision.label();
let args = decision.args();
let (mounts, egress) = match args {
ToolArgs::ReadFile { .. } => (matched_mount(decision.matched_path(), false), None),
ToolArgs::WriteFile { .. } => (matched_mount(decision.matched_path(), true), None),
ToolArgs::Shell { .. } => (
decision
.paths()
.iter()
.map(|path| Mount {
path: path.clone(),
writable: true,
})
.collect(),
None,
),
ToolArgs::HttpFetch { .. } => (Vec::new(), Some(decision.hosts().to_vec())),
};
let spec = RunSpec {
tool: args.tool(),
arguments: args.clone(),
mounts,
egress,
};
match runtime.run(&spec) {
Ok(output) => ToolResponse::Result {
content: output.content,
class: label.class,
untrusted: label.untrusted,
truncated: output.truncated,
},
Err(RunError::Failed(message) | RunError::Unavailable(message)) => {
ToolResponse::Failed { message }
}
}
}
/// The matched path as one mount, or none when the call matched no path.
fn matched_mount(path: Option<&str>, writable: bool) -> Vec<Mount> {
match path {
Some(at) => vec![Mount {
path: at.to_string(),
writable,
}],
None => Vec::new(),
}
}
+266
View File
@@ -0,0 +1,266 @@
//! `brokerd serve`: bring the broker up, listen on both sockets, and keep it running.
use std::fs::{DirBuilder, Permissions};
use std::io;
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
use crate::admin;
use crate::audit::{AuditError, Writer};
use crate::broker::{self, Broker};
use crate::config::Config;
use crate::ledger::Ledger;
use crate::runner::Runtime;
use crate::state::StateStore;
use proto::{ChainFailure, Timestamp};
/// A failure while `brokerd serve` is starting up.
#[derive(Debug)]
pub enum ServeError {
/// The audit chain would not open. Displayed as the audit error's own text.
Audit(AuditError),
/// A directory could not be prepared, or made private.
Dir(PathBuf, io::Error),
/// A socket could not be bound, or made private.
Socket(PathBuf, io::Error),
}
impl std::fmt::Display for ServeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServeError::Audit(e) => write!(f, "{e}"),
ServeError::Dir(path, e) => write!(f, "cannot prepare {}: {e}", path.display()),
ServeError::Socket(path, e) => write!(f, "cannot listen on {}: {e}", path.display()),
}
}
}
impl std::error::Error for ServeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ServeError::Audit(e) => Some(e),
ServeError::Dir(_, e) | ServeError::Socket(_, e) => Some(e),
}
}
}
/// The result of `start`: a running broker with both sockets open.
pub struct Started {
pub broker: Arc<Broker>,
pub recovered: bool,
pub accepted: Option<ChainFailure>,
tools: UnixListener,
admin: UnixListener,
}
/// Start the broker: open the audit chain, bind both sockets, and build the ledger and broker.
pub fn start(
cfg: Config,
accept_break: bool,
runtime: Box<dyn Runtime>,
log: Arc<dyn Fn(&str) + Send + Sync>,
) -> Result<Started, ServeError> {
// The audit lock comes first: if the chain will not open, no directory or socket is made.
let opened = Writer::open(&cfg.audit_dir(), accept_break).map_err(ServeError::Audit)?;
// Both sockets, in the order the task gives.
let tools = listen(&cfg.broker_socket())?;
let admin = listen(&cfg.admin_socket())?;
// The ledger and broker share the one log, each through its own box.
let log1: Box<dyn Fn(&str) + Send + Sync> = {
let log = Arc::clone(&log);
Box::new(move |line: &str| log(line))
};
let ledger = Ledger::new(
Box::new(opened.writer),
StateStore::new(&cfg.state_dir()),
log1,
);
let log2: Box<dyn Fn(&str) + Send + Sync> = {
let log = Arc::clone(&log);
Box::new(move |line: &str| log(line))
};
let broker = Broker::new(cfg, ledger, runtime, log2);
Ok(Started {
broker: Arc::new(broker),
recovered: opened.recovered,
accepted: opened.accepted.map(|b| *b),
tools,
admin,
})
}
/// Bind one socket: prepare its directory at 0700, make it private, remove any stale
/// socket, bind, then make the socket itself private at 0600.
fn listen(socket: &Path) -> Result<UnixListener, ServeError> {
// A socket needs a directory of its own: step 2 makes that directory 0700, which must never
// be `/` or a shared directory reached through a symbolic link.
let dir = match socket.parent() {
Some(parent) if parent.parent().is_some() && !parent.as_os_str().is_empty() => {
parent.to_path_buf()
}
_ => {
return Err(ServeError::Dir(
PathBuf::from(socket),
io::Error::other("a socket needs a directory of its own, not / or none"),
));
}
};
// 1. The directory, at 0700.
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&dir)
.map_err(|e| ServeError::Dir(dir.clone(), e))?;
// `set_permissions` follows a symbolic link, so a link would make the directory it points to
// private instead: refuse it.
let kind = std::fs::symlink_metadata(&dir)
.map_err(|e| ServeError::Dir(dir.clone(), e))?
.file_type();
if !kind.is_dir() {
return Err(ServeError::Dir(
dir,
io::Error::other(
"is a symbolic link or not a directory; brokerd will not change its mode",
),
));
}
// 2. Make it private, always, even when it was already there at 0755.
std::fs::set_permissions(&dir, Permissions::from_mode(0o700))
.map_err(|e| ServeError::Dir(dir.clone(), e))?;
// 3. A stale socket is removed; a missing one is fine.
match std::fs::remove_file(socket) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(ServeError::Socket(socket.to_path_buf(), e)),
}
// 4. Bind, then 5. make the socket private.
let listener =
UnixListener::bind(socket).map_err(|e| ServeError::Socket(PathBuf::from(socket), e))?;
std::fs::set_permissions(socket, Permissions::from_mode(0o600))
.map_err(|e| ServeError::Socket(PathBuf::from(socket), e))?;
Ok(listener)
}
/// Which handler the accept thread runs for each accepted stream.
#[derive(Copy, Clone)]
enum Which {
Tools,
Admin,
}
/// Run the broker: start the expiry thread, one accept thread per socket, then wait for the
/// first listener error.
impl Started {
pub fn run(self) -> io::Result<()> {
let Started {
broker,
tools,
admin,
..
} = self;
// The expiry thread: once a second, expire the approvals whose time has run out.
// `Builder` rather than `spawn`, which panics when the system refuses a thread.
{
let broker = Arc::clone(&broker);
std::thread::Builder::new()
.name("expiry".to_string())
.spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
admin::expire_due(&broker, Timestamp::now());
}
})?;
}
// One accept thread per socket, each with its own clone of the broker.
let (tx, rx) = mpsc::channel::<io::Error>();
accept_loop(tools, Arc::clone(&broker), tx.clone(), Which::Tools)?;
accept_loop(admin, Arc::clone(&broker), tx, Which::Admin)?;
// The first listener error stops the daemon.
match rx.recv() {
Ok(e) => Err(e),
Err(_) => Err(io::Error::other("a listener thread ended")),
}
}
}
/// Accept streams from one listener forever, starting a handler thread per stream. When the
/// system is out of file descriptors or memory, the listener pauses and tries again instead of
/// stopping: otherwise a peer holding many idle connections could stop the daemon, and the other
/// socket with it. Any other error stops the daemon through `tx`.
fn accept_loop(
listener: UnixListener,
broker: Arc<Broker>,
tx: mpsc::Sender<io::Error>,
which: Which,
) -> io::Result<()> {
let name = match which {
Which::Tools => "accept-broker",
Which::Admin => "accept-admin",
};
std::thread::Builder::new()
.name(name.to_string())
.spawn(move || {
let mut reported = false;
for stream in listener.incoming() {
match stream {
Ok(stream) => {
reported = false;
serve_one(stream, &broker, which);
}
Err(e) if out_of_resources(&e) => {
if !reported {
broker.log(&format!(
"brokerd: cannot accept on {name} for now, retrying: {e}\nsee docs/runbook.md#brokerd-listener-lost"
));
reported = true;
}
std::thread::sleep(Duration::from_millis(200));
}
Err(e) => {
// Once `run` has returned, nobody receives; dropping this is correct.
let _ = tx.send(e);
return;
}
}
}
})?;
Ok(())
}
/// Hand one stream to its own thread. If the system refuses the thread, the stream is closed, which
/// the peer sees as the broker being unavailable, and the refusal is printed.
fn serve_one(stream: UnixStream, broker: &Arc<Broker>, which: Which) {
let for_thread = Arc::clone(broker);
let started = std::thread::Builder::new().spawn(move || match which {
Which::Tools => broker::handle(stream, &for_thread),
Which::Admin => admin::handle(stream, &for_thread),
});
if let Err(e) = started {
broker.log(&format!(
"brokerd: cannot start a thread for a connection, so it was closed: {e}\nsee docs/runbook.md#brokerd-listener-lost"
));
}
}
/// Errors from `accept` that mean "not now" rather than "never": out of file descriptors
/// (`EMFILE`, `ENFILE`, the same numbers on Linux and macOS) or out of memory. `accept` already
/// retries `EINTR` itself.
fn out_of_resources(e: &io::Error) -> bool {
const ENFILE: i32 = 23;
const EMFILE: i32 = 24;
matches!(e.raw_os_error(), Some(ENFILE | EMFILE)) || e.kind() == io::ErrorKind::OutOfMemory
}
+149
View File
@@ -0,0 +1,149 @@
//! Where the broker keeps what it knows about a session: one JSON line per session in
//! `<dir>/<id>.json`. A read that fails is an error, never "a new session". Neither value ever
//! goes down: `raise` only ever raises, and it writes atomically so a crash leaves the old file.
//!
//! The file's format is private (`StateFile`), so `SessionState` stays free of serde.
use crate::policy::{Label, SessionState};
use proto::{DataClass, SessionId};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
/// Where the owner looks when a session's state file cannot be read or written.
pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged";
/// A state file that exists but cannot be read, or a write that could not reach disk.
#[derive(Debug)]
pub enum StateError {
/// The file exists and cannot be read, or is not a state.
Unreadable(PathBuf, String),
/// The new state could not be put on disk.
Write(PathBuf, io::Error),
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateError::Unreadable(path, detail) => write!(
f,
"cannot read the session state {}: {}; {}",
path.display(),
detail,
RUNBOOK
),
StateError::Write(path, err) => write!(
f,
"cannot write the session state {}: {}; {}",
path.display(),
err,
RUNBOOK
),
}
}
}
impl std::error::Error for StateError {}
/// The directory that holds one `<id>.json` per session.
#[derive(Debug, Clone)]
pub struct StateStore {
dir: PathBuf,
}
impl StateStore {
/// A store for `dir`. This does not touch the disk.
pub fn new(dir: &Path) -> StateStore {
StateStore {
dir: dir.to_path_buf(),
}
}
/// The path of the file for this session: `<dir>/<id>.json`.
pub fn path(&self, session: &SessionId) -> PathBuf {
self.dir.join(format!("{}.json", session.as_str()))
}
/// The session's current state. A missing file is a fresh session; anything else that cannot
/// be read is an error.
pub fn read(&self, session: &SessionId) -> Result<SessionState, StateError> {
let path = self.path(session);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()),
Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
};
let file = match serde_json::from_str::<StateFile>(&text) {
Ok(file) => file,
Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
};
if file.taint == DataClass::Public {
return Err(StateError::Unreadable(
path,
"a session's taint is never below private".to_string(),
));
}
Ok(SessionState {
taint: file.taint,
untrusted: file.untrusted,
})
}
/// The raised state and whether its results are untrusted. It trusts `current`, never reads the
/// file again, and always writes, so the file exists from the first result on.
pub fn raise(
&self,
session: &SessionId,
current: SessionState,
label: Label,
) -> Result<SessionState, StateError> {
let next = SessionState {
taint: current.taint.max(label.class).max(DataClass::Private),
untrusted: current.untrusted || label.untrusted,
};
let path = self.path(session);
if let Err(err) = Self::persist(&self.dir, &path, next.taint, next.untrusted) {
return Err(StateError::Write(path, err));
}
Ok(next)
}
/// Write the new state to `<path>` atomically, in six steps. Any failure maps to an `io` error
/// the caller turns into `Write`; an error leaves the old file untouched.
fn persist(dir: &Path, path: &Path, taint: DataClass, untrusted: bool) -> io::Result<()> {
// 1. The directory and any missing parents, mode 0700.
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
// 2. Serialise and append the final newline.
let json =
serde_json::to_string(&StateFile { taint, untrusted }).map_err(io::Error::other)?;
let bytes = format!("{json}\n");
// 3. A fresh temporary file; `truncate` replaces a leftover one.
let tmp = path.with_extension("json.tmp");
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
// 4. Write it and force it to disk.
file.write_all(bytes.as_bytes())?;
file.sync_all()?;
// 5. Swap it into place.
fs::rename(&tmp, path)?;
// 6. Pin the rename itself to disk.
fs::File::open(dir)?.sync_all()
}
}
/// One line on disk: the taint and the untrusted flag.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile {
taint: DataClass,
untrusted: bool,
}
+385
View File
@@ -0,0 +1,385 @@
//! One `admin.sock` connection: list, approve, refuse, check the grants; and expiry, which
//! answers an approval the same way. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use brokerd::admin;
use brokerd::broker::{self, Broker};
use brokerd::policy::Label;
use client::{Serve, next, open};
use proto::{
ApprovalAnswer, Approve, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, ErrorCode,
Message, Refuse, SessionId, Timestamp, ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig) {
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
}
/// Sends a call that waits for approval; returns its connection once the pending frame is read.
fn pending(broker: &Arc<Broker>, call: u64, arguments: &str) -> (UnixStream, u64) {
let req = request("s1", call, "read_file", arguments);
let mut stream = open(broker, broker::handle, call, Message::ToolRequest(req));
match next(&mut stream).msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval),
other => panic!("{other:?}"),
}
}
/// One admin request; returns the answer's message after checking its id and `final`.
fn admin(broker: &Arc<Broker>, msg: Message) -> Message {
let mut stream = open(broker, admin::handle, 5, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (5, true), "{env:?}");
env.msg
}
fn approve(broker: &Arc<Broker>, approval: u64) -> Message {
admin(broker, Message::Approve(Approve { approval }))
}
fn refuse(broker: &Arc<Broker>, approval: u64, reason: Option<&str>) -> Message {
let reason = reason.map(str::to_string);
admin(broker, Message::Refuse(Refuse { approval, reason }))
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
match next(stream).msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn outcome(msg: Message) -> DecisionRecord {
match msg {
Message::ApproveResult(r) => r.outcome,
other => panic!("{other:?}"),
}
}
fn error_code(msg: &Message) -> Option<ErrorCode> {
match msg {
Message::Error(e) => Some(e.code),
_ => None,
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
#[test]
fn approvals_lists_the_arguments_as_policy_parsed_them() {
let rig = Rig::new("admin-list");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert!(matches!(
admin(&broker, Message::Approvals(Empty {})),
Message::ApprovalList(list) if list.items.is_empty()
));
// A backslash-u escape of `/` is `/` in JSON; the owner sees the one spelling policy matched.
let escaped = format!(r#"{{"path":"{}n{}a"}}"#, "\\u002f", "\\u002f");
assert!(
escaped.contains("u002f"),
"the escape must survive: {escaped}"
);
let (_stream, approval) = pending(&broker, 1, &escaped);
match admin(&broker, Message::Approvals(Empty {})) {
Message::ApprovalList(list) => {
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].approval, approval);
assert_eq!(list.items[0].arguments, r#"{"path":"/n/a"}"#);
assert_eq!(list.items[0].grant, "notes");
}
other => panic!("{other:?}"),
}
// The audit log keeps the raw string.
match &rig.events()[0] {
AuditEvent::Decision { arguments, .. } => assert_eq!(arguments, &escaped),
other => panic!("{other:?}"),
}
}
#[test]
fn approve_runs_the_call_and_answers_with_the_re_decision() {
let rig = Rig::new("admin-approve");
ask_notes(&rig);
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert_eq!(outcome(approve(&broker, approval)), DecisionRecord::Ask {});
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!(*answer, ApprovalAnswer::Approved);
assert_eq!(by.as_deref(), Some("bxctl"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn refuse_denies_the_call_and_records_the_reason() {
let rig = Rig::new("admin-refuse");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
assert!(matches!(
refuse(&broker, approval, Some("not today")),
Message::Ok(Empty {})
));
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalRefused)
);
assert_eq!(rt.count(), 0);
match &rig.events()[1] {
AuditEvent::Approval { answer, reason, .. } => {
assert_eq!(*answer, ApprovalAnswer::Refused);
assert_eq!(reason.as_deref(), Some("not today"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_unknown_or_answered_id_is_no_such_approval() {
let rig = Rig::new("admin-unknown");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
assert_eq!(
error_code(&approve(&broker, 41)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(
error_code(&refuse(&broker, 41, None)),
Some(ErrorCode::NoSuchApproval)
);
let (_stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
refuse(&broker, approval, None);
assert_eq!(
error_code(&approve(&broker, approval)),
Some(ErrorCode::NoSuchApproval)
);
assert_eq!(rig.events().len(), 2, "one decision, one approval");
}
#[test]
fn approve_and_refuse_at_once_give_exactly_one_answer() {
let rig = Rig::new("admin-race");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
for round in 0..100u64 {
let (mut stream, approval) = pending(&broker, round, r#"{"path":"/n/a"}"#);
let start = Arc::new(Barrier::new(2));
let racers: Vec<_> = [true, false]
.into_iter()
.map(|approving| {
let (broker, start) = (Arc::clone(&broker), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
if approving {
approve(&broker, approval)
} else {
refuse(&broker, approval, None)
}
})
})
.collect();
let answers: Vec<Message> = racers.into_iter().map(|r| r.join().unwrap()).collect();
let losers = answers
.iter()
.filter(|m| error_code(m) == Some(ErrorCode::NoSuchApproval))
.count();
assert_eq!(losers, 1, "round {round}: {answers:?}");
final_answer(&mut stream);
let approvals = rig
.events()
.iter()
.filter(|e| matches!(e, AuditEvent::Approval { decision, .. } if *decision == approval))
.count();
assert_eq!(approvals, 1, "round {round}");
}
}
#[test]
fn approve_after_the_grant_file_is_removed_is_denied() {
let rig = Rig::new("admin-removed");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.remove_grant("notes");
let reason = DenyReason::NoGrant;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn approve_after_the_taint_rose_past_the_grant_is_denied() {
let rig = Rig::new("admin-taint");
let text = grant_text("read_file", "ask", "", "paths = [\"/n\"]")
.replace("max_taint = \"secret\"", "max_taint = \"private\"");
rig.grant("notes", &text);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let s1 = SessionId::new("s1").unwrap();
let secret = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), secret)
.unwrap();
let reason = DenyReason::TaintTooHigh;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn an_approval_that_cannot_be_recorded_is_denied_on_both_sides() {
let rig = Rig::new("admin-norecord");
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
let reason = DenyReason::AuditUnavailable;
assert_eq!(
outcome(approve(&broker, approval)),
DecisionRecord::Denied { reason }
);
assert_eq!(final_answer(&mut stream), denied(reason));
assert_eq!(rt.count(), 0);
}
#[test]
fn a_refusal_that_cannot_be_recorded_is_an_error_for_bxctl() {
let rig = Rig::new("admin-norefuse");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
let (mut stream, approval) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
rig.switch.fail(true);
match refuse(&broker, approval, None) {
Message::Error(e) => {
assert_eq!(e.code, ErrorCode::Internal);
assert!(
e.detail.ends_with("see docs/runbook.md#audit-unavailable"),
"{}",
e.detail
);
}
other => panic!("{other:?}"),
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::AuditUnavailable)
);
}
#[test]
fn expiry_denies_an_approval_nobody_answered() {
let rig = Rig::with_ttl("admin-expire", 100);
ask_notes(&rig);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = pending(&broker, 1, r#"{"path":"/n/a"}"#);
let until = Instant::now() + Duration::from_secs(10);
while admin::expire_due(&broker, Timestamp::now()) == 0 {
assert!(Instant::now() < until, "never expired");
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
final_answer(&mut stream),
denied(DenyReason::ApprovalExpired)
);
match &rig.events()[1] {
AuditEvent::Approval { answer, by, .. } => {
assert_eq!((*answer, by.as_deref()), (ApprovalAnswer::Expired, None));
}
other => panic!("{other:?}"),
}
assert_eq!(rt.count(), 0);
}
#[test]
fn check_grants_reports_every_problem_or_none() {
let rig = Rig::new("admin-grants");
ask_notes(&rig);
let broker = rig.broker(&Recording::answering("x"));
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => assert!(r.problems.is_empty(), "{r:?}"),
other => panic!("{other:?}"),
}
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
rig.grant(
"Worse",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
match admin(&broker, Message::CheckGrants(Empty {})) {
Message::GrantsReport(r) => {
let files: Vec<&str> = r.problems.iter().map(|p| p.file.as_str()).collect();
assert!(files.contains(&"bad.toml"), "{r:?}");
assert!(files.contains(&"Worse.toml"), "{r:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn every_other_kind_on_admin_sock_is_forbidden() {
let rig = Rig::new("admin-forbidden");
let broker = rig.broker(&Recording::answering("x"));
let req = request("s1", 1, "read_file", r#"{"path":"/n/a"}"#);
for msg in [
Message::ToolRequest(req),
Message::Ok(Empty {}),
Message::ToolResponse(denied(DenyReason::NoGrant)),
] {
assert_eq!(error_code(&admin(&broker, msg)), Some(ErrorCode::Forbidden));
}
let lines = rig.lines.with("on admin.sock");
assert_eq!(lines.len(), 3, "{:?}", rig.lines.all());
assert!(lines[0].contains("tool_request"), "{}", lines[0]);
assert!(
lines
.iter()
.all(|l| l.ends_with("see docs/runbook.md#socket-forbidden"))
);
assert!(
rig.events().is_empty(),
"a refused tool request decides nothing"
);
}
+159
View File
@@ -0,0 +1,159 @@
//! The pending-approval table: whoever takes an entry answers it, and everyone else finds it
//! gone. Do not edit.
#[path = "support/build.rs"]
mod build;
use std::sync::{Arc, Barrier};
use brokerd::approvals::{Table, Verdict};
use brokerd::policy::{Ask, Outcome, SessionState, decide};
use build::{grant, now, read, set, ts};
use proto::{CallId, DataClass, DenyReason, Mode, PendingApproval, SessionId, Timestamp};
fn ask() -> Ask {
let grants = set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]);
match decide(read("/n/a"), &grants, SessionState::default(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("the test's call does not ask: {other:?}"),
}
}
fn info(approval: u64, expires: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("s1").unwrap(),
call: CallId(approval + 100),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
grant: "n".to_string(),
taint: DataClass::Private,
created: now(),
expires: ts(expires),
}
}
const LATER: &str = "2026-09-18T12:15:00.000Z";
#[test]
fn a_new_table_is_empty_and_lists_in_id_order() {
let table = Table::new();
assert_eq!(table.list(), []);
let _a = table.insert(info(7, LATER), ask());
let _b = table.insert(info(3, LATER), ask());
assert_eq!(table.list(), [info(3, LATER), info(7, LATER)]);
}
#[test]
fn an_entry_can_be_taken_once() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).expect("the entry is there");
assert_eq!(entry.info, info(5, LATER));
assert_eq!(entry.ask.grant(), "n");
assert!(table.take(5).is_none(), "taken twice");
assert_eq!(table.list(), []);
}
#[test]
fn an_id_never_added_is_not_there() {
let table = Table::new();
let _rx = table.insert(info(5, LATER), ask());
assert!(table.take(6).is_none());
assert_eq!(table.list().len(), 1);
}
#[test]
fn the_verdict_goes_to_the_waiting_side() {
let table = Table::new();
let rx = table.insert(info(5, LATER), ask());
let entry = table.take(5).unwrap();
entry
.reply
.send(Verdict::Denied(DenyReason::ApprovalRefused))
.unwrap();
match rx.recv().unwrap() {
Verdict::Denied(reason) => assert_eq!(reason, DenyReason::ApprovalRefused),
Verdict::Run(_) => panic!("the verdict changed on the way"),
}
}
#[test]
fn an_entry_expires_at_its_expiry_and_not_before() {
let table = Table::new();
let _a = table.insert(info(9, "2026-09-18T12:00:01.000Z"), ask());
let _b = table.insert(info(2, "2026-09-18T12:00:00.500Z"), ask());
let _c = table.insert(info(4, LATER), ask());
let before = ts("2026-09-18T12:00:00.499Z");
assert!(table.take_expired(before).is_empty());
// Exactly at `expires` is expired.
let at = ts("2026-09-18T12:00:00.500Z");
let due: Vec<u64> = table
.take_expired(at)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [2]);
let after = ts("2026-09-18T13:00:00.000Z");
let due: Vec<u64> = table
.take_expired(after)
.iter()
.map(|e| e.info.approval)
.collect();
assert_eq!(due, [4, 9], "in id order");
assert_eq!(table.list(), []);
assert!(table.take_expired(Timestamp::MAX).is_empty());
}
#[test]
fn two_takers_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, LATER), ask());
let start = Arc::new(Barrier::new(2));
let takers: Vec<_> = (0..2)
.map(|_| {
let table = Arc::clone(&table);
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
table.take(1).is_some()
})
})
.collect();
let got: Vec<bool> = takers.into_iter().map(|t| t.join().unwrap()).collect();
assert_eq!(
got.iter().filter(|g| **g).count(),
1,
"round {round}: {got:?}"
);
}
}
#[test]
fn a_taker_and_the_expiry_at_once_one_gets_it() {
for round in 0..100 {
let table = Arc::new(Table::new());
let _rx = table.insert(info(1, "2026-09-18T12:00:00.000Z"), ask());
let start = Arc::new(Barrier::new(2));
let t = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
usize::from(table.take(1).is_some())
})
};
let e = {
let (table, start) = (Arc::clone(&table), Arc::clone(&start));
std::thread::spawn(move || {
start.wait();
table.take_expired(now()).len()
})
};
let total = t.join().unwrap() + e.join().unwrap();
assert_eq!(total, 1, "round {round}");
}
}
+428
View File
@@ -0,0 +1,428 @@
//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit.
//!
//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here.
use brokerd::args::{
ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host,
valid_host, valid_host_pattern, valid_path,
};
#[test]
fn the_four_tool_names() {
let names = ["read_file", "write_file", "shell", "http_fetch"];
for (tool, name) in ToolName::ALL.into_iter().zip(names) {
assert_eq!(tool.as_str(), name);
assert_eq!(ToolName::parse(name), Some(tool));
}
for other in [
"",
"echo",
"clock",
"call_tool",
"Read_File",
"read_file ",
"readfile",
] {
assert_eq!(ToolName::parse(other), None, "{other:?}");
}
}
#[test]
fn valid_paths() {
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
assert_eq!(longest.len(), MAX_PATH);
for path in [
"/",
"/etc",
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/with space/and\ttab",
"/dots.in.names/..hidden/...",
"/unicode/\u{e9}t\u{e9}",
longest.as_str(),
] {
assert!(valid_path(path), "{path:?} should be valid");
}
}
#[test]
fn invalid_paths() {
let too_long = format!("/{}", "a".repeat(MAX_PATH));
assert_eq!(too_long.len(), MAX_PATH + 1);
for path in [
"",
"notes/a.md",
"./notes",
"~/notes",
"/home/kyle/notes/../.ssh/id",
"/home/kyle//notes/./a.md",
"/home//kyle",
"/home/./kyle",
"/home/kyle/",
"/home/kyle/..",
"/..",
"/.",
"//",
"/nul\0byte",
too_long.as_str(),
] {
assert!(!valid_path(path), "{path:?} should be invalid");
}
}
/// The table in the spec, row by row, for the rows about form and containment.
#[test]
fn inside_is_by_whole_components() {
let grant = "/home/kyle/notes";
assert!(inside(grant, "/home/kyle/notes/a.md"));
assert!(inside(grant, "/home/kyle/notes"));
assert!(inside(grant, "/home/kyle/notes/deep/er/b.md"));
assert!(!inside(grant, "/home/kyle/notes2/a.md"));
assert!(!inside(grant, "/home/kyle/note"));
assert!(!inside(grant, "/home/kyle"));
assert!(!inside(grant, "/"));
assert!(!inside(grant, "/other/home/kyle/notes/a.md"));
// A grant of the root is refused when grants are loaded, but the function is still right.
assert!(inside("/", "/etc/passwd"));
assert!(inside("/", "/"));
}
#[test]
fn valid_hosts_and_patterns() {
let label63 = "a".repeat(63);
let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57));
assert_eq!(long.len(), 253);
for host in [
"example.com",
"www.example.com",
"a.b.example.com",
"xn--bcher-kva.example",
"1password.com",
"3.example.org",
"a-b.c-d.io",
long.as_str(),
] {
assert!(valid_host(host), "{host:?} should be a valid host");
assert!(
valid_host_pattern(host),
"{host:?} should be a valid pattern"
);
let wild = format!("*.{host}");
assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host");
}
assert!(valid_host_pattern("*.example.com"));
assert!(valid_host_pattern("*.a.b.example.com"));
}
#[test]
fn invalid_hosts_and_patterns() {
let label64 = format!("{}.com", "a".repeat(64));
let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join("."));
assert!(too_long.len() > 253);
for host in [
"",
"localhost",
"com",
"Example.com",
"example.COM",
"example.com.",
".example.com",
"example..com",
"-example.com",
"example-.com",
"exa_mple.com",
"example.com:443",
"example.com/path",
"user@example.com",
"exa mple.com",
"[::1]",
"::1",
// Every spelling of an IPv4 address: the last label does not start with a letter.
"127.0.0.1",
"127.1",
"10.0.0.0x1",
"1.2.3.4",
"example.123",
"b\u{fc}cher.example",
label64.as_str(),
too_long.as_str(),
] {
assert!(!valid_host(host), "{host:?} should not be a valid host");
assert!(
!valid_host_pattern(host),
"{host:?} should not be a valid pattern"
);
}
for pattern in [
"*",
"*.",
"*.com",
"*example.com",
"www.*.com",
"*.*.example.com",
"**.example.com",
"*.Example.com",
"*.127.0.0.1",
] {
assert!(!valid_host_pattern(pattern), "{pattern:?}");
}
}
/// The host table in the spec, row by row.
#[test]
fn host_matching() {
assert!(host_matches("example.com", "example.com"));
assert!(!host_matches("example.com", "www.example.com"));
assert!(host_matches("*.example.com", "www.example.com"));
assert!(host_matches("*.example.com", "a.b.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
// A suffix is not enough: the match is by whole labels.
assert!(!host_matches("*.example.com", "badexample.com"));
assert!(!host_matches("*.example.com", "www.example.com.evil.org"));
assert!(!host_matches("example.com", "example.com.evil.org"));
assert!(!host_matches("*.example.com", ".example.com"));
}
#[test]
fn valid_urls_and_their_hosts() {
let base = "https://example.com/";
let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len()));
assert_eq!(longest.len(), MAX_URL);
for (url, host) in [
("https://example.com", "example.com"),
("https://example.com/", "example.com"),
("https://example.com:443", "example.com"),
("https://example.com:443/", "example.com"),
("https://www.example.com/a/b.html", "www.example.com"),
("https://example.com/search?q=a+b&x=%20#frag", "example.com"),
("https://example.com/@user", "example.com"),
("https://example.com/a:8080/b", "example.com"),
("https://example.com/https://other.org/", "example.com"),
("https://example.com/back\\slash", "example.com"),
(longest.as_str(), "example.com"),
] {
assert_eq!(url_host(url), Some(host), "{url}");
}
}
#[test]
fn invalid_urls() {
let base = "https://example.com/";
let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1));
assert_eq!(too_long.len(), MAX_URL + 1);
for url in [
"",
"example.com",
"http://example.com/",
"HTTPS://example.com/",
"https:/example.com/",
"https://",
"https:///path",
"ftp://example.com/",
"file:///etc/passwd",
// userinfo
"https://user@example.com/",
"https://user:pw@example.com/",
"https://example.com@evil.org/",
// ports
"https://example.com:8443/",
"https://example.com:80/",
"https://example.com:/",
"https://example.com:443x/",
"https://example.com:4433/",
"https://example.com:443:443/",
// what follows the host must be the end, `:443` or `/`
"https://example.com?q=1",
"https://example.com#frag",
"https://example.com\\@evil.org/",
// hosts that are not host names
"https://localhost/",
"https://127.0.0.1/",
"https://127.1/",
"https://[::1]/",
"https://Example.com/",
"https://example.com./",
"https://b\u{fc}cher.example/",
// the rest must be printable ASCII with no space
"https://example.com/a b",
"https://example.com/a\tb",
"https://example.com/a\nb",
"https://example.com/caf\u{e9}",
"https://example.com/\u{7f}",
" https://example.com/",
"https://example.com/ ",
too_long.as_str(),
] {
assert_eq!(url_host(url), None, "{url:?} should be invalid");
}
}
#[test]
fn each_tool_parses_its_own_arguments() {
assert_eq!(
parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#),
Ok(ToolArgs::ReadFile {
path: "/home/kyle/notes/a.md".to_string()
})
);
assert_eq!(
parse(
ToolName::WriteFile,
r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"#
),
Ok(ToolArgs::WriteFile {
path: "/home/kyle/notes/a.md".to_string(),
content: "line\n".to_string()
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls -l"}"#),
Ok(ToolArgs::Shell {
command: "ls -l".to_string(),
cwd: None
})
);
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: Some("/home/kyle".to_string())
})
);
assert_eq!(
parse(
ToolName::HttpFetch,
r#"{"url":"https://www.example.com/a"}"#
),
Ok(ToolArgs::HttpFetch {
url: "https://www.example.com/a".to_string(),
host: "www.example.com".to_string()
})
);
// Field order and white space in the request do not matter.
assert_eq!(
parse(
ToolName::WriteFile,
" { \"content\" : \"x\" , \"path\" : \"/a/b\" } "
),
Ok(ToolArgs::WriteFile {
path: "/a/b".to_string(),
content: "x".to_string()
})
);
// `command` and `content` are not inspected.
assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok());
assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok());
assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok());
}
#[test]
fn arguments_of_the_wrong_shape_are_refused() {
let cases: [(ToolName, &str); 17] = [
(ToolName::ReadFile, ""),
(ToolName::ReadFile, "null"),
(ToolName::ReadFile, "[]"),
(ToolName::ReadFile, r#""/etc/hosts""#),
(ToolName::ReadFile, "{}"),
(ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#),
(ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#),
(ToolName::ReadFile, r#"{"path":7}"#),
(ToolName::ReadFile, r#"{"path":null}"#),
(ToolName::ReadFile, r#"{"path":"/a"} trailing"#),
(ToolName::WriteFile, r#"{"path":"/a/b"}"#),
(ToolName::WriteFile, r#"{"content":"x"}"#),
(
ToolName::WriteFile,
r#"{"path":"/a/b","content":"x","append":true}"#,
),
(ToolName::Shell, r#"{"cwd":"/a"}"#),
(ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#),
(ToolName::Shell, r#"{"command":["ls"]}"#),
(
ToolName::HttpFetch,
r#"{"url":"https://example.com/","method":"POST"}"#,
),
];
for (tool, text) in cases {
match parse(tool, text) {
Err(ArgsError::Shape(_)) => {}
other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"),
}
}
// One tool's arguments do not fit another tool.
assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err());
assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err());
}
#[test]
fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() {
for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] {
let quoted = serde_json::to_string(bad).unwrap();
let read = format!(r#"{{"path":{quoted}}}"#);
let write = format!(r#"{{"path":{quoted},"content":"x"}}"#);
let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#);
assert_eq!(
parse(ToolName::ReadFile, &read),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::WriteFile, &write),
Err(ArgsError::Path(bad.to_string()))
);
assert_eq!(
parse(ToolName::Shell, &shell),
Err(ArgsError::Path(bad.to_string()))
);
}
assert_eq!(
parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#),
Err(ArgsError::Url("http://example.com/".to_string()))
);
// A NUL can only arrive as a JSON escape; it is refused once decoded.
let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\');
assert!(matches!(
parse(ToolName::ReadFile, &nul),
Err(ArgsError::Path(_))
));
// `cwd: null` is the same as no `cwd`.
assert_eq!(
parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#),
Ok(ToolArgs::Shell {
command: "ls".to_string(),
cwd: None
})
);
}
/// What the owner is shown is the parsed value written out again, so two spellings of one path
/// look the same. The escape is built from pieces so that no tool rewrites it on the way here.
#[test]
fn canonical_json_shows_what_was_parsed() {
let escaped_slash = format!("{}u002f", '\\');
let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}");
assert!(sneaky.contains("u002fetc"));
let args = parse(ToolName::ReadFile, &sneaky).unwrap();
assert_eq!(
args,
ToolArgs::ReadFile {
path: "/etc/hosts".to_string()
}
);
assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#);
// Fields come out in the spec's order whatever order they came in.
let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap();
assert_eq!(
write.canonical_json(),
r#"{"path":"/a/b","content":"x\ny"}"#
);
let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap();
assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#);
// An absent cwd is left out, and the host is never written: it is not an argument.
let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap();
assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#);
let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap();
assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#);
assert_eq!(fetch.tool(), ToolName::HttpFetch);
assert_eq!(write.tool(), ToolName::WriteFile);
}
+258
View File
@@ -0,0 +1,258 @@
//! The audit writer: the chain it writes, the lock, rollover, and stopping after a failed
//! write. Do not edit. Startup checks, recovery and accepted breaks are in `audit_startup.rs`.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use std::os::unix::fs::PermissionsExt;
use audit_dir::{D1, D2, TempDir, denied, lines, ts};
use brokerd::audit::{AuditError, RECOVERED_NOTICE, Writer, verify_dir};
use proto::{AuditRecord, Hash32, sha256};
fn mode(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn the_first_record_starts_the_chain() {
let dir = TempDir::unmade("first");
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
assert!(opened.accepted.is_none());
let mut writer = opened.writer;
assert_eq!(writer.next_seq(), 0);
let seq = writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
assert_eq!(seq, 0);
assert_eq!(writer.next_seq(), 1);
let text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
assert!(text.ends_with('\n'), "a record is one line and its newline");
assert_eq!(text.lines().count(), 1);
let record: AuditRecord = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!((record.seq, record.prev), (0, Hash32::ZERO));
assert_eq!(record.time, ts("2026-09-17T08:00:00.000Z"));
assert_eq!(record.event, denied(1));
assert_eq!(mode(&dir.path), 0o700, "the directory open() made");
assert_eq!(mode(&dir.path.join(D1)), 0o600);
}
#[test]
fn the_chain_runs_across_a_day_boundary() {
let dir = TempDir::unmade("days");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
let times = [
"2026-09-17T23:59:58.000Z",
"2026-09-17T23:59:59.999Z",
"2026-09-18T00:00:00.000Z",
"2026-09-18T00:00:01.000Z",
];
for (i, time) in times.iter().enumerate() {
assert_eq!(writer.append(ts(time), denied(i as u64)).unwrap(), i as u64);
}
let (day1, day2) = (lines(&dir.path, D1), lines(&dir.path, D2));
assert_eq!((day1.len(), day2.len()), (2, 2));
// seq goes on across files, and the new file chains from the last line of the old one.
let first: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(first.seq, 2);
assert_eq!(first.prev, sha256(day1[1].as_bytes()).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!((report.records, report.next_seq), (4, 4));
assert!(report.clock_warnings.is_empty());
}
#[test]
fn reopening_continues_the_chain() {
let dir = TempDir::unmade("reopen");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
writer
.append(ts("2026-09-17T08:00:01.000Z"), denied(2))
.unwrap();
drop(writer);
// One file: the whole of it is checked.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 2);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(3))
.unwrap(),
2
);
drop(writer);
// Two files: the latest is checked, resumed from the last line of the one before.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 3);
assert_eq!(
writer
.append(ts("2026-09-18T08:00:01.000Z"), denied(4))
.unwrap(),
3
);
drop(writer);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.records, 4);
}
#[test]
fn an_empty_latest_file_gets_the_next_record_as_its_first_line() {
let dir = TempDir::case("empty-latest", None);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
let mut writer = opened.writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(9))
.unwrap(),
5
);
let day2 = lines(&dir.path, D2);
assert_eq!(day2.len(), 1);
let record: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
assert_eq!(
record.prev,
sha256(lines(&dir.path, D1)[4].as_bytes()).unwrap()
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_second_writer_is_refused() {
let dir = TempDir::unmade("lock");
let first = Writer::open(&dir.path, false).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
assert!(matches!(error, AuditError::Locked), "{error}");
let text = error.to_string();
assert!(text.starts_with("brokerd is already running"), "{text}");
assert!(
text.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The lock goes when the writer goes, however that happens.
drop(first);
assert!(Writer::open(&dir.path, false).is_ok());
}
#[test]
fn the_writer_never_goes_back_to_an_earlier_file() {
let dir = TempDir::unmade("clock");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-18T00:00:05.000Z"), denied(1))
.unwrap();
// The clock is stepped back over midnight.
writer
.append(ts("2026-09-17T23:59:50.000Z"), denied(2))
.unwrap();
assert!(
!dir.path.join(D1).exists(),
"a record went into an earlier file"
);
assert_eq!(lines(&dir.path, D2).len(), 2);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.clock_warnings.len(), 1);
// It holds across a restart too.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T23:59:55.000Z"), denied(3))
.unwrap();
assert!(!dir.path.join(D1).exists());
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn files_that_are_not_log_files_are_ignored() {
let dir = TempDir::case("good", None);
std::fs::write(dir.path.join("notes.txt"), "not a log\n").unwrap();
std::fs::write(dir.path.join("2026-09-19.jsonl.bak"), "not a log\n").unwrap();
std::fs::write(dir.path.join("latest.jsonl"), "not a log\n").unwrap();
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 10);
writer
.append(ts("2026-09-18T10:00:00.000Z"), denied(9))
.unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().records, 11);
}
/// After one failed write the writer writes nothing more, even when the cause has gone: part of
/// a line may be on disk, and only the next start deals with that.
#[test]
fn a_failed_write_stops_the_writer() {
let dir = TempDir::unmade("sticky");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
// A new day needs a new file, and the directory no longer allows one.
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(dir.path.join("probe"), "").is_ok() {
eprintln!("skipped: this user can write to a read-only directory (root?)");
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
return;
}
let error = writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(2))
.unwrap_err();
assert!(matches!(error, AuditError::Io { .. }), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
for time in ["2026-09-18T08:00:01.000Z", "2026-09-17T08:00:02.000Z"] {
let error = writer.append(ts(time), denied(3)).unwrap_err();
assert!(matches!(error, AuditError::Stopped), "{error}");
assert!(
error
.to_string()
.ends_with("see docs/runbook.md#audit-unavailable"),
"{error}"
);
}
assert_eq!(
lines(&dir.path, D1).len(),
1,
"a stopped writer wrote something"
);
assert!(!dir.path.join(D2).exists());
// A restart puts it right.
drop(writer);
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(
writer
.append(ts("2026-09-18T08:00:03.000Z"), denied(4))
.unwrap(),
1
);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn the_recovered_notice_names_its_runbook_entry() {
assert!(RECOVERED_NOTICE.starts_with("audit: recovered a torn final line"));
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
+134
View File
@@ -0,0 +1,134 @@
//! Audit writer edge cases found in the M3a review: a log with no complete record, a file whose
//! name only looks like a log, and the lock file outliving the writer.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use audit_dir::{D1, D2, TempDir, denied, lines, ts};
use brokerd::audit::{Writer, verify_dir};
use proto::{AuditRecord, Hash32};
/// A kill between creating the day's file and writing its first record leaves it empty. It must
/// open like an empty log, not panic.
#[test]
fn one_zero_length_log_file_opens_as_an_empty_log() {
let dir = TempDir::unmade("zero-length");
std::fs::create_dir_all(&dir.path).unwrap();
std::fs::write(dir.path.join(D1), b"").unwrap();
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered);
let mut writer = opened.writer;
assert_eq!(writer.next_seq(), 0);
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
let record: AuditRecord = serde_json::from_str(&lines(&dir.path, D1)[0]).unwrap();
assert_eq!((record.seq, record.prev), (0, Hash32::ZERO));
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.records, 1);
}
/// The same crash, a day later: the older file holds records and the new one is empty.
#[test]
fn a_zero_length_file_after_records_continues_the_chain() {
let dir = TempDir::unmade("zero-length-later");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
drop(writer);
std::fs::write(dir.path.join(D2), b"").unwrap();
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.next_seq(), 1);
writer
.append(ts("2026-09-18T08:00:00.000Z"), denied(2))
.unwrap();
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.records, 2);
}
/// A crash part-way through the very first record: one file, one torn line, nothing else.
#[test]
fn a_single_torn_first_record_is_recovered() {
let dir = TempDir::unmade("torn-only");
std::fs::create_dir_all(&dir.path).unwrap();
std::fs::write(dir.path.join(D1), br#"{"seq":0,"time":"2026-09-17T08:"#).unwrap();
let opened = Writer::open(&dir.path, false).unwrap();
assert!(opened.recovered);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.recoveries.len(), 1);
}
/// `2026-0x-19.jsonl` is not a log file, for `brokerd` as for `bxctl audit verify`: it is neither
/// verified nor written to, and the real log goes on in its own files.
#[test]
fn a_file_that_only_looks_like_a_log_is_not_part_of_it() {
let dir = TempDir::case("good", None);
let odd = dir.path.join("2026-0x-19.jsonl");
std::fs::write(&odd, b"not a record\n").unwrap();
let mut writer = Writer::open(&dir.path, false)
.expect("a stray file does not break the chain")
.writer;
writer
.append(ts("2026-09-18T09:00:00.000Z"), denied(9))
.unwrap();
assert_eq!(
std::fs::read(&odd).unwrap(),
b"not a record\n",
"never written"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(
report.records, 11,
"the ten fixture records and the new one"
);
}
/// The lock file is never removed: a `brokerd` holding the old file open could otherwise lock it
/// while a new one creates and locks a fresh file, and both would write.
#[test]
fn the_lock_file_outlives_the_writer() {
let dir = TempDir::unmade("lock-stays");
let writer = Writer::open(&dir.path, false).unwrap().writer;
drop(writer);
assert!(dir.path.join(".lock").exists());
Writer::open(&dir.path, false).expect("the lock was released with the writer");
}
/// A torn last line in one file, then an empty later file (created, never written). The torn line
/// is ended in its own file and the recovery goes on the chain after it, so the log verifies and
/// the next start is an ordinary one. Found by the independent review of task 23.
#[test]
fn a_torn_line_before_an_empty_later_file_is_recovered_in_place() {
let dir = TempDir::unmade("torn-then-empty");
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
writer
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
.unwrap();
drop(writer);
let mut d1 = std::fs::read(dir.path.join(D1)).unwrap();
d1.extend_from_slice(br#"{"seq":1,"time":"2026-09-17T09"#);
std::fs::write(dir.path.join(D1), &d1).unwrap();
std::fs::write(dir.path.join(D2), b"").unwrap();
let opened = Writer::open(&dir.path, false).unwrap();
assert!(opened.recovered);
drop(opened);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{:?}", report.failure);
assert_eq!(report.recoveries.len(), 1);
assert!(std::fs::read(dir.path.join(D1)).unwrap().ends_with(b"\n"));
let again = Writer::open(&dir.path, false).expect("the next start is an ordinary one");
assert!(!again.recovered);
}
+300
View File
@@ -0,0 +1,300 @@
//! What `Writer::open` does with the log it finds: refuse a broken chain, recover a torn tail,
//! accept a break when told to. Do not edit. The fixture logs are in
//! `crates/proto/tests/fixtures/audit/`; each test works on a copy.
#[path = "support/audit_dir.rs"]
mod audit_dir;
use audit_dir::{D1, D2, TempDir, denied, lines, snapshot};
use brokerd::audit::{AuditError, Writer, verify_dir};
use proto::{AuditEvent, AuditRecord, Location, Timestamp};
type Case = (
&'static str,
Option<&'static [&'static str]>,
&'static str,
u64,
&'static str,
);
fn at(file: &str, line: u64) -> Location {
Location {
file: file.to_string(),
line,
}
}
/// An ordinary start checks the latest file only, so each damaged file is copied alone: it is
/// then the latest. Nothing may be written to a log that does not verify.
#[test]
fn a_broken_chain_refuses_to_start_and_writes_nothing() {
let parse = "does not parse as an audit record";
// (case, the files to copy, then the failure's file, line and text)
let cases: [Case; 9] = [
(
"changed-byte",
Some(&[D1]),
D1,
4,
"prev is not the hash of the line before",
),
("deleted-line", Some(&[D1]), D1, 3, "seq is 3, expected 2"),
("swapped-lines", Some(&[D1]), D1, 2, "seq is 2, expected 1"),
("seq-gap", None, D1, 3, "seq is 3, expected 2"),
("cut-short", Some(&[D1]), D1, 3, parse),
// Both files: the latest does not chain from the last line of the one before.
(
"file-not-chained",
None,
D2,
1,
"does not chain from the last line of the file before",
),
(
"break-without-failure",
None,
D2,
6,
"an accepted break with no failure before it",
),
("recovery-wrong-hash", None, D2, 6, parse),
("torn-recovery", None, D2, 6, parse),
];
for (case, only, file, line, what) in cases {
let dir = TempDir::case(case, only);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, false)
.err()
.unwrap_or_else(|| panic!("{case}: started"));
let AuditError::Broken(failure) = &error else {
panic!("{case}: {error}");
};
assert_eq!(
(failure.file.as_str(), failure.line, failure.what.as_str()),
(file, line, what),
"{case}"
);
let text = error.to_string();
assert!(
text.starts_with(&format!("{file}:{line}: {what}")),
"{case}: {text}"
);
assert!(
text.ends_with("see docs/runbook.md#audit-chain-broken"),
"{case}: {text}"
);
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A torn tail is recovered: the torn bytes stay, a newline ends them if one is missing, and a
/// `Recovery` record follows in the same file, whatever today's date is.
#[test]
fn a_torn_tail_is_recovered() {
// (case, the torn line's file and number, newline already there)
let cases = [
("torn-tail", D2, 6, false),
("torn-tail-complete-json", D2, 6, false),
("torn-unparseable-newline", D2, 6, true),
("torn-first-line", D2, 1, false),
];
for (case, file, line, has_newline) in cases {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, false).unwrap_or_else(|e| panic!("{case}: {e}"));
assert!(opened.recovered, "{case}");
assert!(opened.accepted.is_none(), "{case}");
let after = snapshot(&dir.path);
assert_eq!(
after.len(),
before.len(),
"{case}: the Recovery went into a new file"
);
let (old, new) = (&before[file], &after[file]);
assert!(
new.starts_with(old),
"{case}: bytes already on disk were changed"
);
let added = &new[old.len()..];
// One newline to end the torn line if it had none, then one line.
let added = if has_newline {
added
} else {
added.strip_prefix(b"\n").expect(case)
};
assert_eq!(added.iter().filter(|b| **b == b'\n').count(), 1, "{case}");
let record: AuditRecord =
serde_json::from_slice(added.strip_suffix(b"\n").expect(case)).expect(case);
assert!(
matches!(record.event, AuditEvent::Recovery { .. }),
"{case}"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.torn_tail, None, "{case}");
assert_eq!(report.recoveries, vec![at(file, line)], "{case}");
// The chain goes on from the Recovery, and the next start finds nothing to recover.
let mut writer = opened.writer;
assert_eq!(
writer.append(Timestamp::now(), denied(9)).unwrap(),
record.seq + 1,
"{case}"
);
drop(writer);
let opened = Writer::open(&dir.path, false).unwrap();
assert!(!opened.recovered, "{case}");
assert_eq!(verify_dir(&dir.path).unwrap().failure, None, "{case}");
}
}
/// Damage in an older file is not seen by an ordinary start. `bxctl audit verify` sees it, and
/// `--accept-break` must too: it verifies the whole log.
#[test]
fn a_break_in_an_older_file_can_be_accepted() {
let dir = TempDir::case("changed-byte", None);
let before = snapshot(&dir.path);
drop(Writer::open(&dir.path, false).expect("the latest file verifies"));
assert_eq!(snapshot(&dir.path), before);
let failure = verify_dir(&dir.path).unwrap().failure.unwrap();
assert_eq!((failure.file.as_str(), failure.line), (D1, 4));
let opened = Writer::open(&dir.path, true).unwrap();
assert!(!opened.recovered);
assert_eq!(
opened.accepted.as_ref().map(|f| (f.file.as_str(), f.line)),
Some((D1, 4))
);
let after = snapshot(&dir.path);
assert_eq!(after[D1], before[D1], "nothing is repaired");
assert!(after[D2].starts_with(&before[D2]));
let last: AuditRecord = serde_json::from_str(lines(&dir.path, D2).last().unwrap()).unwrap();
assert_eq!(
last.event,
AuditEvent::AcceptedBreak {
file: D1.to_string(),
line: 4,
last_good: failure.last_good,
}
);
assert_eq!(
last.seq, 10,
"seq 3 for the failing line, and seven lines to the break"
);
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D2, 6)]);
let mut writer = opened.writer;
assert_eq!(writer.append(Timestamp::now(), denied(9)).unwrap(), 11);
drop(writer);
// The next ordinary start resumes at the latest file and meets a break that names a file
// it has not read.
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
assert_eq!(writer.append(Timestamp::now(), denied(10)).unwrap(), 12);
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn a_break_in_the_latest_file_can_be_accepted() {
// (case, failing line, seq of the break record)
for (case, line, seq) in [("recovery-wrong-hash", 6, 12), ("torn-recovery", 6, 12)] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let opened = Writer::open(&dir.path, true).unwrap_or_else(|e| panic!("{case}: {e}"));
assert_eq!(
opened.accepted.as_ref().map(|f| f.line),
Some(line),
"{case}"
);
let after = snapshot(&dir.path);
assert!(after[D2].starts_with(&before[D2]), "{case}");
// torn-recovery ends without a newline: the break record must start on its own line.
let all = lines(&dir.path, D2);
let last: AuditRecord = serde_json::from_str(all.last().unwrap()).expect(case);
assert!(
matches!(last.event, AuditEvent::AcceptedBreak { .. }),
"{case}"
);
assert_eq!(last.seq, seq, "{case}");
assert_eq!(all.len(), 8, "{case}");
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None, "{case}");
assert_eq!(report.accepted_breaks, vec![at(D2, 8)], "{case}");
drop(opened);
assert!(Writer::open(&dir.path, false).is_ok(), "{case}");
}
}
/// The short check is a shortcut and never the last word: when it cannot be made, or fails, the
/// whole log is verified and that verdict stands. Here the last line of the older file is the
/// damage, so there is nothing to resume from.
#[test]
fn an_accepted_break_at_the_end_of_an_older_file_does_not_stop_later_starts() {
let dir = TempDir::case("good", None);
let day1 = std::fs::read_to_string(dir.path.join(D1)).unwrap();
let cut = format!("{}\n", &day1[..day1.len() - 40]);
std::fs::write(dir.path.join(D1), cut).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 5));
drop(Writer::open(&dir.path, true).unwrap());
let mut writer = Writer::open(&dir.path, false)
.expect("the break was accepted")
.writer;
writer.append(Timestamp::now(), denied(9)).unwrap();
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
}
#[test]
fn accept_break_with_nothing_to_accept_is_an_error() {
for case in ["good", "torn-tail"] {
let dir = TempDir::case(case, None);
let before = snapshot(&dir.path);
let error = Writer::open(&dir.path, true).unwrap_err();
assert!(
matches!(error, AuditError::NothingToAccept),
"{case}: {error}"
);
assert!(error.to_string().starts_with("nothing to accept"), "{case}");
assert_eq!(
snapshot(&dir.path),
before,
"{case}: the log was written to"
);
}
}
/// A second failure after an accepted break needs its own break.
#[test]
fn damage_after_a_break_is_a_new_failure() {
let dir = TempDir::case("accepted-break", None);
drop(Writer::open(&dir.path, false).expect("the fixture verifies"));
let mut text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
text.push_str("{}\n{}\n");
std::fs::write(dir.path.join(D1), text).unwrap();
let error = Writer::open(&dir.path, false).unwrap_err();
let AuditError::Broken(failure) = &error else {
panic!("{error}");
};
assert_eq!((failure.file.as_str(), failure.line), (D1, 8));
drop(Writer::open(&dir.path, true).unwrap());
let report = verify_dir(&dir.path).unwrap();
assert_eq!(report.failure, None);
assert_eq!(report.accepted_breaks, vec![at(D1, 6), at(D1, 10)]);
}
+324
View File
@@ -0,0 +1,324 @@
//! One `broker.sock` connection: decision, record, run, answer; and every way it fails closed.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use brokerd::broker;
use brokerd::ledger::NOT_RECORDED;
use brokerd::runner::{REFUSING, RunError};
use client::{Serve, call, next, open};
use proto::{
ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty,
Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse,
Turn, TurnDone, TurnEvent, Usage, WireError,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
const NOTES: &str = r#"{"path":"/n/a"}"#;
fn read_notes(call_id: u64) -> proto::ToolRequest {
request("s1", call_id, "read_file", NOTES)
}
fn only(frames: &[Envelope]) -> &ToolResponse {
assert_eq!(frames.len(), 1, "{frames:?}");
assert!(frames[0].r#final);
match &frames[0].msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied { reason }
}
fn auto_notes(rig: &Rig, class: &str) {
let text = grant_text("read_file", "auto", "", "paths = [\"/n\"]").replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
);
rig.grant("notes", &text);
}
#[test]
fn a_call_no_grant_allows_is_denied_and_recorded() {
let rig = Rig::new("broker-nogrant");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(41));
assert_eq!(frames[0].id, 41, "the answer carries the request's id");
assert_eq!(only(&frames), &denied(DenyReason::NoGrant));
assert_eq!(rt.count(), 0);
match rig.events().as_slice() {
[AuditEvent::Decision { outcome, .. }] => assert_eq!(
*outcome,
DecisionRecord::Denied {
reason: DenyReason::NoGrant
}
),
other => panic!("{other:?}"),
}
}
#[test]
fn an_allowed_call_runs_and_its_result_raises_the_state() {
let rig = Rig::new("broker-allowed");
auto_notes(&rig, "secret");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(7));
assert_eq!(
only(&frames),
&ToolResponse::Result {
content: "the notes".to_string(),
class: DataClass::Secret,
untrusted: false,
truncated: false,
}
);
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
decision: 0,
status: ResultStatus::Result,
taint_after: DataClass::Secret,
..
}
]
));
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
#[test]
fn a_runtime_failure_is_passed_on_and_recorded_as_failed() {
let rig = Rig::new("broker-refusing");
auto_notes(&rig, "secret");
let rt = Recording::with(Err(RunError::Unavailable(REFUSING.to_string())));
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
match &rig.events()[1] {
AuditEvent::Result {
status,
taint_after,
..
} => assert_eq!(
(*status, *taint_after),
(ResultStatus::Failed, DataClass::Private)
),
other => panic!("{other:?}"),
}
assert!(!rig.state_file("s1").exists(), "a failure changes no state");
}
fn every_kind_but_tool_request() -> Vec<Message> {
let usage = Usage {
cache_n: 0,
prompt_n: 0,
predicted_n: 0,
reasoning_tokens: 0,
thinking_capped: false,
};
vec![
Message::ToolResponse(denied(DenyReason::NoGrant)),
Message::Error(WireError {
code: ErrorCode::Internal,
detail: String::new(),
}),
Message::Turn(Turn {
session: SessionId::new("s1").unwrap(),
content: String::new(),
resume: false,
}),
Message::TurnEvent(TurnEvent::Content {
text: String::new(),
}),
Message::TurnDone(TurnDone {
content: String::new(),
usage,
}),
Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }),
Message::Approve(Approve { approval: 0 }),
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Allowed {},
}),
Message::Refuse(Refuse {
approval: 0,
reason: None,
}),
Message::Ok(Empty {}),
Message::CheckGrants(Empty {}),
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
}),
]
}
#[test]
fn every_other_kind_on_broker_sock_is_forbidden() {
let rig = Rig::new("broker-forbidden");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let kinds = every_kind_but_tool_request();
assert_eq!(kinds.len(), 13, "every Message variant but tool_request");
for (i, msg) in kinds.into_iter().enumerate() {
let id = 100 + i as u64;
let mut stream = open(&broker, broker::handle, id, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (id, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden),
other => panic!("{other:?}"),
}
}
let lines = rig.lines.with("broker.sock");
assert_eq!(lines.len(), 13, "{:?}", rig.lines.all());
for line in &lines {
assert!(
line.ends_with("\nsee docs/runbook.md#socket-forbidden"),
"{line}"
);
}
assert_eq!(rig.lines.with("kind approve on broker.sock").len(), 1);
assert!(
rig.events().is_empty(),
"nothing is recorded for a refused kind"
);
}
#[test]
fn a_frame_that_is_not_json_is_answered_with_bad_message() {
let rig = Rig::new("broker-badframe");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut client, server) = UnixStream::pair().unwrap();
let b = Arc::clone(&broker);
std::thread::spawn(move || broker::handle(server, &b));
client.write_all(&3u32.to_be_bytes()).unwrap();
client.write_all(b"{{{").unwrap();
let env = next(&mut client);
assert_eq!((env.id, env.r#final), (0, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::BadMessage),
other => panic!("{other:?}"),
}
assert!(rig.events().is_empty());
}
#[test]
fn one_invalid_grant_file_denies_a_call_a_valid_file_would_allow() {
let rig = Rig::new("broker-invalid");
auto_notes(&rig, "private");
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
for id in [1, 2] {
let frames = call(&broker, read_notes(id));
assert_eq!(only(&frames), &denied(DenyReason::GrantsInvalid));
}
assert_eq!(rt.count(), 0);
// Printed once for the two calls, with the pointer.
let printed = rig.lines.with("bad.toml");
assert_eq!(printed.len(), 1, "{:?}", rig.lines.all());
assert!(
printed[0].ends_with("see docs/runbook.md#grants-invalid"),
"{}",
printed[0]
);
rig.remove_grant("bad");
let frames = call(&broker, read_notes(3));
assert!(matches!(only(&frames), ToolResponse::Result { .. }));
assert_eq!(rt.count(), 1);
}
#[test]
fn a_damaged_session_state_is_state_unreadable() {
let rig = Rig::new("broker-state");
auto_notes(&rig, "private");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "{\"taint\":\"loud\"}").unwrap();
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::StateUnreadable));
assert_eq!(rt.count(), 0);
let lines = rig.lines.with("s1.json");
assert!(
lines
.iter()
.any(|l| l.ends_with("see docs/runbook.md#broker-state-damaged")),
"{lines:?}"
);
}
#[test]
fn a_failed_audit_write_runs_nothing_now_or_later() {
let rig = Rig::new("broker-audit");
auto_notes(&rig, "private");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
rig.switch.fail(true);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
rig.switch.fail(false);
let frames = call(&broker, read_notes(2));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
assert_eq!(rt.count(), 0);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("broker-ro");
auto_notes(&rig, "secret");
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let rt = Recording::answering("the secret");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rt.count(), 1, "it ran; its content is what is withheld");
let text = format!("{frames:?}");
assert!(!text.contains("the secret"), "{text}");
}
+230
View File
@@ -0,0 +1,230 @@
//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester
//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::broker::{self, Broker, GONE};
use brokerd::ledger::Answer;
use client::{Serve, next, open};
use proto::{
AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig, extra: &str) {
rig.grant(
"notes",
&grant_text("read_file", "ask", extra, "paths = [\"/n\"]"),
);
}
/// Sends a call and reads its pending frame. Returns the connection and the frame's values.
fn start(broker: &Arc<Broker>, arguments: &str) -> (UnixStream, u64, Timestamp) {
let req = request("s1", 9, "read_file", arguments);
let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req));
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (9, false), "{env:?}");
match env.msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => {
(stream, approval, expires)
}
other => panic!("{other:?}"),
}
}
/// What `admin` does for an entry it took: record the answer, send the verdict.
fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord {
let entry = broker.table().take(id).expect("the entry is pending");
answer_entry(broker, entry, answer)
}
fn answer_entry(
broker: &Broker,
entry: brokerd::approvals::Entry,
answer: Answer,
) -> DecisionRecord {
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
entry.info.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
done.outcome
}
fn approved() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
let env = next(stream);
assert!(env.r#final, "{env:?}");
match env.msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn eventually(what: &str, mut done: impl FnMut() -> bool) {
let until = Instant::now() + Duration::from_secs(10);
while !done() {
assert!(Instant::now() < until, "never happened: {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn an_ask_call_waits_is_listed_and_runs_when_approved() {
let rig = Rig::new("pending-approve");
ask_notes(&rig, "");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let before = Timestamp::now();
// Spaced JSON: the table shows the arguments as policy parsed them, not as sent.
let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#);
assert_eq!(approval, 0, "the approval id is the decision's seq");
let items = broker.table().list();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.approval, 0);
assert_eq!((item.session.as_str(), item.call.0), ("s1", 9));
assert_eq!(item.tool, "read_file");
assert_eq!(item.arguments, r#"{"path":"/n/a"}"#);
assert_eq!(
(item.grant.as_str(), item.taint),
("notes", DataClass::Private)
);
assert!(item.created >= before);
assert_eq!(item.expires, expires);
assert_eq!(
expires.unix_millis() - item.created.unix_millis(),
900_000,
"now plus ttl_ms"
);
assert_eq!(rt.count(), 0, "nothing runs while it waits");
assert_eq!(
answer_by_hand(&broker, 0, approved()),
DecisionRecord::Ask {}
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(
matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Approval { decision: 0, .. },
AuditEvent::Result { decision: 0, .. }
]
),
"{events:?}"
);
}
#[test]
fn the_expiry_is_the_grants_when_that_is_earlier() {
let rig = Rig::new("pending-grant-expiry");
let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap();
ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339()));
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#);
assert_eq!(expires, soon);
}
#[test]
fn a_denied_verdict_reaches_the_requester_and_nothing_runs() {
let rig = Rig::new("pending-refuse");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: None,
};
answer_by_hand(&broker, approval, refused);
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused
}
);
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() {
let rig = Rig::new("pending-leave");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#);
drop(stream);
// The waiting thread looks at its connection every second.
eventually("the entry is removed", || broker.table().list().is_empty());
std::thread::sleep(Duration::from_millis(200));
assert_eq!(rig.events().len(), 1, "only the decision");
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert_eq!(report.abandoned, [0]);
assert!(report.failure.is_none());
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() {
let rig = Rig::new("pending-lastlook");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
// Take the entry first, so the waiting thread cannot take it back when it sees the
// connection gone; then approve.
let entry = broker.table().take(approval).unwrap();
drop(stream);
answer_entry(&broker, entry, approved());
eventually("the call is closed", || rig.events().len() == 3);
match &rig.events()[2] {
AuditEvent::Result {
status,
decision,
sha256,
bytes,
..
} => {
assert_eq!((*status, *decision), (ResultStatus::Failed, 0));
assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap());
assert_eq!(*bytes, GONE.len() as u64);
}
other => panic!("{other:?}"),
}
assert_eq!(GONE, "the requester went away");
assert_eq!(rt.count(), 0);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.unfinished.is_empty() && report.abandoned.is_empty());
}
+238
View File
@@ -0,0 +1,238 @@
//! Properties over sequences of calls, through `broker::handle` with many threads at once:
//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the
//! record that let its call run; the runtime sees a call only after `allowed` or an approval.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use brokerd::ledger::Answer;
use client::{Serve, call};
use proto::{
AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn grants(rig: &Rig) {
let with = |class: &str, max: &str| {
grant_text("read_file", "auto", "", "")
.replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
)
.replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\""))
};
rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n"));
rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n"));
// Stops applying once a session has read a secret.
rig.grant(
"public",
&(with("public", "private") + "paths = [\"/p\"]\n"),
);
rig.grant(
"never",
&grant_text("read_file", "deny", "", "paths = [\"/d\"]"),
);
rig.grant(
"asked",
&grant_text("read_file", "ask", "", "paths = [\"/a\"]"),
);
}
/// Which call ran on what authority: (session, call) of every Decision that allowed and every
/// Approval whose re-decision let the call run, keyed by the decision's seq.
fn check_sequence(records: &[AuditRecord]) -> usize {
let mut may_run: BTreeMap<u64, (SessionId, CallId)> = BTreeMap::new();
let mut taint: BTreeMap<String, DataClass> = BTreeMap::new();
let mut results = 0;
for (i, r) in records.iter().enumerate() {
assert_eq!(r.seq, i as u64, "no seq repeats or skips");
match &r.event {
AuditEvent::Decision {
session,
call,
outcome: DecisionRecord::Allowed {},
..
} => {
may_run.insert(r.seq, (session.clone(), *call));
}
AuditEvent::Approval {
session,
call,
decision,
outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {},
..
} => {
may_run.insert(*decision, (session.clone(), *call));
}
AuditEvent::Result {
session,
call,
decision,
taint_after,
..
} => {
results += 1;
let allowed = may_run.remove(decision);
assert_eq!(
allowed,
Some((session.clone(), *call)),
"seq {}: a Result with no record letting its call run",
r.seq
);
let before = taint.insert(session.as_str().to_string(), *taint_after);
assert!(
before.is_none_or(|b| b <= *taint_after),
"seq {}: taint went down",
r.seq
);
}
_ => {}
}
}
assert!(
may_run.is_empty(),
"calls allowed but never finished: {may_run:?}"
);
results
}
#[test]
fn eight_threads_two_sessions_fifty_calls_each() {
let rig = Rig::new("sequence-many");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"];
let threads: Vec<_> = (0..8u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
let session = if t % 2 == 0 { "s-even" } else { "s-odd" };
for i in 0..50u64 {
let path = paths[((t + i) % 5) as usize];
let args = format!(r#"{{"path":"{path}"}}"#);
let req = request(session, t * 1000 + i, "read_file", &args);
let frames = call(&broker, req);
assert_eq!(frames.len(), 1);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none(), "{:?}", report.failure);
assert!(report.unfinished.is_empty());
let records = rig.records();
let decisions = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Decision { .. }))
.count();
assert_eq!(decisions, 400);
let results = check_sequence(&records);
assert_eq!(
rt.count(),
results,
"the runtime saw exactly the allowed calls"
);
assert!(results > 0 && results < 400);
// Both sessions read a secret.
for s in ["s-even", "s-odd"] {
let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
}
#[test]
fn approved_and_refused_calls_run_only_after_an_approval() {
let rig = Rig::new("sequence-ask");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
// An owner who approves even ids and refuses odd ones, as fast as they appear.
let stop = Arc::new(AtomicBool::new(false));
let owner = {
let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop));
std::thread::spawn(move || {
while !stop.load(Ordering::SeqCst) {
for item in broker.table().list() {
let Some(entry) = broker.table().take(item.approval) else {
continue;
};
let answer = if item.approval % 2 == 0 {
Answer::Approved { by: None }
} else {
Answer::Refused {
by: None,
reason: None,
}
};
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
item.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
}
std::thread::sleep(Duration::from_millis(2));
}
})
};
let threads: Vec<_> = (0..4u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
for i in 0..10u64 {
let path = if i % 2 == 0 { "/a/x" } else { "/n/x" };
let args = format!(r#"{{"path":"{path}"}}"#);
let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args));
let last = frames.last().unwrap();
assert!(
matches!(
&last.msg,
Message::ToolResponse(
ToolResponse::Result { .. } | ToolResponse::Denied { .. }
)
),
"{last:?}"
);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
stop.store(true, Ordering::SeqCst);
owner.join().unwrap();
let records = rig.records();
let approvals = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Approval { .. }))
.count();
assert_eq!(approvals, 20, "one Approval for each of the twenty asks");
let results = check_sequence(&records);
assert_eq!(rt.count(), results);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none() && report.abandoned.is_empty());
}
+118
View File
@@ -0,0 +1,118 @@
//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour.
use brokerd::config::{Approvals, Config, ConfigError, Sockets};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
/// What `home` must default to in this process. The test does not set the variable: changing the
/// environment of a running test binary would race with the other tests.
fn default_home() -> PathBuf {
std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"))
}
#[test]
fn an_empty_file_gets_every_default() {
let c = Config::load(&fixture("empty.toml")).unwrap();
assert_eq!(c.paths.home, default_home());
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
assert_eq!(c.sockets, Sockets::default());
assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 });
assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 });
assert_eq!(c, Config::parse("").unwrap());
assert_eq!(c, Config::default());
}
#[test]
fn sockets_and_directories_default_to_places_under_home() {
let c = Config::load(&fixture("home_only.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock")
);
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
// The grants are not under home: the owner writes them, brokerd only reads them.
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
}
#[test]
fn every_key_can_be_set() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants"));
assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock"));
assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock"));
assert_eq!(c.approvals.ttl_ms, 60_000);
// The two directories always follow home.
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
}
#[test]
fn one_socket_set_leaves_the_other_at_its_default() {
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap();
assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
}
#[test]
fn unknown_keys_and_tables_are_errors() {
for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] {
match Config::load(&fixture(name)) {
Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)),
other => panic!("{name}: expected a parse error, got {other:?}"),
}
}
// In every table, not only the one the fixture shows.
for text in [
"[paths]\nhome = \"/h\"\nhouse = \"/h\"\n",
"[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n",
"[approvals]\nttl_ms = 1\nttl_s = 1\n",
"top = 1\n",
"[approvals]\nttl_ms = -5\n",
] {
assert!(Config::parse(text).is_err(), "accepted: {text}");
}
}
#[test]
fn a_missing_file_is_a_read_error_that_names_the_file() {
let path = fixture("does-not-exist.toml");
match Config::load(&path) {
Err(ConfigError::Read(p, _)) => assert_eq!(p, path),
other => panic!("expected a read error, got {other:?}"),
}
let text = Config::load(&path).unwrap_err().to_string();
assert!(text.contains("does-not-exist.toml"), "{text}");
}
#[test]
fn a_parse_error_names_the_file_and_the_key() {
let text = Config::load(&fixture("unknown_key.toml"))
.unwrap_err()
.to_string();
assert!(text.contains("unknown_key.toml"), "{text}");
assert!(text.contains("ttl"), "{text}");
}
+1
View File
@@ -0,0 +1 @@
# Nothing set: every value is a default.
+11
View File
@@ -0,0 +1,11 @@
# Every key set.
[paths]
home = "/srv/boxmaker"
grants = "/srv/boxmaker-grants"
[sockets]
broker = "/run/bx/broker.sock"
admin = "/run/bx/admin.sock"
[approvals]
ttl_ms = 60000
+2
View File
@@ -0,0 +1,2 @@
[paths]
home = "/srv/boxmaker"
+3
View File
@@ -0,0 +1,3 @@
[approvals]
ttl_ms = 60000
ttl = 5
@@ -0,0 +1,2 @@
[secrets]
store = "/etc/boxmaker/secrets"
+2
View File
@@ -0,0 +1,2 @@
[approvals]
ttl_ms = "15 min"
+1
View File
@@ -0,0 +1 @@
An empty set of grants is valid: every call is denied with no_grant.
@@ -0,0 +1,8 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
secret = "api-token"
[constraints]
hosts = ["api.example.com"]
patterns = ["^GET "]
@@ -0,0 +1,7 @@
tool = "read_file"
mode = "auto"
max_taint = "private"
[constraints]
paths = ["notes", "/home/kyle/../etc", "/"]
hosts = ["example.com"]
@@ -0,0 +1,3 @@
tool = "shell"
mode = "auto"
max_taint =
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,7 @@
# The owner meant `mode`. If this file were skipped, fetch-example would allow what it forbids.
tool = "http_fetch"
mdoe = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
+1
View File
@@ -0,0 +1 @@
Grants for the tests. This file is not a grant and is ignored.
@@ -0,0 +1,7 @@
tool = "http_fetch"
mode = "auto"
max_taint = "private"
result_class = "public"
[constraints]
hosts = ["example.com", "*.example.com"]
@@ -0,0 +1,6 @@
tool = "http_fetch"
mode = "deny"
max_taint = "secret"
[constraints]
hosts = ["internal.example.com"]
@@ -0,0 +1,10 @@
# The owner's notes: only the owner writes them, so their content is trusted.
tool = "read_file"
mode = "auto"
max_taint = "secret"
result_class = "private"
untrusted = false
expires = "2027-01-01T00:00:00.000Z"
[constraints]
paths = ["/home/kyle/notes"]
@@ -0,0 +1,6 @@
tool = "write_file"
mode = "ask"
max_taint = "private"
[constraints]
paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"]
@@ -0,0 +1,4 @@
# A shell with nothing mounted.
tool = "shell"
mode = "ask"
max_taint = "secret"
+482
View File
@@ -0,0 +1,482 @@
//! Tests for loading grant files. Do not edit these or the fixtures.
//!
//! One case per loading rule in the M3a spec, each with words its problem text must contain, and
//! the rule that matters most: one invalid file makes the whole set invalid.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::grants::{GrantSet, LoadedGrant, RUNBOOK, load, render, valid_id};
use proto::{Constraints, DataClass, Grant, GrantProblem, Hash32, Mode};
use std::path::{Path, PathBuf};
use tmp::TempDir;
fn fixture(case: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/grants")
.join(case)
}
const GOOD: &str = "tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n\n\
[constraints]\npaths = [\"/home/kyle/notes\"]\n";
/// A directory holding one good grant and the given files; returns the problems of loading it.
fn problems_of(files: &[(&str, &str)]) -> Vec<GrantProblem> {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
for (name, text) in files {
dir.write(name, text);
}
load(dir.path()).expect_err("the set should be invalid")
}
/// Exactly one problem, in `file`, whose text contains every one of `words`.
fn one_problem(files: &[(&str, &str)], file: &str, words: &[&str]) -> GrantProblem {
let problems = problems_of(files);
assert_eq!(problems.len(), 1, "{problems:?}");
let p = problems.into_iter().next().unwrap();
assert_eq!(p.file, file);
for word in words {
assert!(p.problem.contains(word), "{:?} lacks {word:?}", p.problem);
}
p
}
fn body(tool: &str, mode: &str, rest: &str) -> String {
format!("tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n{rest}")
}
#[test]
fn the_valid_fixture_loads_in_id_order_with_file_hashes() {
let set = load(&fixture("valid")).unwrap();
let ids: Vec<&str> = set.grants().iter().map(|g| g.id.as_str()).collect();
assert_eq!(
ids,
[
"fetch-example",
"no-fetch-internal",
"notes-read",
"scratch-write",
"shell-bare"
]
);
let notes = &set.grants()[2];
assert_eq!(notes.grant.tool, "read_file");
assert_eq!(notes.grant.mode, Mode::Auto);
assert!(!notes.grant.untrusted);
assert_eq!(notes.grant.constraints.paths, ["/home/kyle/notes"]);
let bytes = std::fs::read(fixture("valid").join("notes-read.toml")).unwrap();
assert_eq!(notes.sha256, proto::sha256(&bytes).unwrap());
// Defaults from `proto::Grant`.
let scratch = &set.grants()[3];
assert_eq!(scratch.grant.result_class, DataClass::Private);
assert!(scratch.grant.untrusted);
assert_eq!(scratch.grant.expires, None);
}
#[test]
fn files_that_do_not_end_in_toml_are_ignored() {
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
dir.write("good.toml~", "not toml at all {{{");
dir.write("good.toml.bak", "not toml at all {{{");
dir.write("README.md", "# notes");
dir.write("toml", "x");
std::fs::create_dir(dir.path().join("archive")).unwrap();
let set = load(dir.path()).unwrap();
assert_eq!(set.grants().len(), 1);
assert_eq!(set.grants()[0].id, "good");
}
#[test]
fn an_empty_directory_is_a_valid_empty_set() {
let set = load(&fixture("empty")).unwrap();
assert!(set.grants().is_empty());
assert_eq!(set, GrantSet::default());
}
#[test]
fn a_missing_directory_is_a_problem_not_an_empty_set() {
let missing = fixture("does-not-exist");
let problems = load(&missing).unwrap_err();
assert_eq!(problems.len(), 1);
assert!(problems[0].file.contains("does-not-exist"), "{problems:?}");
assert!(problems[0].problem.contains("cannot be read"));
// A file where the directory should be is the same.
let dir = TempDir::new("grants");
let file = dir.write("grants", "x");
assert!(load(&file).is_err());
}
/// The rule the whole design leans on. `fetch-example` alone would allow a fetch that the
/// mistyped `no-fetch-internal` was written to forbid, so nothing loads at all.
#[test]
fn one_invalid_file_makes_the_whole_set_invalid() {
let problems = load(&fixture("one-bad")).unwrap_err();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].file, "no-fetch-internal.toml");
assert_eq!(problems[0].line, Some(3));
assert!(problems[0].problem.contains("mdoe"), "{problems:?}");
// The same directory without the bad file is fine.
let dir = TempDir::new("grants");
for name in ["notes-read.toml", "fetch-example.toml"] {
let text = std::fs::read_to_string(fixture("one-bad").join(name)).unwrap();
dir.write(name, &text);
}
assert_eq!(load(dir.path()).unwrap().grants().len(), 2);
}
#[test]
fn every_problem_in_every_file_is_reported() {
let problems = load(&fixture("many-bad")).unwrap_err();
let got: Vec<(&str, Option<u64>)> =
problems.iter().map(|p| (p.file.as_str(), p.line)).collect();
assert_eq!(
got,
[
("a-secret.toml", None),
("a-secret.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("b-paths.toml", None),
("c-syntax.toml", Some(3)),
],
"{problems:?}"
);
let all: String = problems
.iter()
.map(|p| format!("{}\n", p.problem))
.collect();
for words in [
"secrets are not supported until M4",
"patterns are not supported",
"read_file does not take hosts",
"\"notes\" is not a valid absolute path",
"\"/home/kyle/../etc\" is not a valid absolute path",
"a grant of the whole file system is not supported",
] {
assert!(all.contains(words), "missing {words:?} in:\n{all}");
}
}
#[test]
fn rule_1_unreadable_not_utf8_or_not_a_grant() {
one_problem(&[("bad.toml", "tool = ")], "bad.toml", &[]);
let p = one_problem(
&[("bad.toml", &body("shell", "auto", "colour = \"red\"\n"))],
"bad.toml",
&["colour"],
);
assert_eq!(p.line, Some(4));
one_problem(
&[("bad.toml", "tool = \"shell\"\nmode = \"auto\"\n")],
"bad.toml",
&["max_taint"],
);
one_problem(
&[("bad.toml", &body("shell", "sometimes", ""))],
"bad.toml",
&["sometimes"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "auto", "[constraints]\ncwd = [\"/a\"]\n"),
)],
"bad.toml",
&["cwd"],
);
// Not UTF-8.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::write(dir.path().join("latin1.toml"), b"tool = \"caf\xe9\"\n").unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "latin1.toml");
assert!(problems[0].problem.contains("UTF-8"), "{problems:?}");
// Exists but cannot be read: a directory with a grant's name.
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
std::fs::create_dir(dir.path().join("folder.toml")).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "folder.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_1_a_file_without_read_permission_is_a_problem() {
use std::os::unix::fs::PermissionsExt;
if tmp::running_as_root("rule_1_a_file_without_read_permission_is_a_problem") {
return;
}
let dir = TempDir::new("grants");
dir.write("good.toml", GOOD);
let locked = dir.write("locked.toml", GOOD);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let problems = load(dir.path()).unwrap_err();
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].file, "locked.toml");
assert!(
problems[0].problem.contains("cannot be read"),
"{problems:?}"
);
}
#[test]
fn rule_2_the_file_stem_is_the_id() {
for id in ["a", "notes-read", "0", "a-1-b", &"x".repeat(64)] {
assert!(valid_id(id), "{id:?}");
}
for id in [
"",
"Notes",
"notes_read",
"notes.read",
"notes read",
".hidden",
&"x".repeat(65),
] {
assert!(!valid_id(id), "{id:?}");
}
one_problem(
&[("Bad_Name.toml", GOOD)],
"Bad_Name.toml",
&["not a valid grant id"],
);
one_problem(&[(".toml", GOOD)], ".toml", &["not a valid grant id"]);
one_problem(&[("a.b.toml", GOOD)], "a.b.toml", &["not a valid grant id"]);
}
#[test]
fn rule_3_the_tool_is_one_of_the_four() {
for tool in ["echo", "clock", "Read_File", ""] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[
"unknown tool",
"read_file, write_file, shell and http_fetch",
],
);
}
}
#[test]
fn rules_4_and_5_secrets_and_patterns_are_not_supported() {
one_problem(
&[("bad.toml", &body("shell", "ask", "secret = \"token\"\n"))],
"bad.toml",
&["secrets are not supported until M4"],
);
one_problem(
&[(
"bad.toml",
&body("shell", "ask", "[constraints]\npatterns = [\"^ls\"]\n"),
)],
"bad.toml",
&["patterns are not supported"],
);
// An empty list is the same as no list.
let dir = TempDir::new("grants");
dir.write(
"ok.toml",
&body("shell", "ask", "[constraints]\npatterns = []\nhosts = []\n"),
);
assert!(load(dir.path()).is_ok());
}
/// The table of rule 6, cell by cell.
#[test]
fn rule_6_each_tool_takes_its_own_constraints() {
let paths = "[constraints]\npaths = [\"/a\"]\n";
let hosts = "[constraints]\nhosts = [\"example.com\"]\n";
let both = "[constraints]\npaths = [\"/a\"]\nhosts = [\"example.com\"]\n";
for tool in ["read_file", "write_file"] {
one_problem(
&[("bad.toml", &body(tool, "auto", ""))],
"bad.toml",
&[tool, "needs at least one path"],
);
one_problem(
&[("bad.toml", &body(tool, "auto", both))],
"bad.toml",
&[tool, "does not take hosts"],
);
}
one_problem(
&[("bad.toml", &body("shell", "auto", both))],
"bad.toml",
&["shell does not take hosts"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", ""))],
"bad.toml",
&["http_fetch needs at least one host"],
);
one_problem(
&[("bad.toml", &body("http_fetch", "auto", both))],
"bad.toml",
&["http_fetch does not take paths"],
);
// The allowed cells.
let dir = TempDir::new("grants");
dir.write("r.toml", &body("read_file", "auto", paths));
dir.write("w.toml", &body("write_file", "auto", paths));
dir.write("s1.toml", &body("shell", "auto", paths));
dir.write("s2.toml", &body("shell", "auto", ""));
dir.write("h.toml", &body("http_fetch", "auto", hosts));
assert_eq!(load(dir.path()).unwrap().grants().len(), 5);
// Two wrong cells in one file are two problems.
let wrong = body("http_fetch", "auto", paths);
assert_eq!(problems_of(&[("bad.toml", &wrong)]).len(), 2);
}
#[test]
fn rule_7_paths_are_valid_absolute_paths_and_never_the_root() {
for bad in ["notes", "/a/../b", "/a//b", "/a/./b", "/a/", ""] {
let text = body(
"shell",
"auto",
&format!("[constraints]\npaths = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["is not a valid absolute path"],
);
}
let root = body(
"shell",
"auto",
"[constraints]\npaths = [\"/home/kyle\", \"/\"]\n",
);
one_problem(
&[("bad.toml", &root)],
"bad.toml",
&["a grant of the whole file system is not supported"],
);
}
#[test]
fn rule_8_hosts_are_valid_host_patterns() {
for bad in [
"Example.com",
"example.com:443",
"127.0.0.1",
"localhost",
"*.com",
"https://example.com",
] {
let text = body(
"http_fetch",
"auto",
&format!("[constraints]\nhosts = [{bad:?}]\n"),
);
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&[bad, "is not a valid host pattern"],
);
}
}
#[test]
fn rule_9_a_deny_grant_applies_at_every_taint() {
for taint in ["public", "private"] {
let text = format!("tool = \"shell\"\nmode = \"deny\"\nmax_taint = \"{taint}\"\n");
one_problem(
&[("bad.toml", &text)],
"bad.toml",
&["a deny grant must apply at every taint"],
);
}
let dir = TempDir::new("grants");
dir.write("no-shell.toml", &body("shell", "deny", ""));
assert!(load(dir.path()).is_ok());
// The rule is about deny only.
dir.write(
"ask.toml",
"tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"public\"\n",
);
assert!(load(dir.path()).is_ok());
}
fn loaded(id: &str, tool: &str, mode: Mode) -> LoadedGrant {
LoadedGrant {
id: id.to_string(),
grant: Grant {
tool: tool.to_string(),
mode,
max_taint: DataClass::Secret,
result_class: DataClass::Private,
untrusted: true,
expires: None,
secret: None,
constraints: Constraints::default(),
},
sha256: Hash32::ZERO,
}
}
/// `from_grants` is how tests and the property test build a set without files. It applies the
/// same value rules, sorts by id, and refuses two grants with one id.
#[test]
fn from_grants_applies_the_value_rules() {
let set = GrantSet::from_grants(vec![
loaded("zz", "shell", Mode::Ask),
loaded("aa", "shell", Mode::Deny),
])
.unwrap();
assert_eq!(set.grants()[0].id, "aa");
assert_eq!(set.grants()[1].id, "zz");
let problems = GrantSet::from_grants(vec![
loaded("ok", "shell", Mode::Auto),
loaded("no-paths", "read_file", Mode::Auto),
loaded("Bad", "shell", Mode::Auto),
])
.unwrap_err();
let files: Vec<&str> = problems.iter().map(|p| p.file.as_str()).collect();
assert_eq!(files, ["Bad.toml", "no-paths.toml"]);
let twice = GrantSet::from_grants(vec![
loaded("same", "shell", Mode::Auto),
loaded("same", "shell", Mode::Ask),
])
.unwrap_err();
assert!(
twice[0].problem.contains("two grants have this id"),
"{twice:?}"
);
}
#[test]
fn render_prints_every_problem_and_then_the_runbook_pointer() {
let problems = [
GrantProblem {
file: "a.toml".to_string(),
line: Some(3),
problem: "unknown field `mdoe`".to_string(),
},
GrantProblem {
file: "Bad_Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
];
assert_eq!(
render(&problems),
"a.toml:3: unknown field `mdoe`\n\
Bad_Name.toml: the file name is not a valid grant id\n\
see docs/runbook.md#grants-invalid\n"
);
assert_eq!(RUNBOOK, "see docs/runbook.md#grants-invalid");
assert!(render(&problems).trim_end().ends_with(RUNBOOK));
}
+383
View File
@@ -0,0 +1,383 @@
//! The ledger's first and third steps: decide and record; raise the state and record the result.
//! And what happens after a failed append or a panic. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::panic::AssertUnwindSafe;
use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED};
use brokerd::policy::{Label, SessionState};
use build::{grant, now, read, set};
use proto::{
AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus,
SessionId, ToolResponse,
};
use rig::Rig;
fn one(grants: Vec<build::Build>) -> Grants {
Ok(set(grants))
}
fn notes(mode: Mode) -> Grants {
one(vec![grant("n", "read_file", mode).paths(&["/n"])])
}
fn call(label: Label) -> Call {
Call {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
label,
}
}
fn secret() -> Label {
Label {
class: DataClass::Secret,
untrusted: true,
}
}
fn result(content: &str, label: Label) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: label.class,
untrusted: label.untrusted,
truncated: false,
}
}
#[test]
fn an_allowed_call_is_recorded_in_full() {
let rig = Rig::new("ledger-allowed");
let ledger = rig.ledger();
let request = read("/n/a");
let arguments = request.arguments.clone();
match ledger.decide(request, &notes(Mode::Auto), now()) {
Decided::Allowed { decision, seq } => {
assert_eq!(seq, 0);
assert_eq!(decision.grant(), "n");
}
other => panic!("{other:?}"),
}
let records = rig.records();
assert_eq!(records.len(), 1);
assert_eq!(records[0].time, now());
assert_eq!(
records[0].event,
AuditEvent::Decision {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments,
outcome: DecisionRecord::Allowed {},
grant: Some("n".to_string()),
grant_sha256: Some(proto::sha256(b"n").unwrap()),
taint: DataClass::Private,
untrusted: false,
}
);
}
#[test]
fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() {
let rig = Rig::new("ledger-ask");
let ledger = rig.ledger();
match ledger.decide(read("/n/a"), &notes(Mode::Ask), now()) {
Decided::Ask { ask, seq, state } => {
assert_eq!((seq, ask.grant()), (0, "n"));
assert_eq!(state, SessionState::default());
}
other => panic!("{other:?}"),
}
match &rig.events()[0] {
AuditEvent::Decision { outcome, grant, .. } => {
assert_eq!(*outcome, DecisionRecord::Ask {});
assert_eq!(grant.as_deref(), Some("n"));
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_denial_names_a_grant_only_when_a_grant_denied() {
let rig = Rig::new("ledger-denied");
let ledger = rig.ledger();
let none = ledger.decide(read("/n/a"), &one(Vec::new()), now());
assert!(
matches!(none, Decided::Denied(DenyReason::NoGrant)),
"{none:?}"
);
let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]);
let by = ledger.decide(read("/n/a"), &deny, now());
assert!(
matches!(by, Decided::Denied(DenyReason::DeniedByGrant)),
"{by:?}"
);
let events = rig.events();
let named: Vec<(DecisionRecord, Option<String>)> = events
.iter()
.map(|e| match e {
AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
named,
[
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant
},
None
),
(
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant
},
Some("d".to_string())
),
]
);
}
#[test]
fn invalid_grants_come_first_then_an_unreadable_state() {
let rig = Rig::new("ledger-order");
let ledger = rig.ledger();
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "not json").unwrap();
let invalid: Grants = Err(vec![GrantProblem {
file: "x.toml".to_string(),
line: None,
problem: "bad".to_string(),
}]);
let first = ledger.decide(read("/n/a"), &invalid, now());
assert!(
matches!(first, Decided::Denied(DenyReason::GrantsInvalid)),
"{first:?}"
);
let second = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(second, Decided::Denied(DenyReason::StateUnreadable)),
"{second:?}"
);
// A state brokerd cannot read is recorded as the most sensitive one.
for event in rig.events() {
match event {
AuditEvent::Decision {
taint, untrusted, ..
} => assert_eq!((taint, untrusted), (DataClass::Secret, true)),
other => panic!("{other:?}"),
}
}
let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged");
assert!(!damaged.is_empty(), "{:?}", rig.lines.all());
}
#[test]
fn a_failed_append_denies_this_call_and_every_later_one() {
let rig = Rig::new("ledger-stop");
let ledger = rig.ledger();
rig.switch.fail(true);
let first = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(first, Decided::Denied(DenyReason::AuditUnavailable)),
"{first:?}"
);
assert_eq!(rig.switch.attempts(), 1);
// The sink would work again; the ledger does not try it.
rig.switch.fail(false);
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
let finished = ledger.finish(&call(secret()), result("x", secret()), now());
assert_eq!(
finished,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rig.switch.attempts(), 1, "nothing more was written");
assert!(rig.records().is_empty());
assert!(!rig.state_file("s1").exists(), "the state was not raised");
assert!(!rig.lines.with(STOPPED).is_empty());
assert!(
rig.lines
.all()
.iter()
.all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")),
"{:?}",
rig.lines.all()
);
}
#[test]
fn a_panic_while_holding_the_ledger_denies_every_later_call() {
let rig = Rig::new("ledger-poison");
let ledger = rig.ledger();
rig.switch.panic_next();
let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| {
ledger.decide(read("/n/a"), &notes(Mode::Auto), now())
}));
assert!(panicked.is_err());
let later = ledger.decide(read("/n/a"), &notes(Mode::Auto), now());
assert!(
matches!(later, Decided::Denied(DenyReason::AuditUnavailable)),
"{later:?}"
);
assert_eq!(rig.switch.attempts(), 1);
assert!(
!rig.lines.with(POISONED).is_empty(),
"{:?}",
rig.lines.all()
);
assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable"));
}
#[test]
fn a_result_raises_the_state_then_is_recorded() {
let rig = Rig::new("ledger-result");
let ledger = rig.ledger();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(answer, result("key", secret()), "passed on unchanged");
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(
state,
SessionState {
taint: DataClass::Secret,
untrusted: true
}
);
assert_eq!(
rig.events(),
[AuditEvent::Result {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
decision: 0,
status: ResultStatus::Result,
class: DataClass::Secret,
untrusted: true,
truncated: false,
bytes: 3,
sha256: proto::sha256(b"key").unwrap(),
taint_after: DataClass::Secret,
}]
);
}
#[test]
fn taint_never_goes_down() {
let rig = Rig::new("ledger-down");
let ledger = rig.ledger();
let public = Label {
class: DataClass::Public,
untrusted: false,
};
ledger.finish(&call(secret()), result("a", secret()), now());
ledger.finish(&call(public), result("b", public), now());
let after: Vec<DataClass> = rig
.events()
.iter()
.map(|e| match e {
AuditEvent::Result { taint_after, .. } => *taint_after,
other => panic!("{other:?}"),
})
.collect();
assert_eq!(after, [DataClass::Secret, DataClass::Secret]);
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true));
}
#[test]
fn a_failure_changes_no_state_and_is_recorded_by_its_message() {
let rig = Rig::new("ledger-failed");
let ledger = rig.ledger();
let failed = ToolResponse::Failed {
message: "the tool timed out".to_string(),
};
assert_eq!(
ledger.finish(&call(secret()), failed.clone(), now()),
failed
);
assert!(!rig.state_file("s1").exists());
match &rig.events()[0] {
AuditEvent::Result {
status,
class,
bytes,
sha256,
taint_after,
..
} => {
assert_eq!(*status, ResultStatus::Failed);
assert_eq!(
*class,
DataClass::Secret,
"the label the result would have had"
);
assert_eq!(*bytes, 18);
assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap());
assert_eq!(*taint_after, DataClass::Private, "the taint before");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("ledger-ro");
let ledger = rig.ledger();
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
rig.records().is_empty(),
"no Result for a state not on disk"
);
assert!(
!rig.lines
.with("see docs/runbook.md#broker-state-damaged")
.is_empty()
);
}
#[test]
fn a_result_record_that_cannot_be_written_withholds_the_content() {
let rig = Rig::new("ledger-norecord");
let ledger = rig.ledger();
rig.switch.fail(true);
let answer = ledger.finish(&call(secret()), result("key", secret()), now());
assert_eq!(
answer,
ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
+262
View File
@@ -0,0 +1,262 @@
//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry
//! are recorded as denials. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::approvals::Verdict;
use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger};
use brokerd::policy::{Ask, Label};
use build::{grant, now, read, set};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId,
};
use rig::Rig;
fn asking() -> Grants {
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
}
fn pending(ledger: &Ledger) -> Ask {
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
Decided::Ask { ask, seq: 0, .. } => ask,
other => panic!("{other:?}"),
}
}
fn bxctl() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
}
}
fn denied(reason: DenyReason) -> DecisionRecord {
DecisionRecord::Denied { reason }
}
fn verdict_reason(answered: &Answered) -> Option<DenyReason> {
match &answered.verdict {
Verdict::Run(_) => None,
Verdict::Denied(reason) => Some(*reason),
}
}
/// The one `Approval` record, as (answer, by, reason, outcome, grant).
type Row = (
ApprovalAnswer,
Option<String>,
Option<String>,
DecisionRecord,
Option<String>,
);
fn approval(rig: &Rig) -> Row {
let events = rig.events();
assert_eq!(events.len(), 2, "a decision and one approval: {events:?}");
match &events[1] {
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome,
grant,
..
} => {
assert_eq!(session, &SessionId::new("s1").unwrap());
assert_eq!((*call, *decision, post), (CallId(1), 0, &None));
(
*answer,
by.clone(),
reason.clone(),
outcome.clone(),
grant.clone(),
)
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_that_still_asks_lets_the_call_run() {
let rig = Rig::new("answer-ask");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "n"),
other => panic!("{other:?}"),
}
assert_eq!(answered.outcome, DecisionRecord::Ask {});
let row = approval(&rig);
assert_eq!(
row,
(
ApprovalAnswer::Approved,
Some("bxctl".to_string()),
None,
DecisionRecord::Ask {},
Some("n".to_string())
)
);
}
#[test]
fn an_approval_under_a_grant_that_is_now_auto_records_allowed() {
let rig = Rig::new("answer-auto");
let ledger = rig.ledger();
let ask = pending(&ledger);
let now_auto = Ok(set(vec![
grant("n", "read_file", Mode::Auto).paths(&["/n"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now());
assert!(matches!(answered.verdict, Verdict::Run(_)));
assert_eq!(answered.outcome, DecisionRecord::Allowed {});
}
#[test]
fn the_approval_names_the_grant_matched_now() {
let rig = Rig::new("answer-grant");
let ledger = rig.ledger();
let ask = pending(&ledger);
let wider = Ok(set(vec![
grant("n", "read_file", Mode::Ask).paths(&["/n"]),
grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &wider, now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"),
other => panic!("{other:?}"),
}
assert_eq!(approval(&rig).4.as_deref(), Some("z-deep"));
}
#[test]
fn an_approval_after_the_grant_is_gone_is_denied() {
let rig = Rig::new("answer-gone");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant));
assert_eq!(answered.outcome, denied(DenyReason::NoGrant));
let row = approval(&rig);
assert_eq!(
(row.0, row.3, row.4),
(ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None)
);
}
#[test]
fn an_approval_after_the_taint_rose_is_denied() {
let rig = Rig::new("answer-taint");
let ledger = rig.ledger();
let low = || {
Ok(set(vec![
grant("n", "read_file", Mode::Ask)
.paths(&["/n"])
.max_taint(DataClass::Private),
]))
};
let ask = match ledger.decide(read("/n/a"), &low(), now()) {
Decided::Ask { ask, .. } => ask,
other => panic!("{other:?}"),
};
// Another call of the session read a secret while this one waited.
let s1 = SessionId::new("s1").unwrap();
let label = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), label)
.unwrap();
let answered = ledger.answer(ask, 0, bxctl(), &low(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh));
match &rig.events()[1] {
AuditEvent::Approval { taint, outcome, .. } => {
assert_eq!(*taint, DataClass::Secret, "the state at the re-decision");
assert_eq!(*outcome, denied(DenyReason::TaintTooHigh));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_with_invalid_grants_is_denied() {
let rig = Rig::new("answer-invalid");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid));
}
#[test]
fn a_refusal_is_recorded_with_the_owners_reason() {
let rig = Rig::new("answer-refuse");
let ledger = rig.ledger();
let ask = pending(&ledger);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
reason: Some("not now".to_string()),
};
let answered = ledger.answer(ask, 0, refused, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Refused,
Some("bxctl".to_string()),
Some("not now".to_string()),
denied(DenyReason::ApprovalRefused),
None
)
);
}
#[test]
fn an_expiry_is_recorded_by_nobody() {
let rig = Rig::new("answer-expire");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Expired,
None,
None,
denied(DenyReason::ApprovalExpired),
None
)
);
}
#[test]
fn an_approval_that_cannot_be_recorded_does_not_run() {
let rig = Rig::new("answer-norecord");
let ledger = rig.ledger();
let ask = pending(&ledger);
rig.switch.fail(true);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
assert_eq!(
verdict_reason(&answered),
Some(DenyReason::AuditUnavailable)
);
assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable));
assert_eq!(rig.events().len(), 1, "only the decision");
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
+213
View File
@@ -0,0 +1,213 @@
//! Table tests for `policy::decide`: which arguments each tool's grants cover. Do not edit.
//! `policy_matching.rs` covers how a winner, a label and a reason are picked, `policy_redecide.rs`
//! covers approvals, and `policy_property.rs` checks all of it against an oracle.
#[path = "support/build.rs"]
mod build;
use brokerd::policy::decide;
use build::{allowed, fetch, grant, now, private, read, reason, request, set, shell, write};
use proto::{DenyReason, Mode};
#[test]
fn no_grants_means_no_grant() {
let none = set(vec![]);
for req in [
read("/etc/hosts"),
write("/tmp/x"),
shell(None),
fetch("https://example.com/"),
] {
assert_eq!(
reason(decide(req, &none, private(), now())),
DenyReason::NoGrant
);
}
}
#[test]
fn a_tool_that_is_not_one_of_the_four_is_no_grant_and_its_arguments_are_not_parsed() {
let grants = set(vec![grant("s", "shell", Mode::Auto)]);
for tool in ["echo", "clock", "call_tool", "", "Shell"] {
for arguments in ["{}", "not json", r#"{"command":"ls"}"#] {
let outcome = decide(request(tool, arguments), &grants, private(), now());
assert_eq!(
reason(outcome),
DenyReason::NoGrant,
"{tool:?} {arguments:?}"
);
}
}
}
/// Invalid arguments are refused before matching, so the answer is the same with a grant that
/// would cover them, with a `deny` grant, and with no grant at all.
#[test]
fn invalid_arguments_are_refused_before_matching() {
let covering = set(vec![
grant("r", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
grant("no", "read_file", Mode::Deny).paths(&["/home/kyle"]),
]);
let none = set(vec![]);
for grants in [&covering, &none] {
for req in [
// The rows of the "Paths" table that are about form.
read("/home/kyle/notes/../.ssh/id"),
read("notes/a.md"),
read("/home/kyle//notes/./a.md"),
request("read_file", "{}"),
request(
"read_file",
r#"{"path":"/home/kyle/notes/a.md","mode":"r"}"#,
),
request("read_file", "not json"),
write("/home/kyle/notes/"),
shell(Some("relative")),
fetch("http://example.com/"),
fetch("https://127.0.0.1/"),
fetch("https://user@example.com/"),
] {
let text = req.arguments.clone();
assert_eq!(
reason(decide(req, grants, private(), now())),
DenyReason::InvalidArguments,
"{text}"
);
}
}
}
/// The "Paths" table, the rows about containment.
#[test]
fn read_file_is_covered_inside_a_granted_path() {
let grants = set(vec![
grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
]);
for path in [
"/home/kyle/notes/a.md",
"/home/kyle/notes",
"/home/kyle/notes/x/y/z",
] {
let d = allowed(decide(read(path), &grants, private(), now()));
assert_eq!(d.grant(), "notes");
assert_eq!(d.matched_path(), Some("/home/kyle/notes"));
}
for path in [
"/home/kyle/notes2/a.md",
"/home/kyle",
"/",
"/etc/passwd",
"/home/kyle/note",
] {
assert_eq!(
reason(decide(read(path), &grants, private(), now())),
DenyReason::NoGrant,
"{path}"
);
}
// A grant is for one tool.
let outcome = decide(write("/home/kyle/notes/a.md"), &grants, private(), now());
assert_eq!(reason(outcome), DenyReason::NoGrant);
}
#[test]
fn write_file_is_covered_inside_a_granted_path_but_not_at_the_path_itself() {
let grants = set(vec![
grant("scratch", "write_file", Mode::Auto)
.paths(&["/home/kyle/scratch", "/home/kyle/scratch/out"]),
]);
let d = allowed(decide(
write("/home/kyle/scratch/a.txt"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
// The longest path that holds the argument is the matched one.
let d = allowed(decide(
write("/home/kyle/scratch/out/b.txt"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch/out"));
// A granted path itself cannot be written, but it can lie inside another granted path.
let d = allowed(decide(
write("/home/kyle/scratch/out"),
&grants,
private(),
now(),
));
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
let outcome = decide(write("/home/kyle/scratch"), &grants, private(), now());
assert_eq!(reason(outcome), DenyReason::NoGrant);
}
#[test]
fn shell_is_covered_by_no_paths_and_no_cwd_or_by_a_cwd_inside_a_path() {
let bare = set(vec![grant("bare", "shell", Mode::Auto)]);
let d = allowed(decide(shell(None), &bare, private(), now()));
assert_eq!((d.matched_path(), d.paths().len()), (None, 0));
assert_eq!(
reason(decide(shell(Some("/home/kyle")), &bare, private(), now())),
DenyReason::NoGrant
);
let scoped = set(vec![
grant("scoped", "shell", Mode::Auto).paths(&["/home/kyle/a", "/srv/b"]),
]);
let d = allowed(decide(shell(Some("/srv/b/sub")), &scoped, private(), now()));
assert_eq!(d.matched_path(), Some("/srv/b"));
// The runner mounts every path of the grant, so the decision carries them all.
assert_eq!(d.paths(), ["/home/kyle/a", "/srv/b"]);
assert_eq!(
reason(decide(shell(None), &scoped, private(), now())),
DenyReason::NoGrant
);
assert_eq!(
reason(decide(shell(Some("/srv")), &scoped, private(), now())),
DenyReason::NoGrant
);
}
/// The "Hosts" table, row by row.
#[test]
fn http_fetch_is_covered_when_the_host_matches() {
let exact = set(vec![
grant("exact", "http_fetch", Mode::Auto).hosts(&["example.com"]),
]);
let wild = set(vec![
grant("wild", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
]);
let d = allowed(decide(
fetch("https://example.com/a?b=c"),
&exact,
private(),
now(),
));
assert_eq!(d.hosts(), ["example.com"]);
assert_eq!(d.matched_path(), None);
assert_eq!(
reason(decide(
fetch("https://www.example.com/"),
&exact,
private(),
now()
)),
DenyReason::NoGrant
);
for url in ["https://www.example.com/", "https://a.b.example.com:443/x"] {
allowed(decide(fetch(url), &wild, private(), now()));
}
for url in [
"https://example.com/",
"https://badexample.com/",
"https://example.com.evil.org/",
] {
assert_eq!(
reason(decide(fetch(url), &wild, private(), now())),
DenyReason::NoGrant,
"{url}"
);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Table tests for `policy::decide`: among the grants that cover a call, which one wins, what
//! the result is labelled, and which reason is given when none is left. Do not edit.
#[path = "support/build.rs"]
mod build;
use brokerd::policy::{Label, decide};
use build::{
allowed, asked, denied, fetch, grant, now, private, read, reason, request, secret, set, shell,
};
use proto::{DataClass, DenyReason, Mode};
#[test]
fn the_most_restrictive_mode_wins_among_three_matching_grants() {
let paths = &["/home/kyle/notes"];
let auto = || grant("b-auto", "read_file", Mode::Auto).paths(paths);
let ask = || grant("c-ask", "read_file", Mode::Ask).paths(paths);
let deny = || grant("a-deny", "read_file", Mode::Deny).paths(paths);
let req = || read("/home/kyle/notes/a.md");
let denial = denied(decide(
req(),
&set(vec![auto(), ask(), deny()]),
private(),
now(),
));
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("a-deny"));
assert_eq!(denial.grant_sha256, Some(deny().done().sha256));
let ask_wins = asked(decide(req(), &set(vec![auto(), ask()]), private(), now()));
assert_eq!(ask_wins.grant(), "c-ask");
assert_eq!(
allowed(decide(req(), &set(vec![auto()]), private(), now())).grant(),
"b-auto"
);
// Deny beats a longer path and a lower id: the mode comes first.
let narrow_auto = grant("a-auto", "read_file", Mode::Auto).paths(&["/home/kyle/notes/deep"]);
let wide_deny = grant("z-deny", "read_file", Mode::Deny).paths(&["/home"]);
let outcome = decide(
read("/home/kyle/notes/deep/x"),
&set(vec![narrow_auto, wide_deny]),
private(),
now(),
);
assert_eq!(denied(outcome).grant.as_deref(), Some("z-deny"));
}
#[test]
fn within_a_mode_the_longest_matched_path_wins_and_then_the_lowest_id() {
let grants = set(vec![
grant("a-wide", "read_file", Mode::Auto).paths(&["/home/kyle"]),
grant("z-narrow", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
]);
let d = allowed(decide(
read("/home/kyle/notes/a.md"),
&grants,
private(),
now(),
));
assert_eq!(
(d.grant(), d.matched_path()),
("z-narrow", Some("/home/kyle/notes"))
);
let d = allowed(decide(read("/home/kyle/other"), &grants, private(), now()));
assert_eq!(d.grant(), "a-wide");
// Equal paths: the lowest id in byte order, whatever order the grants were given in.
let tie = set(vec![
grant("g-10", "read_file", Mode::Auto).paths(&["/srv"]),
grant("g-2", "read_file", Mode::Auto).paths(&["/srv"]),
grant("g-1z", "read_file", Mode::Auto).paths(&["/srv"]),
]);
assert_eq!(
allowed(decide(read("/srv/x"), &tie, private(), now())).grant(),
"g-10"
);
// Grants with no matched path all tie, so the id decides.
let hosts = set(vec![
grant("m", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
grant("b", "http_fetch", Mode::Auto).hosts(&["www.example.com"]),
]);
let d = allowed(decide(
fetch("https://www.example.com/"),
&hosts,
private(),
now(),
));
assert_eq!(d.grant(), "b");
}
/// The example in the spec. Whichever id sorts first, the read is labelled `secret`, and the
/// narrower grant is the one recorded and mounted.
#[test]
fn the_label_is_combined_over_every_matching_grant() {
for (home, keys) in [("a-home", "b-keys"), ("z-home", "b-keys")] {
let grants = set(vec![
grant(home, "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.trusted(),
grant(keys, "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
]);
let d = allowed(decide(
read("/home/kyle/keys/id"),
&grants,
private(),
now(),
));
assert_eq!(d.grant(), keys);
assert_eq!(
d.label(),
Label {
class: DataClass::Secret,
untrusted: false
}
);
// Outside `keys` only the wide grant matches, so only its label counts.
let d = allowed(decide(read("/home/kyle/todo"), &grants, private(), now()));
assert_eq!(
d.label(),
Label {
class: DataClass::Private,
untrusted: false
}
);
}
// The winner says trusted and public; another matching grant says otherwise, and it counts.
let grants = set(vec![
grant("narrow", "read_file", Mode::Auto)
.paths(&["/srv/pub/docs"])
.class(DataClass::Public)
.trusted(),
grant("wide", "read_file", Mode::Auto)
.paths(&["/srv/pub"])
.class(DataClass::Private),
]);
let d = allowed(decide(read("/srv/pub/docs/x"), &grants, private(), now()));
assert_eq!(d.grant(), "narrow");
assert_eq!(
d.label(),
Label {
class: DataClass::Private,
untrusted: true
}
);
// An `ask` winner carries the combined label too.
let grants = set(vec![
grant("asks", "read_file", Mode::Ask)
.paths(&["/srv"])
.class(DataClass::Public)
.trusted(),
grant("labels", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Secret),
]);
let ask = asked(decide(read("/srv/x"), &grants, private(), now()));
assert_eq!(
ask.label(),
Label {
class: DataClass::Secret,
untrusted: true
}
);
}
#[test]
fn a_grant_ruled_out_by_taint_or_expiry_adds_nothing_to_the_label() {
let grants = set(vec![
grant("live", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Public)
.trusted(),
grant("old", "read_file", Mode::Auto)
.paths(&["/srv"])
.class(DataClass::Secret)
.expires("2026-01-01T00:00:00.000Z"),
]);
let d = allowed(decide(read("/srv/x"), &grants, private(), now()));
assert_eq!(
d.label(),
Label {
class: DataClass::Public,
untrusted: false
}
);
}
#[test]
fn a_grant_expires_exactly_at_its_time() {
let at = |when: &str| set(vec![grant("g", "shell", Mode::Auto).expires(when)]);
let d = allowed(decide(
shell(None),
&at("2026-09-18T12:00:00.001Z"),
private(),
now(),
));
assert_eq!(d.expires(), Some(build::ts("2026-09-18T12:00:00.001Z")));
for when in [
"2026-09-18T12:00:00.000Z",
"2026-09-18T11:59:59.999Z",
"2020-01-01T00:00:00.000Z",
] {
assert_eq!(
reason(decide(shell(None), &at(when), private(), now())),
DenyReason::GrantExpired,
"{when}"
);
}
let never = set(vec![grant("g", "shell", Mode::Auto)]);
assert_eq!(
allowed(decide(shell(None), &never, private(), now())).expires(),
None
);
}
#[test]
fn a_grant_applies_up_to_its_max_taint() {
let grants = set(vec![
grant("g", "shell", Mode::Auto).max_taint(DataClass::Private),
]);
allowed(decide(shell(None), &grants, private(), now()));
allowed(decide(
shell(None),
&grants,
build::at(DataClass::Public),
now(),
));
assert_eq!(
reason(decide(shell(None), &grants, secret(), now())),
DenyReason::TaintTooHigh
);
// The untrusted flag is not an input to matching.
let mut state = private();
state.untrusted = true;
allowed(decide(shell(None), &grants, state, now()));
}
#[test]
fn the_reason_when_nothing_is_left() {
let expired = || grant("e", "shell", Mode::Auto).expires("2026-01-01T00:00:00.000Z");
let tainted = || grant("t", "shell", Mode::Auto).max_taint(DataClass::Private);
let both = || {
grant("b", "shell", Mode::Auto)
.expires("2026-01-01T00:00:00.000Z")
.max_taint(DataClass::Private)
};
let cases = [
// One candidate expired and another too tainted: expiry is reported first.
(set(vec![expired(), tainted()]), DenyReason::GrantExpired),
(set(vec![tainted(), both()]), DenyReason::TaintTooHigh),
// Ruled out by both is ruled out "only" by neither.
(set(vec![both()]), DenyReason::NoGrant),
(set(vec![expired()]), DenyReason::GrantExpired),
];
for (grants, want) in cases {
assert_eq!(reason(decide(shell(None), &grants, secret(), now())), want);
}
// "Only by expiry" means it would have matched: an expired grant for other arguments, or
// for another tool, is no reason to say `grant_expired`.
let elsewhere = set(vec![
grant("p", "read_file", Mode::Auto)
.paths(&["/srv"])
.expires("2026-01-01T00:00:00.000Z"),
grant("w", "write_file", Mode::Auto)
.paths(&["/home"])
.expires("2026-01-01T00:00:00.000Z"),
]);
assert_eq!(
reason(decide(read("/home/kyle/x"), &elsewhere, private(), now())),
DenyReason::NoGrant
);
}
#[test]
fn a_deny_grant_denies_at_every_taint_until_it_expires() {
let grants = |deny_expires: Option<&str>| {
let deny = grant("no-internal", "http_fetch", Mode::Deny).hosts(&["internal.example.com"]);
let deny = match deny_expires {
Some(when) => deny.expires(when),
None => deny,
};
set(vec![
grant("any", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
deny,
])
};
let url = "https://internal.example.com/";
for state in [build::at(DataClass::Public), private(), secret()] {
let denial = denied(decide(fetch(url), &grants(None), state, now()));
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("no-internal"));
}
allowed(decide(
fetch("https://www.example.com/"),
&grants(None),
secret(),
now(),
));
// An expired deny no longer denies: `expires` on a deny grant means "forbid this until then".
let lapsed = grants(Some("2026-09-18T12:00:00.000Z"));
assert_eq!(
allowed(decide(fetch(url), &lapsed, private(), now())).grant(),
"any"
);
}
/// Documented, not liked: an `ask` grant with a lower `max_taint` than an `auto` grant over the
/// same arguments drops out when taint rises, and the call then runs without asking.
#[test]
fn an_ask_grant_with_a_lower_max_taint_stops_asking_when_taint_rises() {
let grants = set(vec![
grant("asks", "shell", Mode::Ask).max_taint(DataClass::Private),
grant("runs", "shell", Mode::Auto),
]);
assert_eq!(
asked(decide(shell(None), &grants, private(), now())).grant(),
"asks"
);
assert_eq!(
allowed(decide(shell(None), &grants, secret(), now())).grant(),
"runs"
);
}
#[test]
fn a_decision_and_an_ask_carry_what_the_broker_and_the_runner_need() {
let grants = set(vec![
grant("asks", "write_file", Mode::Ask)
.paths(&["/home/kyle/scratch"])
.expires("2027-01-01T00:00:00.000Z")
.class(DataClass::Public),
]);
let req = request(
"write_file",
r#"{ "content": "hello", "path": "/home/kyle/scratch/a.txt" }"#,
);
let ask = asked(decide(req.clone(), &grants, private(), now()));
assert_eq!(ask.request(), &req);
assert_eq!(
ask.args().canonical_json(),
r#"{"path":"/home/kyle/scratch/a.txt","content":"hello"}"#
);
assert_eq!(ask.grant(), "asks");
assert_eq!(ask.grant_sha256(), proto::sha256(b"asks").unwrap());
assert_eq!(ask.matched_path(), Some("/home/kyle/scratch"));
assert_eq!(ask.paths(), ["/home/kyle/scratch"]);
assert!(ask.hosts().is_empty());
assert_eq!(ask.expires(), Some(build::ts("2027-01-01T00:00:00.000Z")));
assert_eq!(
ask.label(),
Label {
class: DataClass::Public,
untrusted: true
}
);
}
+234
View File
@@ -0,0 +1,234 @@
//! Property test for `policy`: random grant sets, states and requests, each decided twice, once
//! by `policy::decide` and once by an oracle. Every case must agree. Do not edit.
//!
//! The generator and the oracle are in `support/oracle.rs`. If this test fails, the oracle is
//! the specification and `policy` is wrong.
//!
//! The generator is a seeded xorshift, so a failure can be replayed: the message names the seed
//! and the case. `BOXMAKER_POLICY_SEED=<n>` runs one more seed, and `BOXMAKER_POLICY_CASES=<n>`
//! changes how many cases each seed runs (default 3000).
#[path = "support/oracle.rs"]
mod oracle;
use brokerd::grants::{GrantSet, LoadedGrant};
use brokerd::policy::{Outcome, SessionState, decide, redecide};
use oracle::{CLASSES, Expected, NOW_MS, Rng, oracle, some_grant, some_grants, some_request};
use proto::{DataClass, DenyReason, Mode, Timestamp};
const SEEDS: [u64; 5] = [1, 2, 3, 0xB0C5, 20_260_918];
// ---------------------------------------------------------------------------------------------
// The comparison.
fn observed(outcome: &Outcome) -> Expected {
match outcome {
Outcome::Allowed(d) => Expected::Allowed {
grant: d.grant().to_string(),
path: d.matched_path().map(str::to_string),
class: d.label().class,
untrusted: d.label().untrusted,
},
Outcome::Ask(a) => Expected::Ask {
grant: a.grant().to_string(),
path: a.matched_path().map(str::to_string),
class: a.label().class,
untrusted: a.label().untrusted,
},
Outcome::Denied(denial) => Expected::Denied {
reason: denial.reason,
grant: denial.grant.clone(),
},
}
}
/// Allowed is 0, ask is 1, denied is 2.
fn restrictiveness(expected: &Expected) -> u8 {
match expected {
Expected::Allowed { .. } => 0,
Expected::Ask { .. } => 1,
Expected::Denied { .. } => 2,
}
}
fn cases() -> usize {
match std::env::var("BOXMAKER_POLICY_CASES") {
Ok(text) => text
.parse()
.expect("BOXMAKER_POLICY_CASES must be a number"),
Err(_) => 3000,
}
}
fn seeds() -> Vec<u64> {
let mut seeds = SEEDS.to_vec();
if let Ok(text) = std::env::var("BOXMAKER_POLICY_SEED") {
seeds.push(text.parse().expect("BOXMAKER_POLICY_SEED must be a number"));
}
seeds
}
fn now() -> Timestamp {
Timestamp::from_unix_millis(NOW_MS).unwrap()
}
fn state(rng: &mut Rng) -> SessionState {
SessionState {
taint: rng.pick(&CLASSES),
untrusted: rng.chance(50),
}
}
fn valid(grants: &[LoadedGrant]) -> GrantSet {
GrantSet::from_grants(grants.to_vec()).expect("the generator only makes valid grants")
}
#[test]
fn decide_agrees_with_the_oracle() {
let mut kinds = [0usize; 3];
for seed in seeds() {
let mut rng = Rng::new(seed);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let state = state(&mut rng);
let want = oracle(&request, &grants, state);
let got = observed(&decide(request.clone(), &valid(&grants), state, now()));
assert_eq!(
got, want,
"seed {seed} case {case}\nrequest: {request:?}\nstate: {state:?}\ngrants: {grants:#?}"
);
kinds[restrictiveness(&want) as usize] += 1;
}
}
// The generator must reach every kind of outcome, or the test proves little.
for (kind, count) in ["allowed", "ask", "denied"].iter().zip(kinds) {
assert!(count > 200, "only {count} cases were {kind}");
}
}
#[test]
fn redecide_agrees_with_the_oracle_under_new_grants_and_a_new_state() {
let mut approvals = 0;
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0xA5A5);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let first = state(&mut rng);
let Outcome::Ask(ask) = decide(request.clone(), &valid(&grants), first, now()) else {
continue;
};
approvals += 1;
// Half the time nothing has changed; otherwise the grants or the state have.
let (later_grants, later) = if rng.chance(50) {
(grants.clone(), first)
} else {
(some_grants(&mut rng), state(&mut rng))
};
let want = oracle(&request, &later_grants, later);
let got = redecide(ask, &valid(&later_grants), later, now());
let context = format!(
"seed {seed} case {case}\nrequest: {request:?}\nlater: {later:?}\ngrants: {later_grants:#?}"
);
match (want, got) {
(
Expected::Allowed {
grant,
path,
class,
untrusted,
},
Ok(d),
)
| (
Expected::Ask {
grant,
path,
class,
untrusted,
},
Ok(d),
) => {
assert_eq!(d.grant(), grant, "{context}");
assert_eq!(d.matched_path().map(str::to_string), path, "{context}");
assert_eq!(
(d.label().class, d.label().untrusted),
(class, untrusted),
"{context}"
);
assert_eq!(d.request(), &request, "{context}");
}
(Expected::Denied { reason, grant }, Err(denial)) => {
assert_eq!((denial.reason, denial.grant), (reason, grant), "{context}");
}
(want, got) => panic!("wanted {want:?}, got {got:?}\n{context}"),
}
}
}
assert!(approvals > 200, "only {approvals} cases asked");
}
/// Adding a `deny` grant to a set never makes any outcome less restrictive.
#[test]
fn adding_a_deny_grant_never_loosens_an_outcome() {
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0x5A5A);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let state = state(&mut rng);
let mut extra = some_grant(&mut rng, 90);
extra.grant.mode = Mode::Deny;
extra.grant.max_taint = DataClass::Secret;
let mut with_deny = grants.clone();
with_deny.push(extra);
let before = observed(&decide(request.clone(), &valid(&grants), state, now()));
let after = observed(&decide(request.clone(), &valid(&with_deny), state, now()));
assert!(
restrictiveness(&after) >= restrictiveness(&before),
"seed {seed} case {case}: {before:?} became {after:?}\nrequest: {request:?}\ngrants: {with_deny:#?}"
);
}
}
}
/// A call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint:
/// reading a secret can never switch off a prohibition.
#[test]
fn a_prohibition_holds_at_every_higher_taint() {
let mut prohibitions = 0;
for seed in seeds() {
let mut rng = Rng::new(seed ^ 0x0F0F);
for case in 0..cases() {
let grants = some_grants(&mut rng);
let request = some_request(&mut rng);
let mut denied_below = false;
for taint in CLASSES {
let state = SessionState {
taint,
untrusted: false,
};
let outcome = observed(&decide(request.clone(), &valid(&grants), state, now()));
let by_grant = matches!(
outcome,
Expected::Denied {
reason: DenyReason::DeniedByGrant,
..
}
);
assert!(
by_grant || !denied_below,
"seed {seed} case {case}: a deny stopped applying at {taint:?}\nrequest: {request:?}\ngrants: {grants:#?}"
);
denied_below = by_grant;
}
prohibitions += usize::from(denied_below);
}
}
assert!(
prohibitions > 200,
"only {prohibitions} cases were prohibited"
);
}
+117
View File
@@ -0,0 +1,117 @@
//! Table tests for `policy::redecide`: an approval lets a call through only if the grants and
//! the session's state, as they are when it is approved, still say `ask` or `auto`. Do not edit.
#[path = "support/build.rs"]
mod build;
use brokerd::grants::GrantSet;
use brokerd::policy::{Ask, Label, Outcome, decide, redecide};
use build::{grant, now, private, read, secret, set};
use proto::{DataClass, DenyReason, Mode};
const PATH: &str = "/home/kyle/notes/a.md";
fn asking() -> build::Build {
grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/notes"])
}
/// An `Ask` for `PATH`, decided under `asking()` alone at `private`.
fn pending() -> Ask {
match decide(read(PATH), &set(vec![asking()]), private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
}
}
#[test]
fn still_ask_lets_the_call_run_under_the_same_grant() {
let decision = redecide(pending(), &set(vec![asking()]), private(), now()).unwrap();
assert_eq!(decision.grant(), "asks");
assert_eq!(decision.request(), &read(PATH));
assert_eq!(decision.matched_path(), Some("/home/kyle/notes"));
assert_eq!(
decision.label(),
Label {
class: DataClass::Private,
untrusted: true
}
);
}
#[test]
fn auto_now_lets_the_call_run_under_the_grant_that_matches_now() {
// The owner has since replaced the ask grant with an auto grant of another name and label.
let grants = set(vec![
grant("now-auto", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Secret)
.trusted(),
]);
let decision = redecide(pending(), &grants, private(), now()).unwrap();
assert_eq!(decision.grant(), "now-auto");
assert_eq!(decision.grant_sha256(), proto::sha256(b"now-auto").unwrap());
assert_eq!(decision.matched_path(), Some("/home/kyle"));
assert_eq!(decision.paths(), ["/home/kyle"]);
assert_eq!(
decision.label(),
Label {
class: DataClass::Secret,
untrusted: false
}
);
}
#[test]
fn the_grant_file_was_removed() {
let denial = redecide(pending(), &GrantSet::default(), private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::NoGrant);
assert_eq!(denial.grant, None);
}
#[test]
fn the_taint_rose_past_max_taint_while_the_approval_waited() {
let narrow = || asking().max_taint(DataClass::Private);
let ask = match decide(read(PATH), &set(vec![narrow()]), private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
};
let denial = redecide(ask, &set(vec![narrow()]), secret(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::TaintTooHigh);
}
#[test]
fn the_grant_expired_while_the_approval_waited() {
let grants = set(vec![asking().expires("2026-09-18T12:10:00.000Z")]);
let ask = match decide(read(PATH), &grants, private(), now()) {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
};
assert_eq!(ask.expires(), Some(build::ts("2026-09-18T12:10:00.000Z")));
let later = build::ts("2026-09-18T12:10:00.000Z");
let denial = redecide(ask, &grants, private(), later).unwrap_err();
assert_eq!(denial.reason, DenyReason::GrantExpired);
}
#[test]
fn a_deny_grant_was_added_while_the_approval_waited() {
let grants = set(vec![
asking(),
grant("no-notes", "read_file", Mode::Deny).paths(&["/home/kyle"]),
]);
let denial = redecide(pending(), &grants, private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::DeniedByGrant);
assert_eq!(denial.grant.as_deref(), Some("no-notes"));
assert_eq!(
denial.grant_sha256,
Some(proto::sha256(b"no-notes").unwrap())
);
}
#[test]
fn the_grants_now_cover_other_arguments_only() {
let grants = set(vec![
grant("asks", "read_file", Mode::Ask).paths(&["/home/kyle/other"]),
]);
let denial = redecide(pending(), &grants, private(), now()).unwrap_err();
assert_eq!(denial.reason, DenyReason::NoGrant);
}
+175
View File
@@ -0,0 +1,175 @@
//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not
//! edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/runtime.rs"]
mod runtime;
use brokerd::args::{ToolArgs, ToolName};
use brokerd::policy::{Decision, Outcome, SessionState, decide};
use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run};
use build::{grant, now, read, request, set};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
use runtime::Recording;
fn allowed(grants: Vec<build::Build>, request: ToolRequest) -> Decision {
match decide(request, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(decision) => decision,
other => panic!("the test's call is not allowed: {other:?}"),
}
}
fn mount(path: &str, writable: bool) -> Mount {
Mount {
path: path.to_string(),
writable,
}
}
#[test]
fn read_file_mounts_the_matched_path_read_only_and_has_no_network() {
let d = allowed(
vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])],
read("/h/notes/deep/a.md"),
);
let rt = Recording::answering("text");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].tool, ToolName::ReadFile);
assert_eq!(
seen[0].arguments,
ToolArgs::ReadFile {
path: "/h/notes/deep/a.md".to_string()
}
);
// The longest path that holds the argument, and only that one.
assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn write_file_mounts_the_matched_path_writable() {
// A grant path equal to the argument does not count, so `/s/out` is written through `/s`.
let d = allowed(
vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])],
request("write_file", r#"{"path":"/s/out","content":"x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::WriteFile);
assert_eq!(seen[0].mounts, [mount("/s", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_mounts_every_path_of_the_grant_writable() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])],
request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::Shell);
assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]);
assert_eq!(seen[0].egress, None);
}
#[test]
fn shell_without_paths_mounts_nothing() {
let d = allowed(
vec![grant("sh", "shell", Mode::Auto)],
request("shell", r#"{"command":"date"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].mounts, []);
assert_eq!(seen[0].egress, None);
}
#[test]
fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() {
let d = allowed(
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])],
request("http_fetch", r#"{"url":"https://www.example.org/x"}"#),
);
let rt = Recording::answering("");
run(d, rt.as_ref());
let seen = rt.seen();
assert_eq!(seen[0].tool, ToolName::HttpFetch);
assert_eq!(seen[0].mounts, []);
assert_eq!(
seen[0].egress,
Some(vec!["example.com".to_string(), "*.example.org".to_string()])
);
}
#[test]
fn a_result_carries_the_label_combined_over_every_matching_grant() {
// `b-keys` has the longer path and wins the mount; the label is the highest class of both
// grants, and untrusted because `a-home` says so.
let d = allowed(
vec![
grant("a-home", "read_file", Mode::Auto)
.paths(&["/home/kyle"])
.class(DataClass::Private),
grant("b-keys", "read_file", Mode::Auto)
.paths(&["/home/kyle/keys"])
.class(DataClass::Secret)
.trusted(),
],
read("/home/kyle/keys/id"),
);
let rt = Recording::with(Ok(RunOutput {
content: "key".to_string(),
truncated: true,
}));
let answer = run(d, rt.as_ref());
assert_eq!(
answer,
ToolResponse::Result {
content: "key".to_string(),
class: DataClass::Secret,
untrusted: true,
truncated: true,
}
);
assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]);
}
#[test]
fn a_run_error_is_a_failure_with_the_runtimes_sentence() {
for error in [
RunError::Failed("the tool timed out".to_string()),
RunError::Unavailable("the container could not start".to_string()),
] {
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
let text = match &error {
RunError::Failed(t) | RunError::Unavailable(t) => t.clone(),
};
let rt = Recording::with(Err(error));
assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text });
}
}
#[test]
fn the_production_runtime_refuses_every_call() {
assert_eq!(REFUSING, "the runner arrives in M3b");
let d = allowed(
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
read("/n/a"),
);
assert_eq!(
run(d, &Refusing),
ToolResponse::Failed {
message: REFUSING.to_string()
}
);
}
+405
View File
@@ -0,0 +1,405 @@
//! `brokerd serve` as a process: its startup, both sockets, and the expiry thread. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use brokerd::audit::RECOVERED_NOTICE;
use brokerd::runner::REFUSING;
use proto::{
AuditEvent, CallId, DenyReason, Empty, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
ResultStatus, SessionId, ToolRequest, ToolResponse,
};
use tmp::TempDir;
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
fn new(tag: &str, ttl_ms: u64) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let text = format!(
"[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[approvals]\nttl_ms = {ttl_ms}\n",
home = dir.path().display()
);
let config = dir.write("brokerd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn tools(&self) -> PathBuf {
self.path("run/loop-broker/broker.sock")
}
fn admin(&self) -> PathBuf {
self.path("run/owner-broker/admin.sock")
}
fn grant(&self, id: &str, mode: &str) {
let text = format!(
"tool = \"read_file\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n\
result_class = \"private\"\nuntrusted = false\n[constraints]\npaths = [\"/n\"]\n"
);
std::fs::write(self.path(&format!("grants/{id}.toml")), text).unwrap();
}
fn command(&self, extra: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_brokerd"));
command
.args(["serve", "--config"])
.arg(&self.config)
.args(extra);
command
}
/// Starts `brokerd serve` and waits until both sockets answer.
fn serve(&self) -> Running {
let child = self
.command(&[])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(Some(child));
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(self.tools()).is_err()
|| UnixStream::connect(self.admin()).is_err()
{
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
running
}
/// Runs `brokerd serve` expecting it to exit by itself.
fn run(&self, extra: &[&str]) -> Output {
self.command(extra).output().unwrap()
}
fn events(&self) -> Vec<AuditEvent> {
let dir = self.path("audit");
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
names
.iter()
.flat_map(|n| {
let text = std::fs::read_to_string(dir.join(n)).unwrap();
text.lines()
.map(|l| serde_json::from_str::<proto::AuditRecord>(l).unwrap().event)
.collect::<Vec<_>>()
})
.collect()
}
}
/// Kills the daemon when dropped; `stop` returns what it printed.
struct Running(Option<Child>);
impl Running {
fn stop(mut self) -> String {
let mut child = self.0.take().unwrap();
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
String::from_utf8_lossy(&output.stderr).to_string()
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn exchange(socket: &Path, id: u64, msg: Message) -> Vec<Envelope> {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut stream, &env).unwrap();
let mut frames = Vec::new();
loop {
let env = proto::read_frame(&mut stream).unwrap();
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
fn read_notes(call: u64) -> Message {
Message::ToolRequest(ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
})
}
fn last_response(frames: &[Envelope]) -> &ToolResponse {
match &frames.last().unwrap().msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
#[test]
fn it_makes_its_directories_0700_and_its_sockets_0600() {
let home = Home::new("serve-modes", 900_000);
// One directory found too open, one made.
std::fs::create_dir_all(home.path("run/owner-broker")).unwrap();
std::fs::set_permissions(
home.path("run/owner-broker"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let running = home.serve();
assert_eq!(mode(&home.path("run/loop-broker")), 0o700);
assert_eq!(mode(&home.path("run/owner-broker")), 0o700);
assert_eq!(mode(&home.tools()), 0o600);
assert_eq!(mode(&home.admin()), 0o600);
assert_eq!(mode(&home.path("audit")), 0o700);
let printed = running.stop();
assert!(printed.contains("serving"), "{printed}");
}
#[test]
fn a_stale_socket_is_replaced() {
let home = Home::new("serve-stale", 900_000);
std::fs::create_dir_all(home.path("run/loop-broker")).unwrap();
drop(UnixListener::bind(home.tools()).unwrap());
assert!(home.tools().exists(), "the stale socket file is there");
let _running = home.serve();
let frames = exchange(&home.tools(), 3, read_notes(3));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::NoGrant
}
);
}
#[test]
fn a_second_brokerd_on_the_same_home_refuses_to_start() {
let home = Home::new("serve-twice", 900_000);
let _running = home.serve();
let second = home.run(&[]);
assert_eq!(second.status.code(), Some(1));
let text = stderr(&second);
assert!(text.contains("brokerd is already running"), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#brokerd-already-running"),
"{text}"
);
// The first still has its sockets.
let frames = exchange(&home.admin(), 1, Message::Approvals(Empty {}));
assert!(matches!(frames[0].msg, Message::ApprovalList(_)));
}
#[test]
fn it_answers_each_socket_and_refuses_the_other_kinds() {
let home = Home::new("serve-kinds", 900_000);
home.grant("notes", "auto");
let running = home.serve();
let frames = exchange(&home.tools(), 7, read_notes(7));
assert_eq!(frames[0].id, 7);
// The production runtime runs nothing.
assert_eq!(
last_response(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
let wrong = exchange(&home.tools(), 8, Message::Approvals(Empty {}));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let wrong = exchange(&home.admin(), 9, read_notes(9));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let printed = running.stop();
assert!(
printed.contains("approvals on broker.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(
printed.contains("tool_request on admin.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(matches!(
home.events().as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
status: ResultStatus::Failed,
..
}
]
));
}
#[test]
fn an_approval_nobody_answers_expires() {
let home = Home::new("serve-expire", 100);
home.grant("notes", "ask");
let _running = home.serve();
let started = Instant::now();
let frames = exchange(&home.tools(), 2, read_notes(2));
assert_eq!(frames.len(), 2, "{frames:?}");
assert!(matches!(
&frames[0].msg,
Message::ToolResponse(ToolResponse::PendingApproval { approval: 0, .. })
));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::ApprovalExpired
}
);
// The expiry thread looks every second.
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
fn copy_case(home: &Home, case: &str, only: &[&str]) {
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::create_dir_all(home.path("audit")).unwrap();
for name in only {
std::fs::copy(
format!("{from}/{name}"),
home.path(&format!("audit/{name}")),
)
.unwrap();
}
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut all: Vec<(String, Vec<u8>)> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|e| {
(
e.file_name().into_string().unwrap(),
std::fs::read(e.path()).unwrap(),
)
})
.collect();
all.sort();
all
}
#[test]
fn a_broken_chain_stops_it_before_any_socket_and_nothing_is_written() {
let home = Home::new("serve-broken", 900_000);
copy_case(&home, "changed-byte", &["2026-09-17.jsonl"]);
let before = snapshot(&home.path("audit"));
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
let text = stderr(&output);
assert!(text.contains("2026-09-17.jsonl:4: "), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#audit-chain-broken"),
"{text}"
);
assert_eq!(snapshot(&home.path("audit")), before);
assert!(!home.tools().exists() && !home.admin().exists());
}
#[test]
fn a_torn_tail_is_recovered_and_it_serves() {
let home = Home::new("serve-torn", 900_000);
copy_case(
&home,
"torn-tail",
&["2026-09-17.jsonl", "2026-09-18.jsonl"],
);
let running = home.serve();
let printed = running.stop();
assert!(printed.contains(RECOVERED_NOTICE), "{printed}");
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
#[test]
fn accept_break_with_nothing_to_accept_exits_2() {
let home = Home::new("serve-nothing", 900_000);
let output = home.run(&["--accept-break"]);
assert_eq!(output.status.code(), Some(2));
assert!(
stderr(&output).contains("nothing to accept"),
"{}",
stderr(&output)
);
}
#[test]
fn bad_arguments_and_bad_configs_do_not_start() {
let brokerd = env!("CARGO_BIN_EXE_brokerd");
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["serve", "--config", "a", "--config", "b"][..],
&["serve", "--config", "a", "--loud"][..],
&["run", "--config", "a"][..],
] {
let output = Command::new(brokerd).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}");
assert!(
stderr(&output).starts_with("usage: brokerd serve"),
"{args:?}"
);
}
let home = Home::new("serve-config", 900_000);
std::fs::write(&home.config, "[paths]\nhoem = \"/x\"\n").unwrap();
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("brokerd.toml"),
"{}",
stderr(&output)
);
std::fs::remove_file(&home.config).unwrap();
assert_eq!(home.run(&[]).status.code(), Some(1));
assert!(
!home.path("audit").exists(),
"nothing made before the config is read"
);
}
+111
View File
@@ -0,0 +1,111 @@
//! `brokerd serve --accept-break` on a real break, through the binary: it records the break and
//! serves, and the next plain start serves too (spec section 12, "Audit edges"; missing from the
//! given tests, found in the M3a review).
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use brokerd::audit::verify_dir;
use tmp::TempDir;
/// Start `brokerd serve` with `extra`, wait until it listens, stop it, and return its stderr.
fn serve_once(config: &Path, tools: &Path, extra: &[&str]) -> String {
let mut child = Command::new(env!("CARGO_BIN_EXE_brokerd"))
.args(["serve", "--config"])
.arg(config)
.args(extra)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(tools).is_err() {
if let Some(status) = child.try_wait().unwrap() {
let mut err = String::new();
child
.stderr
.take()
.unwrap()
.read_to_string(&mut err)
.unwrap();
panic!("brokerd exited with {status}: {err}");
}
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
child.kill().unwrap();
child.wait().unwrap();
let mut err = String::new();
child
.stderr
.take()
.unwrap()
.read_to_string(&mut err)
.unwrap();
err
}
#[test]
fn a_break_is_accepted_and_the_next_plain_start_serves() {
let dir = TempDir::new("accept-break");
let audit = dir.path().join("audit");
std::fs::create_dir_all(&audit).unwrap();
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../proto/tests/fixtures/audit/changed-byte");
// The damage is in the first file; an ordinary start checks only the latest, so copy that one
// alone, as `audit_startup.rs` does.
std::fs::copy(
fixture.join("2026-09-17.jsonl"),
audit.join("2026-09-17.jsonl"),
)
.unwrap();
let config = dir.write(
"brokerd.toml",
&format!(
"[paths]\nhome = \"{0}\"\ngrants = \"{0}/grants\"\n",
dir.path().display()
),
);
let tools = dir.path().join("run/loop-broker/broker.sock");
// A plain start must refuse: wait for it to exit, and fail (not hang) if it serves instead.
let mut plain = Command::new(env!("CARGO_BIN_EXE_brokerd"))
.args(["serve", "--config"])
.arg(&config)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let until = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = plain.try_wait().unwrap() {
break status;
}
if Instant::now() > until {
plain.kill().unwrap();
panic!("a plain start served on a broken chain");
}
std::thread::sleep(Duration::from_millis(20));
};
assert_eq!(status.code(), Some(1), "a plain start refuses first");
let printed = serve_once(&config, &tools, &["--accept-break"]);
assert!(
printed.contains("audit: accepted the break at"),
"{printed}"
);
let printed = serve_once(&config, &tools, &[]);
assert!(!printed.contains("accepted the break"), "{printed}");
let report = verify_dir(&audit).unwrap();
assert_eq!(report.failure, None, "{:?}", report.failure);
assert_eq!(report.accepted_breaks.len(), 1);
}
+203
View File
@@ -0,0 +1,203 @@
//! `brokerd serve` fails closed at startup with a runbook pointer for every reason it cannot start,
//! and never panics on its own command line. Found in the M3a review (findings 3 and 7).
#[path = "support/tmp.rs"]
mod tmp;
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::process::{Command, Output};
use tmp::TempDir;
const START_FAILED: &str = "see docs/runbook.md#brokerd-start-failed";
fn brokerd<I: IntoIterator<Item = S>, S: AsRef<OsStr>>(args: I) -> Output {
Command::new(env!("CARGO_BIN_EXE_brokerd"))
.args(args)
.output()
.unwrap()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
/// A config whose home is `dir`, with `sockets` as the `[sockets]` table's body.
fn config(dir: &TempDir, sockets: &str) -> std::path::PathBuf {
let home = dir.path().join("home");
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
std::fs::create_dir_all(&home).unwrap();
dir.write(
"brokerd.toml",
&format!(
"[paths]\nhome = \"{}\"\ngrants = \"{}\"\n[sockets]\n{sockets}\n",
home.display(),
grants.display()
),
)
}
fn fails_with_pointer(out: &Output) {
assert_eq!(out.status.code(), Some(1), "{}", stderr(out));
assert!(
stderr(out).trim_end().ends_with(START_FAILED),
"{:?} must end with {START_FAILED:?}",
stderr(out)
);
}
#[test]
fn a_missing_config_names_the_entry() {
let dir = TempDir::new("ptr-missing");
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
dir.path().join("nope.toml").as_os_str(),
]);
fails_with_pointer(&out);
}
#[test]
fn an_invalid_config_names_the_entry() {
let dir = TempDir::new("ptr-invalid");
let path = dir.write("brokerd.toml", "[paths]\nhome = 3\n");
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]);
fails_with_pointer(&out);
}
#[test]
fn a_socket_that_cannot_be_bound_names_the_entry() {
let dir = TempDir::new("ptr-bind");
let long = dir.path().join("s".repeat(120)).join("broker.sock");
let path = config(&dir, &format!("broker = \"{}\"", long.display()));
fails_with_pointer(&brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]));
}
#[test]
fn a_socket_directory_that_cannot_be_made_names_the_entry() {
let dir = TempDir::new("ptr-dir");
let file = dir.write("a-file", "");
let path = config(
&dir,
&format!("broker = \"{}\"", file.join("run/broker.sock").display()),
);
fails_with_pointer(&brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]));
}
/// The socket's directory is made private (0700). Through a symbolic link that would change the
/// directory it points to, so a link is refused, and the target keeps its mode.
#[test]
fn a_socket_directory_that_is_a_symbolic_link_is_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("ptr-link");
let shared = dir.path().join("shared");
std::fs::create_dir(&shared).unwrap();
std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o755)).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&shared, &link).unwrap();
let path = config(
&dir,
&format!("broker = \"{}\"", link.join("broker.sock").display()),
);
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]);
fails_with_pointer(&out);
assert!(stderr(&out).contains("symbolic link"), "{}", stderr(&out));
let mode = std::fs::metadata(&shared).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "the link's target keeps its mode");
}
/// A socket directly in `/` would make `/` private: refused before anything is changed.
#[test]
fn a_socket_in_the_root_directory_is_refused() {
let dir = TempDir::new("ptr-root");
let path = config(&dir, "broker = \"/broker.sock\"");
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]);
fails_with_pointer(&out);
assert!(
stderr(&out).contains("directory of its own"),
"{}",
stderr(&out)
);
}
/// A config path that is not UTF-8 is still a path: it is read (here: not found), not a panic.
#[test]
fn a_config_path_that_is_not_utf8_is_read_as_a_path() {
let dir = TempDir::new("ptr-os");
let name = dir.path().join(OsStr::from_bytes(b"conf-\xff.toml"));
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
name.as_os_str(),
]);
fails_with_pointer(&out);
}
/// A flag that is not UTF-8 is not a flag brokerd knows: usage, exit 2, no panic.
#[test]
fn a_flag_that_is_not_utf8_is_a_usage_error() {
let out = brokerd([OsStr::new("serve"), OsStr::from_bytes(b"--\xff")]);
assert_eq!(out.status.code(), Some(2), "{}", stderr(&out));
assert!(stderr(&out).starts_with("usage: brokerd serve"));
let out = brokerd([OsStr::from_bytes(b"\xff")]);
assert_eq!(out.status.code(), Some(2), "{}", stderr(&out));
}
#[test]
fn the_entries_exist() {
let runbook = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/runbook.md"),
)
.unwrap();
for entry in ["## brokerd-start-failed", "## brokerd-listener-lost"] {
assert!(runbook.lines().any(|l| l == entry), "{entry}");
}
}
/// `[approvals] ttl_ms` is between 1 ms and a day: `loopd` waits at most a day after a pending
/// frame, so a longer approval would be abandoned while `bxctl` still listed it.
#[test]
fn an_approval_ttl_outside_a_day_is_a_config_error() {
for ttl in ["0", "86400001", "18446744073709551615"] {
let dir = TempDir::new("ptr-ttl");
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let path = dir.write(
"brokerd.toml",
&format!(
"[paths]\nhome = \"{0}\"\ngrants = \"{0}/grants\"\n[approvals]\nttl_ms = {ttl}\n",
dir.path().display()
),
);
let out = brokerd([
OsStr::new("serve"),
OsStr::new("--config"),
path.as_os_str(),
]);
fails_with_pointer(&out);
assert!(stderr(&out).contains("ttl_ms"), "{ttl}: {}", stderr(&out));
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Tests for the session state files. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::policy::{Label, SessionState};
use brokerd::state::{RUNBOOK, StateError, StateStore};
use proto::{DataClass, SessionId};
use std::os::unix::fs::PermissionsExt;
use tmp::TempDir;
fn id(text: &str) -> SessionId {
SessionId::new(text).unwrap()
}
fn label(class: DataClass, untrusted: bool) -> Label {
Label { class, untrusted }
}
fn state(taint: DataClass, untrusted: bool) -> SessionState {
SessionState { taint, untrusted }
}
/// The store's directory is two levels below the temporary one and does not exist yet, as on a
/// fresh install.
fn store(home: &TempDir) -> StateStore {
StateStore::new(&home.path().join("broker/sessions"))
}
#[test]
fn a_session_with_no_file_is_private_and_trusted() {
let home = TempDir::new("state");
let store = store(&home);
let fresh = store.read(&id("chat-1")).unwrap();
assert_eq!(fresh, state(DataClass::Private, false));
assert_eq!(fresh, SessionState::default());
// Reading creates nothing.
assert!(!home.path().join("broker").exists());
}
#[test]
fn the_first_result_creates_the_file_and_its_directory() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("chat-1");
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, false),
)
.unwrap();
assert_eq!(next, state(DataClass::Private, false));
let path = home.path().join("broker/sessions/chat-1.json");
assert_eq!(store.path(&session), path);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\"taint\":\"private\",\"untrusted\":false}\n"
);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(&home.path().join("broker/sessions")), 0o700);
assert_eq!(mode(&home.path().join("broker")), 0o700);
// No temporary file is left behind.
assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists());
assert_eq!(store.read(&session).unwrap(), next);
}
#[test]
fn taint_and_the_untrusted_flag_only_go_up() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let steps = [
(
label(DataClass::Public, false),
state(DataClass::Private, false),
),
(
label(DataClass::Private, true),
state(DataClass::Private, true),
),
(
label(DataClass::Secret, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Public, false),
state(DataClass::Secret, true),
),
(
label(DataClass::Private, false),
state(DataClass::Secret, true),
),
];
let mut current = store.read(&session).unwrap();
for (result, want) in steps {
current = store.raise(&session, current, result).unwrap();
assert_eq!(current, want);
assert_eq!(store.read(&session).unwrap(), want, "what is on disk");
}
assert_eq!(
std::fs::read_to_string(store.path(&session)).unwrap(),
"{\"taint\":\"secret\",\"untrusted\":true}\n"
);
}
#[test]
fn sessions_do_not_share_state() {
let home = TempDir::new("state");
let store = store(&home);
store
.raise(
&id("a"),
SessionState::default(),
label(DataClass::Secret, true),
)
.unwrap();
assert_eq!(store.read(&id("b")).unwrap(), SessionState::default());
assert_eq!(
store.read(&id("a")).unwrap(),
state(DataClass::Secret, true)
);
}
/// A file that exists but does not hold a valid state is an error, never "no file".
#[test]
fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() {
let home = TempDir::new("state");
let store = store(&home);
std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap();
let session = id("hurt");
for text in [
"",
"{",
"null",
"[]",
"{\"taint\":\"secret\"}",
"{\"untrusted\":false}",
"{\"taint\":\"internal\",\"untrusted\":false}",
"{\"taint\":\"secret\",\"untrusted\":\"no\"}",
"{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}",
"{\"taint\":\"secret\",\"untrusted\":false} trailing",
// A session is never below private, so this file was not written by brokerd.
"{\"taint\":\"public\",\"untrusted\":false}",
] {
std::fs::write(store.path(&session), text).unwrap();
let err = store.read(&session).expect_err(text);
assert!(
matches!(err, StateError::Unreadable(..)),
"{text:?}: {err:?}"
);
let shown = err.to_string();
assert!(shown.contains("hurt.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
}
assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged");
// A good file with or without its final newline reads fine.
for text in [
"{\"taint\":\"secret\",\"untrusted\":true}\n",
"{\"taint\":\"secret\",\"untrusted\":true}",
] {
std::fs::write(store.path(&session), text).unwrap();
assert_eq!(
store.read(&session).unwrap(),
state(DataClass::Secret, true)
);
}
// Not valid UTF-8, and a directory where the file should be.
std::fs::write(store.path(&session), b"\xff\xfe").unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::remove_file(store.path(&session)).unwrap();
std::fs::create_dir(store.path(&session)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
}
#[test]
fn a_file_without_read_permission_is_an_error_not_a_fresh_session() {
if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("locked");
store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
let path = store.path(&session);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
assert!(matches!(
store.read(&session),
Err(StateError::Unreadable(..))
));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn a_failed_write_is_an_error_and_leaves_the_old_state() {
if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") {
return;
}
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let before = store
.raise(
&session,
SessionState::default(),
label(DataClass::Private, true),
)
.unwrap();
let dir = home.path().join("broker/sessions");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let err = store
.raise(&session, before, label(DataClass::Secret, false))
.expect_err("the directory is read-only");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(err, StateError::Write(..)), "{err:?}");
let shown = err.to_string();
assert!(shown.contains("s.json"), "{shown}");
assert!(shown.ends_with(RUNBOOK), "{shown}");
assert_eq!(store.read(&session).unwrap(), before);
}
/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does
/// not stop the next write, and the next write replaces it.
#[test]
fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() {
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let dir = home.path().join("broker/sessions");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap();
assert_eq!(store.read(&session).unwrap(), SessionState::default());
let next = store
.raise(
&session,
SessionState::default(),
label(DataClass::Secret, false),
)
.unwrap();
assert_eq!(store.read(&session).unwrap(), next);
assert!(!dir.join("s.json.tmp").exists());
}
#[test]
fn raise_trusts_the_state_it_is_given_not_the_file() {
// The caller read the state under the ledger lock a moment ago; `raise` does not read again.
let home = TempDir::new("state");
let store = store(&home);
let session = id("s");
let given = state(DataClass::Secret, true);
let next = store
.raise(&session, given, label(DataClass::Public, false))
.unwrap();
assert_eq!(next, given);
// Even a state below private is lifted to private on the way to disk.
let low = store
.raise(
&id("low"),
state(DataClass::Public, false),
label(DataClass::Public, false),
)
.unwrap();
assert_eq!(low, state(DataClass::Private, false));
}
+98
View File
@@ -0,0 +1,98 @@
//! Temporary audit directories for the audit tests. Do not edit.
#![allow(dead_code)] // each test file uses its own part of this
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use proto::{AuditEvent, CallId, DataClass, DecisionRecord, SessionId, Timestamp};
pub const D1: &str = "2026-09-17.jsonl";
pub const D2: &str = "2026-09-18.jsonl";
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A directory under the system's temporary directory, removed when dropped.
pub struct TempDir {
pub path: PathBuf,
}
impl TempDir {
/// A path that does not exist yet.
pub fn unmade(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("brokerd-{tag}-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
TempDir { path }
}
/// A copy of the fixture log `case` from `crates/proto/tests/fixtures/audit/`. With `only`,
/// just those files: damage in an older file is not seen by an ordinary start, so tests of
/// the startup check copy the damaged file alone.
pub fn case(case: &str, only: Option<&[&str]>) -> TempDir {
let dir = TempDir::unmade(case);
std::fs::create_dir_all(&dir.path).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
let mut copied = 0;
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
let name = entry.file_name().into_string().unwrap();
if only.is_none_or(|names| names.contains(&name.as_str())) {
std::fs::copy(entry.path(), dir.path.join(&name)).unwrap();
copied += 1;
}
}
assert!(copied > 0, "{from}: nothing copied");
dir
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// Every log file in `dir` with its bytes.
pub fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap())
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|entry| {
let name = entry.file_name().into_string().unwrap();
(name, std::fs::read(entry.path()).unwrap())
})
.collect()
}
pub fn lines(dir: &Path, file: &str) -> Vec<String> {
let text = std::fs::read_to_string(dir.join(file)).unwrap();
text.lines().map(str::to_string).collect()
}
pub fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
/// A denied decision for call `call`: an event that leaves nothing open in the report.
pub fn denied(call: u64) -> AuditEvent {
AuditEvent::Decision {
session: SessionId::new("chat-1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
outcome: DecisionRecord::Denied {
reason: proto::DenyReason::NoGrant,
},
grant: None,
grant_sha256: None,
taint: DataClass::Private,
untrusted: false,
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Builders for grants and requests, for the policy tests. Do not edit.
//!
//! Included with `#[path = "support/build.rs"] mod build;`.
#![allow(dead_code)] // each test file uses a different part of this module
use brokerd::grants::{GrantSet, LoadedGrant};
use brokerd::policy::{Ask, Decision, Denial, Outcome, SessionState};
use proto::{
CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest,
};
/// The moment every policy test decides at.
pub const NOW: &str = "2026-09-18T12:00:00.000Z";
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub fn now() -> Timestamp {
ts(NOW)
}
pub struct Build(LoadedGrant);
/// A grant with the widest settings: it applies at every taint, never expires, and labels its
/// results `private` and untrusted. Each test narrows what it is about.
pub fn grant(id: &str, tool: &str, mode: Mode) -> Build {
Build(LoadedGrant {
id: id.to_string(),
grant: Grant {
tool: tool.to_string(),
mode,
max_taint: DataClass::Secret,
result_class: DataClass::Private,
untrusted: true,
expires: None,
secret: None,
constraints: Constraints::default(),
},
// Stands in for the file's hash, and differs from grant to grant.
sha256: proto::sha256(id.as_bytes()).unwrap(),
})
}
impl Build {
pub fn paths(mut self, paths: &[&str]) -> Build {
self.0.grant.constraints.paths = paths.iter().map(|p| p.to_string()).collect();
self
}
pub fn hosts(mut self, hosts: &[&str]) -> Build {
self.0.grant.constraints.hosts = hosts.iter().map(|h| h.to_string()).collect();
self
}
pub fn max_taint(mut self, class: DataClass) -> Build {
self.0.grant.max_taint = class;
self
}
pub fn class(mut self, class: DataClass) -> Build {
self.0.grant.result_class = class;
self
}
pub fn trusted(mut self) -> Build {
self.0.grant.untrusted = false;
self
}
pub fn expires(mut self, at: &str) -> Build {
self.0.grant.expires = Some(ts(at));
self
}
pub fn done(self) -> LoadedGrant {
self.0
}
}
pub fn set(grants: Vec<Build>) -> GrantSet {
GrantSet::from_grants(grants.into_iter().map(Build::done).collect())
.unwrap_or_else(|problems| panic!("the test's grants are not valid: {problems:?}"))
}
pub fn request(tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
pub fn read(path: &str) -> ToolRequest {
request("read_file", &format!(r#"{{"path":"{path}"}}"#))
}
pub fn write(path: &str) -> ToolRequest {
request(
"write_file",
&format!(r#"{{"path":"{path}","content":"x"}}"#),
)
}
pub fn shell(cwd: Option<&str>) -> ToolRequest {
match cwd {
Some(cwd) => request("shell", &format!(r#"{{"command":"ls","cwd":"{cwd}"}}"#)),
None => request("shell", r#"{"command":"ls"}"#),
}
}
pub fn fetch(url: &str) -> ToolRequest {
request("http_fetch", &format!(r#"{{"url":"{url}"}}"#))
}
pub fn at(taint: DataClass) -> SessionState {
SessionState {
taint,
untrusted: false,
}
}
pub fn private() -> SessionState {
at(DataClass::Private)
}
pub fn secret() -> SessionState {
at(DataClass::Secret)
}
pub fn allowed(outcome: Outcome) -> Decision {
match outcome {
Outcome::Allowed(decision) => decision,
other => panic!("expected allowed, got {other:?}"),
}
}
pub fn asked(outcome: Outcome) -> Ask {
match outcome {
Outcome::Ask(ask) => ask,
other => panic!("expected ask, got {other:?}"),
}
}
pub fn denied(outcome: Outcome) -> Denial {
match outcome {
Outcome::Denied(denial) => denial,
other => panic!("expected denied, got {other:?}"),
}
}
/// The reason of a denial. Only `denied_by_grant` may name a grant.
pub fn reason(outcome: Outcome) -> DenyReason {
let denial = denied(outcome);
if denial.reason != DenyReason::DeniedByGrant {
assert_eq!(denial.grant, None, "only denied_by_grant names a grant");
assert_eq!(denial.grant_sha256, None);
}
denial.reason
}
+79
View File
@@ -0,0 +1,79 @@
//! A client for `broker::handle` and `admin::handle` over a socket pair, and a `Broker` built on
//! a `Rig`. Do not edit.
//!
//! Included with `#[path = "support/client.rs"] mod client;`, beside `rig`, `runtime`, `sink`
//! and `tmp`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use brokerd::broker::{self, Broker};
use proto::{Envelope, Message, PROTOCOL_VERSION, ToolRequest};
use crate::rig::Rig;
use crate::runtime::{Recording, Shared};
pub trait Serve {
/// A `Broker` on this rig's home, with `runtime` and the rig's flaky sink and log.
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker>;
}
impl Serve for Rig {
fn broker(&self, runtime: &Arc<Recording>) -> Arc<Broker> {
let runtime = Box::new(Shared(Arc::clone(runtime)));
Arc::new(Broker::new(
self.cfg.clone(),
self.ledger(),
runtime,
self.lines.sink(),
))
}
}
/// A connection to `handler` running on its own thread, with `msg` already sent under `id`.
pub fn open(
broker: &Arc<Broker>,
handler: fn(UnixStream, &Broker),
id: u64,
msg: Message,
) -> UnixStream {
let (mut client, server) = UnixStream::pair().unwrap();
let broker = Arc::clone(broker);
std::thread::spawn(move || handler(server, &broker));
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
proto::write_frame(&mut client, &env).unwrap();
client
}
/// The next frame, waiting at most ten seconds. Once the handler has closed its end, macOS
/// refuses the timeout with EINVAL (22); the frame is buffered by then and the read cannot block.
pub fn next(stream: &mut UnixStream) -> Envelope {
if let Err(e) = stream.set_read_timeout(Some(Duration::from_secs(10))) {
let peer_closed = cfg!(target_vendor = "apple") && e.raw_os_error() == Some(22);
assert!(peer_closed, "set_read_timeout: {e}");
}
proto::read_frame(stream).unwrap()
}
/// Sends a tool request to `broker::handle` and reads every frame up to the final one.
pub fn call(broker: &Arc<Broker>, req: ToolRequest) -> Vec<Envelope> {
let id = req.call.0;
let mut stream = open(broker, broker::handle, id, Message::ToolRequest(req));
let mut frames = Vec::new();
loop {
let env = next(&mut stream);
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
+388
View File
@@ -0,0 +1,388 @@
//! The generator and the oracle of the policy property test. Do not edit.
//!
//! Included with `#[path = "support/oracle.rs"] mod oracle;`.
//!
//! The oracle is written to be obviously right, not fast or short. It shares no code with
//! `brokerd`: it splits paths and host names into their parts itself and compares the parts.
//! If the property test fails, the oracle is the specification and `policy` is wrong.
#![allow(dead_code)] // the property test does not use every helper in every build
use brokerd::grants::LoadedGrant;
use brokerd::policy::SessionState;
use proto::{
CallId, Constraints, DataClass, DenyReason, Grant, Mode, SessionId, Timestamp, ToolRequest,
};
/// The moment every case is decided at: 2026-09-18T12:00:00.000Z.
pub const NOW_MS: u64 = 1_789_732_800_000;
// ---------------------------------------------------------------------------------------------
// The generator.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
// xorshift must not start at zero.
Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
}
pub fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
pub fn pick<T: Copy>(&mut self, items: &[T]) -> T {
items[self.below(items.len())]
}
pub fn chance(&mut self, percent: u64) -> bool {
self.next() % 100 < percent
}
}
pub const TOOLS: [&str; 4] = ["read_file", "write_file", "shell", "http_fetch"];
pub const CLASSES: [DataClass; 3] = [DataClass::Public, DataClass::Private, DataClass::Secret];
pub const MODES: [Mode; 3] = [Mode::Auto, Mode::Ask, Mode::Deny];
pub const HOSTS: [&str; 6] = [
"example.com",
"www.example.com",
"a.b.example.com",
"other.org",
"www.other.org",
"badexample.com",
];
pub const PATTERNS: [&str; 6] = [
"example.com",
"*.example.com",
"www.example.com",
"*.b.example.com",
"other.org",
"*.other.org",
];
/// A path of one to four components over a tiny alphabet, so that grants and requests overlap
/// often: `/a`, `/a/b`, `/ab/a/c` and so on. `ab` is there to catch prefix matching by bytes.
pub fn path(rng: &mut Rng) -> String {
let depth = 1 + rng.below(4);
let mut text = String::new();
for _ in 0..depth {
text.push('/');
text.push_str(rng.pick(&["a", "b", "c", "ab"]));
}
text
}
pub fn some_grant(rng: &mut Rng, id: usize) -> LoadedGrant {
let tool = rng.pick(&TOOLS);
let mode = rng.pick(&MODES);
let mut constraints = Constraints::default();
match tool {
"http_fetch" => {
for _ in 0..1 + rng.below(2) {
constraints.hosts.push(rng.pick(&PATTERNS).to_string());
}
}
"shell" if rng.chance(40) => {}
_ => {
for _ in 0..1 + rng.below(3) {
constraints.paths.push(path(rng));
}
}
}
// Expiry around the moment of decision: before it, exactly at it, after it, or never.
let expires = match rng.below(5) {
0 => Some(NOW_MS - 1),
1 => Some(NOW_MS),
2 => Some(NOW_MS + 1),
_ => None,
};
LoadedGrant {
id: format!("g{id:02}"),
grant: Grant {
tool: tool.to_string(),
mode,
// A deny grant must apply at every taint, or the set is invalid.
max_taint: if mode == Mode::Deny {
DataClass::Secret
} else {
rng.pick(&CLASSES)
},
result_class: rng.pick(&CLASSES),
untrusted: rng.chance(50),
expires: expires.map(|ms| Timestamp::from_unix_millis(ms).unwrap()),
secret: None,
constraints,
},
sha256: proto::sha256(format!("file {id}").as_bytes()).unwrap(),
}
}
/// Zero to seven grants, with ids handed out in a scrambled order so that the order of the list
/// says nothing about the order of the ids.
pub fn some_grants(rng: &mut Rng) -> Vec<LoadedGrant> {
let count = rng.below(8);
let mut ids: Vec<usize> = (0..count).collect();
for i in (1..ids.len()).rev() {
ids.swap(i, rng.below(i + 1));
}
ids.into_iter().map(|id| some_grant(rng, id)).collect()
}
pub fn some_request(rng: &mut Rng) -> ToolRequest {
let (tool, arguments) = match rng.below(20) {
0 => ("echo".to_string(), "{}".to_string()),
1 => ("read_file".to_string(), r#"{"path":"a/b"}"#.to_string()),
2 => (
"shell".to_string(),
r#"{"command":"ls","cwd":"/a/../b"}"#.to_string(),
),
3 => (
"http_fetch".to_string(),
r#"{"url":"http://example.com/"}"#.to_string(),
),
4 => ("write_file".to_string(), r#"{"path":"/a/b"}"#.to_string()),
_ => match rng.pick(&TOOLS) {
"read_file" => (
"read_file".to_string(),
format!(r#"{{"path":"{}"}}"#, path(rng)),
),
"write_file" => (
"write_file".to_string(),
format!(r#"{{"path":"{}","content":"x"}}"#, path(rng)),
),
"shell" if rng.chance(40) => ("shell".to_string(), r#"{"command":"ls"}"#.to_string()),
"shell" => (
"shell".to_string(),
format!(r#"{{"command":"ls","cwd":"{}"}}"#, path(rng)),
),
_ => (
"http_fetch".to_string(),
format!(r#"{{"url":"https://{}/x"}}"#, rng.pick(&HOSTS)),
),
},
};
ToolRequest {
session: SessionId::new("prop").unwrap(),
call: CallId(1),
tool,
arguments,
}
}
// ---------------------------------------------------------------------------------------------
// The oracle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expected {
Allowed {
grant: String,
path: Option<String>,
class: DataClass,
untrusted: bool,
},
Ask {
grant: String,
path: Option<String>,
class: DataClass,
untrusted: bool,
},
Denied {
reason: DenyReason,
grant: Option<String>,
},
}
pub fn parts(path: &str) -> Vec<&str> {
path.split('/').filter(|part| !part.is_empty()).collect()
}
/// `inner` is `outer` or lies under it: `outer`'s components are the first of `inner`'s.
pub fn under(outer: &str, inner: &str) -> bool {
let (outer, inner) = (parts(outer), parts(inner));
outer.len() <= inner.len() && outer.iter().zip(&inner).all(|(a, b)| a == b)
}
pub fn host_fits(pattern: &str, host: &str) -> bool {
let host: Vec<&str> = host.split('.').collect();
match pattern.strip_prefix("*.") {
None => pattern.split('.').collect::<Vec<_>>() == host,
Some(base) => {
let base: Vec<&str> = base.split('.').collect();
host.len() > base.len() && host[host.len() - base.len()..] == base[..]
}
}
}
/// What the oracle needs from a request: `None` if the broker must refuse it before matching.
pub enum Call {
UnknownTool,
Invalid,
Read(String),
Write(String),
Shell(Option<String>),
Fetch(String),
}
pub fn understand(request: &ToolRequest) -> Call {
if !TOOLS.contains(&request.tool.as_str()) {
return Call::UnknownTool;
}
// The generator only ever writes the five invalid forms below.
let text = request.arguments.as_str();
let invalid = text.contains("\"a/b\"")
|| text.contains("..")
|| text.contains("http://")
|| (request.tool == "write_file" && !text.contains("content"));
if invalid {
return Call::Invalid;
}
let value: serde_json::Value = serde_json::from_str(text).unwrap();
let field = |name: &str| value.get(name).and_then(|v| v.as_str()).map(str::to_string);
match request.tool.as_str() {
"read_file" => Call::Read(field("path").unwrap()),
"write_file" => Call::Write(field("path").unwrap()),
"shell" => Call::Shell(field("cwd")),
_ => {
let url = field("url").unwrap();
let host = url
.trim_start_matches("https://")
.split('/')
.next()
.unwrap();
Call::Fetch(host.to_string())
}
}
}
/// Whether the grant covers the call, and with which of its paths (the longest that holds it).
pub fn coverage(grant: &Grant, call: &Call) -> Option<Option<String>> {
let holding = |path: &str, itself_counts: bool| -> Option<Option<String>> {
let mut best: Option<&String> = None;
for candidate in &grant.constraints.paths {
if !under(candidate, path) || (!itself_counts && parts(candidate) == parts(path)) {
continue;
}
if best.is_none_or(|b| candidate.len() > b.len()) {
best = Some(candidate);
}
}
best.map(|b| Some(b.clone()))
};
match call {
Call::Read(path) => holding(path, true),
Call::Write(path) => holding(path, false),
Call::Shell(None) if grant.constraints.paths.is_empty() => Some(None),
Call::Shell(None) => None,
Call::Shell(Some(cwd)) => holding(cwd, true),
Call::Fetch(host) => grant
.constraints
.hosts
.iter()
.any(|pattern| host_fits(pattern, host))
.then_some(None),
Call::UnknownTool | Call::Invalid => None,
}
}
pub fn oracle(request: &ToolRequest, grants: &[LoadedGrant], state: SessionState) -> Expected {
let denied = |reason| Expected::Denied {
reason,
grant: None,
};
let call = understand(request);
match call {
Call::UnknownTool => return denied(DenyReason::NoGrant),
Call::Invalid => return denied(DenyReason::InvalidArguments),
_ => {}
}
struct Left<'a> {
id: &'a str,
mode: Mode,
path: Option<String>,
class: DataClass,
untrusted: bool,
}
let mut left: Vec<Left> = Vec::new();
let (mut would_match_but_expired, mut would_match_but_tainted) = (false, false);
for loaded in grants {
let g = &loaded.grant;
if g.tool != request.tool {
continue;
}
let Some(path) = coverage(g, &call) else {
continue;
};
let expired = g.expires.is_some_and(|at| at.unix_millis() <= NOW_MS);
let tainted = state.taint > g.max_taint;
if expired && !tainted {
would_match_but_expired = true;
}
if tainted && !expired {
would_match_but_tainted = true;
}
if !expired && !tainted {
left.push(Left {
id: &loaded.id,
mode: g.mode,
path,
class: g.result_class,
untrusted: g.untrusted,
});
}
}
if left.is_empty() {
return if would_match_but_expired {
denied(DenyReason::GrantExpired)
} else if would_match_but_tainted {
denied(DenyReason::TaintTooHigh)
} else {
denied(DenyReason::NoGrant)
};
}
let class = left.iter().map(|l| l.class).max().unwrap();
let untrusted = left.iter().any(|l| l.untrusted);
// The winner: try each mode from the most restrictive; within it the longest path, then
// the lowest id.
for mode in [Mode::Deny, Mode::Ask, Mode::Auto] {
let mut of_mode: Vec<&Left> = left.iter().filter(|l| l.mode == mode).collect();
if of_mode.is_empty() {
continue;
}
of_mode.sort_by(|a, b| {
let (la, lb) = (
a.path.as_ref().map_or(0, String::len),
b.path.as_ref().map_or(0, String::len),
);
lb.cmp(&la).then(a.id.cmp(b.id))
});
let winner = of_mode[0];
let (grant, path) = (winner.id.to_string(), winner.path.clone());
return match mode {
Mode::Deny => Expected::Denied {
reason: DenyReason::DeniedByGrant,
grant: Some(grant),
},
Mode::Ask => Expected::Ask {
grant,
path,
class,
untrusted,
},
Mode::Auto => Expected::Allowed {
grant,
path,
class,
untrusted,
},
};
}
unreachable!("left is not empty, so one of the three modes has a grant")
}
+118
View File
@@ -0,0 +1,118 @@
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
//! a log to read. Do not edit.
//!
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
//! tests add `client`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use brokerd::audit::Writer;
use brokerd::config::{Approvals, Config, Paths, Sockets};
use brokerd::ledger::Ledger;
use brokerd::state::StateStore;
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
use crate::sink::{Flaky, Lines, Switch};
use crate::tmp::TempDir;
pub struct Rig {
pub dir: TempDir,
pub cfg: Config,
pub switch: Switch,
pub lines: Lines,
}
impl Rig {
pub fn new(tag: &str) -> Rig {
Rig::with_ttl(tag, 900_000)
}
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
let dir = TempDir::new(tag);
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
let cfg = Config {
paths: Paths {
home: dir.path().to_path_buf(),
grants,
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
};
Rig {
dir,
cfg,
switch: Switch::default(),
lines: Lines::default(),
}
}
pub fn state(&self) -> StateStore {
StateStore::new(&self.cfg.state_dir())
}
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
pub fn ledger(&self) -> Ledger {
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
let sink = Flaky {
writer: opened.writer,
switch: self.switch.clone(),
};
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
}
/// Writes `grants/<id>.toml`.
pub fn grant(&self, id: &str, text: &str) {
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
}
pub fn remove_grant(&self, id: &str) {
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
}
pub fn state_file(&self, session: &str) -> PathBuf {
self.cfg.state_dir().join(format!("{session}.json"))
}
/// Every record in the audit log, in order.
pub fn records(&self) -> Vec<AuditRecord> {
let dir = self.cfg.audit_dir();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let text = std::fs::read_to_string(dir.join(name)).unwrap();
for line in text.lines() {
out.push(serde_json::from_str(line).unwrap());
}
}
out
}
pub fn events(&self) -> Vec<AuditEvent> {
self.records().into_iter().map(|r| r.event).collect()
}
}
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
format!(
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
)
}
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new(session).unwrap(),
call: CallId(call),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
+70
View File
@@ -0,0 +1,70 @@
//! A runtime that records what it is asked to run, for the runner and broker tests. Do not edit.
//!
//! Included with `#[path = "support/runtime.rs"] mod runtime;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::{Arc, Mutex};
use brokerd::args::{ToolArgs, ToolName};
use brokerd::runner::{Mount, RunError, RunOutput, RunSpec, Runtime};
/// What one `run` was given, copied out of the `RunSpec`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seen {
pub tool: ToolName,
pub arguments: ToolArgs,
pub mounts: Vec<Mount>,
pub egress: Option<Vec<String>>,
}
pub struct Recording {
seen: Mutex<Vec<Seen>>,
answer: Result<RunOutput, RunError>,
}
impl Recording {
/// Answers every call with `content`, not truncated.
pub fn answering(content: &str) -> Arc<Recording> {
Recording::with(Ok(RunOutput {
content: content.to_string(),
truncated: false,
}))
}
pub fn with(answer: Result<RunOutput, RunError>) -> Arc<Recording> {
Arc::new(Recording {
seen: Mutex::new(Vec::new()),
answer,
})
}
pub fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
pub fn count(&self) -> usize {
self.seen.lock().unwrap().len()
}
}
impl Runtime for Recording {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.seen.lock().unwrap().push(Seen {
tool: spec.tool(),
arguments: spec.arguments().clone(),
mounts: spec.mounts().to_vec(),
egress: spec.egress().map(<[String]>::to_vec),
});
self.answer.clone()
}
}
/// Lets a test keep its `Arc<Recording>` while the broker owns a `Box<dyn Runtime>`.
pub struct Shared(pub Arc<Recording>);
impl Runtime for Shared {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.0.run(spec)
}
}
+78
View File
@@ -0,0 +1,78 @@
//! An audit sink that fails on demand, and a log that tests can read. Do not edit.
//!
//! Included with `#[path = "support/sink.rs"] mod sink;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use brokerd::audit::{AuditError, Writer};
use brokerd::ledger::AuditSink;
use proto::{AuditEvent, Timestamp};
/// Switches shared between a test and its `Flaky` sink.
#[derive(Clone, Default)]
pub struct Switch {
fail: Arc<AtomicBool>,
panic: Arc<AtomicBool>,
attempts: Arc<AtomicUsize>,
}
impl Switch {
/// Every append from now on fails, without writing anything.
pub fn fail(&self, on: bool) {
self.fail.store(on, Ordering::SeqCst);
}
/// The next append panics, as a bug part-way through a write would.
pub fn panic_next(&self) {
self.panic.store(true, Ordering::SeqCst);
}
/// How many appends the ledger has asked for.
pub fn attempts(&self) -> usize {
self.attempts.load(Ordering::SeqCst)
}
}
/// A real `Writer` behind a switch.
pub struct Flaky {
pub writer: Writer,
pub switch: Switch,
}
impl AuditSink for Flaky {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
self.switch.attempts.fetch_add(1, Ordering::SeqCst);
if self.switch.panic.swap(false, Ordering::SeqCst) {
panic!("a bug part-way through a write");
}
if self.switch.fail.load(Ordering::SeqCst) {
return Err(AuditError::Io {
what: "cannot write to the test log".to_string(),
source: std::io::Error::other("the disk is full"),
});
}
self.writer.append(time, event)
}
}
/// Collects every line a ledger or broker prints.
#[derive(Clone, Default)]
pub struct Lines(Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Box<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Box::new(move |line| lines.lock().unwrap().push(line.to_string()))
}
pub fn all(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
/// The lines that hold `text`.
pub fn with(&self, text: &str) -> Vec<String> {
self.all()
.into_iter()
.filter(|l| l.contains(text))
.collect()
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Temporary directories for tests. Do not edit.
//!
//! Included with `#[path = "support/tmp.rs"] mod tmp;`. No crate is used: the name is made from
//! the process id and a counter, and the directory is removed when the value is dropped.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// Writes `text` to `name` inside the directory and returns the full path.
pub fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
// Put back the permissions a test may have taken away, or the removal fails.
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// True when the tests run as root, for whom file permissions do not apply. Tests that depend on
/// a permission error print why they are skipped and return.
pub fn running_as_root(test: &str) -> bool {
let probe = TempDir::new("rootprobe");
let file = probe.write("probe", "x");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
let root = std::fs::read(&file).is_ok();
if root {
eprintln!("{test}: skipped, because this user can read a mode 000 file (root?)");
}
root
}
+297
View File
@@ -0,0 +1,297 @@
//! 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;
/// How long `bxctl` waits for `brokerd` to take or answer an admin request.
pub const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
// Ask brokerd for one message and read one answer, waiting at most `ADMIN_TIMEOUT`.
pub fn request(socket: &Path, msg: Message) -> Result<Message, AdminError> {
request_with_timeout(socket, msg, ADMIN_TIMEOUT)
}
/// `request` with another limit on each read and write, so a `brokerd` that accepts and never
/// answers cannot hang `bxctl`, or a `chat` turn waiting on an approval.
pub fn request_with_timeout(
socket: &Path,
msg: Message,
timeout: std::time::Duration,
) -> Result<Message, AdminError> {
let mut stream =
UnixStream::connect(socket).map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?;
stream
.set_read_timeout(Some(timeout))
.and_then(|()| stream.set_write_timeout(Some(timeout)))
.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(|e| match e {
FrameError::Io(io)
if matches!(
io.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
AdminError::NoAnswer(timeout)
}
other => AdminError::Frame(other),
})?;
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),
/// Writing the output failed; the caller stops rather than write again.
Io(std::io::Error),
/// The request was sent and no answer came in time, so what brokerd did is not known.
NoAnswer(std::time::Duration),
}
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()),
// serde quotes the offending text of a bad frame, decoded: escape it.
AdminError::Frame(e) => write!(f, "{}", escape_json_text(&e.to_string())),
// The detail may carry text from the inference server or a tool: escape it.
AdminError::Refused(w) => {
write!(f, "{}: {}", code_name(w.code), escape_json_text(&w.detail))
}
AdminError::Protocol(s) => write!(f, "{s}"),
AdminError::Io(e) => write!(f, "{e}"),
AdminError::NoAnswer(t) => write!(
f,
"brokerd did not answer within {} s; whether it acted is unknown: check `bxctl approvals` and `bxctl audit verify`",
t.as_secs_f64()
),
}
}
}
// An error writing the output is its own kind, so the caller can stop instead of writing again.
impl From<std::io::Error> for AdminError {
fn from(e: std::io::Error) -> Self {
AdminError::Io(e)
}
}
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",
}
}
+159 -12
View File
@@ -1,14 +1,17 @@
//! `bxctl chat`: a turn client over `loop.sock` and a printer for the events it receives.
use std::io::Write;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, TurnEvent,
WireError, read_frame, write_frame,
};
use std::io::{BufRead, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use proto::{
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Timestamp, Turn,
TurnDone, TurnEvent, WireError, read_frame, write_frame,
};
use crate::admin;
use crate::escape::{escape_json_text, escape_model_text};
#[derive(Debug)]
pub enum ChatError {
Connect(std::io::Error),
@@ -21,8 +24,12 @@ impl std::fmt::Display for ChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChatError::Connect(e) => write!(f, "{e}"),
ChatError::Frame(e) => write!(f, "{e}"),
ChatError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
// serde quotes the offending text of a bad frame, decoded: escape it.
ChatError::Frame(e) => write!(f, "{}", escape_json_text(&e.to_string())),
// The detail may carry the inference server's body: escape it like model text.
ChatError::Refused(w) => {
write!(f, "{}: {}", code_name(w.code), escape_json_text(&w.detail))
}
ChatError::Protocol(s) => write!(f, "{s}"),
}
}
@@ -39,7 +46,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",
@@ -51,6 +58,8 @@ fn code_name(code: ErrorCode) -> &'static str {
ErrorCode::NoSuchSession => "no such session",
ErrorCode::SessionExists => "session exists",
ErrorCode::Inference => "inference",
ErrorCode::Forbidden => "forbidden",
ErrorCode::NoSuchApproval => "no such approval",
}
}
@@ -140,7 +149,7 @@ impl Printer {
out.write_all(b"\x1b[2m")?;
self.dimmed_open = true;
}
out.write_all(text.as_bytes())?;
out.write_all(escape_model_text(text).as_bytes())?;
}
}
TurnEvent::Content { text } => {
@@ -149,11 +158,12 @@ impl Printer {
self.dimmed_open = false;
}
if self.stream_content {
out.write_all(text.as_bytes())?;
out.write_all(escape_model_text(text).as_bytes())?;
}
}
TurnEvent::ToolCallStarted { name } => {
self.close_dimmed(out)?;
let name = escape_model_text(name);
writeln!(out, "[tool {name}]")?;
}
TurnEvent::ToolResult {
@@ -162,6 +172,7 @@ impl Printer {
truncated,
} => {
self.close_dimmed(out)?;
let name = escape_model_text(name);
if *truncated {
writeln!(out, "[{name}: {class:?}, truncated]")?;
} else {
@@ -181,7 +192,8 @@ impl Printer {
self.close_dimmed(out)?;
writeln!(
out,
"[retrying: attempt {attempt} in {after_ms} ms: {error}]"
"[retrying: attempt {attempt} in {after_ms} ms: {}]",
escape_json_text(error)
)?;
}
TurnEvent::ThinkingCapped { tokens } => {
@@ -193,6 +205,32 @@ impl Printer {
writeln!(out, "[cache loss: {got} of {expected}]")?;
}
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {}
TurnEvent::ApprovalPending { .. } => {
self.close_dimmed(out)?;
}
TurnEvent::ToolDenied { name, reason } => {
self.close_dimmed(out)?;
let shown = escape_model_text(name);
writeln!(out, "[denied {shown}: {}]", admin::reason_name(*reason))?;
match reason {
DenyReason::GrantsInvalid => {
writeln!(out, "see docs/runbook.md#grants-invalid")?;
}
DenyReason::AuditUnavailable => {
writeln!(out, "see docs/runbook.md#audit-unavailable")?;
}
DenyReason::StateUnreadable => {
writeln!(out, "see docs/runbook.md#broker-state-damaged")?;
}
DenyReason::NoGrant
| DenyReason::GrantExpired
| DenyReason::TaintTooHigh
| DenyReason::DeniedByGrant
| DenyReason::ApprovalRefused
| DenyReason::ApprovalExpired
| DenyReason::InvalidArguments => {}
}
}
}
out.flush()
}
@@ -209,3 +247,112 @@ impl Printer {
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnPending {
EventOnly,
Show,
Ask,
}
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,
pub out: &'a mut dyn Write,
}
pub fn handle_pending(
admin_socket: &Path,
approval: u64,
ask: bool,
now: Timestamp,
input: &mut dyn BufRead,
out: &mut dyn Write,
) -> std::io::Result<()> {
let items = match admin::list(admin_socket) {
Ok(items) => items,
Err(e) => {
writeln!(out, "approval {approval}: cannot ask brokerd: {e}")?;
return Ok(());
}
};
let Some(item) = items.iter().find(|item| item.approval == approval) else {
writeln!(out, "approval {approval} is no longer pending")?;
return Ok(());
};
out.write_all(b"\x1b[0m")?;
admin::write_block(out, item, now)?;
if !ask {
return Ok(());
}
write!(out, "type {approval} to approve, anything else refuses: ")?;
out.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
if line.ends_with('\n') {
line.pop();
}
if line.ends_with('\r') {
line.pop();
}
let result = if line == approval.to_string() {
admin::cmd_approve(admin_socket, approval, out)
} else {
admin::cmd_refuse(admin_socket, approval, None, out)
};
match result {
Ok(_) => Ok(()),
// The output itself failed: writing another line to it would fail the same way.
Err(admin::AdminError::Io(e)) => Err(e),
Err(e) => {
writeln!(out, "approval {approval}: {e}")?;
Ok(())
}
}
}
/// 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>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, &mut |event| {
if err.is_some() {
return;
}
if let Err(e) = io.printer.event(io.out, event) {
err = Some(e);
return;
}
if let TurnEvent::ApprovalPending { approval, .. } = event
&& approvals.on_pending != OnPending::EventOnly
{
let ask = approvals.on_pending == OnPending::Ask;
if let Err(e) = handle_pending(
approvals.admin_socket,
*approval,
ask,
Timestamp::now(),
io.input,
io.out,
) {
err = Some(e);
}
}
});
if let Some(e) = err {
return Err(e);
}
io.printer.end_reasoning(io.out)?;
Ok(outcome)
}
+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]
Check the audit log's hash chain, reading the files directly.";
// 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;
+125 -121
View File
@@ -4,139 +4,133 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
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::admin::{self, AdminError};
use bxctl::chat::{Approvals, ChatError, OnPending, Printer, TurnIo, new_session_id, stream_turn};
use bxctl::cli::{self, ChatOptions, Command, USAGE};
use bxctl::escape::escape_model_text;
use proto::{ErrorCode, SessionId, Timestamp, TurnDone};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) != Some("chat") {
// `args_os`, because `args` panics on an argument that is not UTF-8. bxctl's arguments are
// text (ids, messages, paths it prints back), so such an argument is a usage error.
let args: Vec<String> = match std::env::args_os()
.skip(1)
.map(|a| a.into_string())
.collect()
{
Ok(args) => args,
Err(_) => {
eprintln!("bxctl: an argument is not valid UTF-8\n{USAGE}");
return ExitCode::from(2);
}
};
// $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"));
// 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 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(()),
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 } => {
let mut out = std::io::stdout().lock();
match bxctl::verify::run(&home, &mut out) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!(
"bxctl: cannot read the audit log under {}: {e}",
home.display()
);
ExitCode::from(1)
}
}
"--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")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock")
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
// Result) does not lose a write failure. The error is checked after run_turn returns.
struct Sink<'a> {
out: &'a mut (dyn Write + 'static),
err: Option<std::io::Error>,
}
impl Sink<'_> {
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
if self.err.is_none() {
self.err = printer.event(self.out, event).err();
// 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)
}
}
}
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the
// first io error that occurred while writing.
fn stream_turn(
socket: &Path,
session: &SessionId,
text: &str,
resume: bool,
sink: &mut Sink,
printer: &mut Printer,
) -> Result<Result<TurnDone, ChatError>, std::io::Error> {
let outcome = run_turn(socket, session, text, resume, &mut |event| {
sink.event(printer, event)
});
// Flush any dimmed block left open before reporting the turn's result.
if sink.err.is_none() {
printer.end_reasoning(sink.out)?;
fn cmd_approvals(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approvals(admin, Timestamp::now(), &mut out)
}
match sink.err.take() {
Some(e) => Err(e),
None => Ok(outcome),
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)
}
fn on_pending(opts: &ChatOptions) -> OnPending {
if opts.json {
OnPending::EventOnly
} else if opts.say.is_some() {
OnPending::Show
} else {
OnPending::Ask
}
}
fn run(opts: &Options) -> ExitCode {
fn run(opts: &ChatOptions) -> ExitCode {
let stdin = std::io::stdin();
let mut input = BufReader::new(stdin.lock());
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
Some(text) => run_say(opts, text, &mut input),
None => run_interactive(opts, &mut input),
}
}
fn run_say(opts: &Options, text: &str) -> ExitCode {
fn run_say(opts: &ChatOptions, text: &str, input: &mut dyn BufRead) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
};
let mut printer = Printer::new(opts.show_thinking, opts.json);
let code = run_turn_twice(opts, &session, text, opts.session.is_some(), &mut printer);
let code = run_turn_twice(
opts,
&session,
text,
opts.session.is_some(),
&mut printer,
input,
);
match code {
Ok(()) => ExitCode::SUCCESS,
Err(RunError::Io(e)) => {
@@ -152,19 +146,25 @@ 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,
printer: &mut Printer,
input: &mut dyn BufRead,
) -> Result<(), RunError> {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
out: &mut handle,
err: None,
let approvals = Approvals {
admin_socket: &opts.admin_socket,
on_pending: on_pending(opts),
};
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
let mut io = TurnIo {
printer,
input,
out: &mut handle,
};
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -172,11 +172,12 @@ fn run_turn_twice(
}
Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
// The named session does not exist: retry once, creating it (resume=false).
let mut sink = Sink {
let mut io = TurnIo {
printer,
input,
out: &mut handle,
err: None,
};
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -189,11 +190,9 @@ fn run_turn_twice(
}
}
fn run_interactive(opts: &Options) -> ExitCode {
fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
loop {
eprint!("> ");
let _ = std::io::stderr().flush();
@@ -222,17 +221,22 @@ fn run_interactive(opts: &Options) -> ExitCode {
}
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
let approvals = Approvals {
admin_socket: &opts.admin_socket,
on_pending: on_pending(opts),
};
let mut io = TurnIo {
printer: &mut printer,
input: reader,
out: &mut handle,
err: None,
};
match stream_turn(
&opts.socket,
this_session,
text,
resume,
&mut sink,
&mut printer,
&approvals,
&mut io,
) {
Err(e) => {
eprintln!("bxctl: {e}");
@@ -255,7 +259,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();
@@ -264,7 +268,7 @@ fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(done.content.as_bytes())?;
out.write_all(escape_model_text(&done.content).as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}
+90
View File
@@ -0,0 +1,90 @@
//! Verifies the whole audit log under a home directory with `proto::ChainVerifier`, reading the
//! files itself so it works when `brokerd` will not start.
use std::io::Write;
use std::path::Path;
use proto::ChainVerifier;
/// Verifies the whole audit log under `home` and prints the report to `out`.
/// `Ok(true)`: the chain verifies. `Ok(false)`: it does not. `Err`: the log cannot be read.
pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result<bool> {
let audit_dir = home.join("audit");
let mut names: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&audit_dir)? {
let entry = entry?;
let name = match entry.file_name().into_string() {
Ok(name) => name,
Err(raw) => {
skipped.push(raw.to_string_lossy().into_owned());
continue;
}
};
if proto::is_audit_log_name(&name) {
names.push(name);
} else if name.ends_with(".jsonl") {
// Looks like a log file but is not named as one: brokerd ignores it too. Say so, so
// that nobody takes "ok" to cover it.
skipped.push(name);
}
}
names.sort();
let mut verifier = ChainVerifier::new();
for name in &names {
let bytes = std::fs::read(audit_dir.join(name))?;
verifier.feed(name, &bytes);
}
let report = verifier.finish();
if let Some(failure) = &report.failure {
writeln!(out, "{}:{}: {}", failure.file, failure.line, failure.what)?;
writeln!(out, "see docs/runbook.md#audit-chain-broken")?;
write_skipped(out, &skipped)?;
return Ok(false);
}
let head = match report.head {
Some(hash) => format!("head {}", hash.to_hex()),
None => "head none".to_string(),
};
writeln!(out, "audit: ok, {} records, {}", report.records, head)?;
for loc in &report.recoveries {
writeln!(out, "recovered line: {}:{}", loc.file, loc.line)?;
}
for loc in &report.accepted_breaks {
writeln!(out, "accepted break: {}:{}", loc.file, loc.line)?;
}
for seq in &report.abandoned {
writeln!(out, "pending or abandoned: approval {}", seq)?;
}
for seq in &report.unfinished {
writeln!(out, "running or unfinished: decision {}", seq)?;
}
for loc in &report.clock_warnings {
writeln!(out, "clock went backwards: {}:{}", loc.file, loc.line)?;
}
if let Some(torn) = &report.torn_tail {
writeln!(
out,
"torn final line: {}:{} (brokerd recovers it at its next start)",
torn.at.file, torn.at.line
)?;
}
write_skipped(out, &skipped)?;
Ok(true)
}
/// One line per file in `audit/` that looks like a log but is not named as one; neither `brokerd`
/// nor this check reads it. Names are escaped: a file name can hold anything.
fn write_skipped(out: &mut dyn Write, skipped: &[String]) -> std::io::Result<()> {
for name in skipped {
writeln!(
out,
"not an audit log file, not checked: {}",
crate::escape::escape_json_text(name)
)?;
}
Ok(())
}
+497
View File
@@ -0,0 +1,497 @@
//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`.
//! Do not edit.
mod support;
use bxctl::admin::{
AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name,
request, write_block,
};
use proto::{
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport,
Message, PROTOCOL_VERSION, Refuse,
};
use std::process::Command;
use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error};
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 text(out: Vec<u8>) -> String {
String::from_utf8(out).unwrap()
}
const ALLOWED: DecisionRecord = DecisionRecord::Allowed {};
// ---- the client ----
#[test]
fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() {
let fake = fake_brokerd_frames(|request| {
assert_eq!(request.v, PROTOCOL_VERSION);
assert_eq!(request.id, 1);
assert!(request.r#final);
vec![Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap();
assert_eq!(answer, Message::Ok(Empty {}));
assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]);
}
#[test]
fn an_error_frame_is_refused_with_its_code_and_detail() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket"));
match request(&fake.socket, Message::Approvals(Empty {})) {
Err(AdminError::Refused(w)) => {
assert_eq!(w.code, ErrorCode::Forbidden);
assert_eq!(w.detail, "not on this socket");
}
other => panic!("{other:?}"),
}
let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err();
assert_eq!(e.to_string(), "forbidden: not on this socket");
}
#[test]
fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() {
let not_final = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: false,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&not_final.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
let other_id = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id + 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&other_id.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
}
#[test]
fn a_connection_closed_without_an_answer_is_a_frame_error() {
let fake = fake_brokerd_frames(|_| Vec::new());
assert!(matches!(
request(&fake.socket, Message::Approvals(Empty {})),
Err(AdminError::Frame(_))
));
}
#[test]
fn no_brokerd_is_a_connect_error_that_names_the_socket() {
let missing = support::temp_socket("nobody-listens.sock");
let e = request(&missing, Message::Approvals(Empty {})).unwrap_err();
assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}");
let message = e.to_string();
assert!(message.starts_with("cannot reach brokerd at "), "{message}");
assert!(message.contains(missing.to_str().unwrap()), "{message}");
}
#[test]
fn list_returns_the_items_and_rejects_any_other_kind() {
let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")];
let fake = brokerd_with(items.clone(), ALLOWED);
assert_eq!(list(&fake.socket).unwrap(), items);
assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]);
let wrong = fake_brokerd(|_| Message::Ok(Empty {}));
assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_))));
}
#[test]
fn reason_names_are_the_wire_names() {
for reason in [
DenyReason::NoGrant,
DenyReason::GrantExpired,
DenyReason::TaintTooHigh,
DenyReason::DeniedByGrant,
DenyReason::ApprovalRefused,
DenyReason::ApprovalExpired,
DenyReason::GrantsInvalid,
DenyReason::AuditUnavailable,
DenyReason::InvalidArguments,
DenyReason::StateUnreadable,
] {
let wire = serde_json::to_string(&reason).unwrap();
assert_eq!(format!("\"{}\"", reason_name(reason)), wire);
}
}
// ---- the block ----
fn block(item: &proto::PendingApproval, now: &str) -> String {
let mut out = Vec::new();
write_block(&mut out, item, ts(now)).unwrap();
text(out)
}
#[test]
fn the_block_is_two_lines_in_this_exact_form() {
let item = pending(
41,
"shell",
r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#,
);
assert_eq!(
block(&item, "2026-09-18T12:02:00.000Z"),
concat!(
"41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n",
" shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n",
)
);
}
#[test]
fn times_are_whole_seconds_minutes_or_hours_rounded_down() {
let item = pending(41, "shell", "{}");
let first = |now: &str| block(&item, now).lines().next().unwrap().to_string();
// created 12:00:00, expires 12:15:00
assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min "));
assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min "));
assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min "));
assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s "));
// At or after `expires` there is nothing left to wait for.
assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired "));
assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired "));
// A clock that is behind the broker's must not underflow.
assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min "));
let mut long = pending(41, "shell", "{}");
long.expires = ts("2026-09-18T15:30:00.000Z");
assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h "));
}
#[test]
fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() {
let mut item = pending(41, "shell", "{}");
for (id, shown) in [
("s1", "session s1 "),
("abcdefghij", "session abcdefghij "),
("abcdefghijk", "session abcdefghi… "),
] {
item.session = proto::SessionId::new(id).unwrap();
let got = block(&item, "2026-09-18T12:00:00.000Z");
assert!(got.contains(shown), "{id}: {got}");
}
}
#[test]
fn taint_is_the_wire_name() {
let mut item = pending(41, "shell", "{}");
item.taint = proto::DataClass::Secret;
assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n"));
}
/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through
/// `escape_json_text`.
#[test]
fn nothing_in_the_block_reaches_the_terminal_raw() {
let rlo = char::from_u32(0x202e).unwrap();
let zwsp = char::from_u32(0x200b).unwrap();
let isolate = char::from_u32(0x2066).unwrap();
let arguments =
format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}");
let mut item = pending(41, "read\x1b[1mfile", &arguments);
item.grant = "notes\nread".to_string();
let got = block(&item, "2026-09-18T12:00:00.000Z");
for c in ['\x1b', rlo, zwsp, isolate] {
assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c));
}
assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}");
assert!(
got.contains(&format!(
"/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J",
esc(0x202e),
esc(0x200b),
esc(0x2066),
esc(0x1b),
esc(0x1b)
)),
"{got:?}"
);
assert!(
got.contains(&format!(" read{}[1mfile ", esc(0x1b))),
"{got:?}"
);
assert!(
got.contains(&format!("grant notes{}read ", esc(0x0a))),
"{got:?}"
);
}
// ---- the commands ----
#[test]
fn approvals_prints_one_block_per_item_in_the_order_given() {
let fake = brokerd_with(
vec![
pending(41, "shell", r#"{"command":"ls"}"#),
pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#),
],
ALLOWED,
);
let mut out = Vec::new();
let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap();
assert!(ok);
let got = text(out);
let lines: Vec<&str> = got.lines().collect();
assert_eq!(lines.len(), 4, "{got}");
assert!(lines[0].starts_with("41 2 min ago "), "{got}");
assert_eq!(lines[1], r#" shell {"command":"ls"}"#);
assert!(lines[2].starts_with("44 2 min ago "), "{got}");
assert_eq!(
lines[3],
r#" read_file {"path":"/home/kyle/notes/a.md"}"#
);
}
#[test]
fn approvals_with_nothing_pending_says_so() {
let fake = brokerd_with(Vec::new(), ALLOWED);
let mut out = Vec::new();
assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap());
assert_eq!(text(out), "no pending approvals\n");
}
#[test]
fn approve_reports_the_re_decision() {
// `ask` and `allowed` both let the call run; only a denial stops it.
for (outcome, line, ok) in [
(DecisionRecord::Allowed {}, "approved 41: runs\n", true),
(DecisionRecord::Ask {}, "approved 41: runs\n", true),
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant,
},
"approved 41: denied (no_grant)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::TaintTooHigh,
},
"approved 41: denied (taint_too_high)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::AuditUnavailable,
},
"approved 41: denied (audit_unavailable)\n",
false,
),
] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome);
let mut out = Vec::new();
assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok);
assert_eq!(text(out), line);
assert_eq!(
fake.requests(),
vec![Message::Approve(Approve { approval: 41 })]
);
}
}
#[test]
fn refuse_sends_the_reason_when_there_is_one() {
for reason in [None, Some("not on a Friday")] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let mut out = Vec::new();
assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap());
assert_eq!(text(out), "refused 41\n");
assert_eq!(
fake.requests(),
vec![Message::Refuse(Refuse {
approval: 41,
reason: reason.map(str::to_string)
})]
);
}
}
#[test]
fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let want = "99: no such approval (already answered or expired)\n";
let mut out = Vec::new();
assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap());
assert_eq!(text(out), want);
let mut out = Vec::new();
assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap());
assert_eq!(text(out), want);
}
/// Every exit of every command: any other error frame is an error, and so is an answer of the
/// wrong kind. Nothing is printed for them.
#[test]
fn other_errors_and_wrong_kinds_are_errors_for_every_command() {
let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let wrong = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {}));
let now = ts("2026-09-18T12:00:00.000Z");
let mut out = Vec::new();
assert!(matches!(
cmd_approvals(&refused.socket, now, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approve(&refused.socket, 41, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_refuse(&refused.socket, 41, None, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_grants_check(&refused.socket, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approvals(&wrong.socket, now, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_approve(&wrong.socket, 41, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_refuse(&wrong.socket, 41, None, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_grants_check(&wrong_for_grants.socket, &mut out),
Err(AdminError::Protocol(_))
));
assert_eq!(text(out), "", "an error prints nothing on the output");
}
#[test]
fn grants_check_prints_ok_or_every_problem() {
let fine = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let mut out = Vec::new();
assert!(cmd_grants_check(&fine.socket, &mut out).unwrap());
assert_eq!(text(out), "grants: ok\n");
assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]);
let broken = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: vec![
GrantProblem {
file: "notes-read.toml".to_string(),
line: Some(3),
problem: "unknown field `mod`".to_string(),
},
GrantProblem {
file: "Bad Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
GrantProblem {
file: "x.toml".to_string(),
line: Some(1),
problem: "two\nlines".to_string(),
},
],
})
});
let mut out = Vec::new();
assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap());
assert_eq!(
text(out),
format!(
"notes-read.toml:3: unknown field `mod`\n\
Bad Name.toml: the file name is not a valid grant id\n\
x.toml:1: two{}lines\n",
esc(0x0a)
),
"every problem, one line each; file and problem go through escape_json_text"
);
}
// ---- the binary ----
fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(args)
.arg("--admin-socket")
.arg(socket)
.output()
.unwrap()
}
#[test]
fn the_binary_prints_on_stdout_and_sets_the_exit_status() {
let fake = brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant,
},
);
let output = bxctl(&["approvals"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.ends_with(" shell {\"command\":\"ls\"}\n"),
"{stdout}"
);
let output = bxctl(&["approve", "41"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"approved 41: denied (denied_by_grant)\n"
);
let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n");
let output = bxctl(&["refuse", "7"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"7: no such approval (already answered or expired)\n"
);
}
#[test]
fn the_binary_reports_an_error_on_stderr_with_status_1() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let output = bxctl(&["grants", "check"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"bxctl: internal: boom\n"
);
}
+42
View File
@@ -0,0 +1,42 @@
//! A `brokerd` that accepts and never answers does not hang `bxctl` (M3a review finding 9).
use std::os::unix::net::UnixListener;
use std::time::{Duration, Instant};
use bxctl::admin::{ADMIN_TIMEOUT, request_with_timeout};
use proto::{Empty, Message};
#[test]
fn the_default_is_thirty_seconds() {
assert_eq!(ADMIN_TIMEOUT, Duration::from_secs(30));
}
#[test]
fn a_silent_brokerd_is_an_error_after_the_timeout() {
let dir = std::env::temp_dir().join(format!("bx-admin-timeout-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let socket = dir.join("admin.sock");
let listener = UnixListener::bind(&socket).unwrap();
let held = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
std::thread::sleep(Duration::from_millis(2_000));
drop(stream);
});
let started = Instant::now();
let got = request_with_timeout(
&socket,
Message::Approvals(Empty {}),
Duration::from_millis(200),
);
let took = started.elapsed();
let text = got.expect_err("a silent brokerd is an error").to_string();
assert!(
text.contains("whether it acted is unknown"),
"brokerd may have approved and run the call before going quiet: {text}"
);
assert!(took < Duration::from_millis(1_500), "waited {took:?}");
held.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
+19
View File
@@ -0,0 +1,19 @@
//! An argument that is not UTF-8 is a usage error, not a panic (M3a review finding 7).
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::process::Command;
#[test]
fn an_argument_that_is_not_utf8_is_a_usage_error() {
let out = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args([OsStr::new("approve"), OsStr::from_bytes(b"4\xff")])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.starts_with("bxctl: an argument is not valid UTF-8"),
"{err}"
);
}
+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");
}
+283
View File
@@ -0,0 +1,283 @@
//! Tests for `bxctl`'s command line. Do not edit.
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
use proto::SessionId;
use std::path::{Path, PathBuf};
use std::process::Command as Process;
const HOME: &str = "/srv/bx";
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
fn ok(words: &[&str]) -> Command {
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
}
fn bad(words: &[&str]) {
assert_eq!(
parse(&args(words), Path::new(HOME)),
Err(UsageError),
"{words:?} must be a usage error"
);
}
fn default_admin() -> PathBuf {
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
}
#[test]
fn chat_defaults_come_from_home() {
assert_eq!(
ok(&["chat"]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
admin_socket: default_admin(),
session: None,
show_thinking: true,
say: None,
json: false,
})
);
}
#[test]
fn chat_takes_every_flag_in_any_order() {
assert_eq!(
ok(&[
"chat",
"--json",
"--admin-socket",
"/tmp/a.sock",
"--say",
"hello there",
"--no-thinking",
"--session",
"s-1",
"--socket",
"/tmp/l.sock",
]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/tmp/l.sock"),
admin_socket: PathBuf::from("/tmp/a.sock"),
session: Some(SessionId::new("s-1").unwrap()),
show_thinking: false,
say: Some("hello there".to_string()),
json: true,
})
);
}
#[test]
fn chat_usage_errors() {
bad(&["chat", "--session", "Not Valid!"]);
bad(&["chat", "--session"]);
bad(&["chat", "--socket"]);
bad(&["chat", "--admin-socket"]);
bad(&["chat", "--say"]);
bad(&["chat", "--dance"]);
bad(&["chat", "stray"]);
}
#[test]
fn approvals() {
assert_eq!(
ok(&["approvals"]),
Command::Approvals {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
Command::Approvals {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
bad(&["approvals", "41"]);
bad(&["approvals", "--admin-socket"]);
bad(&["approvals", "--reason", "x"]);
}
#[test]
fn approve() {
assert_eq!(
ok(&["approve", "41"]),
Command::Approve {
admin_socket: default_admin(),
approval: 41
}
);
// The flag may come before or after the id.
for words in [
["approve", "--admin-socket", "/tmp/a.sock", "41"],
["approve", "41", "--admin-socket", "/tmp/a.sock"],
] {
assert_eq!(
ok(&words),
Command::Approve {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41
}
);
}
assert_eq!(
ok(&["approve", "18446744073709551615"]),
Command::Approve {
admin_socket: default_admin(),
approval: u64::MAX
}
);
}
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
#[test]
fn an_approval_id_is_only_digits() {
bad(&["approve"]);
bad(&["approve", "41", "42"]);
bad(&["approve", "+41"]);
bad(&["approve", "-1"]);
bad(&["approve", "4 1"]);
bad(&["approve", " 41"]);
bad(&["approve", "0x29"]);
bad(&["approve", "forty-one"]);
bad(&["approve", ""]);
bad(&["approve", "18446744073709551616"]);
bad(&["approve", "41", "--reason", "x"]);
bad(&["refuse"]);
bad(&["refuse", "+41"]);
bad(&["refuse", "41", "42"]);
}
#[test]
fn refuse() {
assert_eq!(
ok(&["refuse", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: None
}
);
assert_eq!(
ok(&[
"refuse",
"41",
"--reason",
"not on a Friday",
"--admin-socket",
"/tmp/a.sock"
]),
Command::Refuse {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41,
reason: Some("not on a Friday".to_string())
}
);
// A value is a value, even when it looks like a flag.
assert_eq!(
ok(&["refuse", "--reason", "--admin-socket", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: Some("--admin-socket".to_string())
}
);
bad(&["refuse", "41", "--reason"]);
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
}
#[test]
fn grants_check_and_audit_verify() {
assert_eq!(
ok(&["grants", "check"]),
Command::GrantsCheck {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
Command::GrantsCheck {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
assert_eq!(
ok(&["audit", "verify"]),
Command::AuditVerify {
home: PathBuf::from(HOME)
}
);
assert_eq!(
ok(&["audit", "verify", "--home", "/tmp/h"]),
Command::AuditVerify {
home: PathBuf::from("/tmp/h")
}
);
bad(&["grants"]);
bad(&["grants", "list"]);
bad(&["grants", "check", "extra"]);
bad(&["audit"]);
bad(&["audit", "verify", "--home"]);
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
}
#[test]
fn anything_else_is_a_usage_error() {
bad(&[]);
bad(&["dance"]);
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
}
#[test]
fn usage_names_every_command() {
for word in [
"bxctl chat",
"bxctl approvals",
"bxctl approve <id>",
"bxctl refuse <id>",
"bxctl grants check",
"bxctl audit verify",
"--admin-socket",
"--reason",
"--home",
] {
assert!(USAGE.contains(word), "usage lacks {word:?}");
}
}
#[test]
fn the_binary_prints_usage_and_exits_2() {
for words in [
vec![],
vec!["dance"],
vec!["approve", "+41"],
vec!["refuse"],
] {
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.args(&words)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{words:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
"{words:?}"
);
}
}
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
/// to connect, and says where it tried.
#[test]
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.arg("approvals")
.env("BOXMAKER_HOME", &home)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
let want = home.join("run/owner-broker/admin.sock");
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
}
+150
View File
@@ -0,0 +1,150 @@
//! Tests for `bxctl::escape`: text the model wrote is printed as data. Do not edit.
//!
//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this
//! file can turn one into the character it stands for.
use bxctl::escape::{escape_json_text, escape_model_text};
const BACKSLASH: char = '\\';
/// The escape for one code point: a backslash, `u`, and four lowercase hex digits.
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn ch(code: u32) -> char {
char::from_u32(code).unwrap()
}
/// Every code point that must be escaped, as inclusive ranges.
const HIDDEN: [(u32, u32); 6] = [
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
fn hidden(code: u32) -> bool {
HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code))
}
/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left
/// exactly as it is.
#[test]
fn every_listed_code_point_is_escaped_and_no_other() {
let mut escaped = 0;
for code in 0..0x11000u32 {
let Some(c) = char::from_u32(code) else {
continue; // the surrogates are not characters
};
let text = format!("a{c}b");
let got = escape_json_text(&text);
if hidden(code) {
escaped += 1;
assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}");
} else {
assert_eq!(got, text, "U+{code:04X} must pass through");
}
}
assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full");
}
#[test]
fn the_edges_of_each_range() {
for (lo, hi) in HIDDEN {
assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo));
assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi));
if lo > 0 {
let before = ch(lo - 1).to_string();
assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1);
}
let after = ch(hi + 1).to_string();
assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1);
}
}
#[test]
fn hex_digits_are_lowercase_and_there_are_always_four() {
assert_eq!(escape_json_text("\x1b"), esc(0x1b));
assert!(escape_json_text("\x1b").ends_with("001b"));
assert!(escape_json_text("\0").ends_with("0000"));
assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff"));
assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e"));
}
#[test]
fn an_escape_sequence_cannot_reach_the_terminal() {
let text = "before\x1b[8mhidden\x1b[0m\x07after";
let got = escape_json_text(text);
assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}");
assert_eq!(
got,
format!(
"before{}[8mhidden{}[0m{}after",
esc(0x1b),
esc(0x1b),
esc(0x07)
)
);
}
#[test]
fn a_path_cannot_be_shown_backwards() {
// U+202E makes a terminal draw what follows from right to left.
let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e));
assert_eq!(
escape_json_text(&text),
format!("/home/kyle/notes/{}dm.terces", esc(0x202e))
);
let text = format!("a{}b{}c", ch(0x200b), ch(0x2066));
assert_eq!(
escape_json_text(&text),
format!("a{}b{}c", esc(0x200b), esc(0x2066))
);
}
#[test]
fn ordinary_text_is_unchanged() {
for text in [
"",
"plain",
r#"{"command":"ls -l","cwd":"/home/kyle"}"#,
"naïve café 日本語 🙂",
"a backslash \\ and a quote \" stay as they are",
] {
assert_eq!(escape_json_text(text), text);
assert_eq!(escape_model_text(text), text);
}
}
#[test]
fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() {
let text = "one\n\ttwo\r\n";
assert_eq!(
escape_json_text(text),
format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a))
);
assert_eq!(
escape_model_text(text),
format!("one\n\ttwo{}\n", esc(0x0d)),
"only newline and tab pass; a carriage return could overwrite the line"
);
}
#[test]
fn model_text_escapes_everything_else_the_same_way() {
for (lo, hi) in HIDDEN {
for code in lo..=hi {
if code == 0x0a || code == 0x09 {
continue;
}
assert_eq!(
escape_model_text(&ch(code).to_string()),
esc(code),
"U+{code:04X}"
);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Text from `brokerd` or the inference server is escaped like model text before it reaches the
//! terminal: a `retrying` error and every error detail (M3a review finding 10).
use bxctl::admin::AdminError;
use bxctl::chat::{ChatError, Printer};
use proto::{ErrorCode, TurnEvent, WireError};
const ESC: char = '\u{1b}';
fn hostile() -> String {
format!("the server responded with 503: {ESC}[2J{ESC}]0;owned\u{7}\u{202e}")
}
fn clean(text: &str) {
for c in text.chars() {
let cp = u32::from(c);
assert!(
cp >= 0x20 && !(0x7f..=0x9f).contains(&cp) && !(0x2028..=0x202e).contains(&cp)
|| c == '\n',
"raw {cp:#x} in {text:?}"
);
}
}
#[test]
fn a_retrying_error_is_escaped() {
let mut out = Vec::new();
let mut printer = Printer::new(true, false);
printer
.event(
&mut out,
&TurnEvent::Retrying {
attempt: 1,
after_ms: 2000,
error: hostile(),
},
)
.unwrap();
let text = String::from_utf8(out).unwrap();
clean(&text);
assert!(text.contains("\\u001b[2J"), "{text}");
}
#[test]
fn an_error_detail_is_escaped_in_chat_and_admin_errors() {
let wire = WireError {
code: ErrorCode::Inference,
detail: hostile(),
};
for text in [
ChatError::Refused(wire.clone()).to_string(),
AdminError::Refused(wire).to_string(),
] {
clean(&text);
assert!(text.contains("\\u202e"), "{text}");
}
}
/// A frame that does not decode is reported with serde's message, which quotes the offending
/// text after JSON decoding: from a compromised peer that can be raw escape sequences.
#[test]
fn a_frame_error_is_escaped() {
let bad = r#"{"v":1,"id":1,"final":true,"msg":{"kind":"\u001b[2J\u202e"}}"#;
let error = serde_json::from_str::<proto::Envelope>(bad).unwrap_err();
for text in [
ChatError::Frame(proto::FrameError::Json(
serde_json::from_str::<proto::Envelope>(bad).unwrap_err(),
))
.to_string(),
AdminError::Frame(proto::FrameError::Json(error)).to_string(),
] {
clean(&text);
assert!(text.contains("\\u001b"), "{text}");
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Scripted stand-ins for `brokerd`'s admin socket and for `loopd`, for tests. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use proto::{
ApprovalList, CallId, DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
PendingApproval, SessionId, Timestamp, TurnDone, TurnEvent, Usage, WireError, read_frame,
write_frame,
};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A socket path in a fresh temporary directory.
pub fn temp_socket(name: &str) -> PathBuf {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("bxctl-m3a-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub struct FakeBrokerd {
pub socket: PathBuf,
/// Every request message received, in order.
pub requests: Arc<Mutex<Vec<Message>>>,
}
impl FakeBrokerd {
pub fn requests(&self) -> Vec<Message> {
self.requests.lock().unwrap().clone()
}
}
/// Serves `admin.sock`: one request per connection, answered with the frames `answer` returns,
/// written exactly as given (so a test can send a wrong id or a frame that is not final). An
/// empty list closes the connection without an answer.
pub fn fake_brokerd_frames(
answer: impl Fn(&Envelope) -> Vec<Envelope> + Send + 'static,
) -> FakeBrokerd {
let socket = temp_socket("admin.sock");
let listener = UnixListener::bind(&socket).unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
seen.lock().unwrap().push(request.msg.clone());
for frame in answer(&request) {
if write_frame(&mut stream, &frame).is_err() {
break; // the client went away; the next connection is still served
}
}
}
});
FakeBrokerd { socket, requests }
}
/// The usual case: one final frame with the request's id.
pub fn fake_brokerd(answer: impl Fn(&Message) -> Message + Send + 'static) -> FakeBrokerd {
fake_brokerd_frames(move |request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: true,
msg: answer(&request.msg),
}]
})
}
pub fn wire_error(code: ErrorCode, detail: &str) -> Message {
Message::Error(WireError {
code,
detail: detail.to_string(),
})
}
/// A pending approval created at 12:00:00 that expires at 12:15:00 on 2026-09-18.
pub fn pending(approval: u64, tool: &str, arguments: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("chat-1758196800-123456789").unwrap(),
call: CallId(7),
tool: tool.to_string(),
arguments: arguments.to_string(),
grant: "shell-scratch".to_string(),
taint: DataClass::Private,
created: ts("2026-09-18T12:00:00.000Z"),
expires: ts("2026-09-18T12:15:00.000Z"),
}
}
/// A `brokerd` with these approvals pending. `approve` is answered with `outcome`, `refuse` with
/// `ok`; both answer `no_such_approval` for an id that is not in the list.
pub fn brokerd_with(items: Vec<PendingApproval>, outcome: proto::DecisionRecord) -> FakeBrokerd {
fake_brokerd(move |msg| {
let known = |id: u64| items.iter().any(|item| item.approval == id);
match msg {
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
items: items.clone(),
}),
Message::Approve(a) if known(a.approval) => {
Message::ApproveResult(proto::ApproveResult {
outcome: outcome.clone(),
})
}
Message::Refuse(r) if known(r.approval) => Message::Ok(proto::Empty {}),
Message::Approve(_) | Message::Refuse(_) => {
wire_error(ErrorCode::NoSuchApproval, "no such approval")
}
_ => wire_error(ErrorCode::BadMessage, "not an admin request"),
}
})
}
pub struct FakeLoopd {
pub socket: PathBuf,
/// The content of every turn received, in order.
pub turns: Arc<Mutex<Vec<String>>>,
}
pub fn usage() -> Usage {
Usage {
cache_n: 10,
prompt_n: 5,
predicted_n: 7,
reasoning_tokens: 3,
thinking_capped: false,
}
}
/// Serves `loop.sock`: every turn gets the same events, then `turn_done` with `answer`. It does
/// not wait for an approval to be answered: `bxctl` deals with each event before it reads the
/// next frame, so the order of what it does is fixed all the same.
pub fn fake_loopd(events: Vec<TurnEvent>, answer: &str) -> FakeLoopd {
let socket = temp_socket("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
let answer = answer.to_string();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
let Message::Turn(turn) = request.msg else {
continue;
};
seen.lock().unwrap().push(turn.content);
let mut frames: Vec<(bool, Message)> = events
.iter()
.map(|e| (false, Message::TurnEvent(e.clone())))
.collect();
frames.push((
true,
Message::TurnDone(TurnDone {
content: answer.clone(),
usage: usage(),
}),
));
for (last, msg) in frames {
let frame = Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: last,
msg,
};
if write_frame(&mut stream, &frame).is_err() {
break;
}
}
}
});
FakeLoopd { socket, turns }
}
+179
View File
@@ -0,0 +1,179 @@
//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`.
//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped.
struct Home {
path: PathBuf,
}
impl Home {
fn with_case(case: &str) -> Home {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("bxctl-verify-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
let audit = path.join("audit");
std::fs::create_dir_all(&audit).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap();
}
Home { path }
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn run(case: &str) -> (bool, String) {
let home = Home::with_case(case);
// What brokerd leaves beside the log must not be read as part of it.
std::fs::write(home.path.join("audit/.lock"), "").unwrap();
let mut out = Vec::new();
let ok = bxctl::verify::run(&home.path, &mut out).unwrap();
(ok, String::from_utf8(out).unwrap())
}
/// The hex of the hash of the last line of `file` in `case`.
fn head_of(case: &str, file: &str) -> String {
let path = format!(
"{}/../proto/tests/fixtures/audit/{case}/{file}",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(path).unwrap();
proto::sha256(text.lines().last().unwrap().as_bytes())
.unwrap()
.to_hex()
}
#[test]
fn a_good_log() {
let (ok, out) = run("good");
assert!(ok);
let head = head_of("good", "2026-09-18.jsonl");
assert_eq!(
out,
format!(
"audit: ok, 10 records, head {head}\n\
pending or abandoned: approval 6\n\
running or unfinished: decision 7\n"
)
);
}
#[test]
fn everything_worth_knowing_is_listed_one_per_line() {
let (ok, out) = run("recovered-next-day");
assert!(ok);
let lines: Vec<&str> = out.lines().collect();
assert!(
lines[0].starts_with("audit: ok, 11 records, head "),
"{out}"
);
assert_eq!(
lines[1..],
[
"recovered line: 2026-09-17.jsonl:6",
"pending or abandoned: approval 7",
"running or unfinished: decision 8",
]
);
let (ok, out) = run("accepted-break-older-file");
assert!(ok);
assert!(
out.contains("\naccepted break: 2026-09-18.jsonl:6\n"),
"{out}"
);
let (ok, out) = run("clock-back");
assert!(ok);
assert!(
out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"),
"{out}"
);
}
/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is
/// reported and is not a failure.
#[test]
fn a_torn_tail_is_reported_and_is_ok() {
let (ok, out) = run("torn-tail");
assert!(ok);
assert!(out.starts_with("audit: ok, 10 records, head "), "{out}");
assert!(
out.ends_with(
"\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n"
),
"{out}"
);
}
#[test]
fn a_broken_chain_is_two_lines_and_false() {
let cases = [
(
"changed-byte",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"),
(
"cut-short",
"2026-09-17.jsonl:3: does not parse as an audit record",
),
(
"file-not-chained",
"2026-09-18.jsonl:1: does not chain from the last line of the file before",
),
(
"break-wrong-line",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
];
for (case, first) in cases {
let (ok, out) = run(case);
assert!(!ok, "{case}");
assert_eq!(
out,
format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"),
"{case}"
);
}
}
#[test]
fn an_empty_audit_directory_is_an_empty_log() {
let home = Home::with_case("good");
for entry in std::fs::read_dir(home.path.join("audit")).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
let mut out = Vec::new();
assert!(bxctl::verify::run(&home.path, &mut out).unwrap());
assert_eq!(
String::from_utf8(out).unwrap(),
"audit: ok, 0 records, head none\n"
);
}
/// A home with no audit directory is a mistake in `--home`, not a clean log.
#[test]
fn a_missing_audit_directory_is_an_error() {
let home = Home::with_case("good");
std::fs::remove_dir_all(home.path.join("audit")).unwrap();
let mut out = Vec::new();
let error = bxctl::verify::run(&home.path, &mut out).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(out.is_empty());
}
+38
View File
@@ -0,0 +1,38 @@
//! `bxctl audit verify` says which files in `audit/` it did not read: a file that looks like a
//! log but is not named as one is ignored by `brokerd` too, and "ok" must not seem to cover it.
use std::path::PathBuf;
fn home(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bx-verify-skip-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("audit")).unwrap();
let fixture =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto/tests/fixtures/audit/good");
for entry in std::fs::read_dir(fixture).unwrap() {
let entry = entry.unwrap();
std::fs::copy(entry.path(), dir.join("audit").join(entry.file_name())).unwrap();
}
dir
}
#[test]
fn a_stray_jsonl_file_is_named_and_the_log_still_verifies() {
let dir = home("stray");
std::fs::write(dir.join("audit/2026-0x-19.jsonl"), "x\n").unwrap();
std::fs::write(dir.join("audit/notes.txt"), "not a log\n").unwrap();
let mut out = Vec::new();
let ok = bxctl::verify::run(&dir, &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(ok, "{text}");
assert!(text.starts_with("audit: ok, 10 records"), "{text}");
assert!(
text.contains("not an audit log file, not checked: 2026-0x-19.jsonl"),
"{text}"
);
assert!(
!text.contains("notes.txt"),
"only files that look like logs: {text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
+5 -1
View File
@@ -7,7 +7,11 @@ use std::process;
use inferproxy::{Limits, serve};
fn main() {
let args: Vec<String> = env::args().collect();
// `args_os`, because `args` panics on an argument that is not UTF-8.
let args: Vec<String> = match env::args_os().map(|a| a.into_string()).collect() {
Ok(args) => args,
Err(_) => usage(),
};
let mut listen: Option<String> = None;
let mut upstream: Option<String> = None;
let mut i = 1;
+14
View File
@@ -0,0 +1,14 @@
//! An argument that is not UTF-8 is a usage error, not a panic (M3a review finding 7).
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::process::Command;
#[test]
fn an_argument_that_is_not_utf8_is_a_usage_error() {
let out = Command::new(env!("CARGO_BIN_EXE_inferproxy"))
.args([OsStr::new("--listen"), OsStr::from_bytes(b"/tmp/\xff.sock")])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
}
+7 -1
View File
@@ -25,6 +25,7 @@ pub enum BaselineError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, serde_json::Error),
Hash,
Core(PathBuf, std::io::Error),
}
impl std::fmt::Display for BaselineError {
@@ -33,6 +34,11 @@ impl std::fmt::Display for BaselineError {
BaselineError::Read(path, err) => write!(f, "{}: {err}", path.display()),
BaselineError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
BaselineError::Hash => write!(f, "hashing the baseline failed"),
BaselineError::Core(path, err) => write!(
f,
"{}: {err}; see docs/runbook.md#core-memory-unreadable",
path.display()
),
}
}
}
@@ -58,7 +64,7 @@ impl Baseline {
// A missing file is fine; an unreadable one is reported so the owner is not given a
// session without the memory they curated and no sign of it.
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(BaselineError::Read(core.clone(), e));
return Err(BaselineError::Core(core.clone(), e));
}
Err(_) => {}
}
+235
View File
@@ -0,0 +1,235 @@
//! The tool port to `brokerd`: one connection per call, a deadline per frame, and plain failures.
//!
//! Whatever goes wrong reaching `brokerd` or reading its frames, the model gets a plain failure and
//! the turn goes on. Every failure prints one line ending at `#broker-unavailable`.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::tools::{Pending, ToolPort};
use proto::{
Envelope, FrameError, Message, PROTOCOL_VERSION, Timestamp, ToolRequest, ToolResponse,
read_frame, write_frame,
};
/// The failure every outage reaches the model as.
pub const UNAVAILABLE: &str = "the tool broker is unavailable";
/// What the model is told when no broker is configured.
pub const NOT_CONFIGURED: &str = "no tool broker is configured";
/// The failure a request too big for one frame reaches.
pub const TOO_LARGE: &str = "the request is too large for the tool broker";
/// The longest `BrokerPort` waits for an answer after a pending frame, whatever its `expires`
/// says, so a turn cannot be parked for ever by a far expiry.
pub const MAX_PENDING_WAIT: Duration = Duration::from_secs(24 * 60 * 60);
/// The runbook entry every outage line ends with.
pub const POINTER: &str = "see docs/runbook.md#broker-unavailable";
/// "loopd: {UNAVAILABLE}: {why}; {POINTER}"
pub fn unavailable_line(why: &str) -> String {
format!("loopd: {UNAVAILABLE}: {why}; {POINTER}")
}
/// "loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}"
pub fn not_configured_line() -> String {
format!("loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}")
}
/// Reads through `stream`, but only what is left before `until`: a read past the deadline fails
/// with a timeout, so a peer that sends one byte at a time cannot hold the turn.
struct Deadline<'a> {
stream: &'a UnixStream,
until: Instant,
}
impl std::io::Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.until.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::from(std::io::ErrorKind::TimedOut));
}
crate::socket::set_read_timeout(self.stream, left)?;
let mut stream = self.stream; // `Read` is implemented for `&UnixStream`
stream.read(buf)
}
}
/// The tool port to `brokerd`. One connection per call, with a deadline per frame.
pub struct BrokerPort {
socket: PathBuf,
timeout: Duration,
pending_cap: Duration,
log: Box<dyn Fn(&str) + Send + Sync>,
}
impl BrokerPort {
pub fn new(socket: PathBuf, timeout: Duration) -> BrokerPort {
BrokerPort {
socket,
timeout,
pending_cap: MAX_PENDING_WAIT,
log: Box::new(|l| eprintln!("{l}")),
}
}
/// The same port with another cap on the wait after a pending frame (`MAX_PENDING_WAIT`).
pub fn with_pending_cap(self, pending_cap: Duration) -> BrokerPort {
BrokerPort {
pending_cap,
..self
}
}
pub fn with_log(
socket: PathBuf,
timeout: Duration,
log: Box<dyn Fn(&str) + Send + Sync>,
) -> BrokerPort {
BrokerPort {
socket,
timeout,
pending_cap: MAX_PENDING_WAIT,
log,
}
}
}
impl ToolPort for BrokerPort {
fn call(&self, request: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse {
let want = request.call.0;
// The first deadline, taken before connecting.
let mut until = match Instant::now().checked_add(self.timeout) {
Some(until) => until,
None => return self.unavailable("the timeout is too large"),
};
// Exit 2.
let mut stream = match UnixStream::connect(&self.socket) {
Ok(stream) => stream,
Err(e) => {
return self
.unavailable(&format!("cannot connect to {}: {e}", self.socket.display()));
}
};
// Exit 3. `set_write_timeout` fails for a zero timeout.
if let Err(e) = stream.set_write_timeout(Some(self.timeout)) {
return self.unavailable(&format!("cannot set a timeout: {e}"));
}
// Exit 4. One request frame; the broker is fine if it is too large to send.
let request_frame = Envelope {
v: PROTOCOL_VERSION,
id: want,
r#final: true,
msg: Message::ToolRequest(request.clone()),
};
if let Err(e) = write_frame(&mut stream, &request_frame) {
return match e {
FrameError::TooLarge(_) => ToolResponse::Failed {
message: TOO_LARGE.to_string(),
},
_ => self.unavailable(&format!("cannot send the request: {e}")),
};
}
// Exit 5-11, once per frame. A pending frame is reported, then we read again.
let mut seen_pending = false;
loop {
let mut deadline = Deadline {
stream: &stream,
until,
};
let env = match read_frame(&mut deadline) {
Ok(env) => env,
// Exit 5.
Err(FrameError::Closed) => {
return self.unavailable("the connection closed before the final answer");
}
Err(FrameError::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
return self.unavailable("no answer in time");
}
Err(e) => return self.unavailable(&format!("bad frame: {e}")),
};
// Exit 6.
if env.id != want {
return self
.unavailable(&format!("an answer for request {}, not {}", env.id, want));
}
let r#final = env.r#final;
// Exit 7.
let resp = match env.msg {
Message::ToolResponse(resp) => resp,
Message::Error(e) => {
return self
.unavailable(&format!("the broker reported an error: {}", e.detail));
}
_ => return self.unavailable("an unexpected message"),
};
match resp {
// Exit 8.
ToolResponse::PendingApproval { approval, expires } => {
if r#final {
return self.unavailable("a pending frame marked final");
}
// Exit 10.
if seen_pending {
return self.unavailable("a second pending frame");
}
let wait = expires
.unix_millis()
.saturating_sub(Timestamp::now().unix_millis());
let wait = Duration::from_millis(wait).min(self.pending_cap);
until = match Instant::now().checked_add(wait) {
Some(until) => until,
None => return self.unavailable("an expiry too far away"),
};
until = match until.checked_add(self.timeout) {
Some(until) => until,
None => return self.unavailable("an expiry too far away"),
};
seen_pending = true;
on_pending(&Pending { approval, expires });
}
// Exit 9 and 11.
ToolResponse::Result { .. }
| ToolResponse::Failed { .. }
| ToolResponse::Denied { .. } => {
if r#final {
return resp;
}
return self.unavailable("an answer not marked final");
}
}
}
}
}
/// The one failure no outage reaches the model as, and the one line printed with it.
impl BrokerPort {
fn unavailable(&self, why: &str) -> ToolResponse {
(self.log)(&unavailable_line(why));
ToolResponse::Failed {
message: UNAVAILABLE.to_string(),
}
}
}
/// The port used when no broker is configured: every call fails with `NOT_CONFIGURED` and prints
/// nothing.
pub struct NoBroker;
impl ToolPort for NoBroker {
fn call(&self, _request: &ToolRequest, _on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse {
ToolResponse::Failed {
message: NOT_CONFIGURED.to_string(),
}
}
}
+19
View File
@@ -136,6 +136,23 @@ impl Default for Baseline {
}
}
/// The tool broker (`brokerd`). No socket means no broker: every non-core tool call fails.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Broker {
pub socket: Option<std::path::PathBuf>,
pub timeout_ms: u64,
}
impl Default for Broker {
fn default() -> Self {
Self {
socket: None,
timeout_ms: 120_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
@@ -154,6 +171,8 @@ pub struct Config {
pub r#loop: Loop,
#[serde(default)]
pub baseline: Baseline,
#[serde(default)]
pub broker: Broker,
}
#[derive(Debug)]
+1 -3
View File
@@ -151,9 +151,7 @@ impl Connection {
}
pub fn set_read_timeout(&self, timeout: Duration) -> Result<(), HttpError> {
self.stream
.set_read_timeout(Some(timeout))
.map_err(HttpError::Io)
crate::socket::set_read_timeout(&self.stream, timeout).map_err(HttpError::Io)
}
pub fn received_any(&self) -> bool {
+2
View File
@@ -1,12 +1,14 @@
//! The agent loop: sessions, prompt assembly and memory. It holds no authority.
pub mod baseline;
pub mod broker_port;
pub mod channel;
pub mod config;
pub mod http;
pub mod llama;
pub mod selftest;
pub mod session;
pub mod socket;
pub mod sse;
pub mod tools;
pub mod turn;
+25 -17
View File
@@ -6,19 +6,22 @@ use std::os::unix::net::UnixListener;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use loopd::broker_port::{BrokerPort, NoBroker, not_configured_line};
use loopd::channel::{self, Context};
use loopd::config::Config;
use loopd::llama::Client;
use loopd::selftest::{SelfTestError, run};
use loopd::tools::{FakeTools, Registry};
use loopd::tools::{Registry, ToolPort};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match args.as_slice() {
["selftest", "--config", path] => run_selftest(path),
["serve", "--config", path] => run_serve(path),
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
match (words.as_slice(), args.get(2)) {
([Some("selftest"), Some("--config"), _], Some(path)) => run_selftest(Path::new(path)),
([Some("serve"), Some("--config"), _], Some(path)) => run_serve(Path::new(path)),
_ => {
eprintln!("usage: loopd selftest --config <path>");
eprintln!("usage: loopd serve --config <path>");
@@ -38,14 +41,14 @@ fn run_selftest_check(client: &Client) -> Result<(), SelfTestError> {
Ok(())
}
Err(e) => {
eprintln!("selftest: FAILED: {e}");
eprintln!("selftest: FAILED: {e}; see docs/runbook.md#loopd-selftest-failed");
Err(e)
}
}
}
fn run_selftest(path: &str) -> ExitCode {
let cfg = match Config::load(Path::new(path)) {
fn run_selftest(path: &Path) -> ExitCode {
let cfg = match Config::load(path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("loopd: {e}");
@@ -60,8 +63,8 @@ fn run_selftest(path: &str) -> ExitCode {
}
}
fn run_serve(path: &str) -> ExitCode {
let cfg = match Config::load(Path::new(path)) {
fn run_serve(path: &Path) -> ExitCode {
let cfg = match Config::load(path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("loopd: {e}");
@@ -104,12 +107,17 @@ fn run_serve(path: &str) -> ExitCode {
}
eprintln!("loopd: serving on {}", socket.display());
let ctx = Arc::new(Context::new(
cfg,
client,
Box::new(FakeTools::new()),
Registry::m2b(),
));
let port: Box<dyn ToolPort> = match &cfg.broker.socket {
Some(socket) => Box::new(BrokerPort::new(
socket.clone(),
Duration::from_millis(cfg.broker.timeout_ms),
)),
None => {
eprintln!("{}", not_configured_line());
Box::new(NoBroker)
}
};
let ctx = Arc::new(Context::new(cfg, client, port, Registry::m3a()));
if let Err(e) = channel::serve(listener, ctx) {
eprintln!("loopd: the channel server stopped: {e}");
return ExitCode::from(1);
+5 -1
View File
@@ -29,7 +29,11 @@ impl std::fmt::Display for SessionError {
SessionError::Exists(id) => write!(f, "session already exists: {}", id.as_str()),
SessionError::NotFound(id) => write!(f, "session not found: {}", id.as_str()),
SessionError::Io(path, err) => write!(f, "{}: {err}", path.display()),
SessionError::Torn { path, line, why } => write!(f, "{}:{line}: {why}", path.display()),
SessionError::Torn { path, line, why } => write!(
f,
"{}:{line}: {why}; see docs/runbook.md#session-log-damaged",
path.display()
),
SessionError::Baseline(err) => write!(f, "{err}"),
SessionError::Encode(err) => write!(f, "{err}"),
}
+21
View File
@@ -0,0 +1,21 @@
//! Unix socket options that behave the same on Linux and macOS.
use std::io;
use std::os::unix::net::UnixStream;
use std::time::Duration;
/// `EINVAL` on Darwin (`sys/errno.h`); std does not name it and a crate for one number is too much.
#[cfg(target_vendor = "apple")]
const EINVAL: i32 = 22;
/// Set the read timeout for the next read. macOS refuses every socket option with `EINVAL` once
/// the peer has closed (XNU `sosetoptlock`: both `SS_CANTRCVMORE` and `SS_CANTSENDMORE` set), even
/// with unread data still buffered. A read on such a socket returns that data or the end at once
/// and cannot block, so there is no timeout left to set and the refusal is not an error.
pub fn set_read_timeout(stream: &UnixStream, timeout: Duration) -> io::Result<()> {
match stream.set_read_timeout(Some(timeout)) {
#[cfg(target_vendor = "apple")]
Err(e) if e.raw_os_error() == Some(EINVAL) => Ok(()),
other => other,
}
}
+138 -9
View File
@@ -7,7 +7,20 @@ use crate::llama::ToolSchema;
/// The seam every tool call goes through. `loopd` answers `find_tool` and `call_tool` itself;
/// anything else, including grants, is passed to the port (`brokerd`).
pub trait ToolPort: Send + Sync {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse;
/// Returns the final answer, never `PendingApproval`. If the broker says the call is waiting
/// for the owner, the port calls `on_pending` once and goes on waiting.
fn call(
&self,
request: &proto::ToolRequest,
on_pending: &mut dyn FnMut(&Pending),
) -> proto::ToolResponse;
}
/// One waiting call, as the broker reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pending {
pub approval: u64,
pub expires: proto::Timestamp,
}
#[derive(Debug, Clone, PartialEq)]
@@ -26,7 +39,9 @@ impl Registry {
Registry { entries }
}
/// The tool set for the M2b epoch: `clock` (core) and `echo` (not core).
/// The tool set for the M2b epoch: `clock` (core) and `echo` (not core). This is the test
/// registry: `echo` exists only in `FakeTools` and the recorded conversations, and `loopd
/// serve` never uses it.
pub fn m2b() -> Registry {
Registry::new(vec![
Entry {
@@ -40,6 +55,33 @@ impl Registry {
])
}
/// The tool set for the M3a epoch: `clock` (core), then the four tools `brokerd` decides on.
/// The core entries match `m2b()`, so the tools array is unchanged.
pub fn m3a() -> Registry {
Registry::new(vec![
Entry {
schema: clock_schema(),
core: true,
},
Entry {
schema: read_file_schema(),
core: false,
},
Entry {
schema: write_file_schema(),
core: false,
},
Entry {
schema: shell_schema(),
core: false,
},
Entry {
schema: http_fetch_schema(),
core: false,
},
])
}
/// The fixed tools array: core entries, then `find_tool`, then `call_tool`.
pub fn core_schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self
@@ -76,6 +118,7 @@ impl Registry {
pub const FIND_TOOL: &str = "find_tool";
pub const CALL_TOOL: &str = "call_tool";
pub const CLOCK: &str = "clock";
pub fn clock_schema() -> ToolSchema {
ToolSchema {
@@ -102,6 +145,65 @@ pub fn echo_schema() -> ToolSchema {
}
}
pub fn read_file_schema() -> ToolSchema {
ToolSchema {
name: "read_file".to_string(),
description: "Read a text file. Needs a grant from the owner for the path.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path of the file." }
},
"required": ["path"]
}),
}
}
pub fn write_file_schema() -> ToolSchema {
ToolSchema {
name: "write_file".to_string(),
description: "Write a text file, replacing it. Needs a grant from the owner for the path."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The absolute path of the file." },
"content": { "type": "string", "description": "The whole new content of the file." }
},
"required": ["path", "content"]
}),
}
}
pub fn shell_schema() -> ToolSchema {
ToolSchema {
name: "shell".to_string(),
description: "Run a shell command in a sandbox. Needs a grant from the owner.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command line to run." },
"cwd": { "type": "string", "description": "The absolute path of the directory to run it in." }
},
"required": ["command"]
}),
}
}
pub fn http_fetch_schema() -> ToolSchema {
ToolSchema {
name: "http_fetch".to_string(),
description: "Fetch an https URL. Needs a grant from the owner for the host.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL. It must start with https://" }
},
"required": ["url"]
}),
}
}
fn find_tool_schema() -> ToolSchema {
ToolSchema {
name: "find_tool".to_string(),
@@ -157,6 +259,9 @@ pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch {
if name == CALL_TOOL {
return dispatch_call_tool(registry, arguments);
}
if name == CLOCK {
return Dispatch::Local(proto::Timestamp::now().to_rfc3339());
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
@@ -224,6 +329,32 @@ pub fn cap_result(text: &str, cap: usize) -> (String, bool) {
(result, true)
}
/// The one fixed sentence a denial reaches the model as, named by its reason.
pub fn denial_text(reason: proto::DenyReason) -> &'static str {
match reason {
proto::DenyReason::NoGrant => "Denied: no grant allows this call.",
proto::DenyReason::GrantExpired => "Denied: the grant for this call has expired.",
proto::DenyReason::TaintTooHigh => {
"Denied: this session has seen data too sensitive for this call."
}
proto::DenyReason::DeniedByGrant => "Denied: a grant forbids this call.",
proto::DenyReason::ApprovalRefused => "Denied: the owner refused this call.",
proto::DenyReason::ApprovalExpired => {
"Denied: the approval request expired without an answer."
}
proto::DenyReason::InvalidArguments => "Denied: the arguments are not valid for this tool.",
proto::DenyReason::GrantsInvalid => {
"Denied: the grant files have an error; the owner has been told."
}
proto::DenyReason::AuditUnavailable => {
"Denied: the audit log cannot be written; the owner has been told."
}
proto::DenyReason::StateUnreadable => {
"Denied: this session's broker state is damaged; the owner has been told."
}
}
}
pub struct FakeTools {
calls: Mutex<Vec<proto::ToolRequest>>,
}
@@ -247,19 +378,17 @@ impl FakeTools {
}
impl ToolPort for FakeTools {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse {
fn call(
&self,
request: &proto::ToolRequest,
_on_pending: &mut dyn FnMut(&Pending),
) -> proto::ToolResponse {
self.calls
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(request.clone());
match request.tool.as_str() {
"clock" => proto::ToolResponse::Result {
content: proto::Timestamp::now().to_rfc3339(),
class: proto::DataClass::Public,
untrusted: false,
truncated: false,
},
"echo" => match serde_json::from_str::<serde_json::Value>(&request.arguments) {
Ok(serde_json::Value::Object(obj)) => {
match obj.get("text").and_then(|v| v.as_str()) {
+19 -10
View File
@@ -7,7 +7,7 @@ use crate::config::Config;
use crate::llama::info::{CacheOutcome, cache_outcome};
use crate::llama::{ChatEvent, ChatRequest, Client, InferError};
use crate::session::{Session, SessionError};
use crate::tools::{Registry, ToolPort, cap_result, dispatch};
use crate::tools::{Registry, ToolPort, cap_result, denial_text, dispatch};
use proto::{CallId, DataClass, LogRecord, Timestamp, ToolRequest, ToolResponse, TurnEvent, Usage};
/// What one turn can fail with, beyond the inference server.
@@ -230,7 +230,7 @@ pub fn run_turn(
false,
)
} else {
run_call(rt, session, call, call_id)
run_call(rt, session, call, call_id, on_event)
};
let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap);
@@ -260,6 +260,7 @@ fn run_call(
session: &Session,
call: &proto::ToolCall,
call_id: CallId,
on_event: &mut dyn FnMut(&TurnEvent),
) -> (String, DataClass, bool) {
match dispatch(rt.registry, &call.name, &call.arguments) {
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
@@ -270,7 +271,14 @@ fn run_call(
tool,
arguments,
};
match rt.port.call(&request) {
let response = rt.port.call(&request, &mut |pending| {
on_event(&TurnEvent::ApprovalPending {
approval: pending.approval,
tool: request.tool.clone(),
expires: pending.expires,
});
});
match response {
ToolResponse::Result {
content,
class,
@@ -282,14 +290,15 @@ fn run_call(
DataClass::Public,
false,
),
ToolResponse::Denied { reason } => (
format!("The call was denied: {reason:?}"),
DataClass::Public,
false,
),
ToolResponse::Denied { reason } => {
on_event(&TurnEvent::ToolDenied {
name: request.tool.clone(),
reason,
});
(denial_text(reason).to_string(), DataClass::Public, false)
}
ToolResponse::PendingApproval { .. } => (
"This version of loopd cannot wait for approval; the call was rejected."
.to_string(),
"The tool failed: the tool broker gave no final answer".to_string(),
DataClass::Public,
false,
),
+36
View File
@@ -0,0 +1,36 @@
//! A config path that is not UTF-8 is read as a path, not a panic (M3a review finding 7).
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::process::Command;
#[test]
fn a_config_path_that_is_not_utf8_is_read_as_a_path() {
let path = std::env::temp_dir().join(OsStr::from_bytes(b"loopd-missing-\xff.toml"));
let out = Command::new(env!("CARGO_BIN_EXE_loopd"))
.args([
OsStr::new("selftest"),
OsStr::new("--config"),
path.as_os_str(),
])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(1),
"a config error, not a panic (101)"
);
}
#[test]
fn a_flag_that_is_not_utf8_is_a_usage_error() {
let out = Command::new(env!("CARGO_BIN_EXE_loopd"))
.args([
OsStr::from_bytes(b"serve\xff"),
OsStr::new("--config"),
OsStr::new("x"),
])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
}
+314
View File
@@ -0,0 +1,314 @@
//! Tests for `BrokerPort` against a fake `brokerd` on a temporary socket: answers, approvals and
//! timeouts. Frames that break the protocol are in `broker_port_bad.rs`. Do not edit.
#[path = "support/broker.rs"]
mod fake;
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::thread;
use std::time::Duration;
use fake::{assert_unavailable, broker, call, frame, in_ms, request, result, send, socket_path};
use loopd::broker_port::{BrokerPort, NoBroker, POINTER, TOO_LARGE};
use loopd::tools::{Pending, ToolPort};
use proto::{
DenyReason, Message, PROTOCOL_VERSION, Timestamp, ToolResponse, read_frame, write_frame,
};
#[test]
fn a_result_comes_back_as_it_was_sent() {
let (socket, broker) = broker(|stream, request| {
send(stream, request.id, true, result("hello\n"));
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, result("hello\n"));
assert!(got.pending.is_empty());
assert!(got.lines.is_empty(), "nothing to report: {:?}", got.lines);
// What the broker received: one final frame holding exactly the request.
let sent = broker.join().unwrap();
assert_eq!(sent.v, PROTOCOL_VERSION);
assert!(sent.r#final, "a request is a single final frame");
assert_eq!(sent.msg, Message::ToolRequest(request()));
}
#[test]
fn every_denial_and_a_failure_come_back_as_they_were_sent() {
let mut answers: Vec<ToolResponse> = [
DenyReason::NoGrant,
DenyReason::GrantExpired,
DenyReason::TaintTooHigh,
DenyReason::DeniedByGrant,
DenyReason::ApprovalRefused,
DenyReason::ApprovalExpired,
DenyReason::GrantsInvalid,
DenyReason::AuditUnavailable,
DenyReason::InvalidArguments,
DenyReason::StateUnreadable,
]
.into_iter()
.map(|reason| ToolResponse::Denied { reason })
.collect();
answers.push(ToolResponse::Failed {
message: "the runner arrives in M3b".to_string(),
});
for answer in answers {
let reply = answer.clone();
let (socket, broker) = broker(move |stream, request| {
send(stream, request.id, true, reply);
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, answer);
assert!(
got.lines.is_empty(),
"a denial is not an outage: {:?}",
got.lines
);
broker.join().unwrap();
}
}
#[test]
fn a_pending_frame_reaches_the_callback_and_the_final_frame_is_the_answer() {
let expires = in_ms(60_000);
let (socket, broker) = broker(move |stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 41,
expires,
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(150));
send(stream, request.id, true, result("approved"));
});
let got = call(socket, 2_000, &request());
assert_eq!(got.response, result("approved"));
assert_eq!(
got.pending,
[Pending {
approval: 41,
expires
}],
"called once, with the frame's values"
);
assert!(got.lines.is_empty(), "{:?}", got.lines);
broker.join().unwrap();
}
#[test]
fn an_answer_after_expires_but_within_the_timeout_of_it_is_delivered() {
// expires in 100 ms, timeout 1,500 ms: the port waits until 1,600 ms. The answer comes at
// about 400 ms, well after `expires`: an approval given at the last moment still gets the
// whole timeout to run.
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 1,
expires: in_ms(100),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(400));
send(stream, request.id, true, result("late but good"));
});
let got = call(socket, 1_500, &request());
assert_eq!(got.response, result("late but good"));
assert!(got.lines.is_empty(), "{:?}", got.lines);
broker.join().unwrap();
}
#[test]
fn an_expiry_that_has_already_passed_still_leaves_the_timeout() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 1,
expires: Timestamp::parse("2020-01-01T00:00:00.000Z").unwrap(),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(200));
send(stream, request.id, true, result("fine"));
});
let got = call(socket, 1_500, &request());
assert_eq!(got.response, result("fine"));
broker.join().unwrap();
}
#[test]
fn no_socket_is_unavailable() {
let socket = socket_path();
let _ = std::fs::remove_file(&socket);
let got = call(socket.clone(), 2_000, &request());
assert_unavailable(&got, "no socket file");
assert!(
got.lines[0].contains(&socket.display().to_string()),
"the line names the socket: {}",
got.lines[0]
);
assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took);
}
#[test]
fn a_broker_that_closes_without_answering_is_unavailable() {
let (socket, broker) = broker(|_, _| {});
let got = call(socket, 2_000, &request());
assert_unavailable(&got, "closed before any frame");
assert!(
got.took < Duration::from_millis(1_500),
"a close is seen at once: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn a_broker_that_closes_while_pending_is_unavailable() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 3,
expires: in_ms(60_000),
};
send(stream, request.id, false, pending);
// brokerd was restarted: the connection just ends.
});
let got = call(socket, 2_000, &request());
assert_unavailable(&got, "closed while pending");
assert_eq!(got.pending.len(), 1, "the pending frame was reported first");
assert!(got.took < Duration::from_millis(1_500), "{:?}", got.took);
broker.join().unwrap();
}
#[test]
fn a_broker_that_never_answers_is_unavailable_after_the_timeout() {
let (socket, broker) = broker(|_, _| thread::sleep(Duration::from_millis(1_200)));
let got = call(socket, 300, &request());
assert_unavailable(&got, "silence");
assert!(
got.took >= Duration::from_millis(250),
"gave up early: {:?}",
got.took
);
assert!(
got.took < Duration::from_millis(1_100),
"gave up late: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn a_pending_call_nobody_answers_ends_at_expires_plus_the_timeout() {
let (socket, broker) = broker(|stream, request| {
let pending = ToolResponse::PendingApproval {
approval: 3,
expires: in_ms(300),
};
send(stream, request.id, false, pending);
thread::sleep(Duration::from_millis(1_800));
});
let got = call(socket, 300, &request());
assert_unavailable(&got, "silence while pending");
assert!(
got.took >= Duration::from_millis(550),
"it must wait for expires (300) plus the timeout (300): {:?}",
got.took
);
assert!(
got.took < Duration::from_millis(1_700),
"gave up late: {:?}",
got.took
);
broker.join().unwrap();
}
#[test]
fn the_timeout_is_a_deadline_for_the_frame_not_for_each_read() {
// The four header bytes arrive 250 ms apart, the last at 1,000 ms. A port that only sets a
// 600 ms read timeout sees every single read succeed and returns the result; a port with a
// deadline gives up at 600 ms. The answer tells them apart, so no timing is asserted.
let (socket, broker) = broker(|stream, request| {
let mut bytes = Vec::new();
write_frame(
&mut bytes,
&frame(request.id, true, Message::ToolResponse(result("slow"))),
)
.unwrap();
for byte in bytes.iter().take(4) {
thread::sleep(Duration::from_millis(250));
if stream.write_all(&[*byte]).is_err() {
return;
}
}
let _ = stream.write_all(&bytes[4..]);
});
let got = call(socket, 600, &request());
assert_unavailable(&got, "a trickled frame");
broker.join().unwrap();
}
#[test]
fn a_zero_timeout_fails_closed_and_does_not_panic() {
let (socket, _broker) = broker(|stream, request| {
send(stream, request.id, true, result("too late"));
});
let got = call(socket, 0, &request());
assert_unavailable(&got, "timeout_ms = 0");
}
#[test]
fn a_request_too_large_for_a_frame_is_its_own_failure() {
// Nothing is sent, so the fake broker sees a connection that closes or none at all.
let path = socket_path();
let _ = std::fs::remove_file(&path);
let _listener = UnixListener::bind(&path).unwrap();
let mut big = request();
big.arguments = format!(r#"{{"path":"/{}"}}"#, "a".repeat(proto::MAX_FRAME));
let got = call(path, 1_000, &big);
assert_eq!(
got.response,
ToolResponse::Failed {
message: TOO_LARGE.to_string()
}
);
assert!(
got.lines.is_empty(),
"the broker is fine; this is not an outage: {:?}",
got.lines
);
}
#[test]
fn every_call_is_its_own_connection() {
let path = socket_path();
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap();
let server = thread::spawn(move || {
for n in 0..3u64 {
let (mut stream, _) = listener.accept().unwrap();
let request = read_frame(&mut stream).unwrap();
send(
&mut stream,
request.id,
true,
result(&format!("answer {n}")),
);
}
});
let port = BrokerPort::new(path, Duration::from_millis(2_000));
for n in 0..3 {
let got = port.call(&request(), &mut |_| {});
assert_eq!(got, result(&format!("answer {n}")));
}
server.join().unwrap();
}
#[test]
fn without_a_broker_every_call_fails_in_words_the_model_can_repeat() {
let mut seen = 0;
let got = NoBroker.call(&request(), &mut |_| seen += 1);
assert_eq!(
got,
ToolResponse::Failed {
message: "no tool broker is configured".to_string()
}
);
assert_eq!(seen, 0);
assert_eq!(POINTER, "see docs/runbook.md#broker-unavailable");
}

Some files were not shown because too many files have changed in this diff Show More