Implemented decide and redecide in crates/brokerd/src/policy.rs: SessionState, Label, Denial, private Matched, and Decision/Ask (private fields, Debug only, nine getters each) with the Outcome enum. decide rejects an unknown tool (args not parsed) and malformed arguments before matching, then runs the M1-M5 matching pass in id order and returns Allowed/Ask/Denied by the winner's mode; redecide re-runs matching now and rebuilds the Decision from the Ask. Seven doctests (six compile_fail, one compiling) guard the two facts. policy 7, policy_matching 10, policy_redecide 7, policy_property 4, doc 7 all pass; make gate ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
214 lines
6.9 KiB
Rust
214 lines
6.9 KiB
Rust
//! Table tests for `policy::decide`: which arguments each tool's grants cover. Do not edit.
|
|
//! `policy_matching.rs` covers how a winner, a label and a reason are picked, `policy_redecide.rs`
|
|
//! covers approvals, and `policy_property.rs` checks all of it against an oracle.
|
|
|
|
#[path = "support/build.rs"]
|
|
mod build;
|
|
|
|
use brokerd::policy::decide;
|
|
use build::{allowed, fetch, grant, now, private, read, reason, request, set, shell, write};
|
|
use proto::{DenyReason, Mode};
|
|
|
|
#[test]
|
|
fn no_grants_means_no_grant() {
|
|
let none = set(vec![]);
|
|
for req in [
|
|
read("/etc/hosts"),
|
|
write("/tmp/x"),
|
|
shell(None),
|
|
fetch("https://example.com/"),
|
|
] {
|
|
assert_eq!(
|
|
reason(decide(req, &none, private(), now())),
|
|
DenyReason::NoGrant
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_tool_that_is_not_one_of_the_four_is_no_grant_and_its_arguments_are_not_parsed() {
|
|
let grants = set(vec![grant("s", "shell", Mode::Auto)]);
|
|
for tool in ["echo", "clock", "call_tool", "", "Shell"] {
|
|
for arguments in ["{}", "not json", r#"{"command":"ls"}"#] {
|
|
let outcome = decide(request(tool, arguments), &grants, private(), now());
|
|
assert_eq!(
|
|
reason(outcome),
|
|
DenyReason::NoGrant,
|
|
"{tool:?} {arguments:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Invalid arguments are refused before matching, so the answer is the same with a grant that
|
|
/// would cover them, with a `deny` grant, and with no grant at all.
|
|
#[test]
|
|
fn invalid_arguments_are_refused_before_matching() {
|
|
let covering = set(vec![
|
|
grant("r", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
|
|
grant("no", "read_file", Mode::Deny).paths(&["/home/kyle"]),
|
|
]);
|
|
let none = set(vec![]);
|
|
for grants in [&covering, &none] {
|
|
for req in [
|
|
// The rows of the "Paths" table that are about form.
|
|
read("/home/kyle/notes/../.ssh/id"),
|
|
read("notes/a.md"),
|
|
read("/home/kyle//notes/./a.md"),
|
|
request("read_file", "{}"),
|
|
request(
|
|
"read_file",
|
|
r#"{"path":"/home/kyle/notes/a.md","mode":"r"}"#,
|
|
),
|
|
request("read_file", "not json"),
|
|
write("/home/kyle/notes/"),
|
|
shell(Some("relative")),
|
|
fetch("http://example.com/"),
|
|
fetch("https://127.0.0.1/"),
|
|
fetch("https://user@example.com/"),
|
|
] {
|
|
let text = req.arguments.clone();
|
|
assert_eq!(
|
|
reason(decide(req, grants, private(), now())),
|
|
DenyReason::InvalidArguments,
|
|
"{text}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The "Paths" table, the rows about containment.
|
|
#[test]
|
|
fn read_file_is_covered_inside_a_granted_path() {
|
|
let grants = set(vec![
|
|
grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"]),
|
|
]);
|
|
for path in [
|
|
"/home/kyle/notes/a.md",
|
|
"/home/kyle/notes",
|
|
"/home/kyle/notes/x/y/z",
|
|
] {
|
|
let d = allowed(decide(read(path), &grants, private(), now()));
|
|
assert_eq!(d.grant(), "notes");
|
|
assert_eq!(d.matched_path(), Some("/home/kyle/notes"));
|
|
}
|
|
for path in [
|
|
"/home/kyle/notes2/a.md",
|
|
"/home/kyle",
|
|
"/",
|
|
"/etc/passwd",
|
|
"/home/kyle/note",
|
|
] {
|
|
assert_eq!(
|
|
reason(decide(read(path), &grants, private(), now())),
|
|
DenyReason::NoGrant,
|
|
"{path}"
|
|
);
|
|
}
|
|
// A grant is for one tool.
|
|
let outcome = decide(write("/home/kyle/notes/a.md"), &grants, private(), now());
|
|
assert_eq!(reason(outcome), DenyReason::NoGrant);
|
|
}
|
|
|
|
#[test]
|
|
fn write_file_is_covered_inside_a_granted_path_but_not_at_the_path_itself() {
|
|
let grants = set(vec![
|
|
grant("scratch", "write_file", Mode::Auto)
|
|
.paths(&["/home/kyle/scratch", "/home/kyle/scratch/out"]),
|
|
]);
|
|
let d = allowed(decide(
|
|
write("/home/kyle/scratch/a.txt"),
|
|
&grants,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
|
|
// The longest path that holds the argument is the matched one.
|
|
let d = allowed(decide(
|
|
write("/home/kyle/scratch/out/b.txt"),
|
|
&grants,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.matched_path(), Some("/home/kyle/scratch/out"));
|
|
// A granted path itself cannot be written, but it can lie inside another granted path.
|
|
let d = allowed(decide(
|
|
write("/home/kyle/scratch/out"),
|
|
&grants,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.matched_path(), Some("/home/kyle/scratch"));
|
|
let outcome = decide(write("/home/kyle/scratch"), &grants, private(), now());
|
|
assert_eq!(reason(outcome), DenyReason::NoGrant);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_is_covered_by_no_paths_and_no_cwd_or_by_a_cwd_inside_a_path() {
|
|
let bare = set(vec![grant("bare", "shell", Mode::Auto)]);
|
|
let d = allowed(decide(shell(None), &bare, private(), now()));
|
|
assert_eq!((d.matched_path(), d.paths().len()), (None, 0));
|
|
assert_eq!(
|
|
reason(decide(shell(Some("/home/kyle")), &bare, private(), now())),
|
|
DenyReason::NoGrant
|
|
);
|
|
|
|
let scoped = set(vec![
|
|
grant("scoped", "shell", Mode::Auto).paths(&["/home/kyle/a", "/srv/b"]),
|
|
]);
|
|
let d = allowed(decide(shell(Some("/srv/b/sub")), &scoped, private(), now()));
|
|
assert_eq!(d.matched_path(), Some("/srv/b"));
|
|
// The runner mounts every path of the grant, so the decision carries them all.
|
|
assert_eq!(d.paths(), ["/home/kyle/a", "/srv/b"]);
|
|
assert_eq!(
|
|
reason(decide(shell(None), &scoped, private(), now())),
|
|
DenyReason::NoGrant
|
|
);
|
|
assert_eq!(
|
|
reason(decide(shell(Some("/srv")), &scoped, private(), now())),
|
|
DenyReason::NoGrant
|
|
);
|
|
}
|
|
|
|
/// The "Hosts" table, row by row.
|
|
#[test]
|
|
fn http_fetch_is_covered_when_the_host_matches() {
|
|
let exact = set(vec![
|
|
grant("exact", "http_fetch", Mode::Auto).hosts(&["example.com"]),
|
|
]);
|
|
let wild = set(vec![
|
|
grant("wild", "http_fetch", Mode::Auto).hosts(&["*.example.com"]),
|
|
]);
|
|
let d = allowed(decide(
|
|
fetch("https://example.com/a?b=c"),
|
|
&exact,
|
|
private(),
|
|
now(),
|
|
));
|
|
assert_eq!(d.hosts(), ["example.com"]);
|
|
assert_eq!(d.matched_path(), None);
|
|
assert_eq!(
|
|
reason(decide(
|
|
fetch("https://www.example.com/"),
|
|
&exact,
|
|
private(),
|
|
now()
|
|
)),
|
|
DenyReason::NoGrant
|
|
);
|
|
for url in ["https://www.example.com/", "https://a.b.example.com:443/x"] {
|
|
allowed(decide(fetch(url), &wild, private(), now()));
|
|
}
|
|
for url in [
|
|
"https://example.com/",
|
|
"https://badexample.com/",
|
|
"https://example.com.evil.org/",
|
|
] {
|
|
assert_eq!(
|
|
reason(decide(fetch(url), &wild, private(), now())),
|
|
DenyReason::NoGrant,
|
|
"{url}"
|
|
);
|
|
}
|
|
}
|