use super::*;
use crate::permission::{Action, OpSpec, PermissionConfig, RuleConfig};
fn rule(op: OpSpec, m: &str, effect: Action) -> RuleConfig {
RuleConfig {
op,
pattern: m.to_string(),
effect,
tool: None,
}
}
fn fresh_checker() -> PermissionChecker {
PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
)
}
#[test]
fn prompt_deny_tools_refuses_listed_tool_in_every_mode() {
for mode in [
SecurityMode::Standard,
SecurityMode::Accept,
SecurityMode::Restrictive,
SecurityMode::Yolo,
] {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
mode,
Some(std::path::PathBuf::from("/tmp")),
);
checker.set_prompt_deny_tools(vec!["edit".to_string(), "write".to_string()]);
assert!(
matches!(checker.check("edit", "/tmp/foo"), CheckResult::Denied(_)),
"edit must be denied in mode {:?} when prompt deny-list includes it",
mode,
);
assert!(
matches!(checker.check("write", "/tmp/foo"), CheckResult::Denied(_)),
"write must be denied in mode {:?} when prompt deny-list includes it",
mode,
);
if mode == SecurityMode::Yolo {
assert!(matches!(
checker.check("read", "/tmp/foo"),
CheckResult::Allowed
));
}
}
}
#[test]
fn m4_defaults_allow_safe_ask_dangerous() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
for tool in [
"read",
"glob",
"grep",
"find_files",
"list_dir",
"list_symbols",
"find_definition",
"find_callers",
"find_callees",
"get_symbol_body",
"repo_overview",
"lsp",
"write_todo_list",
"task_status",
"question",
"memory",
"skill",
] {
let result = checker.check_path(tool, "/tmp/anything.rs");
assert!(
matches!(result, CheckResult::Allowed),
"builtin-allow tool {tool} should Allow without prompting; got {result:?}",
);
}
for tool in [
"write",
"edit",
"apply_patch",
"webfetch",
"websearch",
"task",
] {
let result = checker.check_path(tool, "/opt/anywhere/anything.rs");
assert!(
matches!(result, CheckResult::Ask | CheckResult::Denied(_)),
"dangerous tool {tool} should Ask or Deny outside CWD by default; got {result:?}",
);
}
}
#[test]
fn f2_edit_alias_check_path_directly_for_write_and_apply_patch() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Deny)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(matches!(
checker.check_path("edit", "/tmp/x.rs"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check_path("write", "/opt/elsewhere/x.rs"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check_path("apply_patch", "/opt/elsewhere/x.rs"),
CheckResult::Denied(_)
));
}
#[test]
fn write_inside_cwd_allowed_outside_cwd_asks() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/proj")),
);
for tool in ["write", "edit", "apply_patch"] {
assert!(
matches!(
checker.check_path(tool, "/tmp/proj/src/main.rs"),
CheckResult::Allowed
),
"{tool} inside CWD must be auto-allowed",
);
assert!(
matches!(
checker.check_path(tool, "/tmp/proj/src/agent/foo.rs"),
CheckResult::Allowed
),
"{tool} nested-inside-CWD must be auto-allowed",
);
assert!(
matches!(checker.check_path(tool, "/etc/passwd"), CheckResult::Ask),
"{tool} outside CWD must prompt",
);
}
}
#[test]
fn user_write_deny_overrides_cwd_builtin_allow() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "/tmp/proj/build/**", Action::Deny)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/proj")),
);
assert!(matches!(
checker.check_path("write", "/tmp/proj/build/out.txt"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check_path("write", "/tmp/proj/src/main.rs"),
CheckResult::Allowed
));
}
#[test]
fn set_working_dir_refreshes_cwd_allow_rule() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/old")),
);
assert!(matches!(
checker.check_path("write", "/tmp/old/foo.rs"),
CheckResult::Allowed
));
checker.set_working_dir("/tmp/new");
assert!(matches!(
checker.check_path("write", "/tmp/new/foo.rs"),
CheckResult::Allowed
));
assert!(
matches!(
checker.check_path("write", "/tmp/old/foo.rs"),
CheckResult::Ask
),
"stale CWD-allow for /tmp/old must be removed after cd",
);
}
#[test]
fn set_working_dir_does_not_accumulate_stale_rules() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/a")),
);
checker.set_working_dir("/tmp/b");
checker.set_working_dir("/tmp/c");
checker.set_working_dir("/tmp/d");
for stale in ["/tmp/a/x", "/tmp/b/x", "/tmp/c/x"] {
assert!(
matches!(checker.check_path("write", stale), CheckResult::Ask),
"{stale} should no longer be allowed",
);
}
assert!(matches!(
checker.check_path("write", "/tmp/d/x"),
CheckResult::Allowed
));
}
#[test]
fn cwd_allow_refuses_root_and_empty() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/")),
);
assert!(matches!(
checker.check_path("write", "/etc/passwd"),
CheckResult::Ask
));
assert!(matches!(
checker.check_path("write", "/tmp/anything.rs"),
CheckResult::Ask
));
}
#[test]
fn cwd_allow_refuses_paths_with_glob_metachars() {
for dir in ["/tmp/proj-*", "/tmp/p[a-z]", "/tmp/{a,b}"] {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from(dir)),
);
let inside = format!("{}/foo.rs", dir);
assert!(
matches!(checker.check_path("write", &inside), CheckResult::Ask),
"{dir} must not install CWD-allow (glob metachar present)",
);
}
}
#[test]
fn m4_user_rule_overrides_builtin_allow() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Read, "/etc/**", Action::Deny)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(matches!(
checker.check_path("read", "/etc/passwd"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check_path("read", "/tmp/safe.txt"),
CheckResult::Allowed
));
}
#[test]
fn op_rules_govern_arbitrary_and_network_tools() {
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Network, "**", Action::Deny),
RuleConfig {
op: OpSpec::Any,
pattern: "dangerous".to_string(),
effect: Action::Deny,
tool: Some("plugin_xyz".to_string()),
},
],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(matches!(
checker.check("plugin_xyz", "dangerous"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check("websearch", "anything"),
CheckResult::Denied(_)
));
}
#[test]
fn prompt_deny_any_matches_concrete_and_qualified_mcp_names() {
let mut checker = fresh_checker();
checker.set_prompt_deny_tools(vec!["edit".to_string(), "write".to_string()]);
assert!(checker.any_prompt_denied(&["edit", "mcp_tool:fs:edit", "mcp_tool"]));
checker.set_prompt_deny_tools(vec!["mcp_tool".to_string()]);
assert!(checker.any_prompt_denied(&["whatever", "mcp_tool:any:any", "mcp_tool"]));
checker.set_prompt_deny_tools(vec!["mcp_tool:fs:write_file".to_string()]);
assert!(checker.any_prompt_denied(&["write_file", "mcp_tool:fs:write_file", "mcp_tool"]));
assert!(!checker.any_prompt_denied(&[
"write_file",
"mcp_tool:other:write_file",
"mcp_tool:fs:write_other"
]));
}
#[test]
fn prompt_deny_is_case_insensitive() {
let mut checker = fresh_checker();
checker.set_prompt_deny_tools(vec!["Edit".to_string(), "BASH".to_string()]);
assert!(matches!(
checker.check("edit", "foo"),
CheckResult::Denied(_)
));
assert!(matches!(
checker.check("bash", "ls"),
CheckResult::Denied(_)
));
}
#[test]
fn mcp_tool_defaults_to_ask_when_unconfigured() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
let r = checker.check("mcp_tool", "mcp_tool:lattice:lattice_query");
assert!(
matches!(r, CheckResult::Ask),
"unconfigured mcp_tool must default to Ask, got {:?}",
r,
);
}
#[test]
fn accept_mode_does_not_coerce_mcp_to_allow() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Accept,
Some(std::path::PathBuf::from("/tmp")),
);
let r = checker.check("mcp_tool", "mcp_tool:lattice:lattice_query");
assert!(
matches!(r, CheckResult::Ask),
"Accept mode must NOT bypass mcp_tool's default-Ask, got {:?}",
r,
);
}
#[test]
fn accept_mode_does_not_coerce_plugin_tool_to_allow() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Accept,
Some(std::path::PathBuf::from("/tmp")),
);
let r = checker.check("plugin_tool", "my-plugin-tool");
assert!(
matches!(r, CheckResult::Ask),
"Accept mode must NOT bypass plugin_tool's default-Ask, got {:?}",
r,
);
}
#[test]
fn plugin_tool_yolo_allows_default_asks() {
let mut yolo = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Yolo,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(
matches!(
yolo.check("plugin_tool", "my-plugin-tool"),
CheckResult::Allowed
),
"Yolo must allow the plugin tool",
);
let mut standard = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
assert!(
matches!(
standard.check("plugin_tool", "my-plugin-tool"),
CheckResult::Ask
),
"Standard mode defaults the plugin tool to Ask (no builtin-allow)",
);
}
#[test]
fn accept_mode_still_coerces_safe_non_path_tools() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Meta, "*", Action::Ask)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Accept,
Some(std::path::PathBuf::from("/tmp")),
);
let r = checker.check("question", "some question");
assert!(
matches!(r, CheckResult::Allowed),
"Accept mode SHOULD coerce question's Ask → Allow (not high-risk), got {:?}",
r,
);
}
#[test]
fn mcp_tool_explicit_config_overrides_default_ask() {
let config = PermissionConfig {
rules: vec![rule(OpSpec::Mcp, "mcp_tool:lattice:*", Action::Allow)],
..Default::default()
};
let mut checker = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
let r = checker.check("mcp_tool", "mcp_tool:lattice:lattice_query");
assert!(
matches!(r, CheckResult::Allowed),
"explicit Allow rule must win, got {:?}",
r,
);
}
#[test]
fn prompt_deny_empty_is_noop() {
let mut checker = fresh_checker();
checker.set_prompt_deny_tools(Vec::new());
assert!(matches!(
checker.check("read", "/tmp/foo"),
CheckResult::Allowed
));
}
#[test]
fn regression_session_allowlist_cd_star_matches_path_arg() {
let mut checker = fresh_checker();
checker.add_session_allowlist("bash".to_string(), "cd *");
let r1 = checker.check(
"bash",
"cd /Users/yogthos/src/work/rigging-workshop && git diff",
);
assert!(
matches!(r1, CheckResult::Allowed),
"expected Allowed, got {:?}",
r1
);
let r2 = checker.check("bash", "cd /Users/yogthos/src/work/rigging-workshop");
assert!(matches!(r2, CheckResult::Allowed));
}
#[test]
fn path_tool_session_allowlist_keeps_one_segment_semantics() {
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker.add_session_allowlist("write".to_string(), "/probe/src/*");
assert!(matches!(
checker.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed
));
let nested = checker.check_path("write", "/probe/src/agent/main.rs");
assert!(
matches!(nested, CheckResult::Ask),
"/probe/src/* must not match nested path; got {:?}",
nested
);
}
#[test]
fn add_session_allowlist_mirrors_write_to_edit() {
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker.add_session_allowlist("write".to_string(), "/probe/src/**");
assert!(matches!(
checker.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed
));
assert!(
matches!(
checker.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"edit alias must reflect write session-allowlist entry"
);
let mut checker2 = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker2.add_session_allowlist("edit".to_string(), "/probe/src/**");
assert!(
matches!(
checker2.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"write must reflect edit session-allowlist entry"
);
assert!(
matches!(
checker2.check_path("apply_patch", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"apply_patch must reflect edit session-allowlist entry"
);
let mut checker3 = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker3.add_session_allowlist("apply_patch".to_string(), "/probe/src/**");
assert!(
matches!(
checker3.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"edit must reflect apply_patch session-allowlist entry"
);
let mut checker4 = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker4.load_session_allowlist(&[("write".to_string(), "/probe/src/**".to_string())]);
assert!(
matches!(
checker4.check_path("edit", "/probe/src/main.rs"),
CheckResult::Allowed,
),
"load_session_allowlist must also mirror write→edit"
);
let mut checker5 = fresh_checker();
checker5.add_session_allowlist("read".to_string(), "/tmp/**");
assert!(matches!(
checker5.check_path("read", "/tmp/foo.txt"),
CheckResult::Allowed,
));
assert!(
!checker5.is_session_allowed("write", "/tmp/foo.txt"),
"read allowlist entry must not leak to write"
);
}
#[test]
fn remove_session_allowlist_revokes_engine_grant() {
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker.add_session_allowlist("write".to_string(), "/probe/src/**");
assert!(
matches!(
checker.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed
),
"grant should auto-allow before removal"
);
let removed = checker.remove_session_allowlist_at(0);
assert!(removed.is_some(), "removal should return the removed entry");
assert!(
!matches!(
checker.check_path("write", "/probe/src/main.rs"),
CheckResult::Allowed
),
"removed grant must NOT still auto-allow via the engine allowlist",
);
assert!(
!checker.is_session_allowed("write", "/probe/src/main.rs"),
"engine allowlist must no longer report the grant"
);
}
#[test]
fn permission_config_rejects_unknown_fields() {
let ok: Result<PermissionConfig, _> =
serde_json::from_str(r#"{"*":"ask","rules":[{"op":"edit","match":"**","effect":"deny"}]}"#);
assert!(ok.is_ok(), "valid config must parse: {ok:?}");
let legacy: Result<PermissionConfig, _> = serde_json::from_str(r#"{"edit":{"**":"deny"}}"#);
assert!(
legacy.is_err(),
"legacy/unknown permission key must be rejected, got {legacy:?}"
);
let bad_rule: Result<PermissionConfig, _> =
serde_json::from_str(r#"{"rules":[{"op":"edit","pattern":"**","effect":"deny"}]}"#);
assert!(
bad_rule.is_err(),
"rule with unknown field must be rejected, got {bad_rule:?}"
);
}
#[test]
fn regression_load_session_allowlist_preserves_command_semantics() {
let mut checker = fresh_checker();
let saved = vec![("bash".to_string(), "cd *".to_string())];
checker.load_session_allowlist(&saved);
let r = checker.check("bash", "cd /home/me/project");
assert!(matches!(r, CheckResult::Allowed));
}
#[test]
fn pattern_for_tool_distinguishes_path_and_command_tools() {
assert!(crate::permission::engine::pattern_for_tool("bash", "cd *").matches("cd /a/b/c"));
assert!(!crate::permission::engine::pattern_for_tool("read", "cd *").matches("cd /a/b/c"));
assert!(crate::permission::engine::pattern_for_tool("read", "cd *").matches("cd file"));
}
#[test]
fn default_bash_rules_cover_common_flagged_invocations() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/proj")),
);
for cmd in [
"cargo build --release",
"cargo test --bin dirge --features plugin",
"cargo fmt --all --check",
"cargo clippy --all-targets",
"git status -s",
"git log --oneline -10",
"cargo run --release",
"git add -A",
"git commit -m \"msg\"",
"git pull --rebase",
"git fetch origin",
"make test",
"pytest -x tests/",
"npm test -- --coverage",
"go test ./...",
] {
let result = checker.check("bash", cmd);
assert!(
matches!(result, CheckResult::Allowed),
"{cmd:?} should be auto-allowed by default bash rules; got {result:?}",
);
}
}
#[test]
fn default_bash_rules_keep_high_risk_gated() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/proj")),
);
for cmd in [
"git push",
"git push origin main",
"git reset --hard",
"git rebase -i main",
"git stash drop",
"git checkout main",
"git checkout -- src/main.rs",
"git switch -c feat/foo",
"git restore --staged file.rs",
"git restore src/main.rs",
"git clean -fd",
"npm install lodash",
"pip install requests",
"curl http://example.com",
"wget http://example.com",
"sudo make install",
"python3 script.py",
"python -c \"import os; os.system('x')\"",
"node index.js",
"node -e \"require('child_process').exec('x')\"",
"npx eslint .",
"npx some-remote-tool",
] {
let result = checker.check("bash", cmd);
assert!(
matches!(result, CheckResult::Ask | CheckResult::Denied(_)),
"{cmd:?} must NOT be silently allowed; got {result:?}",
);
}
for cmd in [
"rm -rf /etc",
"sudo rm -rf /usr",
"dd if=/dev/zero of=/dev/sda",
] {
let result = checker.check("bash", cmd);
assert!(
matches!(result, CheckResult::Denied(_)),
"{cmd:?} must remain hard-denied; got {result:?}",
);
}
}
#[test]
fn memory_tool_standard_mode_auto_approved() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
for action in ["view", "add", "replace", "remove"] {
let result = checker.check("memory", action);
assert!(
matches!(result, CheckResult::Allowed),
"memory.{action} must auto-allow in Standard; got {result:?}",
);
}
}
#[test]
fn issue_tool_standard_mode_auto_approved() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp")),
);
for action in [
"create", "start", "block", "close", "update", "list", "show",
] {
let result = checker.check("issue", action);
assert!(
matches!(result, CheckResult::Allowed),
"issue.{action} must auto-allow in Standard; got {result:?}",
);
}
}
#[test]
fn memory_tool_restrictive_mode_writes_prompt_reads_allow() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Restrictive,
Some(std::path::PathBuf::from("/tmp")),
);
let result = checker.check("memory", "view");
assert!(
matches!(result, CheckResult::Allowed),
"memory.view must Allow in Restrictive (it's a read); got {result:?}",
);
for action in ["add", "replace", "remove"] {
let result = checker.check("memory", action);
assert!(
matches!(result, CheckResult::Ask),
"memory.{action} must Ask in Restrictive (it's a write); got {result:?}",
);
}
}
#[test]
fn memory_tool_yolo_short_circuits() {
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Yolo,
Some(std::path::PathBuf::from("/tmp")),
);
for action in ["view", "add", "replace", "remove"] {
let result = checker.check("memory", action);
assert!(
matches!(result, CheckResult::Allowed),
"memory.{action} must Allow in Yolo; got {result:?}",
);
}
}
#[test]
fn accept_mode_auto_approves_edit_inside_cwd() {
let dir = std::env::temp_dir().join(format!("dirge-yevn-edit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Accept,
Some(dir.clone()),
);
let target = dir.join("src").join("foo.rs");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "").unwrap();
let result = checker.check_path("edit", target.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"Accept mode must auto-approve edit inside cwd; got {:?}",
result,
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn accept_mode_auto_approves_write_inside_cwd() {
let dir = std::env::temp_dir().join(format!("dirge-yevn-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Accept,
Some(dir.clone()),
);
let existing = dir.join("src").join("foo.rs");
std::fs::create_dir_all(existing.parent().unwrap()).unwrap();
std::fs::write(&existing, "").unwrap();
assert!(
matches!(
checker.check_path("write", existing.to_str().unwrap()),
CheckResult::Allowed
),
"Accept mode must auto-approve write to existing file inside cwd",
);
let newfile = dir.join("src").join("new-file.rs");
let result = checker.check_path("write", newfile.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"Accept mode must auto-approve write to new file inside cwd; got {:?}",
result,
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn accept_mode_auto_approves_edit_inside_cwd_through_symlink() {
let root = std::env::temp_dir().join(format!("dirge-yevn-symlink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let link = root.join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real, &link).unwrap();
let mut checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Accept,
Some(link.clone()),
);
let src = real.join("src");
std::fs::create_dir_all(&src).unwrap();
let via_link = link.join("src").join("foo.rs");
std::fs::write(real.join("src").join("foo.rs"), "").unwrap();
let result = checker.check_path("edit", via_link.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"edit via symlinked cwd path must Allow in Accept mode; got {:?}",
result,
);
let via_real = real.join("src").join("foo.rs");
let result = checker.check_path("edit", via_real.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"edit via canonical cwd path must Allow in Accept mode; got {:?}",
result,
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn allow_always_folder_persists_in_session_allowlist() {
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker.add_session_allowlist("write".to_string(), "/project/src/**");
assert!(matches!(
checker.check_path("write", "/project/src/foo.rs"),
CheckResult::Allowed
));
assert!(matches!(
checker.check_path("write", "/project/src/bar.rs"),
CheckResult::Allowed
));
}
#[test]
fn allow_always_folder_covers_subsequent_writes_to_same_folder() {
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
checker.add_session_allowlist("write".to_string(), "/project/src/**");
assert!(matches!(
checker.check_path("write", "/project/src/foo.rs"),
CheckResult::Allowed
));
let result = checker.check_path("write", "/project/src/bar.rs");
assert!(
matches!(result, CheckResult::Allowed),
"subsequent write to same folder must hit allowlist (no re-prompt); got {:?}",
result,
);
let result = checker.check_path("write", "/project/src/agent/baz.rs");
assert!(
matches!(result, CheckResult::Allowed),
"nested write under allowlisted folder must Allow; got {:?}",
result,
);
assert!(matches!(
checker.check_path("edit", "/project/src/bar.rs"),
CheckResult::Allowed
));
assert!(matches!(
checker.check_path("apply_patch", "/project/src/bar.rs"),
CheckResult::Allowed
));
}
#[test]
fn allow_always_folder_resolves_through_symlinks() {
let root = std::env::temp_dir().join(format!(
"dirge-yevn-allowlist-symlink-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let real = root.join("real");
let src = real.join("src");
std::fs::create_dir_all(&src).unwrap();
let link = root.join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real, &link).unwrap();
let cfg = PermissionConfig {
default: Some(Action::Ask),
..Default::default()
};
let mut checker = PermissionChecker::new(
&cfg,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
);
let approved_input = link.join("src").join("foo.rs");
let approved_parent = approved_input.parent().unwrap();
let approved_pattern = format!("{}/**", approved_parent.display());
checker.add_session_allowlist("write".to_string(), &approved_pattern);
let probe_existing = real.join("src").join("foo.rs");
std::fs::write(&probe_existing, "").unwrap();
let via_real = real.join("src").join("foo.rs");
let result = checker.check_path("write", via_real.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"write to canonical-form path must Allow via symlinked allowlist entry; got {:?}",
result,
);
let via_link = link.join("src").join("foo.rs");
let result = checker.check_path("write", via_link.to_str().unwrap());
assert!(
matches!(result, CheckResult::Allowed),
"write to symlinked-form path must Allow via symlinked allowlist entry; got {:?}",
result,
);
let _ = std::fs::remove_dir_all(&root);
}
mod reported_permission_ux_regressions {
use super::*;
fn checker_in(dir: &str) -> PermissionChecker {
PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from(dir)),
)
}
#[test]
fn probe_write_dev_null_allowed() {
let mut c = checker_in("/tmp");
assert!(
matches!(c.check_path("write", "/dev/null"), CheckResult::Allowed),
"write /dev/null should be Allowed, got {:?}",
c.check_path("write", "/dev/null")
);
}
#[test]
fn probe_memory_actions_allowed() {
for action in ["view", "add", "replace", "remove"] {
let mut c = checker_in("/tmp");
assert!(
matches!(c.check("memory", action), CheckResult::Allowed),
"memory {action} should be Allowed, got {:?}",
c.check("memory", action)
);
}
}
#[test]
fn probe_skill_actions_allowed() {
for action in ["load", "list", "create:foo", "edit:foo", "patch:foo"] {
let mut c = checker_in("/tmp");
assert!(
matches!(c.check("skill", action), CheckResult::Allowed),
"skill {action} should be Allowed, got {:?}",
c.check("skill", action)
);
}
}
#[test]
fn probe_skill_restrictive_demotes_writes_not_reads() {
let mk = || {
PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Restrictive,
Some(std::path::PathBuf::from("/tmp")),
)
};
for read in ["load", "list"] {
assert!(
matches!(mk().check("skill", read), CheckResult::Allowed),
"skill {read} must stay Allowed under Restrictive, got {:?}",
mk().check("skill", read)
);
}
for write in ["create:foo", "edit:foo", "patch:foo", "delete:foo"] {
assert!(
matches!(mk().check("skill", write), CheckResult::Ask),
"skill {write} must demote to Ask under Restrictive, got {:?}",
mk().check("skill", write)
);
}
}
#[test]
fn probe_worktree_sibling_switch_reanchors_cwd_allow() {
let mut c = checker_in("/repo/main");
assert!(
matches!(
c.check_path("write", "/repo/wt/src/foo.rs"),
CheckResult::Ask
),
"pre-switch: sibling-worktree write should be external/Ask",
);
c.set_working_dir("/repo/wt");
assert!(
matches!(
c.check_path("write", "/repo/wt/src/foo.rs"),
CheckResult::Allowed
),
"post-switch: in-worktree write must be auto-allowed",
);
assert!(
matches!(
c.check_path("write", "/repo/main/src/x.rs"),
CheckResult::Ask
),
"post-switch: old-repo write should now be external/Ask",
);
}
#[test]
fn probe_read_inside_cwd_allowed() {
let mut c = checker_in("/tmp/proj");
assert!(
matches!(
c.check_path("read", "/tmp/proj/src/main.rs"),
CheckResult::Allowed
),
"read inside cwd should be Allowed"
);
}
#[test]
fn probe_write_inside_cwd_allowed() {
let mut c = checker_in("/tmp/proj");
let r = c.check_path("write", "/tmp/proj/src/main.rs");
assert!(
matches!(r, CheckResult::Allowed),
"write inside cwd should be Allowed, got {:?}",
r
);
}
#[test]
fn probe_write_outside_cwd_still_asks() {
let mut c = checker_in("/tmp/proj");
let r = c.check_path("write", "/etc/evil.conf");
assert!(
matches!(r, CheckResult::Ask),
"write OUTSIDE cwd must still Ask, got {:?}",
r
);
}
#[test]
fn probe_sticky_allow_bash_similar_command() {
let mut c = checker_in("/tmp");
c.add_session_allowlist("bash".to_string(), "cargo *");
assert!(
matches!(
c.check("bash", "cargo test --bin dirge"),
CheckResult::Allowed
),
"sticky-allow cargo * should match cargo test --bin dirge"
);
assert!(
matches!(c.check("bash", "cargo build"), CheckResult::Allowed),
"sticky-allow cargo * should match cargo build"
);
}
#[test]
fn probe_session_allows_now_coalesces_after_allow_always() {
let mut c = checker_in("/tmp");
assert!(
!c.session_allows_now("bash", "cargo build"),
"nothing allowed yet"
);
c.add_session_allowlist("bash".to_string(), "cargo *");
assert!(
c.session_allows_now("bash", "cargo build"),
"queued cargo sibling must be coalesced after allow-always",
);
assert!(
!c.session_allows_now("bash", "git status"),
"unrelated queued command must still prompt",
);
let proj = std::env::temp_dir().join(format!("dirge-coalesce-{}", std::process::id()));
std::fs::create_dir_all(proj.join("sub")).unwrap();
let mut pc = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(proj.clone()),
);
pc.set_working_dir(proj.to_str().unwrap());
pc.add_session_allowlist("write".to_string(), "sub/**");
let abs = proj.join("sub/other.rs");
assert!(
pc.session_allows_now("write", abs.to_str().unwrap()),
"absolute sibling write must be coalesced by relative allow-always",
);
assert!(
!pc.session_allows_now("write", "/etc/evil.conf"),
"path outside the working dir must not be coalesced",
);
let _ = std::fs::remove_dir_all(&proj);
}
}
#[test]
fn explain_renders_decision_trace() {
let checker = PermissionChecker::new(
&PermissionConfig::default(),
SecurityMode::Standard,
Some(std::path::PathBuf::from("/tmp/proj")),
);
let report = checker.explain("bash", "frobnicate --hard", false);
assert!(report.contains("frobnicate"), "names the input: {report}");
assert!(report.contains("Ask"), "shows the effect: {report}");
assert!(
report.contains("default"),
"names the deciding policy: {report}"
);
let report = checker.explain("read", "/tmp/proj/src/main.rs", true);
assert!(report.contains("Allow"), "read allowed: {report}");
assert!(
report.contains("builtin-allow"),
"names builtin-allow as decider: {report}",
);
}