Load grant files, failing closed on any invalid file
crates/brokerd/src/grants.rs reads grants/*.toml into a GrantSet: load reports every problem in every file and returns either a complete valid set or the full problem list, never a partial one; from_grants sorts by id and collects every rule-2..9 problem; render prints each problem then the runbook pointer. All 17 grants tests pass; make gate prints gate: ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
//! 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; keep going.
|
||||
names.push(String::new());
|
||||
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,
|
||||
};
|
||||
before.bytes().filter(|&b| b == b'\n').count() as u64 + 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(),
|
||||
});
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod grants;
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -46,6 +46,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
| M3a/01-proto-audit-types | 2026-09-22 | done | 1 | pass | none | Finished the blocked task. `audit.rs` now holds the chained shapes: `DecisionRecord` (`Allowed {}`, `Ask {}`, `Denied { reason }`), `ApprovalAnswer`, `ResultStatus`, `AuditEvent` (Decision/Approval/Result/Recovery/AcceptedBreak), and `AuditRecord { seq, time, prev, event }`; `lib.rs` re-exports the five names. All `Option`s emit as `null` (no `skip_serializing_if`); `deny_unknown_fields` on all three object enums/struct. Tests copied from `docs/plans/M3a/files/`: `records` 3 passed, `strict` 5 passed. Proved the brace rule has teeth: with `Allowed`/`Ask` as unit variants, `audit_records_reject_unknown_keys_at_every_depth` accepted `{"outcome":"allowed","zz_unknown":true}` and failed; braces restored, it passes again. NOTE: `docs/plans/M3a/files/crates/proto/tests/strict.rs` was already locally modified in the working tree (the committed version walks 16 wire fixtures that do not exist on m3a and are created by task 02) — I copied it as-is from the path, which is why `strict` is 5 passed; I did not touch any other protected file. `git status` was not empty at start because of that pre-existing modification, which I left uncommitted and unstaged. | OpenCode |
|
||||
| M3a/03-proto-chain-verifier | 2026-09-19 | done | 1 | pass | implementation matches the reference tree's chain.rs verbatim |
|
||||
| M3a/04-brokerd-config | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/config.rs: Paths (Default: home is $BOXMAKER_HOME via var_os else /var/lib/boxmaker, grants /etc/boxmaker/grants), Sockets (derived Default), Approvals (Default ttl_ms 900_000) and Config (derived Default), all with serde(deny_unknown_fields, default) and Config at top level; hand-written ConfigError Read/Parse with Display and std::error::Error; parse/load/broker_socket/admin_socket/audit_dir/state_dir. Added serde, serde_json, toml to Cargo.toml, `pub mod config;` to lib.rs, and `brokerd` to the serde and serde_json "Used by" cells in dependencies.md. 7 config tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
| M3a/06-brokerd-grants | 2026-09-23 | done | 2 | fail | none | Wrote crates/brokerd/src/grants.rs: `RUNBOOK`, `LoadedGrant`, `GrantSet` (private `grants` field, `from_grants` sorts by id and collects every problem, `grants()`), `valid_id`, `load` (read_dir -> one directory problem, a missing dir is not empty, sorted names, skip non-.toml, read/utf8/toml/sha256 each record a problem and continue, then `from_grants`, stable sort by file), `render`, and `span_line` (count newlines in `text.get(..offset)` + 1). Rules 2-9 live in `check_grant`/`check_tool_constraints`; an unknown tool skips rule 6 only. First gate failed on clippy `needless_borrows_for_generic_args` (pass `format!()` not `&format!()` to the `impl Into<String>` `push`); all 17 grants tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
| M3a/05-brokerd-args | 2026-09-23 | done | 4 | fail | none | Wrote crates/brokerd/src/args.rs (MAX_PATH, MAX_URL, ToolName with ALL/parse/as_str, ToolArgs with tool/canonical_json, ArgsError with hand-written Display+Error, parse, valid_path, inside, valid_host, valid_host_pattern, host_matches, url_host) and added `pub mod args;` to lib.rs. 13 args tests pass; `make gate` prints `gate: ok`. Three clippy fixes before a clean gate: collapsed the shell `cwd` if-let into an edition-2024 let-chain, `('a'..='z').contains` -> `is_ascii_lowercase`, and the trailing `/` match -> `?`. `source()` returns None because `String` does not implement `std::error::Error`. The URL rules read the host as written (no `to_lowercase`); uppercase fails `valid_host`, matching the test that lists `https://Example.com/` as invalid. | OpenCode | Wrote crates/proto/src/chain.rs: `ChainVerifier`, a pure line-holding state machine (each line is judged only once the next one has arrived, so a `Recovery` record can mark the line before it not-a-record), plus `ChainFailure`, `TornTail`, `ChainReport`, `Location`. Holds each line, checks recovery against the next, then rule 1 (parse, expected seq, prev with the file-before text for line 1 of a resumed/continued verifier), the failed-region counting of rule 5, the resumed-earlier-file break exception of rule 6, run/ask tracking for `abandoned`/`unfinished`, and clock warnings; `finish` reports the torn tail and the break's required seq/prev. Added `pub mod chain` and the five re-exports to lib.rs and the same line to audit.rs. The single worker subagent for this task entered an unrecoverable reasoning loop on the state machine and was not completing, so the orchestrator implemented it directly from the spec and fixtures. 13 chain tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user