mod glob;
pub use glob::{glob_matches, glob_matches_path};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionAction {
Allow,
Deny,
Prompt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub pattern: String,
pub action: PermissionAction,
pub priority: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory_scope: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionRuleSet {
rules: Vec<PermissionRule>,
}
pub fn is_sensitive_file(path: &str) -> bool {
let filename = path.rsplit('/').next().unwrap_or(path);
let lower = filename.to_lowercase();
if lower == ".env" {
return true;
}
if let Some(suffix) = lower.strip_prefix(".env.") {
return !matches!(suffix, "example" | "sample" | "template");
}
matches!(
lower.as_str(),
"credentials.json"
| "service-account.json"
| "id_rsa"
| "id_ed25519"
| ".npmrc"
| ".pypirc"
)
}
impl PermissionRuleSet {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn with_defaults() -> Self {
let mut rs = Self::new();
rs.add_rule(PermissionRule {
pattern: "read_file:*.env".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "read_file:*.env.*".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "read_file:*.env.example".into(),
action: PermissionAction::Allow,
priority: 1001,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "read_file:*.env.sample".into(),
action: PermissionAction::Allow,
priority: 1001,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "read_file:*.env.template".into(),
action: PermissionAction::Allow,
priority: 1001,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "edit_file:*.env".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "edit_file:*.env.*".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "write_file:*.env".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs.add_rule(PermissionRule {
pattern: "write_file:*.env.*".into(),
action: PermissionAction::Deny,
priority: 1000,
directory_scope: None,
});
rs
}
pub fn add_rule(&mut self, rule: PermissionRule) {
self.rules.push(rule);
}
pub fn remove_rules<F: Fn(&PermissionRule) -> bool>(&mut self, predicate: F) {
self.rules.retain(|r| !predicate(r));
}
pub fn rules(&self) -> &[PermissionRule] {
&self.rules
}
pub fn evaluate(
&self,
tool_name: &str,
args: &str,
working_dir: Option<&Path>,
) -> Option<PermissionAction> {
let input = format!("{tool_name}:{args}");
let mut sorted: Vec<&PermissionRule> = self.rules.iter().collect();
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
for rule in sorted {
if let Some(ref scope) = rule.directory_scope {
match working_dir {
Some(dir) => {
if !glob_matches_path(scope, &dir.to_string_lossy()) {
continue;
}
}
None => continue, }
}
if glob_matches(&rule.pattern, &input) {
return Some(rule.action.clone());
}
}
None
}
pub fn evaluate_simple(&self, tool_name: &str, args: &str) -> Option<PermissionAction> {
self.evaluate(tool_name, args, None)
}
}
#[cfg(test)]
mod tests;