Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving), the memory tools and built-in grants in brokerd, the memory block in loopd, bxctl reindex and memory, and the device checks with memoryd. Each task was built as a reference and gated. Each was replayed on its own at its end state (854 to 936 tests) and mutation-checked. Each skeleton compiles and fails its tests. Every todo comment was read beside its signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
//! 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());
|
||||
let builtin: Vec<&str> = set.matchable().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
builtin,
|
||||
["builtin-memory", "builtin-recall"],
|
||||
"only the built-ins"
|
||||
);
|
||||
}
|
||||
|
||||
#[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,200 @@
|
||||
//! Decided memory calls go to `memoryd` with the session's state, everything else to the other
|
||||
//! runtime; `memoryd`'s label can only make a result stricter; and a container runtime never runs
|
||||
//! a memory tool (M5a spec, section 8). Do not edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/fake_podman.rs"]
|
||||
mod fake_podman;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::container::{IN_MEMORYD, Podman};
|
||||
use brokerd::memory_port::{MemoryPort, Routed, UNREACHABLE};
|
||||
use brokerd::policy::{Outcome, SessionState, decide};
|
||||
use brokerd::runner::run;
|
||||
use build::{grant, now, read, request, set};
|
||||
use fake_podman::{Fake, Lines, serial};
|
||||
use proto::{
|
||||
DataClass, Envelope, MemoryCall, Message, Mode, ToolRequest, ToolResponse, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
use runtime::{Recording, Shared};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// A fake `memoryd`: answers one call with `answer` and hands back what it was sent.
|
||||
fn fake_memoryd(socket: &Path, answer: ToolResponse) -> mpsc::Receiver<MemoryCall> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = read_frame(&mut stream).unwrap();
|
||||
let Message::MemoryCall(call) = env.msg else {
|
||||
panic!("not a memory call: {:?}", env.msg)
|
||||
};
|
||||
tx.send(call).unwrap();
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: Message::ToolResponse(answer),
|
||||
};
|
||||
write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn result(class: DataClass, untrusted: bool) -> ToolResponse {
|
||||
ToolResponse::Result {
|
||||
content: "[1] memory · …".to_string(),
|
||||
class,
|
||||
untrusted,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn decide_in(req: ToolRequest, state: SessionState) -> brokerd::policy::Decision {
|
||||
let grants = set(vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])]);
|
||||
match decide(req, &grants, state, now()) {
|
||||
Outcome::Allowed(d) => d,
|
||||
other => panic!("not allowed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(socket: &Path, other: &std::sync::Arc<Recording>) -> Routed {
|
||||
Routed {
|
||||
memory: MemoryPort::new(socket.to_path_buf(), Duration::from_secs(5)),
|
||||
other: Box::new(Shared(std::sync::Arc::clone(other))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_call_goes_to_memoryd_with_the_session_state() {
|
||||
let dir = TempDir::new("mp-route");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let seen = fake_memoryd(&socket, result(DataClass::Private, false));
|
||||
let other = Recording::answering("container");
|
||||
let state = SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: true,
|
||||
};
|
||||
let got = run(
|
||||
decide_in(request("memory", r#"{"text":"x","action":"note"}"#), state),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { .. }), "{got:?}");
|
||||
let call = seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!((call.taint, call.untrusted), (DataClass::Secret, true));
|
||||
assert_eq!(call.request.tool, "memory");
|
||||
assert_eq!(
|
||||
call.request.arguments, r#"{"action":"note","text":"x"}"#,
|
||||
"canonical"
|
||||
);
|
||||
assert_eq!(other.count(), 0, "nothing reached the other runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_go_to_the_other_runtime() {
|
||||
let dir = TempDir::new("mp-other");
|
||||
let other = Recording::answering("the file");
|
||||
let got = run(
|
||||
decide_in(read("/n/a"), SessionState::default()),
|
||||
&routed(&dir.path().join("none.sock"), &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { ref content, .. } if content == "the file"));
|
||||
assert_eq!(other.count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoryd_can_make_a_result_stricter_but_never_laxer() {
|
||||
let dir = TempDir::new("mp-label");
|
||||
let other = Recording::answering("unused");
|
||||
for (n, (answer, want)) in [
|
||||
((DataClass::Secret, true), (DataClass::Secret, true)),
|
||||
((DataClass::Public, false), (DataClass::Private, false)),
|
||||
((DataClass::Private, true), (DataClass::Private, true)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let socket = dir.path().join(format!("m-{n}.sock"));
|
||||
let _seen = fake_memoryd(&socket, result(answer.0, answer.1));
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
let ToolResponse::Result {
|
||||
class, untrusted, ..
|
||||
} = got
|
||||
else {
|
||||
panic!("{got:?}")
|
||||
};
|
||||
assert_eq!((class, untrusted), want, "memoryd said {answer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_and_silence_are_failed_results() {
|
||||
let dir = TempDir::new("mp-fail");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let _seen = fake_memoryd(
|
||||
&socket,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string(),
|
||||
},
|
||||
);
|
||||
let other = Recording::answering("unused");
|
||||
let add = r#"{"action":"add","file":"memory","text":"x"}"#;
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string()
|
||||
}
|
||||
);
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&dir.path().join("nobody.sock"), &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: UNREACHABLE.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_runtime_never_runs_a_memory_tool() {
|
||||
let _s = serial();
|
||||
let fake = Fake::new("memory", "exit 0");
|
||||
let log = Lines::default();
|
||||
let podman = Podman::new(fake.runner(""), fake.dir.join("egress"), log.sink());
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&podman,
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: IN_MEMORYD.to_string()
|
||||
}
|
||||
);
|
||||
assert!(fake.calls().is_empty(), "podman was never called");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! `recall` and `memory` in `brokerd`: their arguments, the built-in grants that allow them in
|
||||
//! every session, and grant files that name them refused (M5a spec, section 8). Do not edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::args::{ToolArgs, ToolName, parse};
|
||||
use brokerd::builtin::{BUILT_IN, MEMORY, RECALL};
|
||||
use brokerd::grants::{GrantSet, load};
|
||||
use brokerd::policy::{Label, Outcome, decide};
|
||||
use build::{allowed, grant, now, private, request, secret, set};
|
||||
use proto::{DataClass, DenyReason, Mode, sha256};
|
||||
use tmp::TempDir;
|
||||
|
||||
#[test]
|
||||
fn the_names_and_arguments_of_the_memory_tools() {
|
||||
assert_eq!(ToolName::parse("recall"), Some(ToolName::Recall));
|
||||
assert_eq!(ToolName::parse("memory"), Some(ToolName::Memory));
|
||||
assert_eq!(
|
||||
(ToolName::Recall.as_str(), ToolName::Memory.as_str()),
|
||||
("recall", "memory")
|
||||
);
|
||||
assert!(ToolName::Recall.is_memory() && ToolName::Memory.is_memory());
|
||||
assert!(
|
||||
ToolName::ALL.iter().all(|t| !t.is_memory()),
|
||||
"ALL is the container tools"
|
||||
);
|
||||
let recall = parse(ToolName::Recall, r#"{ "k": 3, "query": "launch" }"#).unwrap();
|
||||
assert_eq!(recall.tool(), ToolName::Recall);
|
||||
assert_eq!(recall.canonical_json(), r#"{"query":"launch","k":3}"#);
|
||||
let memory = parse(ToolName::Memory, r#"{"text":"x","action":"note"}"#).unwrap();
|
||||
assert!(matches!(memory, ToolArgs::Memory(_)));
|
||||
assert_eq!(memory.canonical_json(), r#"{"action":"note","text":"x"}"#);
|
||||
for (tool, bad) in [
|
||||
(ToolName::Recall, r#"{"query":""}"#),
|
||||
(ToolName::Recall, r#"{"query":"x","k":11}"#),
|
||||
(
|
||||
ToolName::Memory,
|
||||
r#"{"action":"add","file":"soul","text":"x"}"#,
|
||||
),
|
||||
(
|
||||
ToolName::Memory,
|
||||
r#"{"action":"add","file":"memory","text":"a\n§\nb"}"#,
|
||||
),
|
||||
(
|
||||
ToolName::Memory,
|
||||
r#"{"action":"consolidate","file":"memory","entries":[]}"#,
|
||||
),
|
||||
] {
|
||||
assert!(parse(tool, bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_are_allowed_in_every_session_by_the_built_in_grants() {
|
||||
let grants = set(vec![]);
|
||||
for state in [private(), secret()] {
|
||||
let d = allowed(decide(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
&grants,
|
||||
state,
|
||||
now(),
|
||||
));
|
||||
assert_eq!(d.grant(), "builtin-recall");
|
||||
assert_eq!(d.grant_sha256(), sha256(RECALL.as_bytes()).unwrap());
|
||||
assert_eq!(
|
||||
d.label(),
|
||||
Label {
|
||||
class: DataClass::Private,
|
||||
untrusted: false
|
||||
}
|
||||
);
|
||||
let d = allowed(decide(
|
||||
request("memory", r#"{"action":"note","text":"x"}"#),
|
||||
&grants,
|
||||
state,
|
||||
now(),
|
||||
));
|
||||
assert_eq!(d.grant(), "builtin-memory");
|
||||
assert_eq!(d.grant_sha256(), sha256(MEMORY.as_bytes()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_set_without_built_ins_has_no_memory_tools() {
|
||||
let outcome = decide(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
&GrantSet::default(),
|
||||
private(),
|
||||
now(),
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, Outcome::Denied(ref d) if d.reason == DenyReason::NoGrant),
|
||||
"{outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id() {
|
||||
let dir = TempDir::new("memory-grants");
|
||||
std::fs::write(
|
||||
dir.path().join("mine.toml"),
|
||||
"tool = \"memory\"\nmode = \"ask\"\nmax_taint = \"secret\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("off.toml"),
|
||||
"tool = \"recall\"\nmode = \"deny\"\nmax_taint = \"secret\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("builtin-x.toml"),
|
||||
"tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"private\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let problems = load(dir.path()).unwrap_err();
|
||||
let got: Vec<(String, String)> = problems.into_iter().map(|p| (p.file, p.problem)).collect();
|
||||
assert!(
|
||||
got.contains(&("mine.toml".to_string(), BUILT_IN.to_string())),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(
|
||||
got.contains(&("off.toml".to_string(), BUILT_IN.to_string())),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(
|
||||
got.contains(&(
|
||||
"builtin-x.toml".to_string(),
|
||||
"grant ids starting builtin- are reserved".to_string()
|
||||
)),
|
||||
"{got:?}"
|
||||
);
|
||||
let with_shell = set(vec![grant("s", "shell", Mode::Ask)]);
|
||||
assert_eq!(
|
||||
with_shell.grants().len(),
|
||||
1,
|
||||
"the built-ins are not listed as the owner's"
|
||||
);
|
||||
assert_eq!(with_shell.matchable().count(), 3);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 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 },
|
||||
runner: None,
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
};
|
||||
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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user