pub mod command_parse;
pub mod manager;
pub mod pattern;
pub mod scope;
pub mod settings;
pub use manager::PermissionManager;
use crate::config::LOCAL_CONFIG_FILE;
use crate::error::{Result, SofosError};
use crate::tools::permissions::pattern::WEB_FETCH_SCOPE;
use std::collections::HashSet;
use std::net::IpAddr;
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, PartialEq, Eq)]
pub enum CommandPermission {
Allowed,
Denied,
Ask,
}
pub(crate) fn grant_dir_for_path(path: &Path) -> &str {
let parent = path.parent().and_then(|p| p.to_str()).unwrap_or("");
if parent.is_empty() || Path::new(parent).parent().is_none() {
path.to_str().unwrap_or("")
} else {
parent
}
}
pub fn check_external_path_session_access(
workspace: &Path,
scope: &str,
canonical_path: &str,
dir_to_grant: &str,
interactive: bool,
session_allowed: &Arc<Mutex<HashSet<String>>>,
session_denied: &Arc<Mutex<HashSet<String>>>,
) -> Result<()> {
let canonical = Path::new(canonical_path);
if let Ok(allowed_dirs) = session_allowed.lock() {
for dir in allowed_dirs.iter() {
if canonical.starts_with(Path::new(dir)) {
return Ok(());
}
}
}
if let Ok(denied_dirs) = session_denied.lock() {
for dir in denied_dirs.iter() {
if canonical.starts_with(Path::new(dir)) {
return Err(SofosError::ToolExecution(format!(
"{} access denied for '{}' (denied earlier this session)",
scope, canonical_path
)));
}
}
}
if !interactive {
return Err(SofosError::ToolExecution(format!(
"Path '{}' is outside workspace and not explicitly allowed\n\
Hint: Add {}({}/**) to 'allow' list in {}",
canonical_path, scope, dir_to_grant, LOCAL_CONFIG_FILE
)));
}
let mut pm = PermissionManager::new(workspace.to_path_buf())?;
let (allowed, remember) = pm.ask_user_path_permission(scope, dir_to_grant)?;
if allowed {
if !remember {
if let Ok(mut dirs) = session_allowed.lock() {
dirs.insert(dir_to_grant.to_string());
}
}
Ok(())
} else {
if !remember {
if let Ok(mut dirs) = session_denied.lock() {
dirs.insert(dir_to_grant.to_string());
}
}
Err(SofosError::ToolExecution(format!(
"{} access denied by user for '{}'",
scope, canonical_path
)))
}
}
fn web_fetch_blocked_internal_host(host: &str) -> Option<&'static str> {
let bare = host
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(host);
if let Ok(ip) = bare.parse::<IpAddr>() {
return blocked_ip_reason(ip);
}
if host == "localhost" || host.ends_with(".localhost") {
return Some("the local host");
}
if host.ends_with(".internal") {
return Some("an internal-only host");
}
None
}
fn blocked_ip_reason(ip: IpAddr) -> Option<&'static str> {
if ip.is_loopback() {
return Some("a loopback address");
}
if ip.is_unspecified() {
return Some("the unspecified address");
}
match ip {
IpAddr::V4(v4) => {
if v4.is_link_local() {
Some("a link-local address (the cloud-metadata range)")
} else if v4.is_private() {
Some("a private address")
} else {
None
}
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return blocked_ip_reason(IpAddr::V4(v4));
}
let octets = v6.octets();
if (octets[0] & 0xfe) == 0xfc {
Some("a unique-local address")
} else if octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 {
Some("a link-local address")
} else {
None
}
}
}
}
pub fn check_web_fetch_session_access(
workspace: &Path,
host: &str,
interactive: bool,
session_allowed: &Arc<Mutex<HashSet<String>>>,
session_denied: &Arc<Mutex<HashSet<String>>>,
) -> Result<()> {
let host_key = PermissionManager::canonical_web_fetch_host(host);
if let Some(reason) = web_fetch_blocked_internal_host(&host_key) {
return Err(SofosError::ToolExecution(format!(
"web_fetch to '{host}' is refused: it is {reason}. Loopback, \
private, link-local, and cloud-metadata addresses are blocked."
)));
}
let mut manager = PermissionManager::new(workspace.to_path_buf())?;
match manager.check_web_fetch_permission(&host_key) {
CommandPermission::Denied => {
return Err(SofosError::ToolExecution(format!(
"web_fetch to '{}' is blocked by a {} deny rule in {}",
host,
WEB_FETCH_SCOPE,
crate::config::config_files_hint()
)));
}
CommandPermission::Allowed => return Ok(()),
CommandPermission::Ask => {}
}
if let Ok(allowed) = session_allowed.lock() {
if allowed
.iter()
.any(|granted| scope::web_fetch_host_matches(&host_key, granted))
{
return Ok(());
}
}
if let Ok(denied) = session_denied.lock() {
if denied.contains(&host_key) {
return Err(SofosError::ToolExecution(format!(
"web_fetch to '{}' was declined earlier this session",
host
)));
}
}
if !interactive {
return Err(SofosError::ToolExecution(format!(
"web_fetch to '{}' needs approval, which is unavailable in a non-interactive \
session.\nHint: add {} to the 'allow' list in {}",
host,
PermissionManager::normalize_web_fetch(&host_key),
LOCAL_CONFIG_FILE
)));
}
let (allowed, remember) = manager.ask_user_web_fetch_permission(&host_key)?;
if allowed {
if !remember {
if let Ok(mut set) = session_allowed.lock() {
set.insert(host_key);
}
}
Ok(())
} else {
if !remember {
if let Ok(mut set) = session_denied.lock() {
set.insert(host_key);
}
}
Err(SofosError::ToolExecution(format!(
"web_fetch to '{}' was declined",
host
)))
}
}
pub fn check_mcp_session_access(
workspace: &Path,
server: &str,
tool: &str,
interactive: bool,
session_allowed: &Arc<Mutex<HashSet<String>>>,
session_denied: &Arc<Mutex<HashSet<String>>>,
) -> Result<()> {
let server_key = PermissionManager::canonical_mcp_server(server);
let mut manager = PermissionManager::new(workspace.to_path_buf())?;
match manager.check_mcp_permission(&server_key) {
CommandPermission::Denied => {
return Err(SofosError::ToolExecution(format!(
"MCP tool '{}' from server '{}' is blocked by a deny rule in {}",
tool,
server,
crate::config::config_files_hint()
)));
}
CommandPermission::Allowed => return Ok(()),
CommandPermission::Ask => {}
}
if let Ok(allowed) = session_allowed.lock() {
if allowed.contains(&server_key) {
return Ok(());
}
}
if let Ok(denied) = session_denied.lock() {
if denied.contains(&server_key) {
return Err(SofosError::ToolExecution(format!(
"MCP server '{}' was declined earlier this session",
server
)));
}
}
if !interactive {
return Err(SofosError::ToolExecution(format!(
"MCP tool '{}' from server '{}' needs approval, which is unavailable in a \
non-interactive session.\nHint: add {} to the 'allow' list in {}",
tool,
server,
PermissionManager::normalize_mcp(&server_key),
LOCAL_CONFIG_FILE
)));
}
let (allowed, remember) = manager.ask_user_mcp_permission(server, tool)?;
if allowed {
if !remember {
if let Ok(mut set) = session_allowed.lock() {
set.insert(server_key);
}
}
Ok(())
} else {
if !remember {
if let Ok(mut set) = session_denied.lock() {
set.insert(server_key);
}
}
Err(SofosError::ToolExecution(format!(
"MCP tool '{}' from server '{}' was declined",
tool, server
)))
}
}
#[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(());
#[cfg(windows)]
const HOME_ENV_VAR: &str = "USERPROFILE";
#[cfg(not(windows))]
const HOME_ENV_VAR: &str = "HOME";
struct HomeDirGuard {
previous: Option<std::ffi::OsString>,
}
impl HomeDirGuard {
fn set(path: &std::path::Path) -> Self {
let previous = std::env::var_os(HOME_ENV_VAR);
std::env::set_var(HOME_ENV_VAR, path);
Self { previous }
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var(HOME_ENV_VAR, value),
None => std::env::remove_var(HOME_ENV_VAR),
}
}
}
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: PermissionSettings::default(),
local_settings_path: temp_dir.path().join(".sofos/config.local.toml"),
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 has_read_deny_rules_detects_only_read_denies() {
let temp = TempDir::new().unwrap();
let mut without_read = PermissionSettings::default();
without_read.permissions.deny.push("Bash(rm)".to_string());
without_read
.permissions
.deny
.push("Write(./out)".to_string());
assert!(
!create_test_manager(without_read, &temp).has_read_deny_rules(),
"Bash and Write denies must not count as read denies"
);
let mut with_read = PermissionSettings::default();
with_read
.permissions
.deny
.push("Read(~/.ssh/id_rsa)".to_string());
assert!(
create_test_manager(with_read, &temp).has_read_deny_rules(),
"a Read deny must be detected"
);
}
#[test]
fn web_fetch_domain_parses_both_forms_and_normalizes() {
assert_eq!(
PermissionManager::extract_web_fetch_domain("WebFetch(domain:Blog.Rust-Lang.org)"),
Some("blog.rust-lang.org".to_string()),
"the domain: form is parsed and lower-cased"
);
assert_eq!(
PermissionManager::extract_web_fetch_domain("WebFetch(example.com)"),
Some("example.com".to_string()),
"a bare host (hand-edited) is accepted too"
);
assert_eq!(
PermissionManager::extract_web_fetch_domain("Read(./secret)"),
None,
"a non web-fetch rule is ignored"
);
assert_eq!(
PermissionManager::extract_web_fetch_domain("WebFetch(domain:)"),
None,
"an empty host is rejected"
);
assert_eq!(
PermissionManager::normalize_web_fetch("API.Example.COM"),
"WebFetch(domain:api.example.com)",
"persisted rules use the canonical domain: form, lower-cased"
);
}
#[test]
fn web_fetch_permission_matches_host_and_subdomains_with_deny_winning() {
let temp_dir = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings
.permissions
.allow
.push("WebFetch(domain:rust-lang.org)".to_string());
settings
.permissions
.deny
.push("WebFetch(domain:internal.rust-lang.org)".to_string());
let manager = create_test_manager(settings, &temp_dir);
assert_eq!(
manager.check_web_fetch_permission("rust-lang.org"),
CommandPermission::Allowed,
"the exact allowed host matches"
);
assert_eq!(
manager.check_web_fetch_permission("blog.rust-lang.org"),
CommandPermission::Allowed,
"a subdomain of an allowed host matches on a label boundary"
);
assert_eq!(
manager.check_web_fetch_permission("internal.rust-lang.org"),
CommandPermission::Denied,
"a deny rule wins over the broader allow"
);
assert_eq!(
manager.check_web_fetch_permission("evilrust-lang.org"),
CommandPermission::Ask,
"a look-alike host that is not a real subdomain does not match the allow"
);
assert_eq!(
manager.check_web_fetch_permission("crates.io"),
CommandPermission::Ask,
"an unconfigured host falls through to a prompt"
);
assert_eq!(
manager.check_web_fetch_permission("internal.rust-lang.org."),
CommandPermission::Denied,
"a trailing FQDN dot must not slip a request past the deny rule"
);
assert_eq!(
manager.check_web_fetch_permission("blog.rust-lang.org."),
CommandPermission::Allowed,
"a trailing FQDN dot still matches the allow rule"
);
}
#[test]
fn web_fetch_remember_round_trips_through_local_config() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(
PermissionManager::normalize_web_fetch("Blog.Example.com"),
true,
);
manager.save_settings().unwrap();
let reloaded = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
let exact = reloaded.check_web_fetch_permission("blog.example.com");
let subdomain = reloaded.check_web_fetch_permission("cdn.blog.example.com");
let written =
std::fs::read_to_string(workspace.path().join(".sofos/config.local.toml")).unwrap();
assert_eq!(
exact,
CommandPermission::Allowed,
"remembered host is allowed"
);
assert_eq!(
subdomain,
CommandPermission::Allowed,
"remembered host also covers its subdomains after reload"
);
assert!(
written.contains("WebFetch(domain:blog.example.com)"),
"the rule is saved in canonical lower-cased domain form: {written}"
);
}
#[test]
fn web_fetch_session_access_applies_config_rules_and_refuses_when_non_interactive() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[permissions]\n\
allow = [\"WebFetch(domain:allowed.example.com)\"]\n\
deny = [\"WebFetch(domain:blocked.example.com)\"]\n\
ask = []\n",
)
.unwrap();
let allowed = std::sync::Arc::new(Mutex::new(HashSet::new()));
let denied = std::sync::Arc::new(Mutex::new(HashSet::new()));
let check = |host: &str| {
check_web_fetch_session_access(workspace.path(), host, false, &allowed, &denied)
};
let exact = check("allowed.example.com").is_ok();
let subdomain = check("docs.allowed.example.com").is_ok();
let blocked = check("blocked.example.com").is_err();
let unknown = check("unknown.example.com").is_err();
assert!(exact, "an allowed host passes");
assert!(subdomain, "a subdomain of an allowed host passes");
assert!(blocked, "a denied host is refused");
assert!(
unknown,
"an unconfigured host is refused in a non-interactive session"
);
}
#[test]
fn mcp_rule_parses_and_normalizes() {
assert_eq!(
PermissionManager::extract_mcp_server("Mcp(GitHub)"),
Some("github".to_string()),
"the server name is parsed and lower-cased"
);
assert_eq!(
PermissionManager::extract_mcp_server("Read(./secret)"),
None,
"a non-MCP rule is ignored"
);
assert_eq!(
PermissionManager::extract_mcp_server("Mcp()"),
None,
"an empty server name is rejected"
);
assert_eq!(
PermissionManager::normalize_mcp("GitHub"),
"Mcp(github)",
"persisted rules use the canonical lower-cased form"
);
}
#[test]
fn mcp_permission_matches_server_with_deny_winning() {
let temp = TempDir::new().unwrap();
let mut settings = PermissionSettings::default();
settings.permissions.allow.push("Mcp(docs)".to_string());
settings.permissions.allow.push("Mcp(github)".to_string());
settings.permissions.deny.push("Mcp(github)".to_string());
let manager = create_test_manager(settings, &temp);
assert_eq!(
manager.check_mcp_permission("docs"),
CommandPermission::Allowed,
"an allowed server passes"
);
assert_eq!(
manager.check_mcp_permission("GitHub"),
CommandPermission::Denied,
"a denied server is refused even when also allowed, matched case-insensitively"
);
assert_eq!(
manager.check_mcp_permission("other"),
CommandPermission::Ask,
"an unconfigured server prompts"
);
}
#[test]
fn mcp_session_access_applies_config_rules_and_refuses_when_non_interactive() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[permissions]\n\
allow = [\"Mcp(docs)\"]\n\
deny = [\"Mcp(secrets)\"]\n\
ask = []\n",
)
.unwrap();
let allowed = std::sync::Arc::new(Mutex::new(HashSet::new()));
let denied = std::sync::Arc::new(Mutex::new(HashSet::new()));
let check = |server: &str| {
check_mcp_session_access(
workspace.path(),
server,
"some_tool",
false,
&allowed,
&denied,
)
};
let allowed_server = check("docs").is_ok();
let denied_server = check("secrets").is_err();
let unknown = check("other").is_err();
assert!(allowed_server, "an allowed server passes");
assert!(denied_server, "a denied server is refused");
assert!(
unknown,
"an unconfigured server is refused in a non-interactive session"
);
}
#[test]
fn mcp_session_grant_passes_and_session_denial_refuses() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let allowed = std::sync::Arc::new(Mutex::new(HashSet::new()));
let denied = std::sync::Arc::new(Mutex::new(HashSet::new()));
allowed.lock().unwrap().insert("docs".to_string());
denied.lock().unwrap().insert("secrets".to_string());
let check = |server: &str| {
check_mcp_session_access(
workspace.path(),
server,
"some_tool",
false,
&allowed,
&denied,
)
};
let granted = check("docs").is_ok();
let refused = check("secrets").is_err();
let granted_mixed_case = check("Docs").is_ok();
assert!(granted, "a session-allowed server passes without asking");
assert!(refused, "a session-denied server is refused");
assert!(
granted_mixed_case,
"a session grant matches the server case-insensitively"
);
}
#[test]
fn mcp_remember_round_trips_through_local_config() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("Docs"), true);
manager.save_settings().unwrap();
let reloaded = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
let decision = reloaded.check_mcp_permission("docs");
let written =
std::fs::read_to_string(workspace.path().join(".sofos/config.local.toml")).unwrap();
assert_eq!(
decision,
CommandPermission::Allowed,
"a remembered server is allowed after reload"
);
assert!(
written.contains("Mcp(docs)"),
"the rule is saved in canonical lower-cased form: {written}"
);
}
#[test]
fn save_settings_preserves_other_sections() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[mcp-servers.docs]\n\
command = \"/srv/docs\"\n\n\
[permissions]\n\
allow = [\"Read(./a)\"]\n\
deny = []\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
assert!(
written.contains("mcp-servers"),
"the [mcp-servers] section must survive a permission write: {written}"
);
assert!(
written.contains("/srv/docs"),
"the MCP server command must survive: {written}"
);
assert!(
written.contains("Mcp(docs)"),
"the remembered rule must be written: {written}"
);
assert!(
written.contains("Read(./a)"),
"the pre-existing permission rule must survive: {written}"
);
assert!(
written.contains("allow = [\n"),
"a populated array is written one entry per line: {written}"
);
}
#[test]
fn save_settings_keeps_file_valid_with_top_level_keys() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"schema = \"1\"\n\n[mcp-servers.docs]\ncommand = \"/srv\"\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
let reparsed: toml::Table = toml::from_str(&written)
.unwrap_or_else(|e| panic!("save produced invalid TOML: {e}\n---\n{written}"));
assert_eq!(reparsed.get("schema").and_then(|v| v.as_str()), Some("1"));
assert!(reparsed.contains_key("mcp-servers"));
assert!(reparsed.contains_key("permissions"));
}
#[test]
fn remember_adds_permissions_to_mcp_only_config() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[mcp-servers.docs]\ncommand = \"/srv\"\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
assert!(
written.contains("mcp-servers"),
"MCP section kept: {written}"
);
assert!(written.contains("/srv"), "MCP command kept: {written}");
assert!(written.contains("Mcp(docs)"), "rule added: {written}");
}
#[test]
fn save_settings_preserves_comments() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"# top of file\n\n# the docs server\n[mcp-servers.docs]\n\
command = \"/srv/docs\" # inline note\n\n\
[permissions]\nallow = [\"Read(./a)\"]\ndeny = []\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
assert!(
written.contains("# top of file"),
"top comment kept: {written}"
);
assert!(
written.contains("# the docs server"),
"section comment kept: {written}"
);
assert!(
written.contains("# inline note"),
"inline comment kept: {written}"
);
assert!(written.contains("Mcp(docs)"), "rule added: {written}");
}
#[test]
fn save_settings_preserves_permissions_section_decor() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[mcp-servers.docs]\ncommand = \"/srv\"\n\n\
[permissions]\n\
# my read rules\n\
allow = [\"Read(./a)\"]\n\
# my deny rules\n\
deny = []\n\
future_key = \"keep me\"\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
assert!(
written.contains("\n\n[permissions]"),
"blank line before [permissions] kept: {written}"
);
assert!(
written.contains("# my read rules"),
"comment on the allow key kept: {written}"
);
assert!(
written.contains("# my deny rules"),
"comment on the deny key kept: {written}"
);
assert!(
written.contains("future_key = \"keep me\""),
"unmodeled key under [permissions] kept: {written}"
);
assert!(written.contains("Mcp(docs)"), "rule added: {written}");
}
#[test]
fn save_settings_separates_a_new_permissions_section() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[mcp-servers.docs]\ncommand = \"/srv\"\n",
)
.unwrap();
let mut manager = PermissionManager::new(workspace.path().to_path_buf()).unwrap();
manager.remember_rule(PermissionManager::normalize_mcp("docs"), true);
manager.save_settings().unwrap();
let written = std::fs::read_to_string(sofos.join("config.local.toml")).unwrap();
assert!(
written.contains("\n\n[permissions]"),
"new [permissions] section is set off by a blank line: {written}"
);
}
#[test]
fn permissions_section_allows_omitting_lists() {
let only_allow: PermissionSettings =
toml::from_str("[permissions]\nallow = [\"Bash(echo)\"]\n").unwrap();
assert_eq!(only_allow.permissions.allow, vec!["Bash(echo)".to_string()]);
assert!(only_allow.permissions.deny.is_empty());
assert!(only_allow.permissions.ask.is_empty());
let only_deny: PermissionSettings =
toml::from_str("[permissions]\ndeny = [\"Bash(rm)\"]\n").unwrap();
assert_eq!(only_deny.permissions.deny, vec!["Bash(rm)".to_string()]);
assert!(only_deny.permissions.allow.is_empty());
let empty: PermissionSettings = toml::from_str("[permissions]\n").unwrap();
assert!(empty.permissions.allow.is_empty());
assert!(empty.permissions.deny.is_empty());
}
#[test]
fn config_without_permissions_section_loads() {
let parsed: PermissionSettings =
toml::from_str("[mcp-servers.docs]\ncommand = \"/srv\"\n").unwrap();
assert!(parsed.permissions.allow.is_empty());
assert!(parsed.permissions.deny.is_empty());
assert!(parsed.permissions.ask.is_empty());
}
#[test]
fn manager_loads_with_mcp_only_local_config() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[mcp-servers.docs]\ncommand = \"/srv/docs\"\n",
)
.unwrap();
let result = PermissionManager::new(workspace.path().to_path_buf());
assert!(
result.is_ok(),
"a local config with only MCP servers must load: {:?}",
result.err()
);
}
#[test]
fn web_fetch_session_allow_covers_subdomains() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let allowed = std::sync::Arc::new(Mutex::new(HashSet::new()));
allowed.lock().unwrap().insert("example.com".to_string());
let denied = std::sync::Arc::new(Mutex::new(HashSet::new()));
let check = |host: &str| {
check_web_fetch_session_access(workspace.path(), host, false, &allowed, &denied)
};
let exact = check("example.com").is_ok();
let subdomain = check("cdn.example.com").is_ok();
let lookalike = check("evilexample.com").is_err();
assert!(exact, "the exact session-allowed host passes");
assert!(
subdomain,
"a subdomain of a session-allowed host passes without asking again"
);
assert!(
lookalike,
"a look-alike host is not covered and is refused non-interactively"
);
}
#[test]
fn web_fetch_classifies_internal_and_public_hosts() {
let blocked = [
"169.254.169.254", "127.0.0.1", "10.1.2.3", "192.168.0.5", "172.16.9.9", "0.0.0.0", "[::1]", "[fd00:ec2::254]", "[fe80::1]", "[::ffff:169.254.169.254]", "localhost",
"build.internal",
];
for host in blocked {
let key = PermissionManager::canonical_web_fetch_host(host);
assert!(
web_fetch_blocked_internal_host(&key).is_some(),
"{host} should be blocked as an internal target"
);
}
let public = [
"example.com",
"8.8.8.8", "172.32.0.1", "[2606:4700::1]", "notlocalhost.com",
];
for host in public {
let key = PermissionManager::canonical_web_fetch_host(host);
assert!(
web_fetch_blocked_internal_host(&key).is_none(),
"{host} is a public host and should not be blocked"
);
}
}
#[test]
fn web_fetch_block_matches_what_the_url_parser_resolves() {
let blocked = [
"http://169.254.169.254/latest/meta-data/",
"http://[::ffff:169.254.169.254]/",
"http://LocalHost:8080/",
"http://[::1]/",
];
for url in blocked {
let parsed = reqwest::Url::parse(url).unwrap();
let key = PermissionManager::canonical_web_fetch_host(parsed.host_str().unwrap());
assert!(
web_fetch_blocked_internal_host(&key).is_some(),
"{url} (host {key}) must be blocked"
);
}
let public = reqwest::Url::parse("https://example.com/").unwrap();
let key = PermissionManager::canonical_web_fetch_host(public.host_str().unwrap());
assert!(web_fetch_blocked_internal_host(&key).is_none());
}
#[test]
fn web_fetch_internal_block_overrides_allow_rule_and_session_grant() {
let _lock = HOME_MUTEX.lock().unwrap();
let home = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(home.path());
let workspace = TempDir::new().unwrap();
let sofos = workspace.path().join(".sofos");
std::fs::create_dir_all(&sofos).unwrap();
std::fs::write(
sofos.join("config.local.toml"),
"[permissions]\n\
allow = [\"WebFetch(domain:169.254.169.254)\"]\n\
deny = []\n\
ask = []\n",
)
.unwrap();
let allowed = std::sync::Arc::new(Mutex::new(HashSet::new()));
allowed.lock().unwrap().insert("localhost".to_string());
let denied = std::sync::Arc::new(Mutex::new(HashSet::new()));
let metadata = check_web_fetch_session_access(
workspace.path(),
"169.254.169.254",
true,
&allowed,
&denied,
);
let loopback =
check_web_fetch_session_access(workspace.path(), "localhost", true, &allowed, &denied);
assert!(
metadata.is_err(),
"an allow rule cannot open the cloud-metadata IP"
);
assert!(loopback.is_err(), "a session grant cannot open localhost");
}
#[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.remember_rule("Bash(custom:*)".to_string(), true);
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);
}
#[cfg(unix)]
#[test]
fn test_tilde_expansion_in_permissions() {
let _lock = HOME_MUTEX.lock().unwrap();
let _temp_dir = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(std::path::Path::new("/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");
assert_eq!(expanded, "/home/testuser/file.txt");
assert_eq!(expanded_dir, "/home/testuser");
assert_eq!(not_tilde, "./file.txt");
}
#[cfg(unix)]
#[test]
fn test_tilde_expansion_trims_leading_separator_in_remainder() {
let _lock = HOME_MUTEX.lock().unwrap();
let _temp_dir = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(std::path::Path::new("/home/testuser"));
let single = PermissionManager::expand_tilde_pub("~/foo");
let double = PermissionManager::expand_tilde_pub("~//foo");
let triple = PermissionManager::expand_tilde_pub("~///foo");
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");
}
#[cfg(unix)]
#[test]
fn test_tilde_in_allow_rules() {
let _lock = HOME_MUTEX.lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let _home_guard = HomeDirGuard::set(std::path::Path::new("/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");
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 _home_guard = HomeDirGuard::set(&home_dir);
let manager = PermissionManager::new(workspace.clone()).unwrap();
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 _home_guard = HomeDirGuard::set(&home_dir);
let manager = PermissionManager::new(workspace.clone()).unwrap();
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/readonly/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_command_not_rememberable() {
assert!(PermissionManager::command_not_rememberable(
"for file in $(find src -name '*.rs'); do echo \"$file\"; done"
));
assert!(PermissionManager::command_not_rememberable(
"python3 - <<'PY'\nprint('hi')\nPY"
));
assert!(PermissionManager::command_not_rememberable("echo `whoami`"));
assert!(PermissionManager::command_not_rememberable(
"diff <(sort a) <(sort b)"
));
assert!(PermissionManager::command_not_rememberable(
"sed -n '10,20p' file.txt"
));
assert!(!PermissionManager::command_not_rememberable(
"cargo build --release"
));
}
#[test]
fn test_is_persistable_grant_dir() {
assert!(!PermissionManager::is_persistable_grant_dir("/"));
assert!(!PermissionManager::is_persistable_grant_dir(""));
assert!(!PermissionManager::is_persistable_grant_dir(
"/Users/alex/git/aal/sofos-code:"
));
assert!(PermissionManager::is_persistable_grant_dir("/work"));
assert!(PermissionManager::is_persistable_grant_dir(
"/Users/alex/Pictures"
));
}
#[test]
fn test_grant_dir_for_path() {
use std::path::Path;
assert_eq!(grant_dir_for_path(Path::new("/a/b/c.txt")), "/a/b");
assert_eq!(grant_dir_for_path(Path::new("/etc/hosts")), "/etc");
assert_eq!(grant_dir_for_path(Path::new("/work")), "/work");
}
#[test]
fn test_remember_rule_dedup() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
manager.remember_rule("Bash(/work/**)".to_string(), true);
manager.remember_rule("Bash(/work/**)".to_string(), true);
let hits = manager
.settings
.permissions
.allow
.iter()
.filter(|r| *r == "Bash(/work/**)")
.count();
assert_eq!(hits, 1);
}
#[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
);
}
#[test]
fn blanket_bash_allow_does_not_let_wrappers_smuggle_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());
for cmd in [
"(rm -rf /tmp/x)",
"\\rm -rf /tmp/x",
"'rm' -rf /tmp/x",
"\"rm\" -rf /tmp/x",
"/bin/rm /tmp/x",
"/usr/bin/sudo whoami",
"{ rm -rf /tmp/x; }",
] {
assert_eq!(
manager.check_command_permission(cmd).unwrap(),
CommandPermission::Denied,
"wrapper-prefixed forbidden command should be denied: {}",
cmd
);
}
}
#[test]
fn lone_ampersand_splits_segments() {
let segs = PermissionManager::split_compound_command("ls foo & rm bar");
assert_eq!(segs, vec!["ls foo", "rm bar"]);
let segs = PermissionManager::split_compound_command("ls&rm bar");
assert_eq!(segs, vec!["ls", "rm bar"]);
let segs = PermissionManager::split_compound_command("cmd 2>&1");
assert_eq!(segs, vec!["cmd 2>&1"]);
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 foo & rm bar").unwrap(),
CommandPermission::Denied
);
}
#[test]
fn extract_base_command_strips_wrappers_and_path_prefix() {
assert_eq!(PermissionManager::extract_base_command("(rm -rf /"), "rm");
assert_eq!(PermissionManager::extract_base_command("'rm' x"), "rm");
assert_eq!(PermissionManager::extract_base_command("\\rm x"), "rm");
assert_eq!(PermissionManager::extract_base_command("/bin/rm x"), "rm");
assert_eq!(PermissionManager::extract_base_command("./rm x"), "rm");
}
#[test]
fn enumerate_compound_bases_strips_wrappers_in_each_segment() {
let bases = PermissionManager::enumerate_compound_bases("ls && (rm bar)");
assert_eq!(bases, vec!["ls".to_string(), "rm".to_string()]);
let bases = PermissionManager::enumerate_compound_bases("'echo' hi; \\sudo whoami");
assert_eq!(bases, vec!["echo".to_string(), "sudo".to_string()]);
}
#[test]
fn dangerous_env_prefix_forces_ask() {
let temp_dir = TempDir::new().unwrap();
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
for cmd in [
"PATH=. cargo build",
"LD_PRELOAD=./evil.so ls",
"LD_LIBRARY_PATH=. ./bin",
"DYLD_INSERT_LIBRARIES=./evil.dylib ls",
"DYLD_LIBRARY_PATH=. ls",
"NODE_PATH=. node script.js",
"PYTHONPATH=. python script.py",
] {
assert_eq!(
manager.check_command_permission(cmd).unwrap(),
CommandPermission::Ask,
"dangerous env prefix should force Ask: {cmd}"
);
}
assert_eq!(
manager
.check_command_permission("FOO=bar cargo build")
.unwrap(),
CommandPermission::Allowed
);
}
#[test]
fn dangerous_env_prefix_overrides_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());
assert_eq!(
manager
.check_command_permission("PATH=. cargo build")
.unwrap(),
CommandPermission::Ask
);
}
#[test]
fn dangerous_env_prefix_in_compound_segment_caught() {
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; PATH=. cargo build")
.unwrap(),
CommandPermission::Ask
);
assert_eq!(
manager
.check_command_permission("cat foo && LD_PRELOAD=evil.so ls")
.unwrap(),
CommandPermission::Ask
);
}
#[test]
fn merge_does_not_drop_global_deny_for_local_allow() {
let mut global = PermissionSettings::default();
global.permissions.deny.push("Bash(rm)".to_string());
let mut local = PermissionSettings::default();
local.permissions.allow.push("Bash(rm)".to_string());
global.merge(local);
assert!(
global.permissions.deny.contains(&"Bash(rm)".to_string()),
"global deny must survive a local-allow with the same key"
);
assert!(
global.permissions.allow.contains(&"Bash(rm)".to_string()),
"local allow is still merged in"
);
}
#[test]
fn normalize_command_key_collapses_internal_whitespace() {
assert_eq!(
PermissionManager::normalize_command_key("ls /etc"),
PermissionManager::normalize_command_key("ls /etc"),
);
assert_eq!(
PermissionManager::normalize_command_key("ls\t/etc"),
PermissionManager::normalize_command_key("ls /etc"),
);
assert_eq!(
PermissionManager::normalize_command_key(" ls /etc "),
"Bash(ls /etc)".to_string()
);
assert_eq!(
PermissionManager::normalize_command("/tmp/file with spaces"),
"Bash(/tmp/file with spaces)".to_string()
);
}
#[test]
fn lexical_normalisation_keeps_deny_globs_honest() {
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/keys"),
CommandPermission::Denied,
"direct child must still match"
);
assert_eq!(
manager.check_read_permission("./secrets/../allowed.txt"),
CommandPermission::Allowed,
"lexically-normalised path that escapes the deny prefix is allowed"
);
assert_eq!(
manager.check_read_permission(".//secrets/keys"),
CommandPermission::Denied,
"double-slash variant is still inside the deny scope"
);
assert_eq!(
manager.check_read_permission("./secrets/./keys"),
CommandPermission::Denied,
"interior `.` segment is still inside the deny scope"
);
}
}