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 - 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. 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 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 ## The gate
Generated
+3
View File
@@ -7,6 +7,9 @@ name = "brokerd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"proto", "proto",
"serde",
"serde_json",
"toml",
] ]
[[package]] [[package]]
+4
View File
@@ -6,10 +6,14 @@ gate:
cargo fmt --all --check cargo fmt --all --check
cargo clippy --workspace --all-targets --locked --offline -- -D warnings cargo clippy --workspace --all-targets --locked --offline -- -D warnings
cargo test --workspace --locked --offline 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 cargo deny --offline check bans licenses sources
sh scripts/check-lines.sh sh scripts/check-lines.sh
sh scripts/check-crate-deps.sh sh scripts/check-crate-deps.sh
sh scripts/check-dep-docs.sh sh scripts/check-dep-docs.sh
sh scripts/check-runbook.sh
sh scripts/test-gate-scripts.sh sh scripts/test-gate-scripts.sh
@echo "gate: ok" @echo "gate: ok"
+3
View File
@@ -10,3 +10,6 @@ workspace = true
[dependencies] [dependencies]
proto.workspace = true 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. //! 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 policy;
pub mod runner; pub mod runner;
pub mod serve;
pub mod state;
+130 -3
View File
@@ -1,4 +1,131 @@
fn main() { //! `brokerd`: the broker role. `serve` takes the audit lock, binds both sockets, and serves.
eprintln!("brokerd: not implemented until M3");
std::process::exit(2); 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 //! A `Decision` can only be built here, in `decide` and `redecide`: its fields are private, and no
//! are private: //! constructor is exposed.
//! //!
//! ```compile_fail //! ```compile_fail
//! let request = proto::ToolRequest { //! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(), //! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1), //! call: proto::CallId(1),
//! tool: "read_file".to_string(), //! tool: "shell".to_string(),
//! arguments: "{}".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 //! ```compile_fail
//! let request = proto::ToolRequest { //! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(), //! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1), //! call: proto::CallId(1),
//! tool: "read_file".to_string(), //! tool: "shell".to_string(),
//! arguments: "{}".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 //! ```compile_fail
//! fail because of `Decision` and not because of a mistake in the setup: //! 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 { //! let request = proto::ToolRequest {
//! session: proto::SessionId::new("s1").unwrap(), //! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1), //! call: proto::CallId(1),
//! tool: "read_file".to_string(), //! tool: "shell".to_string(),
//! arguments: "{}".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)] #[derive(Debug)]
pub struct Decision { pub struct Decision {
request: ToolRequest, 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 { 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 { pub fn request(&self) -> &ToolRequest {
&self.request &self.request
} }
pub fn args(&self) -> &ToolArgs {
&self.args
}
pub fn grant(&self) -> &str { 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. impl Ask {
pub fn decide(request: ToolRequest) -> Result<Decision, DenyReason> { pub fn request(&self) -> &ToolRequest {
let _ = request; &self.request
Err(DenyReason::NoGrant) }
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
}
} }
#[cfg(test)] /// The result of deciding a call: run it, ask, or deny it.
mod tests { #[derive(Debug)]
use super::*; pub enum Outcome {
use proto::{CallId, SessionId}; Allowed(Decision),
Ask(Ask),
Denied(Denial),
}
fn request() -> ToolRequest { pub fn decide(
ToolRequest { request: ToolRequest,
session: SessionId::new("s1").unwrap(), grants: &GrantSet,
call: CallId(1), state: SessionState,
tool: "read_file".to_string(), now: Timestamp,
arguments: "{}".to_string(), ) -> 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,
});
} }
} }
#[test] MatchResult {
fn no_grants_means_deny() { left,
assert_eq!(decide(request()).unwrap_err(), DenyReason::NoGrant); expired,
} tainted,
#[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");
} }
} }
/// 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 crate::policy::Decision;
use proto::ToolResponse; use proto::ToolResponse;
/// Takes the Decision by value, so one decision cannot run a tool twice. /// A directory mounted for one call: a path and whether the runtime may write to it.
pub fn run(decision: Decision) -> ToolResponse { #[derive(Debug, Clone, PartialEq, Eq)]
let _ = decision; pub struct Mount {
ToolResponse::Failed { pub path: String,
message: "no tool runner until M3".to_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. //! `bxctl chat`: a turn client over `loop.sock` and a printer for the events it receives.
use std::io::Write; use std::io::{BufRead, Write};
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, TurnEvent,
WireError, read_frame, write_frame,
};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::Path; 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)] #[derive(Debug)]
pub enum ChatError { pub enum ChatError {
Connect(std::io::Error), 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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ChatError::Connect(e) => write!(f, "{e}"), ChatError::Connect(e) => write!(f, "{e}"),
ChatError::Frame(e) => write!(f, "{e}"), // serde quotes the offending text of a bad frame, decoded: escape it.
ChatError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail), 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}"), 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. // 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 { match code {
ErrorCode::BadFrame => "bad frame", ErrorCode::BadFrame => "bad frame",
ErrorCode::BadVersion => "bad version", ErrorCode::BadVersion => "bad version",
@@ -51,6 +58,8 @@ fn code_name(code: ErrorCode) -> &'static str {
ErrorCode::NoSuchSession => "no such session", ErrorCode::NoSuchSession => "no such session",
ErrorCode::SessionExists => "session exists", ErrorCode::SessionExists => "session exists",
ErrorCode::Inference => "inference", ErrorCode::Inference => "inference",
ErrorCode::Forbidden => "forbidden",
ErrorCode::NoSuchApproval => "no such approval",
} }
} }
@@ -140,7 +149,7 @@ impl Printer {
out.write_all(b"\x1b[2m")?; out.write_all(b"\x1b[2m")?;
self.dimmed_open = true; self.dimmed_open = true;
} }
out.write_all(text.as_bytes())?; out.write_all(escape_model_text(text).as_bytes())?;
} }
} }
TurnEvent::Content { text } => { TurnEvent::Content { text } => {
@@ -149,11 +158,12 @@ impl Printer {
self.dimmed_open = false; self.dimmed_open = false;
} }
if self.stream_content { if self.stream_content {
out.write_all(text.as_bytes())?; out.write_all(escape_model_text(text).as_bytes())?;
} }
} }
TurnEvent::ToolCallStarted { name } => { TurnEvent::ToolCallStarted { name } => {
self.close_dimmed(out)?; self.close_dimmed(out)?;
let name = escape_model_text(name);
writeln!(out, "[tool {name}]")?; writeln!(out, "[tool {name}]")?;
} }
TurnEvent::ToolResult { TurnEvent::ToolResult {
@@ -162,6 +172,7 @@ impl Printer {
truncated, truncated,
} => { } => {
self.close_dimmed(out)?; self.close_dimmed(out)?;
let name = escape_model_text(name);
if *truncated { if *truncated {
writeln!(out, "[{name}: {class:?}, truncated]")?; writeln!(out, "[{name}: {class:?}, truncated]")?;
} else { } else {
@@ -181,7 +192,8 @@ impl Printer {
self.close_dimmed(out)?; self.close_dimmed(out)?;
writeln!( writeln!(
out, out,
"[retrying: attempt {attempt} in {after_ms} ms: {error}]" "[retrying: attempt {attempt} in {after_ms} ms: {}]",
escape_json_text(error)
)?; )?;
} }
TurnEvent::ThinkingCapped { tokens } => { TurnEvent::ThinkingCapped { tokens } => {
@@ -193,6 +205,32 @@ impl Printer {
writeln!(out, "[cache loss: {got} of {expected}]")?; writeln!(out, "[cache loss: {got} of {expected}]")?;
} }
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {} 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() out.flush()
} }
@@ -209,3 +247,112 @@ impl Printer {
Ok(()) 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. //! The owner's command-line tool.
pub mod admin;
pub mod chat; pub mod chat;
pub mod cli;
pub mod escape;
pub mod verify;
+132 -128
View File
@@ -4,139 +4,133 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn}; use bxctl::admin::{self, AdminError};
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent}; use bxctl::chat::{Approvals, ChatError, OnPending, Printer, TurnIo, new_session_id, stream_turn};
use bxctl::cli::{self, ChatOptions, Command, USAGE};
const USAGE: &str = use bxctl::escape::escape_model_text;
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]"; use proto::{ErrorCode, SessionId, Timestamp, TurnDone};
struct Options {
socket: PathBuf,
session: Option<SessionId>,
show_thinking: bool,
say: Option<String>,
json: bool,
}
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect(); // `args_os`, because `args` panics on an argument that is not UTF-8. bxctl's arguments are
if args.first().map(String::as_str) != Some("chat") { // text (ids, messages, paths it prints back), so such an argument is a usage error.
eprintln!("{USAGE}"); let args: Vec<String> = match std::env::args_os()
return ExitCode::from(2); .skip(1)
} .map(|a| a.into_string())
match parse_chat(&args[1..]) { .collect()
Ok(opts) => run(&opts), {
Err(()) => { Ok(args) => args,
eprintln!("{USAGE}"); Err(_) => {
ExitCode::from(2) eprintln!("bxctl: an argument is not valid UTF-8\n{USAGE}");
return 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; // $BOXMAKER_HOME defaults to /var/lib/boxmaker; defaults for the sockets are read from it.
while i < args.len() { let home = std::env::var_os("BOXMAKER_HOME")
match args[i].as_str() {
"--socket" => {
i += 1;
match args.get(i) {
Some(p) => opts.socket = PathBuf::from(p),
None => return Err(()),
}
}
"--session" => {
i += 1;
match args.get(i) {
Some(s) => match SessionId::new(s) {
Ok(id) => opts.session = Some(id),
Err(_) => return Err(()),
},
None => return Err(()),
}
}
"--say" => {
i += 1;
match args.get(i) {
Some(t) => opts.say = Some(t.to_string()),
None => return Err(()),
}
}
"--no-thinking" => opts.show_thinking = false,
"--json" => opts.json = true,
_ => return Err(()),
}
i += 1;
}
Ok(opts)
}
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
fn default_socket() -> PathBuf {
let base = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")); .unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock") // Parse before connecting, so a usage error exits 2 even without a broker.
} let command = match cli::parse(&args, &home) {
Ok(command) => command,
// A writer that records the first io error it hits, so the on_event closure (which cannot return a Err(_) => {
// Result) does not lose a write failure. The error is checked after run_turn returns. eprintln!("{USAGE}");
struct Sink<'a> { return ExitCode::from(2);
out: &'a mut (dyn Write + 'static), }
err: Option<std::io::Error>, };
} match command {
Command::Chat(opts) => run(&opts),
impl Sink<'_> { Command::Approvals { admin_socket } => exit(|| cmd_approvals(&admin_socket)),
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) { Command::Approve {
if self.err.is_none() { admin_socket,
self.err = printer.event(self.out, event).err(); 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)
}
}
} }
} }
} }
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the // Run a command, mapping its result to an exit code: success for true, 1 for false or an error.
// first io error that occurred while writing. fn exit<F: FnOnce() -> Result<bool, AdminError>>(f: F) -> ExitCode {
fn stream_turn( match f() {
socket: &Path, Ok(true) => ExitCode::SUCCESS,
session: &SessionId, Ok(false) => ExitCode::from(1),
text: &str, Err(e) => {
resume: bool, eprintln!("bxctl: {e}");
sink: &mut Sink, ExitCode::from(1)
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)?;
}
match sink.err.take() {
Some(e) => Err(e),
None => Ok(outcome),
} }
} }
fn run(opts: &Options) -> ExitCode { fn cmd_approvals(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approvals(admin, Timestamp::now(), &mut out)
}
fn cmd_approve(admin: &Path, id: u64) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approve(admin, id, &mut out)
}
fn cmd_refuse(admin: &Path, id: u64, reason: Option<&str>) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_refuse(admin, id, reason, &mut out)
}
fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_grants_check(admin, &mut out)
}
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: &ChatOptions) -> ExitCode {
let stdin = std::io::stdin();
let mut input = BufReader::new(stdin.lock());
match &opts.say { match &opts.say {
Some(text) => run_say(opts, text), Some(text) => run_say(opts, text, &mut input),
None => run_interactive(opts), 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 { let session = match &opts.session {
Some(s) => s.clone(), Some(s) => s.clone(),
None => new_session_id(), None => new_session_id(),
}; };
let mut printer = Printer::new(opts.show_thinking, opts.json); 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 { match code {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(RunError::Io(e)) => { 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. // Runs a --say turn, retrying once when the named session does not exist.
fn run_turn_twice( fn run_turn_twice(
opts: &Options, opts: &ChatOptions,
session: &SessionId, session: &SessionId,
text: &str, text: &str,
resume: bool, resume: bool,
printer: &mut Printer, printer: &mut Printer,
input: &mut dyn BufRead,
) -> Result<(), RunError> { ) -> Result<(), RunError> {
let stderr = std::io::stderr(); let stderr = std::io::stderr();
let mut handle = stderr.lock(); let mut handle = stderr.lock();
let mut sink = Sink { let approvals = Approvals {
out: &mut handle, admin_socket: &opts.admin_socket,
err: None, 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)), Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => { Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?; 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 => { Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
// The named session does not exist: retry once, creating it (resume=false). // 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, 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)), Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => { Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?; 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 printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None; let mut session = None;
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
loop { loop {
eprint!("> "); eprint!("> ");
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
@@ -222,17 +221,22 @@ fn run_interactive(opts: &Options) -> ExitCode {
} }
let stderr = std::io::stderr(); let stderr = std::io::stderr();
let mut handle = stderr.lock(); 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, out: &mut handle,
err: None,
}; };
match stream_turn( match stream_turn(
&opts.socket, &opts.socket,
this_session, this_session,
text, text,
resume, resume,
&mut sink, &approvals,
&mut printer, &mut io,
) { ) {
Err(e) => { Err(e) => {
eprintln!("bxctl: {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. // 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 { if opts.json {
let line = serde_json::to_string(done).map_err(std::io::Error::other)?; let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
let mut err = std::io::stderr().lock(); 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 stdout = std::io::stdout();
let mut out = stdout.lock(); 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.write_all(b"\n")?;
out.flush() 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}; use inferproxy::{Limits, serve};
fn main() { 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 listen: Option<String> = None;
let mut upstream: Option<String> = None; let mut upstream: Option<String> = None;
let mut i = 1; 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), Read(PathBuf, std::io::Error),
Parse(PathBuf, serde_json::Error), Parse(PathBuf, serde_json::Error),
Hash, Hash,
Core(PathBuf, std::io::Error),
} }
impl std::fmt::Display for BaselineError { 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::Read(path, err) => write!(f, "{}: {err}", path.display()),
BaselineError::Parse(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::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 // 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. // session without the memory they curated and no sign of it.
Err(e) if e.kind() != std::io::ErrorKind::NotFound => { Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(BaselineError::Read(core.clone(), e)); return Err(BaselineError::Core(core.clone(), e));
} }
Err(_) => {} 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)] #[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Config { pub struct Config {
@@ -154,6 +171,8 @@ pub struct Config {
pub r#loop: Loop, pub r#loop: Loop,
#[serde(default)] #[serde(default)]
pub baseline: Baseline, pub baseline: Baseline,
#[serde(default)]
pub broker: Broker,
} }
#[derive(Debug)] #[derive(Debug)]
+1 -3
View File
@@ -151,9 +151,7 @@ impl Connection {
} }
pub fn set_read_timeout(&self, timeout: Duration) -> Result<(), HttpError> { pub fn set_read_timeout(&self, timeout: Duration) -> Result<(), HttpError> {
self.stream crate::socket::set_read_timeout(&self.stream, timeout).map_err(HttpError::Io)
.set_read_timeout(Some(timeout))
.map_err(HttpError::Io)
} }
pub fn received_any(&self) -> bool { 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. //! The agent loop: sessions, prompt assembly and memory. It holds no authority.
pub mod baseline; pub mod baseline;
pub mod broker_port;
pub mod channel; pub mod channel;
pub mod config; pub mod config;
pub mod http; pub mod http;
pub mod llama; pub mod llama;
pub mod selftest; pub mod selftest;
pub mod session; pub mod session;
pub mod socket;
pub mod sse; pub mod sse;
pub mod tools; pub mod tools;
pub mod turn; pub mod turn;
+25 -17
View File
@@ -6,19 +6,22 @@ use std::os::unix::net::UnixListener;
use std::path::Path; use std::path::Path;
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use loopd::broker_port::{BrokerPort, NoBroker, not_configured_line};
use loopd::channel::{self, Context}; use loopd::channel::{self, Context};
use loopd::config::Config; use loopd::config::Config;
use loopd::llama::Client; use loopd::llama::Client;
use loopd::selftest::{SelfTestError, run}; use loopd::selftest::{SelfTestError, run};
use loopd::tools::{FakeTools, Registry}; use loopd::tools::{Registry, ToolPort};
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect(); // `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
let args: Vec<&str> = args.iter().map(String::as_str).collect(); let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match args.as_slice() { let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
["selftest", "--config", path] => run_selftest(path), match (words.as_slice(), args.get(2)) {
["serve", "--config", path] => run_serve(path), ([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 selftest --config <path>");
eprintln!("usage: loopd serve --config <path>"); eprintln!("usage: loopd serve --config <path>");
@@ -38,14 +41,14 @@ fn run_selftest_check(client: &Client) -> Result<(), SelfTestError> {
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
eprintln!("selftest: FAILED: {e}"); eprintln!("selftest: FAILED: {e}; see docs/runbook.md#loopd-selftest-failed");
Err(e) Err(e)
} }
} }
} }
fn run_selftest(path: &str) -> ExitCode { fn run_selftest(path: &Path) -> ExitCode {
let cfg = match Config::load(Path::new(path)) { let cfg = match Config::load(path) {
Ok(cfg) => cfg, Ok(cfg) => cfg,
Err(e) => { Err(e) => {
eprintln!("loopd: {e}"); eprintln!("loopd: {e}");
@@ -60,8 +63,8 @@ fn run_selftest(path: &str) -> ExitCode {
} }
} }
fn run_serve(path: &str) -> ExitCode { fn run_serve(path: &Path) -> ExitCode {
let cfg = match Config::load(Path::new(path)) { let cfg = match Config::load(path) {
Ok(cfg) => cfg, Ok(cfg) => cfg,
Err(e) => { Err(e) => {
eprintln!("loopd: {e}"); eprintln!("loopd: {e}");
@@ -104,12 +107,17 @@ fn run_serve(path: &str) -> ExitCode {
} }
eprintln!("loopd: serving on {}", socket.display()); eprintln!("loopd: serving on {}", socket.display());
let ctx = Arc::new(Context::new( let port: Box<dyn ToolPort> = match &cfg.broker.socket {
cfg, Some(socket) => Box::new(BrokerPort::new(
client, socket.clone(),
Box::new(FakeTools::new()), Duration::from_millis(cfg.broker.timeout_ms),
Registry::m2b(), )),
)); 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) { if let Err(e) = channel::serve(listener, ctx) {
eprintln!("loopd: the channel server stopped: {e}"); eprintln!("loopd: the channel server stopped: {e}");
return ExitCode::from(1); 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::Exists(id) => write!(f, "session already exists: {}", id.as_str()),
SessionError::NotFound(id) => write!(f, "session not found: {}", id.as_str()), SessionError::NotFound(id) => write!(f, "session not found: {}", id.as_str()),
SessionError::Io(path, err) => write!(f, "{}: {err}", path.display()), 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::Baseline(err) => write!(f, "{err}"),
SessionError::Encode(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; /// 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`). /// anything else, including grants, is passed to the port (`brokerd`).
pub trait ToolPort: Send + Sync { 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)] #[derive(Debug, Clone, PartialEq)]
@@ -26,7 +39,9 @@ impl Registry {
Registry { entries } 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 { pub fn m2b() -> Registry {
Registry::new(vec![ Registry::new(vec![
Entry { 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`. /// The fixed tools array: core entries, then `find_tool`, then `call_tool`.
pub fn core_schemas(&self) -> Vec<ToolSchema> { pub fn core_schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self let mut schemas: Vec<ToolSchema> = self
@@ -76,6 +118,7 @@ impl Registry {
pub const FIND_TOOL: &str = "find_tool"; pub const FIND_TOOL: &str = "find_tool";
pub const CALL_TOOL: &str = "call_tool"; pub const CALL_TOOL: &str = "call_tool";
pub const CLOCK: &str = "clock";
pub fn clock_schema() -> ToolSchema { pub fn clock_schema() -> ToolSchema {
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 { fn find_tool_schema() -> ToolSchema {
ToolSchema { ToolSchema {
name: "find_tool".to_string(), name: "find_tool".to_string(),
@@ -157,6 +259,9 @@ pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch {
if name == CALL_TOOL { if name == CALL_TOOL {
return dispatch_call_tool(registry, arguments); return dispatch_call_tool(registry, arguments);
} }
if name == CLOCK {
return Dispatch::Local(proto::Timestamp::now().to_rfc3339());
}
Dispatch::Port { Dispatch::Port {
tool: name.to_string(), tool: name.to_string(),
arguments: arguments.to_string(), arguments: arguments.to_string(),
@@ -224,6 +329,32 @@ pub fn cap_result(text: &str, cap: usize) -> (String, bool) {
(result, true) (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 { pub struct FakeTools {
calls: Mutex<Vec<proto::ToolRequest>>, calls: Mutex<Vec<proto::ToolRequest>>,
} }
@@ -247,19 +378,17 @@ impl FakeTools {
} }
impl ToolPort for 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 self.calls
.lock() .lock()
.unwrap_or_else(|p| p.into_inner()) .unwrap_or_else(|p| p.into_inner())
.push(request.clone()); .push(request.clone());
match request.tool.as_str() { 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) { "echo" => match serde_json::from_str::<serde_json::Value>(&request.arguments) {
Ok(serde_json::Value::Object(obj)) => { Ok(serde_json::Value::Object(obj)) => {
match obj.get("text").and_then(|v| v.as_str()) { 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::info::{CacheOutcome, cache_outcome};
use crate::llama::{ChatEvent, ChatRequest, Client, InferError}; use crate::llama::{ChatEvent, ChatRequest, Client, InferError};
use crate::session::{Session, SessionError}; 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}; use proto::{CallId, DataClass, LogRecord, Timestamp, ToolRequest, ToolResponse, TurnEvent, Usage};
/// What one turn can fail with, beyond the inference server. /// What one turn can fail with, beyond the inference server.
@@ -230,7 +230,7 @@ pub fn run_turn(
false, false,
) )
} else { } 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); let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap);
@@ -260,6 +260,7 @@ fn run_call(
session: &Session, session: &Session,
call: &proto::ToolCall, call: &proto::ToolCall,
call_id: CallId, call_id: CallId,
on_event: &mut dyn FnMut(&TurnEvent),
) -> (String, DataClass, bool) { ) -> (String, DataClass, bool) {
match dispatch(rt.registry, &call.name, &call.arguments) { match dispatch(rt.registry, &call.name, &call.arguments) {
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false), crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
@@ -270,7 +271,14 @@ fn run_call(
tool, tool,
arguments, 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 { ToolResponse::Result {
content, content,
class, class,
@@ -282,14 +290,15 @@ fn run_call(
DataClass::Public, DataClass::Public,
false, false,
), ),
ToolResponse::Denied { reason } => ( ToolResponse::Denied { reason } => {
format!("The call was denied: {reason:?}"), on_event(&TurnEvent::ToolDenied {
DataClass::Public, name: request.tool.clone(),
false, reason,
), });
(denial_text(reason).to_string(), DataClass::Public, false)
}
ToolResponse::PendingApproval { .. } => ( ToolResponse::PendingApproval { .. } => (
"This version of loopd cannot wait for approval; the call was rejected." "The tool failed: the tool broker gave no final answer".to_string(),
.to_string(),
DataClass::Public, DataClass::Public,
false, 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