1use std::path::PathBuf;
19use std::sync::{Arc, OnceLock, RwLock};
20
21use regex::Regex;
22
23use super::{ResolvedPolicy, parse_file, resolve};
24use crate::core::input_filters::{FilterAction, FilterConfig};
25
26const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
29
30pub struct ActivePolicy {
33 pub resolved: ResolvedPolicy,
34 pub redaction: Vec<(String, Regex)>,
38 pub filters: FilterConfig,
41 pub egress: crate::core::egress::EgressConfig,
44}
45
46impl ActivePolicy {
47 pub(crate) fn from_resolved(resolved: ResolvedPolicy) -> Self {
48 let redaction = resolved
49 .redaction
50 .iter()
51 .filter_map(|(label, pat)| Regex::new(pat).ok().map(|re| (label.clone(), re)))
52 .collect();
53 let filters = FilterConfig::new(
54 filter_action(resolved.filters.pii.as_ref()),
55 filter_action(resolved.filters.classification.as_ref()),
56 filter_action(resolved.filters.injection.as_ref()),
57 &resolved.filters.blocked_labels,
58 );
59 let egress = crate::core::egress::EgressConfig::new(
60 &resolved.egress.forbidden_patterns,
61 resolved.egress.block_secrets.unwrap_or(false),
62 resolved.egress.max_writes_per_min,
63 );
64 Self {
65 resolved,
66 redaction,
67 filters,
68 egress,
69 }
70 }
71
72 pub(crate) fn deny_all() -> Self {
74 Self::from_resolved(ResolvedPolicy {
75 name: "invalid-local-policy".into(),
76 version: "0.0.0".into(),
77 description: "deny all because the local policy pack is invalid".into(),
78 chain: Vec::new(),
79 default_read_mode: None,
80 allow_tools: Some(Vec::new()),
81 deny_tools: Vec::new(),
82 max_context_tokens: None,
83 audit_retention_days: None,
84 redaction: std::collections::BTreeMap::new(),
85 filters: crate::core::policy::FilterRules::default(),
86 egress: crate::core::policy::EgressRules::default(),
87 routing: crate::core::policy::RoutingPolicyRules::default(),
88 budgets: crate::core::policy::BudgetRules::default(),
89 })
90 }
91
92 #[must_use]
96 pub fn tool_allowed(&self, tool: &str) -> bool {
97 if self.resolved.deny_tools.iter().any(|t| t == tool) {
98 return false;
99 }
100 match &self.resolved.allow_tools {
101 Some(allow) => allow.iter().any(|t| t == tool),
102 None => true,
103 }
104 }
105}
106
107fn filter_action(opt: Option<&String>) -> FilterAction {
110 opt.map(String::as_str)
111 .and_then(FilterAction::parse)
112 .unwrap_or(FilterAction::Off)
113}
114
115struct Cache {
116 loaded: bool,
117 active: Option<Arc<ActivePolicy>>,
118}
119
120fn cache() -> &'static RwLock<Cache> {
121 static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
122 CACHE.get_or_init(|| {
123 RwLock::new(Cache {
124 loaded: false,
125 active: None,
126 })
127 })
128}
129
130fn load_from_disk() -> Option<Arc<ActivePolicy>> {
131 let local = match load_local_pack() {
132 Ok(local) => local,
133 Err(msg) => {
134 tracing::error!(
135 "policy: failed to load invalid {PROJECT_PACK_PATH} ({msg}); enforcing deny-all"
136 );
137 return Some(Arc::new(ActivePolicy::deny_all()));
138 }
139 };
140 let effective = match crate::core::policy::org::active_resolved() {
145 Some(org) => crate::core::policy::floor::merge_floor(&org, local.as_ref()),
146 None => local?,
147 };
148 Some(Arc::new(ActivePolicy::from_resolved(effective)))
149}
150
151fn load_local_pack() -> Result<Option<ResolvedPolicy>, String> {
154 let path = PathBuf::from(PROJECT_PACK_PATH);
155 if !path.exists() {
156 return Ok(None);
157 }
158 match parse_file(&path).and_then(|p| resolve(&p)) {
159 Ok(resolved) => Ok(Some(resolved)),
160 Err(e) => Err(format!("{}: {e}", path.display())),
161 }
162}
163
164#[must_use]
167pub fn active() -> Option<Arc<ActivePolicy>> {
168 {
169 let r = cache().read().expect("policy cache poisoned");
170 if r.loaded {
171 return r.active.clone();
172 }
173 }
174 let loaded = load_from_disk();
175 let mut w = cache().write().expect("policy cache poisoned");
176 if !w.loaded {
179 w.loaded = true;
180 w.active = loaded;
181 }
182 w.active.clone()
183}
184
185#[must_use]
187pub fn is_active() -> bool {
188 active().is_some()
189}
190
191pub fn reload() {
193 let loaded = load_from_disk();
194 let mut w = cache().write().expect("policy cache poisoned");
195 w.loaded = true;
196 w.active = loaded;
197}
198
199#[cfg(test)]
201pub fn set_active_for_test(resolved: Option<ResolvedPolicy>) {
202 let mut w = cache().write().expect("policy cache poisoned");
203 w.loaded = true;
204 w.active = resolved.map(|r| Arc::new(ActivePolicy::from_resolved(r)));
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use std::collections::BTreeMap;
211 use std::fs;
212
213 struct CurrentDirGuard {
214 previous: std::path::PathBuf,
215 _lock: std::sync::MutexGuard<'static, ()>,
216 }
217
218 impl CurrentDirGuard {
219 fn enter(dir: &std::path::Path) -> Self {
220 static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
221 let lock = LOCK.get_or_init(|| std::sync::Mutex::new(()));
222 let guard = lock
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner);
225 let previous = std::env::current_dir().expect("read current directory");
226 std::env::set_current_dir(dir).expect("enter temporary directory");
227 Self {
228 previous,
229 _lock: guard,
230 }
231 }
232 }
233
234 impl Drop for CurrentDirGuard {
235 fn drop(&mut self) {
236 std::env::set_current_dir(&self.previous).expect("restore current directory");
237 }
238 }
239
240 fn rp(allow: Option<Vec<&str>>, deny: Vec<&str>, redaction: &[(&str, &str)]) -> ResolvedPolicy {
241 ResolvedPolicy {
242 name: "test".into(),
243 version: "1.0.0".into(),
244 description: "t".into(),
245 chain: vec![],
246 default_read_mode: None,
247 allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
248 deny_tools: deny.into_iter().map(String::from).collect(),
249 max_context_tokens: None,
250 audit_retention_days: None,
251 redaction: redaction
252 .iter()
253 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
254 .collect::<BTreeMap<_, _>>(),
255 filters: crate::core::policy::FilterRules::default(),
256 egress: crate::core::policy::EgressRules::default(),
257 routing: crate::core::policy::RoutingPolicyRules::default(),
258 budgets: crate::core::policy::BudgetRules::default(),
259 }
260 }
261
262 #[test]
263 fn deny_list_blocks_listed_tool() {
264 let p = ActivePolicy::from_resolved(rp(None, vec!["ctx_url_read"], &[]));
265 assert!(!p.tool_allowed("ctx_url_read"));
266 assert!(p.tool_allowed("ctx_read"));
267 }
268
269 #[test]
270 fn allow_list_is_exclusive() {
271 let p = ActivePolicy::from_resolved(rp(Some(vec!["ctx_read"]), vec![], &[]));
272 assert!(p.tool_allowed("ctx_read"));
273 assert!(!p.tool_allowed("ctx_shell"));
274 }
275
276 #[test]
277 fn compiles_redaction_patterns() {
278 let p = ActivePolicy::from_resolved(rp(None, vec![], &[("emp", r"EMP-\d{4}")]));
279 assert_eq!(p.redaction.len(), 1);
280 assert_eq!(p.redaction[0].0, "emp");
281 }
282
283 #[test]
284 fn load_from_disk_fails_closed_on_invalid_pack() {
285 let temp = tempfile::tempdir().expect("create temporary directory");
286 let policy_dir = temp.path().join(".lean-ctx");
287 fs::create_dir(&policy_dir).expect("create policy directory");
288 fs::write(policy_dir.join("policy.toml"), "[policy\n").expect("write invalid policy");
289 let _cwd = CurrentDirGuard::enter(temp.path());
290
291 let policy = load_from_disk().expect("invalid pack activates deny-all");
292
293 assert!(!policy.tool_allowed("ctx_read"));
294 assert!(!policy.tool_allowed("ctx_shell"));
295 }
296}