use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AccessLevel {
None,
#[default]
Read,
Write,
Admin,
Owner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Permission {
pub action: PermissionAction,
pub path_pattern: Option<String>,
pub effect: PermissionEffect,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PermissionAction {
Read,
Write,
Execute,
Tool(String),
Admin,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PermissionEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectAccess {
pub default_level: AccessLevel,
pub deny_rules: Vec<Permission>,
pub protected_paths: Vec<String>,
pub restricted_tools: Vec<String>,
}
impl ProjectAccess {
pub fn new() -> Self {
ProjectAccess {
default_level: AccessLevel::Write,
deny_rules: Vec::new(),
protected_paths: vec![
".env".to_string(),
".env.*".to_string(),
"**/secrets/**".to_string(),
"**/*.pem".to_string(),
"**/*.key".to_string(),
"**/credentials*".to_string(),
],
restricted_tools: vec![
"execute_command".to_string(), ],
}
}
pub fn can_access_path(&self, path: &Path, level: &AccessLevel) -> bool {
if *level == AccessLevel::Owner {
return true;
}
let path_str = path.to_string_lossy();
for pattern in &self.protected_paths {
if Self::matches_glob(pattern, &path_str) {
return false;
}
}
for rule in &self.deny_rules {
if rule.effect == PermissionEffect::Deny {
if let Some(ref pattern) = rule.path_pattern {
if Self::matches_glob(pattern, &path_str) {
return false;
}
}
}
}
true
}
pub fn can_use_tool(&self, tool: &str, level: &AccessLevel) -> bool {
if *level >= AccessLevel::Admin {
return true;
}
!self.restricted_tools.contains(&tool.to_string())
}
fn matches_glob(pattern: &str, path: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
let prefix_ok = prefix.is_empty() || path.starts_with(prefix);
let suffix_ok = if suffix.is_empty() {
true
} else if suffix.starts_with('*') {
let ext = suffix.trim_start_matches('*');
path.ends_with(ext)
} else {
path.ends_with(suffix)
};
return prefix_ok && suffix_ok;
}
}
if pattern.contains('*') && !pattern.contains("**") {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
return path.starts_with(parts[0]) && path.ends_with(parts[1]);
}
}
pattern == path
}
pub fn deny(&mut self, action: PermissionAction, path_pattern: Option<&str>) {
self.deny_rules.push(Permission {
action,
path_pattern: path_pattern.map(String::from),
effect: PermissionEffect::Deny,
});
}
pub fn protect_path(&mut self, pattern: &str) {
self.protected_paths.push(pattern.to_string());
}
pub fn restrict_tool(&mut self, tool: &str) {
if !self.restricted_tools.contains(&tool.to_string()) {
self.restricted_tools.push(tool.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_access() {
let access = ProjectAccess::new();
assert_eq!(access.default_level, AccessLevel::Write);
}
#[test]
fn test_protected_paths() {
let access = ProjectAccess::new();
let level = AccessLevel::Write;
assert!(!access.can_access_path(Path::new(".env"), &level));
assert!(!access.can_access_path(Path::new(".env.production"), &level));
assert!(access.can_access_path(Path::new("src/main.rs"), &level));
}
#[test]
fn test_owner_bypasses_all() {
let access = ProjectAccess::new();
let level = AccessLevel::Owner;
assert!(access.can_access_path(Path::new(".env"), &level));
assert!(access.can_access_path(Path::new("secrets/api.key"), &level));
}
#[test]
fn test_glob_matching() {
assert!(ProjectAccess::matches_glob("*.rs", "main.rs"));
assert!(ProjectAccess::matches_glob("**/*.key", "secrets/api.key"));
assert!(ProjectAccess::matches_glob(".env.*", ".env.production"));
assert!(!ProjectAccess::matches_glob("*.rs", "main.py"));
}
}