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:
@@ -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
@@ -7,6 +7,9 @@ name = "brokerd"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proto",
|
"proto",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proto.workspace = true
|
proto.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>,
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+454
-51
@@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use proto::{CallId, SessionId};
|
|
||||||
|
|
||||||
fn request() -> ToolRequest {
|
|
||||||
ToolRequest {
|
|
||||||
session: SessionId::new("s1").unwrap(),
|
|
||||||
call: CallId(1),
|
|
||||||
tool: "read_file".to_string(),
|
|
||||||
arguments: "{}".to_string(),
|
|
||||||
}
|
}
|
||||||
|
pub fn args(&self) -> &ToolArgs {
|
||||||
|
&self.args
|
||||||
}
|
}
|
||||||
|
pub fn grant(&self) -> &str {
|
||||||
#[test]
|
&self.matched.grant
|
||||||
fn no_grants_means_deny() {
|
|
||||||
assert_eq!(decide(request()).unwrap_err(), DenyReason::NoGrant);
|
|
||||||
}
|
}
|
||||||
|
pub fn grant_sha256(&self) -> Hash32 {
|
||||||
#[test]
|
self.matched.grant_sha256
|
||||||
fn decision_exposes_request_and_grant() {
|
}
|
||||||
let d = Decision::new(request(), "g1".to_string());
|
pub fn matched_path(&self) -> Option<&str> {
|
||||||
assert_eq!(d.request().tool, "read_file");
|
self.matched.matched_path.as_deref()
|
||||||
assert_eq!(d.grant(), "g1");
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The result of deciding a call: run it, ask, or deny it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Allowed(Decision),
|
||||||
|
Ask(Ask),
|
||||||
|
Denied(Denial),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decide(
|
||||||
|
request: ToolRequest,
|
||||||
|
grants: &GrantSet,
|
||||||
|
state: SessionState,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Outcome {
|
||||||
|
// 1. An unknown tool is denied before its arguments are even parsed.
|
||||||
|
let Some(tool) = ToolName::parse(&request.tool) else {
|
||||||
|
return Outcome::Denied(Denial::new(DenyReason::NoGrant));
|
||||||
|
};
|
||||||
|
// 2. Malformed arguments are refused before matching, whatever the grants say.
|
||||||
|
let Ok(args) = args::parse(tool, &request.arguments) else {
|
||||||
|
return Outcome::Denied(Denial::new(DenyReason::InvalidArguments));
|
||||||
|
};
|
||||||
|
|
||||||
|
let MatchResult {
|
||||||
|
left,
|
||||||
|
expired,
|
||||||
|
tainted,
|
||||||
|
} = match_grants(&args, grants, state, now);
|
||||||
|
|
||||||
|
// 3. No grant left: the reason is whatever the ruled-out grants reminded us of.
|
||||||
|
let Some(winner) = winner(&left) else {
|
||||||
|
let reason = match (expired, tainted) {
|
||||||
|
(true, _) => DenyReason::GrantExpired,
|
||||||
|
(_, true) => DenyReason::TaintTooHigh,
|
||||||
|
(_, _) => DenyReason::NoGrant,
|
||||||
|
};
|
||||||
|
return Outcome::Denied(Denial::new(reason));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. The label is over every grant left, not the winner alone.
|
||||||
|
let matched = build_matched(&left, &winner);
|
||||||
|
|
||||||
|
match winner.mode {
|
||||||
|
Mode::Deny => Outcome::Denied(Denial {
|
||||||
|
reason: DenyReason::DeniedByGrant,
|
||||||
|
grant: Some(winner.id.clone()),
|
||||||
|
grant_sha256: Some(winner.grant_sha256),
|
||||||
|
}),
|
||||||
|
Mode::Ask => Outcome::Ask(Ask {
|
||||||
|
request,
|
||||||
|
args,
|
||||||
|
matched,
|
||||||
|
}),
|
||||||
|
Mode::Auto => Outcome::Allowed(Decision {
|
||||||
|
request,
|
||||||
|
args,
|
||||||
|
matched,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redecide(
|
||||||
|
ask: Ask,
|
||||||
|
grants: &GrantSet,
|
||||||
|
state: SessionState,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Result<Decision, Denial> {
|
||||||
|
let MatchResult {
|
||||||
|
left,
|
||||||
|
expired,
|
||||||
|
tainted,
|
||||||
|
} = match_grants(ask.args(), grants, state, now);
|
||||||
|
|
||||||
|
let Some(winner) = winner(&left) else {
|
||||||
|
let reason = match (expired, tainted) {
|
||||||
|
(true, _) => DenyReason::GrantExpired,
|
||||||
|
(_, true) => DenyReason::TaintTooHigh,
|
||||||
|
(_, _) => DenyReason::NoGrant,
|
||||||
|
};
|
||||||
|
return Err(Denial::new(reason));
|
||||||
|
};
|
||||||
|
|
||||||
|
let matched = build_matched(&left, &winner);
|
||||||
|
|
||||||
|
match winner.mode {
|
||||||
|
Mode::Deny => Err(Denial {
|
||||||
|
reason: DenyReason::DeniedByGrant,
|
||||||
|
grant: Some(winner.id.clone()),
|
||||||
|
grant_sha256: Some(winner.grant_sha256),
|
||||||
|
}),
|
||||||
|
Mode::Ask | Mode::Auto => Ok(Decision {
|
||||||
|
request: ask.request().clone(),
|
||||||
|
args: ask.args().clone(),
|
||||||
|
matched,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grants left after matching, and the two flags that pick the denial reason when none are
|
||||||
|
/// left.
|
||||||
|
struct MatchResult {
|
||||||
|
left: Vec<Candidate>,
|
||||||
|
expired: bool,
|
||||||
|
tainted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Candidate {
|
||||||
|
id: String,
|
||||||
|
grant_sha256: Hash32,
|
||||||
|
mode: Mode,
|
||||||
|
matched_path: Option<String>,
|
||||||
|
paths: Vec<String>,
|
||||||
|
hosts: Vec<String>,
|
||||||
|
expires: Option<Timestamp>,
|
||||||
|
result_class: DataClass,
|
||||||
|
untrusted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match a call against every grant, in id order, keeping the ones that still stand.
|
||||||
|
fn match_grants(
|
||||||
|
args: &ToolArgs,
|
||||||
|
grants: &GrantSet,
|
||||||
|
state: SessionState,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> MatchResult {
|
||||||
|
let mut left: Vec<Candidate> = Vec::new();
|
||||||
|
let mut expired = false;
|
||||||
|
let mut tainted = false;
|
||||||
|
|
||||||
|
for loaded in grants.grants() {
|
||||||
|
// M1: only grants for this tool play a part.
|
||||||
|
if loaded.grant.tool != args.tool().as_str() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// M2: does this grant cover the arguments, and with which path (if any)?
|
||||||
|
let Some(matched_path) = covers(args, &loaded.grant) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// M3: a grant that would match but is past its expiry or beyond the session's taint does
|
||||||
|
// not stand. Remember each reason, so the denial can name the right one.
|
||||||
|
let expired_at = loaded.grant.expires.is_some_and(|at| now >= at);
|
||||||
|
let too_tainted = state.taint > loaded.grant.max_taint;
|
||||||
|
if expired_at && !too_tainted {
|
||||||
|
expired = true;
|
||||||
|
}
|
||||||
|
if too_tainted && !expired_at {
|
||||||
|
tainted = true;
|
||||||
|
}
|
||||||
|
if !expired_at && !too_tainted {
|
||||||
|
left.push(Candidate {
|
||||||
|
id: loaded.id.clone(),
|
||||||
|
grant_sha256: loaded.sha256,
|
||||||
|
mode: loaded.grant.mode,
|
||||||
|
matched_path,
|
||||||
|
paths: loaded.grant.constraints.paths.clone(),
|
||||||
|
hosts: loaded.grant.constraints.hosts.clone(),
|
||||||
|
expires: loaded.grant.expires,
|
||||||
|
result_class: loaded.grant.result_class,
|
||||||
|
untrusted: loaded.grant.untrusted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchResult {
|
||||||
|
left,
|
||||||
|
expired,
|
||||||
|
tainted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the grant covers the call, and the longest of its paths that holds the call. `None`
|
||||||
|
/// means the grant does not cover the call at all.
|
||||||
|
fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
|
||||||
|
match args {
|
||||||
|
ToolArgs::ReadFile { path } => best_path(grant, path, true),
|
||||||
|
ToolArgs::WriteFile { path, .. } => best_path(grant, path, false),
|
||||||
|
ToolArgs::Shell { cwd: None, .. } => {
|
||||||
|
if grant.constraints.paths.is_empty() {
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
|
||||||
|
ToolArgs::HttpFetch { host, .. } => {
|
||||||
|
if grant
|
||||||
|
.constraints
|
||||||
|
.hosts
|
||||||
|
.iter()
|
||||||
|
.any(|pattern| host_matches(pattern, host))
|
||||||
|
{
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the
|
||||||
|
/// argument does not count.
|
||||||
|
fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option<Option<String>> {
|
||||||
|
let mut best: Option<String> = None;
|
||||||
|
for candidate in &grant.constraints.paths {
|
||||||
|
if !inside(candidate, target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !itself_counts && candidate == target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if best
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|found| candidate.len() > found.len())
|
||||||
|
{
|
||||||
|
best = Some(candidate.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `None` means no path holds, so the grant does not cover the call; a held path becomes
|
||||||
|
// `Some(Some(p))` so `covers` can tell it apart from a covered call with no path.
|
||||||
|
best.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the winning grant's `Matched`, with the label over every standing grant.
|
||||||
|
fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched {
|
||||||
|
Matched {
|
||||||
|
grant: winner.id.clone(),
|
||||||
|
grant_sha256: winner.grant_sha256,
|
||||||
|
matched_path: winner.matched_path.clone(),
|
||||||
|
paths: winner.paths.clone(),
|
||||||
|
hosts: winner.hosts.clone(),
|
||||||
|
expires: winner.expires,
|
||||||
|
label: combined_label(left),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most restrictive standing grant, then the longest matched path, then the lowest id.
|
||||||
|
fn winner(candidates: &[Candidate]) -> Option<Candidate> {
|
||||||
|
for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() {
|
||||||
|
let mut best: Option<&Candidate> = None;
|
||||||
|
for candidate in candidates.iter().filter(|c| c.mode == mode) {
|
||||||
|
best = Some(match best {
|
||||||
|
Some(found) if !better(candidate, found) => found,
|
||||||
|
_ => candidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(found) = best {
|
||||||
|
return Some(found.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id.
|
||||||
|
fn better(a: &Candidate, b: &Candidate) -> bool {
|
||||||
|
let (la, lb) = (
|
||||||
|
a.matched_path.as_ref().map_or(0, String::len),
|
||||||
|
b.matched_path.as_ref().map_or(0, String::len),
|
||||||
|
);
|
||||||
|
match la.cmp(&lb) {
|
||||||
|
std::cmp::Ordering::Greater => true,
|
||||||
|
std::cmp::Ordering::Less => false,
|
||||||
|
std::cmp::Ordering::Equal => a.id < b.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label over every standing grant: the highest data class, and untrusted if any says so.
|
||||||
|
fn combined_label(candidates: &[Candidate]) -> Label {
|
||||||
|
let mut class = DataClass::Public;
|
||||||
|
for candidate in candidates {
|
||||||
|
if candidate.result_class > class {
|
||||||
|
class = candidate.result_class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let untrusted = candidates.iter().any(|c| c.untrusted);
|
||||||
|
Label { class, untrusted }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)]);
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Nothing set: every value is a default.
|
||||||
+11
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[paths]
|
||||||
|
home = "/srv/boxmaker"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[approvals]
|
||||||
|
ttl_ms = 60000
|
||||||
|
ttl = 5
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[secrets]
|
||||||
|
store = "/etc/boxmaker/secrets"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[approvals]
|
||||||
|
ttl_ms = "15 min"
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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, ¬es(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"), ¬es(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"), ¬es(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"), ¬es(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"), ¬es(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"), ¬es(Mode::Auto), now())
|
||||||
|
}));
|
||||||
|
assert!(panicked.is_err());
|
||||||
|
let later = ledger.decide(read("/n/a"), ¬es(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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
+129
-125
@@ -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.
|
||||||
|
let args: Vec<String> = match std::env::args_os()
|
||||||
|
.skip(1)
|
||||||
|
.map(|a| a.into_string())
|
||||||
|
.collect()
|
||||||
|
{
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("bxctl: an argument is not valid UTF-8\n{USAGE}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// $BOXMAKER_HOME defaults to /var/lib/boxmaker; defaults for the sockets are read from it.
|
||||||
|
let home = std::env::var_os("BOXMAKER_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
|
||||||
|
// Parse before connecting, so a usage error exits 2 even without a broker.
|
||||||
|
let command = match cli::parse(&args, &home) {
|
||||||
|
Ok(command) => command,
|
||||||
|
Err(_) => {
|
||||||
eprintln!("{USAGE}");
|
eprintln!("{USAGE}");
|
||||||
return ExitCode::from(2);
|
return ExitCode::from(2);
|
||||||
}
|
}
|
||||||
match parse_chat(&args[1..]) {
|
|
||||||
Ok(opts) => run(&opts),
|
|
||||||
Err(()) => {
|
|
||||||
eprintln!("{USAGE}");
|
|
||||||
ExitCode::from(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_chat(args: &[String]) -> Result<Options, ()> {
|
|
||||||
let mut opts = Options {
|
|
||||||
socket: default_socket(),
|
|
||||||
session: None,
|
|
||||||
show_thinking: true,
|
|
||||||
say: None,
|
|
||||||
json: false,
|
|
||||||
};
|
};
|
||||||
let mut i = 0;
|
match command {
|
||||||
while i < args.len() {
|
Command::Chat(opts) => run(&opts),
|
||||||
match args[i].as_str() {
|
Command::Approvals { admin_socket } => exit(|| cmd_approvals(&admin_socket)),
|
||||||
"--socket" => {
|
Command::Approve {
|
||||||
i += 1;
|
admin_socket,
|
||||||
match args.get(i) {
|
approval,
|
||||||
Some(p) => opts.socket = PathBuf::from(p),
|
} => exit(|| cmd_approve(&admin_socket, approval)),
|
||||||
None => return Err(()),
|
Command::Refuse {
|
||||||
|
admin_socket,
|
||||||
|
approval,
|
||||||
|
reason,
|
||||||
|
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
|
||||||
|
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
|
||||||
|
Command::AuditVerify { home } => {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
match bxctl::verify::run(&home, &mut out) {
|
||||||
|
Ok(true) => ExitCode::SUCCESS,
|
||||||
|
Ok(false) => ExitCode::from(1),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"bxctl: cannot read the audit log under {}: {e}",
|
||||||
|
home.display()
|
||||||
|
);
|
||||||
|
ExitCode::from(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--session" => {
|
|
||||||
i += 1;
|
|
||||||
match args.get(i) {
|
|
||||||
Some(s) => match SessionId::new(s) {
|
|
||||||
Ok(id) => opts.session = Some(id),
|
|
||||||
Err(_) => return Err(()),
|
|
||||||
},
|
|
||||||
None => return Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--say" => {
|
|
||||||
i += 1;
|
|
||||||
match args.get(i) {
|
|
||||||
Some(t) => opts.say = Some(t.to_string()),
|
|
||||||
None => return Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--no-thinking" => opts.show_thinking = false,
|
|
||||||
"--json" => opts.json = true,
|
|
||||||
_ => return Err(()),
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
Ok(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
|
|
||||||
fn default_socket() -> PathBuf {
|
|
||||||
let base = std::env::var_os("BOXMAKER_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
|
|
||||||
base.join("run").join("loop").join("loop.sock")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
|
|
||||||
// Result) does not lose a write failure. The error is checked after run_turn returns.
|
|
||||||
struct Sink<'a> {
|
|
||||||
out: &'a mut (dyn Write + 'static),
|
|
||||||
err: Option<std::io::Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sink<'_> {
|
|
||||||
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
|
|
||||||
if self.err.is_none() {
|
|
||||||
self.err = printer.event(self.out, event).err();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(¬_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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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(_) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user