use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use regex::Regex;
use super::{ResolvedPolicy, parse_file, resolve};
use crate::core::input_filters::{FilterAction, FilterConfig};
const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
pub struct ActivePolicy {
pub resolved: ResolvedPolicy,
pub redaction: Vec<(String, Regex)>,
pub filters: FilterConfig,
pub egress: crate::core::egress::EgressConfig,
}
impl ActivePolicy {
pub(crate) fn from_resolved(resolved: ResolvedPolicy) -> Self {
let redaction = resolved
.redaction
.iter()
.filter_map(|(label, pat)| Regex::new(pat).ok().map(|re| (label.clone(), re)))
.collect();
let filters = FilterConfig::new(
filter_action(resolved.filters.pii.as_ref()),
filter_action(resolved.filters.classification.as_ref()),
filter_action(resolved.filters.injection.as_ref()),
&resolved.filters.blocked_labels,
);
let egress = crate::core::egress::EgressConfig::new(
&resolved.egress.forbidden_patterns,
resolved.egress.block_secrets.unwrap_or(false),
resolved.egress.max_writes_per_min,
);
Self {
resolved,
redaction,
filters,
egress,
}
}
pub(crate) fn deny_all() -> Self {
Self::from_resolved(ResolvedPolicy {
name: "invalid-local-policy".into(),
version: "0.0.0".into(),
description: "deny all because the local policy pack is invalid".into(),
chain: Vec::new(),
default_read_mode: None,
allow_tools: Some(Vec::new()),
deny_tools: Vec::new(),
max_context_tokens: None,
audit_retention_days: None,
redaction: std::collections::BTreeMap::new(),
filters: crate::core::policy::FilterRules::default(),
egress: crate::core::policy::EgressRules::default(),
routing: crate::core::policy::RoutingPolicyRules::default(),
budgets: crate::core::policy::BudgetRules::default(),
})
}
#[must_use]
pub fn tool_allowed(&self, tool: &str) -> bool {
if self.resolved.deny_tools.iter().any(|t| t == tool) {
return false;
}
match &self.resolved.allow_tools {
Some(allow) => allow.iter().any(|t| t == tool),
None => true,
}
}
}
fn filter_action(opt: Option<&String>) -> FilterAction {
opt.map(String::as_str)
.and_then(FilterAction::parse)
.unwrap_or(FilterAction::Off)
}
struct Cache {
loaded: bool,
active: Option<Arc<ActivePolicy>>,
}
fn cache() -> &'static RwLock<Cache> {
static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
CACHE.get_or_init(|| {
RwLock::new(Cache {
loaded: false,
active: None,
})
})
}
fn load_from_disk() -> Option<Arc<ActivePolicy>> {
let local = match load_local_pack() {
Ok(local) => local,
Err(msg) => {
tracing::error!(
"policy: failed to load invalid {PROJECT_PACK_PATH} ({msg}); enforcing deny-all"
);
return Some(Arc::new(ActivePolicy::deny_all()));
}
};
let effective = match crate::core::policy::org::active_resolved() {
Some(org) => crate::core::policy::floor::merge_floor(&org, local.as_ref()),
None => local?,
};
Some(Arc::new(ActivePolicy::from_resolved(effective)))
}
fn load_local_pack() -> Result<Option<ResolvedPolicy>, String> {
let path = PathBuf::from(PROJECT_PACK_PATH);
if !path.exists() {
return Ok(None);
}
match parse_file(&path).and_then(|p| resolve(&p)) {
Ok(resolved) => Ok(Some(resolved)),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
#[must_use]
pub fn active() -> Option<Arc<ActivePolicy>> {
{
let r = cache().read().expect("policy cache poisoned");
if r.loaded {
return r.active.clone();
}
}
let loaded = load_from_disk();
let mut w = cache().write().expect("policy cache poisoned");
if !w.loaded {
w.loaded = true;
w.active = loaded;
}
w.active.clone()
}
#[must_use]
pub fn is_active() -> bool {
active().is_some()
}
pub fn reload() {
let loaded = load_from_disk();
let mut w = cache().write().expect("policy cache poisoned");
w.loaded = true;
w.active = loaded;
}
#[cfg(test)]
pub fn set_active_for_test(resolved: Option<ResolvedPolicy>) {
let mut w = cache().write().expect("policy cache poisoned");
w.loaded = true;
w.active = resolved.map(|r| Arc::new(ActivePolicy::from_resolved(r)));
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::fs;
struct CurrentDirGuard {
previous: std::path::PathBuf,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CurrentDirGuard {
fn enter(dir: &std::path::Path) -> Self {
static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
let lock = LOCK.get_or_init(|| std::sync::Mutex::new(()));
let guard = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = std::env::current_dir().expect("read current directory");
std::env::set_current_dir(dir).expect("enter temporary directory");
Self {
previous,
_lock: guard,
}
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
std::env::set_current_dir(&self.previous).expect("restore current directory");
}
}
fn rp(allow: Option<Vec<&str>>, deny: Vec<&str>, redaction: &[(&str, &str)]) -> ResolvedPolicy {
ResolvedPolicy {
name: "test".into(),
version: "1.0.0".into(),
description: "t".into(),
chain: vec![],
default_read_mode: None,
allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
deny_tools: deny.into_iter().map(String::from).collect(),
max_context_tokens: None,
audit_retention_days: None,
redaction: redaction
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<BTreeMap<_, _>>(),
filters: crate::core::policy::FilterRules::default(),
egress: crate::core::policy::EgressRules::default(),
routing: crate::core::policy::RoutingPolicyRules::default(),
budgets: crate::core::policy::BudgetRules::default(),
}
}
#[test]
fn deny_list_blocks_listed_tool() {
let p = ActivePolicy::from_resolved(rp(None, vec!["ctx_url_read"], &[]));
assert!(!p.tool_allowed("ctx_url_read"));
assert!(p.tool_allowed("ctx_read"));
}
#[test]
fn allow_list_is_exclusive() {
let p = ActivePolicy::from_resolved(rp(Some(vec!["ctx_read"]), vec![], &[]));
assert!(p.tool_allowed("ctx_read"));
assert!(!p.tool_allowed("ctx_shell"));
}
#[test]
fn compiles_redaction_patterns() {
let p = ActivePolicy::from_resolved(rp(None, vec![], &[("emp", r"EMP-\d{4}")]));
assert_eq!(p.redaction.len(), 1);
assert_eq!(p.redaction[0].0, "emp");
}
#[test]
fn load_from_disk_fails_closed_on_invalid_pack() {
let temp = tempfile::tempdir().expect("create temporary directory");
let policy_dir = temp.path().join(".lean-ctx");
fs::create_dir(&policy_dir).expect("create policy directory");
fs::write(policy_dir.join("policy.toml"), "[policy\n").expect("write invalid policy");
let _cwd = CurrentDirGuard::enter(temp.path());
let policy = load_from_disk().expect("invalid pack activates deny-all");
assert!(!policy.tool_allowed("ctx_read"));
assert!(!policy.tool_allowed("ctx_shell"));
}
}