#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum Permission {
#[default]
ReadOnly,
ReadWrite,
ReadWriteWithApproval,
}
#[derive(Clone, Debug, Default)]
pub struct McpPolicy {
pub excluded_folders: Vec<String>,
pub permission: Permission,
pub require_grounding: bool,
}
impl McpPolicy {
fn normalize(s: &str) -> String {
let s = s.replace('\\', "/");
let mut t = s.trim();
if let Some(inner) = t.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
t = inner;
}
t.trim_start_matches('/').trim_end_matches('/').to_string()
}
pub fn is_hidden(&self, rel_path: &str) -> bool {
let norm = Self::normalize(rel_path);
if norm.is_empty() {
return false;
}
self.excluded_folders.iter().any(|f| {
let f = Self::normalize(f);
!f.is_empty() && (norm == f || norm.starts_with(&format!("{f}/")))
})
}
pub fn text_references_excluded(&self, text: &str) -> bool {
let norm = text.replace('\\', "/");
self.excluded_folders.iter().any(|f| {
let f = f.replace('\\', "/");
let f = f.trim_start_matches('/').trim_end_matches('/');
!f.is_empty() && norm.contains(f)
})
}
pub fn allows_mutation(&self) -> bool {
matches!(
self.permission,
Permission::ReadWrite | Permission::ReadWriteWithApproval
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn excluded_folder_paths_are_hidden() {
let pol = McpPolicy {
excluded_folders: vec!["Personal/Finanzas".into()],
permission: Permission::ReadOnly,
require_grounding: false,
};
assert!(pol.is_hidden("Personal/Finanzas/Presupuesto.md"));
assert!(pol.is_hidden("Personal/Finanzas"));
assert!(pol.is_hidden("Personal\\Finanzas\\x.md"));
assert!(!pol.is_hidden("Proyectos/Roadmap.md"));
assert!(!pol.is_hidden("Personal/Finanzas2/x.md"));
assert!(pol.is_hidden("<Personal/Finanzas/secret.md>"));
assert!(!pol.is_hidden("<Public/open.md>"));
assert!(!pol.is_hidden(""));
assert!(!pol.is_hidden("<>"));
}
#[test]
fn pattern_side_is_normalized() {
let pol = McpPolicy {
excluded_folders: vec!["Personal\\Finanzas".into()],
..Default::default()
};
assert!(pol.is_hidden("Personal/Finanzas/x.md"));
let pol = McpPolicy {
excluded_folders: vec!["/Personal/Finanzas/".into()],
..Default::default()
};
assert!(pol.is_hidden("Personal/Finanzas/x.md"));
assert!(!pol.is_hidden("Personal/Finanzas2/x.md"));
}
#[test]
fn text_references_excluded_scrubs_inlined_paths() {
let pol = McpPolicy {
excluded_folders: vec!["Personal/Finanzas".into()],
..Default::default()
};
assert!(pol
.text_references_excluded("Personal/Finanzas/secret.md -> links_to -> Public/open.md"));
assert!(pol.text_references_excluded("Personal\\Finanzas\\secret.md -> tagged -> money"));
assert!(!pol.text_references_excluded("Public/open.md -> links_to -> Proyectos/Roadmap.md"));
assert!(!McpPolicy::default().text_references_excluded("Personal/Finanzas/x.md"));
}
#[test]
fn read_only_forbids_mutations() {
assert!(!McpPolicy::default().allows_mutation());
let rw = McpPolicy {
permission: Permission::ReadWrite,
..Default::default()
};
assert!(rw.allows_mutation());
}
}