Skip to main content

lean_ctx/core/policy/
runtime.rs

1//! Runtime view of the active context policy pack (GL #673 / #489 enforcement).
2//!
3//! [`active`] loads and resolves the project policy pack
4//! (`.lean-ctx/policy.toml`) once, folds in a trusted central org policy as a
5//! floor when one is installed (GL #674), then caches the [`ResolvedPolicy`]
6//! together with its precompiled redaction regexes so the MCP hot path
7//! ([`crate::server::policy_guard`] and the `call_tool` redaction step) can
8//! consult it cheaply.
9//!
10//! **Opt-in & backward-compatible:** with no project pack present, [`active`]
11//! returns `None` and nothing is gated — existing behavior is preserved
12//! exactly. An invalid local pack activates a deny-all policy so enforcement
13//! never fails open.
14//!
15//! **Local-Free Invariant:** enforcement derived from this view only ever
16//! constrains the *agent* pipeline; it never gates a human's own local reads.
17
18use 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
26/// Project-local pack location, relative to the working directory (matches the
27/// `lean-ctx policy` CLI's `PROJECT_PACK_PATH`).
28const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
29
30/// A resolved policy plus its precompiled redaction regexes — the cached,
31/// hot-path-ready form.
32pub struct ActivePolicy {
33    pub resolved: ResolvedPolicy,
34    /// `(label, compiled regex)` — labels are the pack's `[redaction]` keys.
35    /// Patterns that fail to compile are skipped (validation already rejects
36    /// them on load, so this is defense-in-depth, not the primary guard).
37    pub redaction: Vec<(String, Regex)>,
38    /// Compiled inbound content filters (GL #675), built once from the pack's
39    /// `[filters]` section.
40    pub filters: FilterConfig,
41    /// Compiled egress/output DLP config (GL #676), built once from the pack's
42    /// `[egress]` section.
43    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    /// Block every tool after a local policy pack fails to load.
73    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    /// Whether `tool` is permitted by this policy's allow/deny lists.
93    /// `deny_tools` always wins; an `allow_tools` allowlist, when set, is
94    /// exclusive (only listed tools pass).
95    #[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
107/// Map a resolved `[filters]` action string to a [`FilterAction`]. Absent or
108/// (defensively) unparseable ⇒ `Off`; validation already rejects bad tokens.
109fn 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    // A central org policy (GL #674), when present + signed + trusted, is folded
141    // in as an un-bypassable floor *beneath* the local pack: the local pack can
142    // only ever tighten it. Untrusted/invalid org policies are ignored here
143    // (fail-open) — `org::active_resolved` already logged why.
144    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
151/// The project-local pack (`.lean-ctx/policy.toml`), resolved. `None` only when
152/// the file is absent; malformed packs return an error so callers fail closed.
153fn 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/// The active resolved policy, or `None` when no project or org pack exists.
165/// Loaded once and cached; call [`reload`] after the pack changes.
166#[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    // Another thread may have loaded between the read and the write; keep the
177    // first result so concurrent callers see a stable view.
178    if !w.loaded {
179        w.loaded = true;
180        w.active = loaded;
181    }
182    w.active.clone()
183}
184
185/// Cheap "is a policy active?" probe for the hot path.
186#[must_use]
187pub fn is_active() -> bool {
188    active().is_some()
189}
190
191/// Re-read the project pack (e.g. after a `policy` edit). Idempotent.
192pub 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/// Test hook: force the active policy without touching disk.
200#[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}