use async_trait::async_trait;
use glob::Pattern;
use serde::{Deserialize, Serialize};
use super::path_normalization::{
normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
normalize_path_for_policy_with_fs,
};
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathAllowlistConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub file_access_allow: Vec<String>,
#[serde(default)]
pub file_write_allow: Vec<String>,
#[serde(default)]
pub patch_allow: Vec<String>,
}
fn default_enabled() -> bool {
true
}
impl PathAllowlistConfig {
pub fn merge_with(&self, child: &Self) -> Self {
Self {
enabled: child.enabled,
file_access_allow: if child.file_access_allow.is_empty() {
self.file_access_allow.clone()
} else {
child.file_access_allow.clone()
},
file_write_allow: if child.file_write_allow.is_empty() {
self.file_write_allow.clone()
} else {
child.file_write_allow.clone()
},
patch_allow: if child.patch_allow.is_empty() {
self.patch_allow.clone()
} else {
child.patch_allow.clone()
},
}
}
}
pub struct PathAllowlistGuard {
name: String,
enabled: bool,
file_access_allow: Vec<Pattern>,
file_write_allow: Vec<Pattern>,
patch_allow: Vec<Pattern>,
}
impl PathAllowlistGuard {
pub fn with_config(config: PathAllowlistConfig) -> Self {
let file_access_allow = config
.file_access_allow
.iter()
.filter_map(|p| Pattern::new(p).ok())
.collect::<Vec<_>>();
let file_write_allow = config
.file_write_allow
.iter()
.filter_map(|p| Pattern::new(p).ok())
.collect::<Vec<_>>();
let patch_allow = if config.patch_allow.is_empty() {
file_write_allow.clone()
} else {
config
.patch_allow
.iter()
.filter_map(|p| Pattern::new(p).ok())
.collect::<Vec<_>>()
};
Self {
name: "path_allowlist".to_string(),
enabled: config.enabled,
file_access_allow,
file_write_allow,
patch_allow,
}
}
fn matches_any(patterns: &[Pattern], path: &str) -> bool {
patterns.iter().any(|p| p.matches(path))
}
fn matches_allowlist(&self, patterns: &[Pattern], path: &str) -> bool {
let lexical_path = normalize_path_for_policy(path);
let resolved_path = normalize_path_for_policy_with_fs(path);
let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
let resolved_differs_from_lexical_target = lexical_abs_path
.as_deref()
.map(|abs| abs != resolved_path.as_str())
.unwrap_or(resolved_path != lexical_path);
if resolved_differs_from_lexical_target {
return Self::matches_any(patterns, &resolved_path);
}
Self::matches_any(patterns, &lexical_path)
|| Self::matches_any(patterns, &resolved_path)
|| lexical_abs_path
.as_deref()
.map(|abs| Self::matches_any(patterns, abs))
.unwrap_or(false)
}
pub fn is_file_access_allowed(&self, path: &str) -> bool {
if !self.enabled {
return true;
}
self.matches_allowlist(&self.file_access_allow, path)
}
pub fn is_file_write_allowed(&self, path: &str) -> bool {
if !self.enabled {
return true;
}
self.matches_allowlist(&self.file_write_allow, path)
}
pub fn is_patch_allowed(&self, path: &str) -> bool {
if !self.enabled {
return true;
}
self.matches_allowlist(&self.patch_allow, path)
}
}
impl Default for PathAllowlistGuard {
fn default() -> Self {
Self::with_config(PathAllowlistConfig {
enabled: false,
file_access_allow: Vec::new(),
file_write_allow: Vec::new(),
patch_allow: Vec::new(),
})
}
}
#[async_trait]
impl Guard for PathAllowlistGuard {
fn name(&self) -> &str {
&self.name
}
fn handles(&self, action: &GuardAction<'_>) -> bool {
if !self.enabled {
return false;
}
matches!(
action,
GuardAction::FileAccess(_) | GuardAction::FileWrite(_, _) | GuardAction::Patch(_, _)
)
}
async fn check(&self, action: &GuardAction<'_>, _context: &GuardContext) -> GuardResult {
if !self.enabled {
return GuardResult::allow(&self.name);
}
let (path, allowed) = match action {
GuardAction::FileAccess(path) => (*path, self.is_file_access_allowed(path)),
GuardAction::FileWrite(path, _) => (*path, self.is_file_write_allowed(path)),
GuardAction::Patch(path, _) => (*path, self.is_patch_allowed(path)),
_ => return GuardResult::allow(&self.name),
};
if allowed {
GuardResult::allow(&self.name)
} else {
GuardResult::block(
&self.name,
Severity::Error,
format!("Path not in allowlist: {}", path),
)
.with_details(serde_json::json!({
"path": path,
"reason": "path_not_allowlisted",
}))
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn test_allows_paths_inside_scope() {
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec!["**/repo/**".to_string()],
file_write_allow: vec!["**/repo/**".to_string()],
patch_allow: vec![],
});
assert!(guard.is_file_access_allowed("/tmp/repo/src/main.rs"));
assert!(guard.is_file_write_allowed("/tmp/repo/src/main.rs"));
assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
}
#[test]
fn test_denies_paths_outside_scope() {
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec!["**/repo/**".to_string()],
file_write_allow: vec!["**/repo/**".to_string()],
patch_allow: vec![],
});
assert!(!guard.is_file_access_allowed("/etc/passwd"));
assert!(!guard.is_file_write_allowed("/etc/passwd"));
assert!(!guard.is_patch_allowed("/etc/passwd"));
}
#[test]
fn test_patch_allow_falls_back_to_file_write_allow() {
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec![],
file_write_allow: vec!["**/repo/**".to_string()],
patch_allow: vec![],
});
assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs"));
}
#[test]
fn relative_allowlist_globs_match_when_target_is_unchanged() {
let rel_dir = format!("target/path-allowlist-rel-{}", uuid::Uuid::new_v4());
std::fs::create_dir_all(&rel_dir).expect("create rel dir");
let rel_file = format!("{rel_dir}/main.rs");
std::fs::write(&rel_file, "fn main() {}\n").expect("write file");
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec![format!("{rel_dir}/**")],
file_write_allow: vec![format!("{rel_dir}/**")],
patch_allow: vec![format!("{rel_dir}/**")],
});
assert!(
guard.is_file_access_allowed(&rel_file),
"relative file-access allowlist glob should match"
);
assert!(
guard.is_file_write_allowed(&rel_file),
"relative file-write allowlist glob should match"
);
assert!(
guard.is_patch_allowed(&rel_file),
"relative patch allowlist glob should match"
);
let _ = std::fs::remove_dir_all(&rel_dir);
}
#[cfg(unix)]
#[test]
fn symlink_escape_outside_allowlist_is_denied() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!("path-allowlist-{}", uuid::Uuid::new_v4()));
let allowed_dir = root.join("allowed");
let outside_dir = root.join("outside");
std::fs::create_dir_all(&allowed_dir).expect("create allowed dir");
std::fs::create_dir_all(&outside_dir).expect("create outside dir");
let target = outside_dir.join("secret.txt");
std::fs::write(&target, "sensitive").expect("write target");
let link = allowed_dir.join("link.txt");
symlink(&target, &link).expect("create symlink");
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec![format!("{}/allowed/**", root.display())],
file_write_allow: vec![format!("{}/allowed/**", root.display())],
patch_allow: vec![],
});
assert!(
!guard.is_file_access_allowed(link.to_str().expect("utf-8 path")),
"symlink target outside allowlist must be denied"
);
let _ = std::fs::remove_dir_all(&root);
}
}