pub mod command_parse;
pub mod manager;
pub mod pattern;
pub mod scope;
pub mod settings;
pub use manager::PermissionManager;
#[derive(Debug, PartialEq, Eq)]
pub enum CommandPermission {
Allowed,
Denied,
Ask,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::permissions::command_parse::is_env_assignment;
use crate::tools::permissions::settings::PermissionSettings;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex;
use tempfile::TempDir;
static HOME_MUTEX: Mutex<()> = Mutex::new(());
fn create_test_manager(settings: PermissionSettings, temp_dir: &TempDir) -> PermissionManager {
let (read_allow, read_deny) = PermissionManager::build_scope_globs(
&settings,
PermissionManager::extract_read_pattern,
)
.unwrap();
let (write_allow, write_deny) = PermissionManager::build_scope_globs(
&settings,
PermissionManager::extract_write_pattern,
)
.unwrap();
let (bash_allow, bash_deny) = PermissionManager::build_scope_globs(
&settings,
PermissionManager::extract_bash_path_pattern,
)
.unwrap();
PermissionManager {
settings,
local_settings_path: temp_dir.path().join(".sofos/config.local.toml"),
global_settings_path: None,
allowed_commands: HashSet::new(),
forbidden_commands: HashSet::new(),
read_allow_set: read_allow,
read_deny_set: read_deny,
write_allow_set: write_allow,
write_deny_set: write_deny,
bash_path_allow_set: bash_allow,
bash_path_deny_set: bash_deny,
global_rules: HashSet::new(),
}
}
#[test]
fn read_glob_single_star_does_not_cross_separator() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.deny
.push("Read(./secrets/*)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("./secrets/creds.json"),
CommandPermission::Denied,
"direct child of ./secrets/ must still match the single-star deny"
);
assert_eq!(
manager.check_read_permission("./secrets/nested/creds.json"),
CommandPermission::Allowed,
"`*` must not cross `/`, so the nested file is NOT covered by the deny"
);
let mut recursive = PermissionSettings::default();
recursive
.permissions
.deny
.push("Read(./secrets/**)".to_string());
let recursive_manager = create_test_manager(recursive, &temp_dir);
assert_eq!(
recursive_manager.check_read_permission("./secrets/nested/creds.json"),
CommandPermission::Denied,
"`**` must still walk every depth under ./secrets/"
);
}
#[test]
fn test_read_exact_and_wildcard_matching() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(./allowed.txt)".to_string());
settings
.permissions
.deny
.push("Read(./secrets/*)".to_string());
settings.permissions.deny.push("Read(./.env.*)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("./allowed.txt"),
CommandPermission::Allowed
);
assert_eq!(
manager.check_read_permission("./secrets/creds.json"),
CommandPermission::Denied
);
assert_eq!(
manager.check_read_permission("./.env.local"),
CommandPermission::Denied
);
assert_eq!(
manager.check_read_permission("./other.txt"),
CommandPermission::Allowed
);
}
#[test]
fn test_read_allow_overrides_wildcard_deny() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(./secrets/allowed.txt)".to_string());
settings
.permissions
.deny
.push("Read(./secrets/*)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("./secrets/allowed.txt"),
CommandPermission::Allowed
);
}
#[test]
fn test_is_read_explicit_allow_detects_allow_glob() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/outside/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_read_explicit_allow("/outside/secret.txt"));
assert!(!manager.is_read_explicit_allow("/other/secret.txt"));
}
#[test]
fn test_is_read_explicit_allow_glob_matches_base_directory() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/outside/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_read_explicit_allow("/outside"));
assert!(manager.is_read_explicit_allow("/outside/secret.txt"));
assert!(manager.is_read_explicit_allow("/outside/sub/deep.txt"));
assert!(!manager.is_read_explicit_allow("/other"));
}
#[test]
fn test_is_read_explicit_allow_absolute_path_glob() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/Users/alex/test/images/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_read_explicit_allow("/Users/alex/test/images/test.jpg"));
assert!(manager.is_read_explicit_allow("/Users/alex/test/images/subdir/photo.png"));
assert!(!manager.is_read_explicit_allow("/Users/alex/other/test.jpg"));
}
#[test]
fn test_read_prefix_variants_match_globs() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.deny
.push("Read(./test/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("test/file.txt"),
CommandPermission::Denied
);
assert_eq!(
manager.check_read_permission("./test/inner/file.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_tilde_expansion() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
let zshrc_path = format!("{}/.zshrc", home_path.display());
settings
.permissions
.allow
.push(format!("Read({})", zshrc_path));
}
let manager = create_test_manager(settings, &temp_dir);
if std::env::var_os("HOME").is_some() {
assert_eq!(
manager.check_read_permission("~/.zshrc"),
CommandPermission::Allowed
);
}
}
#[test]
fn test_tilde_in_glob_patterns() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
settings
.permissions
.allow
.push(format!("Read({}/.config/**)", home_path.display()));
}
let manager = create_test_manager(settings, &temp_dir);
if std::env::var_os("HOME").is_some() {
assert_eq!(
manager.check_read_permission("~/.config/sofos/test.toml"),
CommandPermission::Allowed
);
}
}
#[test]
fn test_allowed_commands() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager.check_command_permission("cargo build").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("cargo test").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("ls -la").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("cat file.txt").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("git status").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("npm test").unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn test_forbidden_commands() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager.check_command_permission("rm -rf /").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("sudo ls").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("chmod 777 file").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("mv file1 file2").unwrap(),
CommandPermission::Ask
);
assert_eq!(
manager.check_command_permission("cp file1 file2").unwrap(),
CommandPermission::Ask
);
assert_eq!(
manager.check_command_permission("mkdir subdir").unwrap(),
CommandPermission::Ask
);
}
#[test]
fn env_prefix_does_not_bypass_forbidden_base() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("FOO=bar rm -rf /")
.unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager
.check_command_permission("A=1 B=2 C=3 sudo ls")
.unwrap(),
CommandPermission::Denied
);
assert_eq!(
PermissionManager::extract_base_command("1BAD=x rm"),
"1BAD=x"
);
}
#[test]
fn is_env_assignment_matches_posix_names_only() {
assert!(is_env_assignment("FOO=bar"));
assert!(is_env_assignment("_FOO=bar"));
assert!(is_env_assignment("FOO_1=bar"));
assert!(is_env_assignment("FOO="));
assert!(!is_env_assignment("1FOO=bar")); assert!(!is_env_assignment("FOO-X=bar")); assert!(!is_env_assignment("FOO"));
assert!(!is_env_assignment("=bar"));
assert!(!is_env_assignment(""));
}
#[test]
fn test_unknown_commands() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("custom_script.sh")
.unwrap(),
CommandPermission::Ask
);
assert_eq!(
manager.check_command_permission("unknown_tool").unwrap(),
CommandPermission::Ask
);
}
#[test]
fn test_settings_persistence() {
let temp_dir = TempDir::new().unwrap();
let workspace = temp_dir.path().to_path_buf();
{
let mut manager = PermissionManager::new(workspace.clone()).unwrap();
manager
.settings
.permissions
.allow
.push("Bash(custom:*)".to_string());
manager.save_settings().unwrap();
}
let manager = PermissionManager::new(workspace).unwrap();
assert!(
manager
.settings
.permissions
.allow
.contains(&"Bash(custom:*)".to_string())
);
}
#[test]
fn test_wildcard_matching() {
let temp_dir = TempDir::new().unwrap();
let workspace = temp_dir.path().to_path_buf();
let mut manager = PermissionManager::new(workspace).unwrap();
manager
.settings
.permissions
.allow
.push("Bash(custom:*)".to_string());
assert_eq!(
manager.check_command_permission("custom anything").unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn test_exact_match_priority() {
let temp_dir = TempDir::new().unwrap();
let workspace = temp_dir.path().to_path_buf();
let mut manager = PermissionManager::new(workspace).unwrap();
manager
.settings
.permissions
.allow
.push("Bash(exact command)".to_string());
assert_eq!(
manager.check_command_permission("exact command").unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn test_flexible_toml_format() {
let toml_content = r#"
[permissions]
allow = [
"Bash(custom_command_1)",
"Bash(custom_command_2:*)",
]
deny = ["Bash(dangerous_command)"]
ask = []
"#;
let settings: PermissionSettings =
toml::from_str(toml_content).expect("Failed to parse flexible TOML format");
assert_eq!(settings.permissions.allow.len(), 2);
assert_eq!(settings.permissions.allow[0], "Bash(custom_command_1)");
assert_eq!(settings.permissions.allow[1], "Bash(custom_command_2:*)");
assert_eq!(settings.permissions.deny.len(), 1);
assert_eq!(settings.permissions.deny[0], "Bash(dangerous_command)");
assert_eq!(settings.permissions.ask.len(), 0);
let inline_toml = r#"
[permissions]
allow = ["Bash(cmd1)", "Bash(cmd2)"]
deny = ["Bash(bad)"]
ask = []
"#;
let inline_settings: PermissionSettings =
toml::from_str(inline_toml).expect("Failed to parse inline TOML format");
assert_eq!(inline_settings.permissions.allow.len(), 2);
assert_eq!(inline_settings.permissions.deny.len(), 1);
}
#[test]
fn test_tilde_expansion_in_permissions() {
let _lock = HOME_MUTEX.lock().unwrap();
let _temp_dir = TempDir::new().unwrap();
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/testuser");
let expanded = PermissionManager::expand_tilde_pub("~/file.txt");
let expanded_dir = PermissionManager::expand_tilde_pub("~");
let not_tilde = PermissionManager::expand_tilde_pub("./file.txt");
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
assert_eq!(expanded, "/home/testuser/file.txt");
assert_eq!(expanded_dir, "/home/testuser");
assert_eq!(not_tilde, "./file.txt");
}
#[test]
fn test_tilde_expansion_trims_leading_separator_in_remainder() {
let _lock = HOME_MUTEX.lock().unwrap();
let _temp_dir = TempDir::new().unwrap();
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/testuser");
let single = PermissionManager::expand_tilde_pub("~/foo");
let double = PermissionManager::expand_tilde_pub("~//foo");
let triple = PermissionManager::expand_tilde_pub("~///foo");
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
assert_eq!(single, "/home/testuser/foo");
assert_eq!(
double, "/home/testuser/foo",
"double-slash after tilde must not escape home"
);
assert_eq!(
triple, "/home/testuser/foo",
"any number of leading slashes must not escape home"
);
}
#[cfg(windows)]
#[test]
fn test_tilde_expansion_uses_userprofile_on_windows() {
let _lock = HOME_MUTEX.lock().unwrap();
let _temp_dir = TempDir::new().unwrap();
let original = std::env::var_os("USERPROFILE");
std::env::set_var("USERPROFILE", r"C:\Users\testuser");
let expanded = PermissionManager::expand_tilde_pub("~/docs/file.txt");
let expanded_dir = PermissionManager::expand_tilde_pub("~");
match original {
Some(home) => std::env::set_var("USERPROFILE", home),
None => std::env::remove_var("USERPROFILE"),
}
assert_eq!(expanded, r"C:\Users\testuser\docs\file.txt");
assert_eq!(expanded_dir, r"C:\Users\testuser");
}
#[test]
fn test_tilde_in_allow_rules() {
let _lock = HOME_MUTEX.lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/testuser");
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(~/.zshrc)".to_string());
let manager = create_test_manager(settings, &temp_dir);
let check_tilde = manager.is_read_explicit_allow("~/.zshrc");
let check_abs = manager.is_read_explicit_allow("/home/testuser/.zshrc");
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
assert!(check_tilde);
assert!(check_abs);
}
#[test]
fn test_glob_patterns_recursive() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.deny
.push("Read(./secrets/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("./secrets/file.txt"),
CommandPermission::Denied
);
assert_eq!(
manager.check_read_permission("./secrets/nested/deep/file.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_exact_allow_overrides_glob_deny() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(./secrets/exception.txt)".to_string());
settings
.permissions
.deny
.push("Read(./secrets/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("./secrets/exception.txt"),
CommandPermission::Allowed
);
assert_eq!(
manager.check_read_permission("./secrets/blocked.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_settings_merge_local_overrides_global() {
let mut global = PermissionSettings::default();
global
.permissions
.allow
.push("Bash(global_cmd)".to_string());
global
.permissions
.allow
.push("Bash(shared_cmd)".to_string());
global
.permissions
.deny
.push("Read(./global_secret)".to_string());
let mut local = PermissionSettings::default();
local.permissions.allow.push("Bash(local_cmd)".to_string());
local.permissions.allow.push("Bash(shared_cmd)".to_string());
local
.permissions
.deny
.push("Read(./local_secret)".to_string());
global.merge(local);
assert_eq!(global.permissions.allow[0], "Bash(local_cmd)");
assert_eq!(global.permissions.allow[1], "Bash(shared_cmd)");
assert_eq!(global.permissions.allow[2], "Bash(global_cmd)");
assert_eq!(global.permissions.deny.len(), 2);
assert!(
global
.permissions
.deny
.contains(&"Read(./local_secret)".to_string())
);
assert!(
global
.permissions
.deny
.contains(&"Read(./global_secret)".to_string())
);
}
#[test]
fn test_settings_merge_handles_empty_configs() {
let mut global = PermissionSettings::default();
global
.permissions
.allow
.push("Bash(global_cmd)".to_string());
let local = PermissionSettings::default();
global.merge(local);
assert_eq!(global.permissions.allow.len(), 1);
assert_eq!(global.permissions.allow[0], "Bash(global_cmd)");
}
#[test]
fn test_global_config_supplements_local() {
let _lock = HOME_MUTEX.lock().unwrap();
use std::fs;
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path().join("home");
fs::create_dir_all(home_dir.join(".sofos")).unwrap();
fs::write(
home_dir.join(".sofos/config.toml"),
r#"[permissions]
allow = ["Bash(global_allowed)"]
deny = ["Read(./global_denied)"]
ask = []
"#,
)
.unwrap();
let workspace = temp_dir.path().join("workspace");
fs::create_dir_all(workspace.join(".sofos")).unwrap();
fs::write(
workspace.join(".sofos/config.local.toml"),
r#"[permissions]
allow = ["Bash(local_allowed)"]
deny = []
ask = []
"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", &home_dir);
let manager = PermissionManager::new(workspace.clone()).unwrap();
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
assert!(
manager
.settings
.permissions
.allow
.contains(&"Bash(local_allowed)".to_string())
);
assert!(
manager
.settings
.permissions
.allow
.contains(&"Bash(global_allowed)".to_string())
);
assert!(
manager
.settings
.permissions
.deny
.contains(&"Read(./global_denied)".to_string())
);
}
#[test]
fn test_rule_source_detection() {
let _lock = HOME_MUTEX.lock().unwrap();
use std::fs;
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path().join("home");
fs::create_dir_all(home_dir.join(".sofos")).unwrap();
fs::write(
home_dir.join(".sofos/config.toml"),
r#"[permissions]
allow = []
deny = ["Read(./global_denied)"]
ask = []
"#,
)
.unwrap();
let workspace = temp_dir.path().join("workspace");
fs::create_dir_all(workspace.join(".sofos")).unwrap();
fs::write(
workspace.join(".sofos/config.local.toml"),
r#"[permissions]
allow = []
deny = ["Read(./local_denied)"]
ask = []
"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", &home_dir);
let manager = PermissionManager::new(workspace.clone()).unwrap();
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
let global_rule_source = manager.get_rule_source("Read(./global_denied)");
assert!(global_rule_source.contains("~/.sofos/config.toml"));
let local_rule_source = manager.get_rule_source("Read(./local_denied)");
assert_eq!(local_rule_source, ".sofos/config.local.toml");
}
#[test]
fn test_write_explicit_allow_glob() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Write(/tmp/output/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_write_explicit_allow("/tmp/output/file.txt"));
assert!(manager.is_write_explicit_allow("/tmp/output/sub/deep.txt"));
assert!(manager.is_write_explicit_allow("/tmp/output"));
assert!(!manager.is_write_explicit_allow("/tmp/other/file.txt"));
assert!(!manager.is_write_explicit_allow("/etc/passwd"));
}
#[test]
fn test_write_scope_independent_of_read() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/data/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_read_explicit_allow("/data/file.txt"));
assert!(!manager.is_write_explicit_allow("/data/file.txt"));
}
#[test]
fn test_bash_path_allowed_with_glob() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Bash(/var/log/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_bash_path_allowed("/var/log/syslog"));
assert!(manager.is_bash_path_allowed("/var/log/nginx/access.log"));
assert!(manager.is_bash_path_allowed("/var/log"));
assert!(!manager.is_bash_path_allowed("/var/other/file"));
assert!(!manager.is_bash_path_allowed("/etc/passwd"));
}
#[test]
fn test_bash_path_pattern_requires_glob_char() {
assert!(PermissionManager::extract_bash_path_pattern("Bash(/tmp/**)").is_some());
assert!(PermissionManager::extract_bash_path_pattern("Bash(~/docs/*)").is_some());
assert!(PermissionManager::extract_bash_path_pattern("Bash(/tmp/)").is_none());
assert!(PermissionManager::extract_bash_path_pattern("Bash(/usr/bin/ls)").is_none());
assert!(PermissionManager::extract_bash_path_pattern("Bash(npm test)").is_none());
assert!(PermissionManager::extract_bash_path_pattern("Bash(cargo:*)").is_none());
}
#[test]
fn test_bash_path_independent_of_read_and_write() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/data/**)".to_string());
settings
.permissions
.allow
.push("Write(/data/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_read_explicit_allow("/data/file.txt"));
assert!(manager.is_write_explicit_allow("/data/file.txt"));
assert!(!manager.is_bash_path_allowed("/data/file.txt"));
}
#[test]
fn test_extract_write_pattern() {
assert_eq!(
PermissionManager::extract_write_pattern("Write(/tmp/**)"),
Some("/tmp/**")
);
assert_eq!(
PermissionManager::extract_write_pattern("Write(~/docs/file.txt)"),
Some("~/docs/file.txt")
);
assert!(PermissionManager::extract_write_pattern("Read(/tmp/**)").is_none());
assert!(PermissionManager::extract_write_pattern("Bash(ls)").is_none());
}
#[test]
fn test_glob_deny_overrides_glob_allow() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/data/**)".to_string());
settings
.permissions
.deny
.push("Read(/data/secret/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("/data/public/file.txt"),
CommandPermission::Allowed
);
assert_eq!(
manager.check_read_permission("/data/secret/passwords.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_write_glob_deny_overrides_glob_allow() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Write(/tmp/**)".to_string());
settings
.permissions
.deny
.push("Write(/tmp/protected/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_write_permission("/tmp/safe/file.txt"),
CommandPermission::Allowed
);
assert_eq!(
manager.check_write_permission("/tmp/protected/file.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_bash_path_deny_overrides_allow() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Bash(/data/**)".to_string());
settings
.permissions
.deny
.push("Bash(/data/secret/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert!(manager.is_bash_path_allowed("/data/public/file.txt"));
assert!(manager.is_bash_path_denied("/data/secret/file.txt"));
assert!(manager.is_bash_path_allowed("/data/secret/file.txt"));
}
#[test]
fn test_exact_allow_still_overrides_glob_deny() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("Read(/data/secret/exception.txt)".to_string());
settings
.permissions
.deny
.push("Read(/data/secret/**)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_read_permission("/data/secret/exception.txt"),
CommandPermission::Allowed
);
assert_eq!(
manager.check_read_permission("/data/secret/other.txt"),
CommandPermission::Denied
);
}
#[test]
fn test_volatile_line_args_sed_range() {
assert!(PermissionManager::command_has_volatile_line_args(
"nl -ba tests/foo.rs | sed -n '1270,1320p'"
));
assert!(PermissionManager::command_has_volatile_line_args(
"sed -n 10,20p file.txt"
));
assert!(PermissionManager::command_has_volatile_line_args(
"sed '5d' file.txt"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"sed \"1,$q\" file.txt"
));
}
#[test]
fn test_volatile_line_args_head_tail() {
assert!(PermissionManager::command_has_volatile_line_args(
"head -n 50 big.log"
));
assert!(PermissionManager::command_has_volatile_line_args(
"head -50 big.log"
));
assert!(PermissionManager::command_has_volatile_line_args(
"tail -n 100 /var/log/syslog"
));
assert!(PermissionManager::command_has_volatile_line_args(
"tail +20 file.txt"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"head file.txt"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"tail -f /var/log/syslog"
));
}
#[test]
fn test_volatile_line_args_grep_context() {
assert!(PermissionManager::command_has_volatile_line_args(
"grep -A 3 pattern file.txt"
));
assert!(PermissionManager::command_has_volatile_line_args(
"grep -B5 pattern file.txt"
));
assert!(PermissionManager::command_has_volatile_line_args(
"rg -C 10 needle ."
));
assert!(!PermissionManager::command_has_volatile_line_args(
"grep -i pattern file.txt"
));
}
#[test]
fn test_volatile_line_args_awk_nr() {
assert!(PermissionManager::command_has_volatile_line_args(
"awk 'NR==5' file.txt"
));
assert!(PermissionManager::command_has_volatile_line_args(
"awk 'NR<=10{print}' file.txt"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"awk '/pattern/' file.txt"
));
assert!(PermissionManager::command_has_volatile_line_args(
"awk 'NR==var; NR==5 {print}' file.txt"
));
}
#[test]
fn test_volatile_line_args_covers_named_patterns() {
let cases = [
"sed -n '5p' file.txt",
"sed -n '10,20p' file.txt",
"head -n 50 big.log",
"tail -n 100 /var/log/syslog",
"grep -A 5 pattern file.txt",
"grep -B 5 pattern file.txt",
"grep -C 5 pattern file.txt",
"awk 'NR==5' file.txt",
];
for cmd in cases {
assert!(
PermissionManager::command_has_volatile_line_args(cmd),
"expected `{cmd}` to be classified as volatile"
);
}
}
#[test]
fn test_volatile_line_args_plain_commands() {
assert!(!PermissionManager::command_has_volatile_line_args(
"cargo build --release"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"git log --oneline"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"ls -la src/"
));
}
#[test]
fn test_volatile_line_args_inside_compound_shell() {
assert!(PermissionManager::command_has_volatile_line_args(
"for f in src/*.rs; do sed -n '1,320p' \"$f\" | nl -ba; done"
));
assert!(PermissionManager::command_has_volatile_line_args(
"cat README.md && head -n 50 CHANGELOG.md"
));
assert!(PermissionManager::command_has_volatile_line_args(
"echo start; tail -n 20 build.log"
));
assert!(!PermissionManager::command_has_volatile_line_args(
"for f in *.rs; do echo \"$f\"; cat \"$f\"; done"
));
}
#[test]
fn test_split_compound_command_respects_quotes() {
let segs = PermissionManager::split_compound_command("echo 'a; b' && ls");
assert_eq!(segs, vec!["echo 'a; b'", "ls"]);
let segs = PermissionManager::split_compound_command("echo \"x | y\" | wc -l");
assert_eq!(segs, vec!["echo \"x | y\"", "wc -l"]);
}
#[test]
fn test_split_compound_command_keeps_stderr_redirect() {
let segs = PermissionManager::split_compound_command("cargo test 2>&1 | tee out.log");
assert_eq!(segs, vec!["cargo test 2>&1", "tee out.log"]);
}
#[test]
fn compound_for_loop_of_allowed_commands_is_allowed() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission(
"for f in src/recipient/*.rs src/recipient/native/*.rs; \
do echo '===== '\"$f\"' ====='; sed -n '1,320p' \"$f\" | nl -ba; done"
)
.unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn compound_with_forbidden_base_is_denied() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("cat foo && rm bar")
.unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager
.check_command_permission("for f in *.rs; do rm \"$f\"; done")
.unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("ls; sudo whoami").unwrap(),
CommandPermission::Denied
);
}
#[test]
fn compound_with_unknown_base_asks() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("cat foo && some_custom_tool bar")
.unwrap(),
CommandPermission::Ask
);
assert_eq!(
manager
.check_command_permission("for f in *; do unknown_tool \"$f\"; done")
.unwrap(),
CommandPermission::Ask
);
}
#[test]
fn trailing_shell_comment_does_not_force_ask() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("ls -la; # quick listing")
.unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn blanket_bash_allow_auto_allows_non_forbidden() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.settings.permissions.allow.push("Bash".to_string());
assert_eq!(
manager
.check_command_permission("some_custom_tool --flag")
.unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("ls -la").unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager.check_command_permission("foo && bar; baz").unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn blanket_bash_allow_still_blocks_forbidden() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.settings.permissions.allow.push("Bash".to_string());
assert_eq!(
manager.check_command_permission("rm -rf /").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("sudo whoami").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager
.check_command_permission("ls && rm tmp.txt")
.unwrap(),
CommandPermission::Denied
);
}
#[test]
fn blanket_bash_deny_rejects_everything() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.settings.permissions.deny.push("Bash".to_string());
assert_eq!(
manager.check_command_permission("ls -la").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("cargo build").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager
.check_command_permission("any_tool whatsoever")
.unwrap(),
CommandPermission::Denied
);
}
#[test]
fn blanket_bash_beats_specific_companions() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager
.settings
.permissions
.deny
.push("Bash(curl --version)".to_string());
manager.settings.permissions.deny.push("Bash".to_string());
assert_eq!(
manager.check_command_permission("ls").unwrap(),
CommandPermission::Denied
);
assert_eq!(
manager.check_command_permission("curl --version").unwrap(),
CommandPermission::Denied
);
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager
.settings
.permissions
.allow
.push("Bash(curl --version)".to_string());
manager.settings.permissions.allow.push("Bash".to_string());
assert_eq!(
manager
.check_command_permission("custom_unknown_tool")
.unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn blanket_bash_allow_beats_specific_deny_across_lists() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.settings.permissions.allow.push("Bash".to_string());
manager
.settings
.permissions
.deny
.push("Bash(curl --version)".to_string());
assert_eq!(
manager.check_command_permission("curl --version").unwrap(),
CommandPermission::Allowed
);
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager
.settings
.permissions
.allow
.push("Bash(curl --version)".to_string());
manager.settings.permissions.deny.push("Bash".to_string());
assert_eq!(
manager.check_command_permission("curl --version").unwrap(),
CommandPermission::Denied
);
}
#[test]
fn blanket_bash_deny_beats_blanket_allow() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.settings.permissions.allow.push("Bash".to_string());
manager.settings.permissions.deny.push("Bash".to_string());
assert_eq!(
manager.check_command_permission("ls").unwrap(),
CommandPermission::Denied
);
}
#[test]
fn while_and_if_compounds_are_inspected() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(
manager
.check_command_permission("if grep -q foo file.txt; then echo found; fi")
.unwrap(),
CommandPermission::Allowed
);
assert_eq!(
manager
.check_command_permission("if true; then rm file; fi")
.unwrap(),
CommandPermission::Denied
);
}
}