use proptest::prelude::*;
use crate::command_verdict_in;
use crate::pathctx::PathCtx;
fn workspace() -> PathCtx {
PathCtx { cwd: Some("/work".into()), root: Some("/work".into()), ..Default::default() }
}
fn absolute_home() -> Option<String> {
std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1)
}
const HOME_SPELLING_CONTEXTS: &[&str] = &["cat {}", "grep -rn x {}", "ls {}", "echo hi > {}", "sed -i s/a/b/ {}", "rm -rf {}"];
fn arb_home_relative_path() -> impl Strategy<Value = String> {
let first = proptest::sample::select(vec!["app", "app", "peer", "app-evil", ".config", ".ssh"]);
let rest = proptest::collection::vec(
proptest::sample::select(vec!["src", "main.rs", "notes", ".git", "hooks", "pre-commit", ".envrc", ".ssh", "id_rsa", ".aws", "x"]),
0..4,
);
(first, rest).prop_map(|(f, r)| std::iter::once(f).chain(r).collect::<Vec<_>>().join("/"))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(400))]
#[test]
fn home_and_absolute_spellings_classify_alike(rel in arb_home_relative_path(), ctx in proptest::sample::select(HOME_SPELLING_CONTEXTS.to_vec())) {
let Some(home) = absolute_home() else { return Ok(()) };
let root = format!("{home}/projects/app");
let ws = || PathCtx { cwd: Some(root.clone()), root: Some(root.clone()), ..Default::default() };
let tilde = ctx.replace("{}", &format!("~/projects/{rel}"));
let absolute = ctx.replace("{}", &format!("{home}/projects/{rel}"));
prop_assert_eq!(
command_verdict_in(&tilde, ws()).is_allowed(),
command_verdict_in(&absolute, ws()).is_allowed(),
"`{}` and `{}` disagree", tilde, absolute,
);
}
}
#[test]
fn a_root_above_a_protected_place_does_not_unprotect_it() {
for os in ["linux", "macos"] {
let failures = crate::engine::resolve::regions::with_os(os, roots_above_protected_places_unprotecting);
assert!(failures.is_empty(), "[{os}] a root above a protected place unprotected it:\n {}", failures.join("\n "));
}
}
fn roots_above_protected_places_unprotecting() -> Vec<String> {
let Some(home) = absolute_home() else { return Vec::new() };
let anchored: Vec<String> = crate::engine::resolve::regions::anchored_protected_paths_here()
.into_iter()
.filter(|p| !p.contains('*'))
.collect();
assert!(anchored.len() > 20, "the region table must supply the witnesses");
let mut failures = Vec::new();
for region in &anchored {
let place = match region.trim_end_matches('/').strip_prefix('~') {
Some(rest) => format!("{home}{rest}"),
None => region.trim_end_matches('/').to_string(),
};
let file = if region.ends_with('/') { format!("{region}inner") } else { region.clone() };
let absolute = match file.strip_prefix('~') {
Some(rest) => format!("{home}{rest}"),
None => file.clone(),
};
let parents: Vec<String> = std::iter::successors(std::path::Path::new(&absolute).parent(), |p| p.parent())
.map(|p| p.to_string_lossy().into_owned())
.filter(|p| !p.is_empty() && *p != place && !p.starts_with(&format!("{place}/")))
.collect();
for ctx in ["cat {}", "echo hi > {}", "rm -rf {}"] {
let elsewhere = command_verdict_in(&ctx.replace("{}", &absolute.replace(' ', "\\ ")), workspace()).is_allowed();
for root in &parents {
let here = PathCtx { cwd: Some(root.clone()), root: Some(root.clone()), ..Default::default() };
let relative = absolute.strip_prefix(root.as_str()).map_or(absolute.as_str(), |r| r.trim_start_matches('/'));
for spelling in [absolute.as_str(), relative, file.as_str()] {
let line = ctx.replace("{}", &spelling.replace(' ', "\\ "));
if command_verdict_in(&line, here.clone()).is_allowed() && !elsewhere {
failures.push(format!("`{line}` from root `{root}`"));
}
}
}
}
}
failures
}
#[test]
fn another_users_home_stays_shielded_from_a_root_above_it() {
for root in ["/", "/Users", "/home"] {
let here = PathCtx { cwd: Some(root.into()), root: Some(root.into()), ..Default::default() };
for parent in ["/Users", "/home"] {
let path = format!("{parent}/someone-else/notes.txt");
if !path.starts_with(&format!("{}/", root.trim_end_matches('/'))) {
continue;
}
let relative = path.strip_prefix(root).unwrap_or(&path).trim_start_matches('/');
for spelling in [path.as_str(), relative] {
let line = format!("cat {spelling}");
assert!(!command_verdict_in(&line, here.clone()).is_allowed(), "`{line}` from root `{root}`");
}
}
}
}
fn arb_sweep_target() -> impl Strategy<Value = String> {
let first = proptest::sample::select(vec![
"~", "~/", "~/projects", "~/.zshrc", "~/.ssh", "~/.ssh/id_rsa", "~/.config", "~/.config/safe-chains.toml",
"~/.claude/settings.json", "~/Library", "~/Library/Keychains", "~/Library/LaunchAgents/x.plist", "/etc", "/etc/shadow",
"/etc/sudoers", "/Users", "/home",
]);
let glob = proptest::sample::select(vec!["", "/*", "/.ss?", "/x"]);
(first, glob).prop_map(|(f, g)| format!("{}{g}", f.trim_end_matches('/')))
}
const SWEEP_CONTEXTS: &[&str] =
&["cat {}", "grep -r token {}", "rm -rf {}", "echo x >> {}", "cp -r {} /tmp/x", "tar czf /tmp/x.tgz {}", "find {} -delete"];
proptest! {
#![proptest_config(ProptestConfig::with_cases(600))]
#[test]
fn a_root_holding_protected_places_is_never_more_permissive(target in arb_sweep_target(), ctx in proptest::sample::select(SWEEP_CONTEXTS.to_vec()), root_pick in 0usize..3) {
let Some(home) = absolute_home() else { return Ok(()) };
let root = [home.as_str(), "/", "/Users"][root_pick].to_string();
let absolute = match target.strip_prefix('~') {
Some(rest) => format!("{home}{rest}"),
None => target.clone(),
};
let elsewhere = command_verdict_in(&ctx.replace("{}", &absolute), workspace()).is_allowed()
&& command_verdict_in(&ctx.replace("{}", &target), workspace()).is_allowed();
let here = PathCtx { cwd: Some(root.clone()), root: Some(root.clone()), ..Default::default() };
let relative = match absolute.strip_prefix(root.trim_end_matches('/')) { Some("") => ".", Some(r) if r.starts_with('/') => r.trim_start_matches('/'), _ => absolute.as_str() };
for spelling in [target.as_str(), absolute.as_str(), relative] {
let line = ctx.replace("{}", spelling);
prop_assert!(elsewhere || !command_verdict_in(&line, here.clone()).is_allowed(), "`{}` from root `{}` approves, from /work it does not", line, root);
}
}
}
#[test]
fn a_root_holding_protected_places_is_recognised_in_every_spelling() {
let Some(home) = absolute_home() else { return };
let mut cases = vec![
("/.".to_string(), vec!["cat etc/master.passwd", "rm -rf etc"]),
(format!("{home}/."), vec!["echo x >> .zshrc", "rm -rf ."]),
(format!("{home}/projects/.."), vec!["echo x >> .zshrc", "cat .config/gh/hosts.yml"]),
];
if cfg!(target_os = "macos") {
cases.push(("/private/etc".to_string(), vec!["cat master.passwd", "echo x > sudoers", "rm -rf ."]));
cases.push(("/private".to_string(), vec!["cat etc/master.passwd", "echo x > etc/sudoers", "rm -rf etc"]));
cases.push(("/ETC".to_string(), vec!["echo x > sudoers"]));
cases.push((home.to_uppercase(), vec!["echo x >> .zshrc", "echo x > .config/safe-chains.toml"]));
}
for (root, commands) in cases {
let here = PathCtx { cwd: Some(root.clone()), root: Some(root.clone()), ..Default::default() };
for line in commands {
assert!(!command_verdict_in(line, here.clone()).is_allowed(), "`{line}` from root `{root}`");
}
}
}
#[test]
fn a_project_root_holding_a_trust_file_still_writes_its_other_files() {
let Some(home) = absolute_home() else { return };
let root = format!("{home}/.claude");
let here = PathCtx { cwd: Some(root.clone()), root: Some(root), ..Default::default() };
for line in ["echo x > CLAUDE.md", "echo x > skills/foo/SKILL.md", "cat settings.json"] {
assert!(command_verdict_in(line, here.clone()).is_allowed(), "`{line}` should approve");
}
for line in ["echo x > settings.json", "echo x > settings.jso?", "rm -rf .", "cp x settings.json"] {
assert!(!command_verdict_in(line, here.clone()).is_allowed(), "`{line}` should ask");
}
}
proptest! {
#[test]
fn a_transfer_is_judged_at_the_name_it_arrives_under(
verb in proptest::sample::select(vec!["cp", "cp -r", "mv", "cp -f"]),
frozen in proptest::sample::select(vec![".envrc", ".git"]),
from in proptest::sample::select(vec!["/tmp", "/tmp/x", "./src", "vendor"]),
into in proptest::sample::select(vec![".", "./", "sub", "sub/", "a/b"]),
) {
let command = format!("{verb} {from}/{frozen} {into}");
prop_assert!(!command_verdict_in(&command, workspace()).is_allowed(), "`{command}` writes {into}/{frozen}");
}
}
#[test]
fn a_transfer_with_an_ordinary_name_still_lands_in_the_workspace() {
for command in ["cp /tmp/notes.md .", "cp a.txt b.txt", "cp a.txt out/", "mv a.txt sub/", "cp -r /tmp/d ."] {
assert!(command_verdict_in(command, workspace()).is_allowed(), "{command}");
}
}
#[test]
fn an_in_place_backup_is_judged_where_it_lands() {
for command in [
"sed -i'.git/hooks/*' s/x/x/ pre-commit", "sed --in-place=.git/hooks/* s/x/x/ pre-commit",
"perl -i'.git/hooks/*' -pe 1 pre-commit", "perl -pi.git/hooks/* -e 1 pre-commit",
] {
assert!(!command_verdict_in(command, workspace()).is_allowed(), "`{command}` writes its backup into a protected place");
}
for command in ["sed -i.bak s/x/y/ notes", "sed -i '' s/x/y/ notes", "perl -pi.orig -e 1 notes"] {
assert!(command_verdict_in(command, workspace()).is_allowed(), "{command}");
}
}