Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,77 @@
|
||||
//! 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.
|
||||
pub fn next(stream: &mut UnixStream) -> Envelope {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user