pub mod auto_classifier;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecisionReason {
Rule { source: RuleSource, pattern: String },
Mode(PermissionMode),
Hook { name: String },
SafetyCheck { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Permission {
Allowed(DecisionReason),
Denied(DecisionReason, String),
Unknown,
}
impl Permission {
pub fn is_allowed(&self) -> bool {
matches!(self, Permission::Allowed(_))
}
pub fn is_denied(&self) -> bool {
matches!(self, Permission::Denied(_, _))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
#[serde(alias = "allow")]
#[default]
Default,
AcceptEdits,
BypassPermissions,
#[serde(alias = "deny")]
#[serde(alias = "interactive")]
DontAsk,
Plan {
pre_plan_mode: Box<PermissionMode>,
bypass_available: bool,
},
Auto,
}
impl<'de> Deserialize<'de> for PermissionMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, MapAccess, Visitor};
use std::fmt;
struct PermissionModeVisitor;
impl<'de> Visitor<'de> for PermissionModeVisitor {
type Value = PermissionMode;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str(
"a permission mode string (\"default\", \"acceptEdits\", \
\"bypassPermissions\", \"dontAsk\", \"plan\", \"auto\", \
or legacy \"allow\"/\"deny\"/\"interactive\") \
or a Plan object {\"prePlanMode\": \"...\", \"bypassAvailable\": bool}",
)
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<PermissionMode, E> {
match s {
"default" | "allow" => Ok(PermissionMode::Default),
"acceptEdits" | "accept_edits" => Ok(PermissionMode::AcceptEdits),
"bypassPermissions" | "bypass_permissions" => {
Ok(PermissionMode::BypassPermissions)
}
"dontAsk" | "dont_ask" | "deny" | "interactive" => Ok(PermissionMode::DontAsk),
"auto" => Ok(PermissionMode::Auto),
"plan" => Ok(PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
}),
_ => Err(de::Error::unknown_variant(
s,
&[
"default",
"acceptEdits",
"bypassPermissions",
"auto",
"dontAsk",
"plan",
],
)),
}
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<PermissionMode, M::Error> {
let mut pre_plan_mode: Option<PermissionMode> = None;
let mut bypass_available: Option<bool> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"prePlanMode" | "pre_plan_mode" => {
if pre_plan_mode.is_some() {
return Err(de::Error::duplicate_field("pre_plan_mode"));
}
pre_plan_mode = Some(map.next_value()?);
}
"bypassAvailable" | "bypass_available" => {
if bypass_available.is_some() {
return Err(de::Error::duplicate_field("bypass_available"));
}
bypass_available = Some(map.next_value()?);
}
other => {
return Err(de::Error::unknown_field(
other,
&[
"pre_plan_mode",
"prePlanMode",
"bypass_available",
"bypassAvailable",
],
));
}
}
}
let pre_plan_mode =
pre_plan_mode.ok_or_else(|| de::Error::missing_field("pre_plan_mode"))?;
let bypass_available = bypass_available.unwrap_or(false);
Ok(PermissionMode::Plan {
pre_plan_mode: Box::new(pre_plan_mode),
bypass_available,
})
}
}
deserializer.deserialize_any(PermissionModeVisitor)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
pub enum RuleSource {
Session,
Project,
#[default]
User,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PermissionLayer {
pub source: RuleSource,
pub allow: Vec<String>,
pub deny: Vec<String>,
pub interactive: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct LayeredPermissionsConfig {
#[serde(default)]
pub mode: PermissionMode,
#[serde(default)]
pub layers: Vec<PermissionLayer>,
}
impl LayeredPermissionsConfig {
pub fn check_static(
&self,
tool_name: &str,
is_readonly: bool,
content: Option<&str>,
) -> Permission {
if !is_readonly {
if let Some(path) = extract_file_path_from_content(tool_name, content) {
for protected in PROTECTED_PATHS {
if path_contains_protected(&path, protected) {
return Permission::Denied(
DecisionReason::SafetyCheck { path: path.clone() },
format!(
"writing to `{path}` is protected and requires explicit confirmation"
),
);
}
}
}
}
if let PermissionMode::Plan {
bypass_available, ..
} = &self.mode
{
if !is_readonly && tool_name != "exit_plan_mode" && !bypass_available {
return Permission::Denied(
DecisionReason::Mode(self.mode.clone()),
"write tools are blocked in plan mode".to_string(),
);
}
}
if matches!(self.mode, PermissionMode::BypassPermissions) {
return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
}
if matches!(self.mode, PermissionMode::DontAsk) && self.any_interactive(tool_name) {
return Permission::Denied(
DecisionReason::Mode(self.mode.clone()),
format!("tool `{tool_name}` requires interaction but mode is dontAsk"),
);
}
if matches!(self.mode, PermissionMode::AcceptEdits) && !is_readonly {
return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
}
for pattern in self.all_deny() {
if matches_pattern(pattern, tool_name) {
return Permission::Denied(
DecisionReason::Rule {
source: RuleSource::User,
pattern: pattern.to_string(),
},
format!("tool `{tool_name}` matches deny pattern `{pattern}`"),
);
}
}
for pattern in self.all_allow() {
if matches_pattern(pattern, tool_name) {
return Permission::Allowed(DecisionReason::Rule {
source: RuleSource::User,
pattern: pattern.to_string(),
});
}
}
Permission::Unknown
}
pub fn is_plan_mode(&self, tool_name: &str) -> bool {
let _ = tool_name; matches!(self.mode, PermissionMode::Plan { .. })
}
pub fn is_interactive(&self, tool_name: &str) -> bool {
if matches!(
self.check_static(tool_name, false, None),
Permission::Denied(_, _)
) {
return false;
}
self.any_interactive(tool_name)
}
pub fn any_interactive(&self, tool_name: &str) -> bool {
self.all_interactive()
.any(|p| matches_pattern(p, tool_name))
}
pub fn all_deny(&self) -> impl Iterator<Item = &str> {
self.layers
.iter()
.flat_map(|l| l.deny.iter())
.map(|s| s.as_str())
}
pub fn all_allow(&self) -> impl Iterator<Item = &str> {
self.layers
.iter()
.flat_map(|l| l.allow.iter())
.map(|s| s.as_str())
}
pub fn all_interactive(&self) -> impl Iterator<Item = &str> {
self.layers
.iter()
.flat_map(|l| l.interactive.iter())
.map(|s| s.as_str())
}
pub fn add_session_rule(&mut self, behavior: RuleBehavior, pattern: String) {
let layer = self.session_layer_mut();
match behavior {
RuleBehavior::Allow => layer.allow.push(pattern),
RuleBehavior::Deny => layer.deny.push(pattern),
RuleBehavior::Interactive => layer.interactive.push(pattern),
}
}
pub fn remove_session_rule(&mut self, behavior: RuleBehavior, pattern: &str) {
let layer = self.session_layer_mut();
let list = match behavior {
RuleBehavior::Allow => &mut layer.allow,
RuleBehavior::Deny => &mut layer.deny,
RuleBehavior::Interactive => &mut layer.interactive,
};
list.retain(|r| !r.contains(pattern));
}
pub fn session_rules(&self) -> &PermissionLayer {
self.layers
.iter()
.find(|l| l.source == RuleSource::Session)
.expect("session layer always present after first mutation")
}
fn session_layer_mut(&mut self) -> &mut PermissionLayer {
if !self.layers.iter().any(|l| l.source == RuleSource::Session) {
self.layers.insert(
0,
PermissionLayer {
source: RuleSource::Session,
..Default::default()
},
);
}
self.layers
.iter_mut()
.find(|l| l.source == RuleSource::Session)
.expect("session layer just created")
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct OldPermissionsConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub interactive: Vec<String>,
#[serde(default)]
pub plan: Vec<String>,
#[serde(default)]
pub mode: PermissionMode,
}
impl From<OldPermissionsConfig> for LayeredPermissionsConfig {
fn from(old: OldPermissionsConfig) -> Self {
let mut layers = Vec::new();
if !old.allow.is_empty() || !old.deny.is_empty() || !old.interactive.is_empty() {
layers.push(PermissionLayer {
source: RuleSource::User,
allow: old.allow,
deny: old.deny,
interactive: old.interactive,
});
}
LayeredPermissionsConfig {
mode: old.mode,
layers,
}
}
}
pub type PermissionsConfig = LayeredPermissionsConfig;
pub type SharedPermissions = Arc<RwLock<LayeredPermissionsConfig>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleBehavior {
Allow,
Deny,
Interactive,
}
const PROTECTED_PATHS: &[&str] = &[
".git",
".recursive",
".ssh",
".gnupg",
".bashrc",
".zshrc",
".profile",
".bash_profile",
".bash_logout",
".env",
];
fn extract_file_path_from_content(tool_name: &str, content: Option<&str>) -> Option<String> {
let content = content?;
match tool_name {
"write_file" | "read_file" => Some(content.to_string()),
"apply_patch" => {
for line in content.lines() {
for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest.trim().to_string());
}
}
}
None
}
_ => None,
}
}
fn path_contains_protected(path: &str, protected: &str) -> bool {
std::path::Path::new(path)
.components()
.any(|c| c.as_os_str().to_string_lossy().as_ref() == protected)
}
fn matches_pattern(pattern: &str, name: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
if prefix.is_empty() {
return true;
}
name.starts_with(prefix)
} else {
name == pattern
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_mode_default_is_default() {
assert_eq!(PermissionMode::default(), PermissionMode::Default);
}
#[test]
fn test_permission_mode_deserialize_default() {
let mode: PermissionMode = serde_json::from_str("\"default\"").unwrap();
assert_eq!(mode, PermissionMode::Default);
}
#[test]
fn test_permission_mode_deserialize_accept_edits() {
let mode: PermissionMode = serde_json::from_str("\"acceptEdits\"").unwrap();
assert_eq!(mode, PermissionMode::AcceptEdits);
}
#[test]
fn test_permission_mode_deserialize_bypass() {
let mode: PermissionMode = serde_json::from_str("\"bypassPermissions\"").unwrap();
assert_eq!(mode, PermissionMode::BypassPermissions);
}
#[test]
fn test_permission_mode_deserialize_dont_ask() {
let mode: PermissionMode = serde_json::from_str("\"dontAsk\"").unwrap();
assert_eq!(mode, PermissionMode::DontAsk);
}
#[test]
fn test_permission_mode_deserialize_plan_string() {
let mode: PermissionMode = serde_json::from_str("\"plan\"").unwrap();
assert_eq!(
mode,
PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
}
);
}
#[test]
fn test_permission_mode_deserialize_plan_object() {
let json = r#"{"prePlanMode": "acceptEdits", "bypassAvailable": true}"#;
let mode: PermissionMode = serde_json::from_str(json).unwrap();
assert_eq!(
mode,
PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
bypass_available: true,
}
);
}
#[test]
fn test_permission_mode_deserialize_plan_object_default_bypass() {
let json = r#"{"prePlanMode": "default"}"#;
let mode: PermissionMode = serde_json::from_str(json).unwrap();
assert_eq!(
mode,
PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
}
);
}
#[test]
fn test_permission_mode_old_allow_is_default() {
let mode: PermissionMode = serde_json::from_str("\"allow\"").unwrap();
assert_eq!(mode, PermissionMode::Default);
}
#[test]
fn test_permission_mode_old_deny_is_dont_ask() {
let mode: PermissionMode = serde_json::from_str("\"deny\"").unwrap();
assert_eq!(mode, PermissionMode::DontAsk);
}
#[test]
fn test_permission_mode_old_interactive_is_dont_ask() {
let mode: PermissionMode = serde_json::from_str("\"interactive\"").unwrap();
assert_eq!(mode, PermissionMode::DontAsk);
}
#[test]
fn test_permission_mode_snake_case_accept_edits() {
let mode: PermissionMode = serde_json::from_str("\"accept_edits\"").unwrap();
assert_eq!(mode, PermissionMode::AcceptEdits);
}
#[test]
fn test_permission_mode_snake_case_bypass() {
let mode: PermissionMode = serde_json::from_str("\"bypass_permissions\"").unwrap();
assert_eq!(mode, PermissionMode::BypassPermissions);
}
#[test]
fn test_permission_mode_snake_case_dont_ask() {
let mode: PermissionMode = serde_json::from_str("\"dont_ask\"").unwrap();
assert_eq!(mode, PermissionMode::DontAsk);
}
#[test]
fn test_mode_serialize_default() {
let json = serde_json::to_string(&PermissionMode::Default).unwrap();
assert_eq!(json, "\"default\"");
}
#[test]
fn test_mode_serialize_accept_edits() {
let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
assert_eq!(json, "\"acceptEdits\"");
}
#[test]
fn test_mode_serialize_bypass() {
let json = serde_json::to_string(&PermissionMode::BypassPermissions).unwrap();
assert_eq!(json, "\"bypassPermissions\"");
}
#[test]
fn test_mode_serialize_dont_ask() {
let json = serde_json::to_string(&PermissionMode::DontAsk).unwrap();
assert_eq!(json, "\"dontAsk\"");
}
#[test]
fn test_mode_serialize_plan() {
let mode = PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
bypass_available: true,
};
let json = serde_json::to_string(&mode).unwrap();
assert!(json.contains("\"pre_plan_mode\":\"acceptEdits\""));
assert!(json.contains("\"bypass_available\":true"));
}
#[test]
fn test_plan_mode_blocks_write() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
},
..Default::default()
};
let result = config.check_static("write_file", false, None);
assert!(result.is_denied());
if let Permission::Denied(reason, msg) = result {
assert!(matches!(
reason,
DecisionReason::Mode(PermissionMode::Plan { .. })
));
assert!(msg.contains("plan mode"));
} else {
panic!("expected Denied");
}
}
#[test]
fn test_plan_mode_allows_exit() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
},
..Default::default()
};
let result = config.check_static("exit_plan_mode", false, None);
assert!(!result.is_denied());
assert!(matches!(result, Permission::Unknown));
}
#[test]
fn test_plan_mode_bypass_write_continues() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::BypassPermissions),
bypass_available: true,
},
..Default::default()
};
let result = config.check_static("write_file", false, None);
assert!(!result.is_denied());
assert!(matches!(result, Permission::Unknown));
}
#[test]
fn test_bypass_skips_deny_rules() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::BypassPermissions,
layers: vec![PermissionLayer {
source: RuleSource::User,
deny: vec!["run_shell".into()],
..Default::default()
}],
};
let result = config.check_static("run_shell", false, None);
assert!(result.is_allowed());
if let Permission::Allowed(reason) = result {
assert!(matches!(
reason,
DecisionReason::Mode(PermissionMode::BypassPermissions)
));
} else {
panic!("expected Allowed");
}
}
#[test]
fn test_dontask_converts_interactive() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::DontAsk,
layers: vec![PermissionLayer {
source: RuleSource::User,
interactive: vec!["run_shell".into()],
..Default::default()
}],
};
let result = config.check_static("run_shell", false, None);
assert!(result.is_denied());
if let Permission::Denied(reason, msg) = result {
assert!(matches!(
reason,
DecisionReason::Mode(PermissionMode::DontAsk)
));
assert!(msg.contains("dontAsk"));
} else {
panic!("expected Denied");
}
}
#[test]
fn test_accept_edits_allows_write() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::AcceptEdits,
..Default::default()
};
let result = config.check_static("write_file", false, None);
assert!(result.is_allowed());
if let Permission::Allowed(reason) = result {
assert!(matches!(
reason,
DecisionReason::Mode(PermissionMode::AcceptEdits)
));
} else {
panic!("expected Allowed");
}
}
#[test]
fn test_deny_rule_takes_effect() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![PermissionLayer {
source: RuleSource::User,
deny: vec!["run_shell".into()],
..Default::default()
}],
};
let result = config.check_static("run_shell", true, None);
assert!(result.is_denied());
if let Permission::Denied(reason, _msg) = result {
assert!(matches!(
reason,
DecisionReason::Rule {
source: RuleSource::User,
..
}
));
} else {
panic!("expected Denied");
}
}
#[test]
fn test_allow_rule_takes_effect() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["read_file".into()],
..Default::default()
}],
};
let result = config.check_static("read_file", true, None);
assert!(result.is_allowed());
if let Permission::Allowed(reason) = result {
assert!(matches!(
reason,
DecisionReason::Rule {
source: RuleSource::User,
..
}
));
} else {
panic!("expected Allowed");
}
}
#[test]
fn test_plan_mode_allows_readonly() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
},
..Default::default()
};
let result = config.check_static("read_file", true, None);
assert!(!result.is_denied());
}
#[test]
fn test_any_interactive_detects_match() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
interactive: vec!["run_*".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.any_interactive("run_shell"));
assert!(!config.any_interactive("read_file"));
}
#[test]
fn test_empty_config_passthrough() {
let config = LayeredPermissionsConfig::default();
let result = config.check_static("anything", false, None);
assert!(matches!(result, Permission::Unknown));
}
#[test]
fn test_single_layer_deny() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
deny: vec!["run_shell".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("run_shell", false, None).is_denied());
assert!(!config.check_static("read_file", false, None).is_denied());
}
#[test]
fn test_single_layer_allow() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["read_file".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("read_file", false, None).is_allowed());
assert!(matches!(
config.check_static("run_shell", false, None),
Permission::Unknown
));
}
#[test]
fn test_is_interactive_layer_match() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
interactive: vec!["run_shell".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.is_interactive("run_shell"));
assert!(!config.is_interactive("read_file"));
}
#[test]
fn test_deny_wins_across_layers() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
deny: vec!["run_shell".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
allow: vec!["run_shell".into()],
..Default::default()
},
],
..Default::default()
};
assert!(config.check_static("run_shell", false, None).is_denied());
}
#[test]
fn test_allow_union_across_layers() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
allow: vec!["write_file".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
allow: vec!["read_file".into()],
..Default::default()
},
],
..Default::default()
};
assert!(config.check_static("read_file", true, None).is_allowed());
assert!(config.check_static("write_file", false, None).is_allowed());
}
#[test]
fn test_interactive_union() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
interactive: vec!["write_file".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
interactive: vec!["run_shell".into()],
..Default::default()
},
],
..Default::default()
};
assert!(config.is_interactive("run_shell"));
assert!(config.is_interactive("write_file"));
assert!(!config.is_interactive("read_file"));
}
#[test]
fn test_session_layer_present() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Session,
..Default::default()
},
PermissionLayer {
source: RuleSource::Project,
allow: vec!["read_file".into()],
..Default::default()
},
],
..Default::default()
};
assert!(config.check_static("read_file", true, None).is_allowed());
}
#[test]
fn test_deny_overrides_allow_same_layer() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["run_shell".into()],
deny: vec!["run_shell".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("run_shell", false, None).is_denied());
}
#[test]
fn test_wildcard_matches_prefix() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["run_*".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("run_shell", false, None).is_allowed());
assert!(config
.check_static("run_background", false, None)
.is_allowed());
assert!(matches!(
config.check_static("read_file", false, None),
Permission::Unknown
));
}
#[test]
fn test_wildcard_exact() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["*".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("anything", false, None).is_allowed());
assert!(config.check_static("", false, None).is_allowed());
}
#[test]
fn test_deny_with_wildcard() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["*".into()],
deny: vec!["run_*".into()],
..Default::default()
}],
..Default::default()
};
assert!(config.check_static("read_file", true, None).is_allowed());
assert!(config.check_static("run_shell", false, None).is_denied());
}
#[test]
fn test_matches_pattern_exact() {
assert!(matches_pattern("run_shell", "run_shell"));
assert!(!matches_pattern("run_shell", "run_background"));
}
#[test]
fn test_matches_pattern_wildcard() {
assert!(matches_pattern("run_*", "run_shell"));
assert!(matches_pattern("run_*", "run_background"));
assert!(!matches_pattern("run_*", "read_file"));
}
#[test]
fn test_matches_pattern_star_only() {
assert!(matches_pattern("*", "anything"));
assert!(matches_pattern("*", ""));
}
#[test]
fn test_is_plan_mode_when_mode_is_plan() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::Plan {
pre_plan_mode: Box::new(PermissionMode::Default),
bypass_available: false,
},
..Default::default()
};
assert!(config.is_plan_mode("anything"));
}
#[test]
fn test_is_plan_mode_false_when_default() {
let config = LayeredPermissionsConfig::default();
assert!(!config.is_plan_mode("anything"));
}
#[test]
fn test_old_config_converts_to_layered() {
let old = OldPermissionsConfig {
allow: vec!["read_file".into()],
deny: vec!["run_shell".into()],
interactive: vec!["write_file".into()],
plan: vec![],
mode: PermissionMode::Default,
};
let layered: LayeredPermissionsConfig = old.into();
assert_eq!(layered.layers.len(), 1);
assert_eq!(layered.layers[0].source, RuleSource::User);
assert_eq!(layered.layers[0].allow, vec!["read_file"]);
assert_eq!(layered.layers[0].deny, vec!["run_shell"]);
assert_eq!(layered.layers[0].interactive, vec!["write_file"]);
}
#[test]
fn test_old_config_empty_allow_produces_no_layer() {
let old = OldPermissionsConfig::default();
let layered: LayeredPermissionsConfig = old.into();
assert_eq!(layered.layers.len(), 0);
}
#[test]
fn test_all_deny_union() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
deny: vec!["write_file".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
deny: vec!["run_shell".into()],
..Default::default()
},
],
..Default::default()
};
let denies: Vec<&str> = config.all_deny().collect();
assert!(denies.contains(&"write_file"));
assert!(denies.contains(&"run_shell"));
assert_eq!(denies.len(), 2);
}
#[test]
fn test_all_allow_union() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
allow: vec!["read_file".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
allow: vec!["write_file".into()],
..Default::default()
},
],
..Default::default()
};
let allows: Vec<&str> = config.all_allow().collect();
assert!(allows.contains(&"read_file"));
assert!(allows.contains(&"write_file"));
assert_eq!(allows.len(), 2);
}
#[test]
fn test_all_interactive_union() {
let config = LayeredPermissionsConfig {
layers: vec![
PermissionLayer {
source: RuleSource::Project,
interactive: vec!["run_shell".into()],
..Default::default()
},
PermissionLayer {
source: RuleSource::User,
interactive: vec!["write_file".into()],
..Default::default()
},
],
..Default::default()
};
let interactives: Vec<&str> = config.all_interactive().collect();
assert!(interactives.contains(&"run_shell"));
assert!(interactives.contains(&"write_file"));
assert_eq!(interactives.len(), 2);
}
#[test]
fn permission_is_allowed_helper() {
let reason = DecisionReason::Mode(PermissionMode::Default);
assert!(Permission::Allowed(reason.clone()).is_allowed());
assert!(!Permission::Allowed(reason).is_denied());
}
#[test]
fn permission_is_denied_helper() {
let reason = DecisionReason::Mode(PermissionMode::DontAsk);
assert!(Permission::Denied(reason.clone(), "blocked".into()).is_denied());
assert!(!Permission::Denied(reason, "blocked".into()).is_allowed());
}
#[test]
fn passthrough_is_neither() {
assert!(!Permission::Unknown.is_allowed());
assert!(!Permission::Unknown.is_denied());
}
#[test]
fn decision_reason_rule_debug() {
let reason = DecisionReason::Rule {
source: RuleSource::User,
pattern: "run_shell".into(),
};
let debug = format!("{:?}", reason);
assert!(debug.contains("Rule"));
assert!(debug.contains("User"));
assert!(debug.contains("run_shell"));
}
#[test]
fn decision_reason_mode_debug() {
let reason = DecisionReason::Mode(PermissionMode::DontAsk);
let debug = format!("{:?}", reason);
assert!(debug.contains("DontAsk"));
}
#[test]
fn decision_reason_hook_debug() {
let reason = DecisionReason::Hook {
name: "my_hook".into(),
};
let debug = format!("{:?}", reason);
assert!(debug.contains("Hook"));
assert!(debug.contains("my_hook"));
}
#[test]
fn decision_reason_safety_check_debug() {
let reason = DecisionReason::SafetyCheck {
path: "/etc/passwd".into(),
};
let debug = format!("{:?}", reason);
assert!(debug.contains("SafetyCheck"));
assert!(debug.contains("/etc/passwd"));
}
#[test]
fn check_static_deny_returns_rule_reason() {
let config = LayeredPermissionsConfig {
layers: vec![PermissionLayer {
source: RuleSource::User,
deny: vec!["run_shell".into()],
..Default::default()
}],
..Default::default()
};
let result = config.check_static("run_shell", false, None);
assert!(result.is_denied());
if let Permission::Denied(reason, msg) = result {
assert!(matches!(
reason,
DecisionReason::Rule {
source: RuleSource::User,
..
}
));
assert!(msg.contains("run_shell"));
} else {
panic!("expected Denied");
}
}
#[test]
fn protected_path_denied_in_default_mode() {
let config = LayeredPermissionsConfig::default();
let result = config.check_static("write_file", false, Some(".git/config"));
assert!(result.is_denied());
if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
assert_eq!(path, ".git/config");
assert!(msg.contains("protected"));
} else {
panic!("expected Denied(SafetyCheck), got {result:?}");
}
}
#[test]
fn protected_path_denied_in_bypass_mode() {
let config = LayeredPermissionsConfig {
mode: PermissionMode::BypassPermissions,
..Default::default()
};
let result = config.check_static("write_file", false, Some(".ssh/id_rsa"));
assert!(result.is_denied());
if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
assert_eq!(path, ".ssh/id_rsa");
assert!(msg.contains("protected"));
} else {
panic!("expected Denied(SafetyCheck), got {result:?}");
}
}
#[test]
fn protected_path_readonly_allowed() {
let config = LayeredPermissionsConfig::default();
let result = config.check_static("read_file", true, Some(".git/config"));
assert!(matches!(result, Permission::Unknown));
}
#[test]
fn non_protected_path_not_blocked() {
let config = LayeredPermissionsConfig::default();
let result = config.check_static("write_file", false, Some("src/main.rs"));
assert!(matches!(result, Permission::Unknown));
}
#[test]
fn nested_protected_path_detected() {
let config = LayeredPermissionsConfig::default();
let result =
config.check_static("write_file", false, Some("some/dir/.recursive/config.toml"));
assert!(result.is_denied());
if let Permission::Denied(DecisionReason::SafetyCheck { path }, _) = &result {
assert_eq!(path, "some/dir/.recursive/config.toml");
} else {
panic!("expected Denied(SafetyCheck), got {result:?}");
}
}
#[test]
fn path_contains_protected_fn() {
assert!(path_contains_protected(".git/config", ".git"));
assert!(path_contains_protected("a/.git/config", ".git"));
assert!(path_contains_protected(
"some/dir/.recursive/config.toml",
".recursive"
));
assert!(!path_contains_protected("legitimate.git_info/x", ".git"));
assert!(path_contains_protected(".env", ".env"));
assert!(!path_contains_protected(".env.example", ".env"));
assert!(!path_contains_protected("", ".git"));
assert!(path_contains_protected(".ssh/authorized_keys", ".ssh"));
}
#[test]
fn add_session_allow_rule() {
let mut config = LayeredPermissionsConfig::default();
config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
let session = config.session_rules();
assert_eq!(session.source, RuleSource::Session);
assert!(session.allow.contains(&"run_shell".to_string()));
assert!(session.deny.is_empty());
assert!(session.interactive.is_empty());
}
#[test]
fn add_session_deny_rule() {
let mut config = LayeredPermissionsConfig::default();
config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
let session = config.session_rules();
assert!(session.deny.contains(&"run_shell".to_string()));
}
#[test]
fn add_session_interactive_rule() {
let mut config = LayeredPermissionsConfig::default();
config.add_session_rule(RuleBehavior::Interactive, "run_shell".into());
let session = config.session_rules();
assert!(session.interactive.contains(&"run_shell".to_string()));
}
#[test]
fn remove_session_rule() {
let mut config = LayeredPermissionsConfig::default();
config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
config.add_session_rule(RuleBehavior::Allow, "write_file".into());
assert_eq!(config.session_rules().allow.len(), 2);
config.remove_session_rule(RuleBehavior::Allow, "run_shell");
assert_eq!(config.session_rules().allow.len(), 1);
assert!(config
.session_rules()
.allow
.contains(&"write_file".to_string()));
}
#[test]
fn session_layer_created_on_first_use() {
let mut config = LayeredPermissionsConfig::default();
assert!(config.layers.is_empty());
config.add_session_rule(RuleBehavior::Allow, "read_file".into());
assert_eq!(config.layers.len(), 1);
assert_eq!(config.layers[0].source, RuleSource::Session);
}
#[test]
fn session_deny_takes_precedence_over_user_allow() {
let mut config = LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![PermissionLayer {
source: RuleSource::User,
allow: vec!["run_shell".into()],
..Default::default()
}],
};
config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
let result = config.check_static("run_shell", false, None);
assert!(result.is_denied());
}
#[test]
fn shared_permissions_arc_clone_sees_mutation() {
let config = LayeredPermissionsConfig::default();
let shared = Arc::new(RwLock::new(config));
let clone = Arc::clone(&shared);
{
let mut guard = shared.try_write().unwrap();
guard.add_session_rule(RuleBehavior::Allow, "run_shell".into());
}
let guard = clone.try_read().unwrap();
assert!(guard
.session_rules()
.allow
.contains(&"run_shell".to_string()));
}
}