use crate::permission::checker::{CheckResult, PermissionChecker};
use crate::permission::{Action, OpSpec, PermissionConfig, RuleConfig, SecurityMode};
fn rule(op: OpSpec, m: &str, effect: Action) -> RuleConfig {
RuleConfig {
op,
pattern: m.to_string(),
effect,
tool: None,
}
}
fn make_checker(mode: SecurityMode) -> PermissionChecker {
PermissionChecker::new(
&PermissionConfig::default(),
mode,
Some(std::path::PathBuf::from("/home/user/project")),
)
}
#[test]
fn yolo_allows_everything() {
let mut checker = make_checker(SecurityMode::Yolo);
assert_eq!(checker.check("bash", "rm -rf /"), CheckResult::Allowed);
assert_eq!(checker.check("write", "/etc/passwd"), CheckResult::Allowed);
}
#[test]
fn restrictive_makes_unconfigured_tool_ask() {
let mut checker = make_checker(SecurityMode::Restrictive);
let result = checker.check("some_tool", "any input");
assert!(matches!(result, CheckResult::Ask));
}
#[test]
fn standard_asks_unknown_tool_with_default() {
let mut checker = make_checker(SecurityMode::Standard);
let result = checker.check("some_tool", "any input");
assert!(matches!(result, CheckResult::Ask));
}
#[test]
fn accept_auto_allows_inside_working_dir() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Ask)],
..PermissionConfig::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Accept,
Some(std::path::PathBuf::from("/home/user/project")),
);
let result = checker.check_path("write", "/home/user/project/src/main.rs");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn accept_asks_for_external_path() {
let mut checker = make_checker(SecurityMode::Accept);
let external_path = if cfg!(windows) {
"D:\\outside\\file.txt"
} else {
"/etc/config.conf"
};
let result = checker.check_path("write", external_path);
assert!(
matches!(result, CheckResult::Ask),
"expected Ask, got {:?} for path: {}",
result,
external_path,
);
}
#[test]
fn deny_rule_blocks_regardless_of_mode() {
let mut checker = make_checker(SecurityMode::Standard);
let result = checker.check("bash", "rm -rf /home/user/project");
assert!(matches!(result, CheckResult::Denied(_)));
}
#[test]
fn deny_rule_not_blocked_by_yolo() {
let mut checker = make_checker(SecurityMode::Yolo);
let result = checker.check("bash", "rm -rf /home/user/project");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn deny_message_names_the_matching_rule() {
let mut checker = make_checker(SecurityMode::Standard);
let result = checker.check("bash", "rm -rf /home/user/project");
match result {
CheckResult::Denied(msg) => {
assert!(
msg.contains("rm") || msg.contains("rule"),
"deny message must reference the rule: {msg}",
);
assert!(
!msg.eq("Blocked by permission rules"),
"deny message must not be generic: {msg}",
);
}
other => panic!("expected Denied; got {other:?}"),
}
}
#[test]
fn doom_loop_deny_names_the_call() {
use crate::permission::{Action, PermissionConfig};
let config = PermissionConfig {
doom_loop: Some(Action::Deny),
..PermissionConfig::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(matches!(
checker.check("bash", "frobnicate xyz"),
CheckResult::Ask
));
assert!(matches!(
checker.check("bash", "frobnicate xyz"),
CheckResult::Ask
));
assert!(matches!(
checker.check("bash", "frobnicate xyz"),
CheckResult::Ask
));
let result = checker.check("bash", "frobnicate xyz");
match result {
CheckResult::Denied(msg) => {
assert!(msg.contains("Doom loop"), "must say Doom loop: {msg}");
assert!(
msg.contains("bash") && msg.contains("frobnicate"),
"must name tool + call preview: {msg}",
);
}
other => panic!("expected Denied on the 4th identical Ask; got {other:?}"),
}
}
#[test]
fn doom_loop_triggers_after_three_repeated_calls() {
let mut checker = make_checker(SecurityMode::Standard);
checker.check("bash", "frobnicate xyz");
checker.check("bash", "frobnicate xyz");
let result = checker.check("bash", "frobnicate xyz");
assert!(matches!(result, CheckResult::Ask));
}
#[test]
fn doom_loop_does_not_trigger_before_three() {
let mut checker = make_checker(SecurityMode::Standard);
checker.check("bash", "ls -la");
let result = checker.check("bash", "ls -la");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn doom_loop_resets_for_different_inputs() {
let mut checker = make_checker(SecurityMode::Standard);
checker.check("bash", "ls");
checker.check("bash", "ls");
checker.check("bash", "pwd");
let result = checker.check("bash", "pwd");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn session_allowlist_bypasses_rules() {
let mut checker = make_checker(SecurityMode::Restrictive);
checker.add_session_allowlist("bash".into(), "cargo test **");
let result = checker.check("bash", "cargo test --all");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn session_allowlist_is_tool_specific() {
let mut checker = make_checker(SecurityMode::Restrictive);
checker.add_session_allowlist("read".into(), "**");
assert!(matches!(
checker.check("read", "/etc/passwd"),
CheckResult::Allowed
));
assert!(matches!(
checker.check("write", "some/file.txt"),
CheckResult::Ask
));
}
#[test]
fn external_absolute_path_outside_cwd_is_detected() {
let mut checker = make_checker(SecurityMode::Standard);
let external_path = if cfg!(windows) {
"D:\\outside\\secret.txt"
} else {
"/etc/shadow"
};
let result = checker.check_path("write", external_path);
assert!(
matches!(result, CheckResult::Ask),
"expected Ask, got {:?}",
result,
);
}
#[test]
fn relative_path_is_not_external() {
let mut checker = make_checker(SecurityMode::Accept);
let result = checker.check_path("read", "src/lib.rs");
assert!(matches!(result, CheckResult::Allowed));
}
#[test]
fn relative_path_escaping_cwd_is_external() {
let base = std::env::temp_dir().join(format!("dirge-f18-extern-{}", std::process::id()));
let cwd = base.join("a/b/c"); let escaped_dir = base.join("escaped");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&escaped_dir).unwrap();
let escaped_file = escaped_dir.join("file.rs");
std::fs::write(&escaped_file, "").unwrap();
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Ask)],
..PermissionConfig::default()
};
let mut checker = PermissionChecker::new(&config, SecurityMode::Accept, Some(cwd.clone()));
std::fs::write(cwd.join("local.rs"), "").unwrap();
let internal = checker.check_path("write", "local.rs");
assert!(
matches!(internal, CheckResult::Allowed),
"in-tree path should auto-allow in Accept: got {:?}",
internal,
);
let escape = checker.check_path("write", "../../../escaped/file.rs");
assert!(
matches!(escape, CheckResult::Ask),
"escape attempt must surface as Ask in Accept; got {:?}",
escape,
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dev_null_is_always_allowed_for_all_tools() {
let mut checker = make_checker(SecurityMode::Standard);
for tool in ["write", "edit", "apply_patch", "read", "grep"] {
let result = checker.check_path(tool, "/dev/null");
assert!(
matches!(result, CheckResult::Allowed),
"{tool} /dev/null must be Allowed in Standard mode; got {result:?}",
);
}
let mut checker_accept = make_checker(SecurityMode::Accept);
for tool in ["write", "edit", "apply_patch"] {
let result = checker_accept.check_path(tool, "/dev/null");
assert!(
matches!(result, CheckResult::Allowed),
"{tool} /dev/null must be Allowed in Accept mode; got {result:?}",
);
}
let mut checker_restr = make_checker(SecurityMode::Restrictive);
for tool in ["write", "edit", "apply_patch"] {
let result = checker_restr.check_path(tool, "/dev/null");
assert!(
matches!(result, CheckResult::Allowed),
"{tool} /dev/null must be Allowed in Restrictive mode; got {result:?}",
);
}
}
#[test]
fn session_allowlist_takes_effect_for_path_tool_on_next_check() {
let mut checker = make_checker(SecurityMode::Standard);
let out_path = if cfg!(windows) {
"C:\\Windows\\Temp\\test.txt"
} else {
"/tmp/allowlist_test.txt"
};
let before = checker.check_path("write", out_path);
assert!(
matches!(before, CheckResult::Ask),
"baseline write to {out_path} must Ask; got {before:?}",
);
let pattern = if cfg!(windows) {
"C:\\Windows\\Temp\\**"
} else {
"/tmp/**"
};
checker.add_session_allowlist("write".to_string(), pattern);
let after = checker.check_path("write", out_path);
assert!(
matches!(after, CheckResult::Allowed),
"after adding session allowlist entry {pattern}, write to {out_path} must be Allowed; got {after:?}",
);
let nested = if cfg!(windows) {
"C:\\Windows\\Temp\\subdir\\nested.txt"
} else {
"/tmp/subdir/nested.txt"
};
let nested_result = checker.check_path("write", nested);
assert!(
matches!(nested_result, CheckResult::Allowed),
"nested path {nested} must be Allowed after session allowlist {pattern}; got {nested_result:?}",
);
}
#[test]
fn session_allowlist_relative_pattern_matches_absolute_check_inside_cwd() {
let proj = std::env::temp_dir().join(format!(
"dirge-relpat-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let sub = proj.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Restrictive,
Some(proj.clone()),
);
checker.set_working_dir(proj.to_str().unwrap());
checker.add_session_allowlist("write".to_string(), "sub/**");
let abs = sub.join("other.rs");
let result = checker.check_path("write", abs.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"absolute write to {abs:?} must be Allowed after relative `sub/**` allow-always; got {result:?}",
);
let outside = if cfg!(windows) {
"C:\\Windows\\Temp\\sub\\evil.txt".to_string()
} else {
"/tmp/sub/evil.txt".to_string()
};
let outside_result = checker.check_path("write", &outside);
assert!(
matches!(outside_result, CheckResult::Ask),
"write outside the working dir must still Ask; the relative `sub/**` allow must anchor at cwd, not match any `/.../sub/*`; got {outside_result:?}",
);
let _ = std::fs::remove_dir_all(&proj);
}
#[test]
fn explicit_granular_rules_take_effect() {
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Read, "*.md", Action::Allow),
rule(OpSpec::Read, "*.rs", Action::Ask),
],
..PermissionConfig::default()
};
let mut checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
assert_eq!(checker.check("read", "README.md"), CheckResult::Allowed);
assert_eq!(checker.check("read", "main.rs"), CheckResult::Ask);
}