lean_ctx/core/policy/
runtime.rs1use std::path::PathBuf;
18use std::sync::{Arc, OnceLock, RwLock};
19
20use regex::Regex;
21
22use super::{ResolvedPolicy, parse_file, resolve};
23use crate::core::input_filters::{FilterAction, FilterConfig};
24
25const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
28
29pub struct ActivePolicy {
32 pub resolved: ResolvedPolicy,
33 pub redaction: Vec<(String, Regex)>,
37 pub filters: FilterConfig,
40 pub egress: crate::core::egress::EgressConfig,
43}
44
45impl ActivePolicy {
46 pub(crate) fn from_resolved(resolved: ResolvedPolicy) -> Self {
47 let redaction = resolved
48 .redaction
49 .iter()
50 .filter_map(|(label, pat)| Regex::new(pat).ok().map(|re| (label.clone(), re)))
51 .collect();
52 let filters = FilterConfig::new(
53 filter_action(resolved.filters.pii.as_ref()),
54 filter_action(resolved.filters.classification.as_ref()),
55 filter_action(resolved.filters.injection.as_ref()),
56 &resolved.filters.blocked_labels,
57 );
58 let egress = crate::core::egress::EgressConfig::new(
59 &resolved.egress.forbidden_patterns,
60 resolved.egress.block_secrets.unwrap_or(false),
61 resolved.egress.max_writes_per_min,
62 );
63 Self {
64 resolved,
65 redaction,
66 filters,
67 egress,
68 }
69 }
70
71 #[must_use]
75 pub fn tool_allowed(&self, tool: &str) -> bool {
76 if self.resolved.deny_tools.iter().any(|t| t == tool) {
77 return false;
78 }
79 match &self.resolved.allow_tools {
80 Some(allow) => allow.iter().any(|t| t == tool),
81 None => true,
82 }
83 }
84}
85
86fn filter_action(opt: Option<&String>) -> FilterAction {
89 opt.map(String::as_str)
90 .and_then(FilterAction::parse)
91 .unwrap_or(FilterAction::Off)
92}
93
94struct Cache {
95 loaded: bool,
96 active: Option<Arc<ActivePolicy>>,
97}
98
99fn cache() -> &'static RwLock<Cache> {
100 static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
101 CACHE.get_or_init(|| {
102 RwLock::new(Cache {
103 loaded: false,
104 active: None,
105 })
106 })
107}
108
109fn load_from_disk() -> Option<Arc<ActivePolicy>> {
110 let local = load_local_pack();
111 let effective = match crate::core::policy::org::active_resolved() {
116 Some(org) => crate::core::policy::floor::merge_floor(&org, local.as_ref()),
117 None => local?,
118 };
119 Some(Arc::new(ActivePolicy::from_resolved(effective)))
120}
121
122fn load_local_pack() -> Option<ResolvedPolicy> {
126 let path = PathBuf::from(PROJECT_PACK_PATH);
127 if !path.exists() {
128 return None;
129 }
130 match parse_file(&path).and_then(|p| resolve(&p)) {
131 Ok(resolved) => Some(resolved),
132 Err(e) => {
133 tracing::warn!(
134 "policy: ignoring invalid {} ({e}); no local policy enforced",
135 path.display()
136 );
137 None
138 }
139 }
140}
141
142#[must_use]
145pub fn active() -> Option<Arc<ActivePolicy>> {
146 {
147 let r = cache().read().expect("policy cache poisoned");
148 if r.loaded {
149 return r.active.clone();
150 }
151 }
152 let loaded = load_from_disk();
153 let mut w = cache().write().expect("policy cache poisoned");
154 if !w.loaded {
157 w.loaded = true;
158 w.active = loaded;
159 }
160 w.active.clone()
161}
162
163#[must_use]
165pub fn is_active() -> bool {
166 active().is_some()
167}
168
169pub fn reload() {
171 let loaded = load_from_disk();
172 let mut w = cache().write().expect("policy cache poisoned");
173 w.loaded = true;
174 w.active = loaded;
175}
176
177#[cfg(test)]
179pub fn set_active_for_test(resolved: Option<ResolvedPolicy>) {
180 let mut w = cache().write().expect("policy cache poisoned");
181 w.loaded = true;
182 w.active = resolved.map(|r| Arc::new(ActivePolicy::from_resolved(r)));
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use std::collections::BTreeMap;
189
190 fn rp(allow: Option<Vec<&str>>, deny: Vec<&str>, redaction: &[(&str, &str)]) -> ResolvedPolicy {
191 ResolvedPolicy {
192 name: "test".into(),
193 version: "1.0.0".into(),
194 description: "t".into(),
195 chain: vec![],
196 default_read_mode: None,
197 allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
198 deny_tools: deny.into_iter().map(String::from).collect(),
199 max_context_tokens: None,
200 audit_retention_days: None,
201 redaction: redaction
202 .iter()
203 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
204 .collect::<BTreeMap<_, _>>(),
205 filters: crate::core::policy::FilterRules::default(),
206 egress: crate::core::policy::EgressRules::default(),
207 routing: crate::core::policy::RoutingPolicyRules::default(),
208 budgets: crate::core::policy::BudgetRules::default(),
209 }
210 }
211
212 #[test]
213 fn deny_list_blocks_listed_tool() {
214 let p = ActivePolicy::from_resolved(rp(None, vec!["ctx_url_read"], &[]));
215 assert!(!p.tool_allowed("ctx_url_read"));
216 assert!(p.tool_allowed("ctx_read"));
217 }
218
219 #[test]
220 fn allow_list_is_exclusive() {
221 let p = ActivePolicy::from_resolved(rp(Some(vec!["ctx_read"]), vec![], &[]));
222 assert!(p.tool_allowed("ctx_read"));
223 assert!(!p.tool_allowed("ctx_shell"));
224 }
225
226 #[test]
227 fn compiles_redaction_patterns() {
228 let p = ActivePolicy::from_resolved(rp(None, vec![], &[("emp", r"EMP-\d{4}")]));
229 assert_eq!(p.redaction.len(), 1);
230 assert_eq!(p.redaction[0].0, "emp");
231 }
232}