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 pack is ignored (logged), never bricking the agent.
13//!
14//! **Local-Free Invariant:** enforcement derived from this view only ever
15//! constrains the *agent* pipeline; it never gates a human's own local reads.
16
17use 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
25/// Project-local pack location, relative to the working directory (matches the
26/// `lean-ctx policy` CLI's `PROJECT_PACK_PATH`).
27const PROJECT_PACK_PATH: &str = ".lean-ctx/policy.toml";
28
29/// A resolved policy plus its precompiled redaction regexes — the cached,
30/// hot-path-ready form.
31pub struct ActivePolicy {
32    pub resolved: ResolvedPolicy,
33    /// `(label, compiled regex)` — labels are the pack's `[redaction]` keys.
34    /// Patterns that fail to compile are skipped (validation already rejects
35    /// them on load, so this is defense-in-depth, not the primary guard).
36    pub redaction: Vec<(String, Regex)>,
37    /// Compiled inbound content filters (GL #675), built once from the pack's
38    /// `[filters]` section.
39    pub filters: FilterConfig,
40    /// Compiled egress/output DLP config (GL #676), built once from the pack's
41    /// `[egress]` section.
42    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    /// Whether `tool` is permitted by this policy's allow/deny lists.
72    /// `deny_tools` always wins; an `allow_tools` allowlist, when set, is
73    /// exclusive (only listed tools pass).
74    #[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
86/// Map a resolved `[filters]` action string to a [`FilterAction`]. Absent or
87/// (defensively) unparseable ⇒ `Off`; validation already rejects bad tokens.
88fn 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    // A central org policy (GL #674), when present + signed + trusted, is folded
112    // in as an un-bypassable floor *beneath* the local pack: the local pack can
113    // only ever tighten it. Untrusted/invalid org policies are ignored here
114    // (fail-open) — `org::active_resolved` already logged why.
115    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
122/// The project-local pack (`.lean-ctx/policy.toml`), resolved. `None` when the
123/// file is absent or invalid — a malformed local pack must never brick the
124/// agent (fail-open); `lean-ctx policy validate` surfaces the same error.
125fn 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/// The active resolved policy, or `None` when no (valid) project pack exists.
143/// Loaded once and cached; call [`reload`] after the pack changes.
144#[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    // Another thread may have loaded between the read and the write; keep the
155    // first result so concurrent callers see a stable view.
156    if !w.loaded {
157        w.loaded = true;
158        w.active = loaded;
159    }
160    w.active.clone()
161}
162
163/// Cheap "is a policy active?" probe for the hot path.
164#[must_use]
165pub fn is_active() -> bool {
166    active().is_some()
167}
168
169/// Re-read the project pack (e.g. after a `policy` edit). Idempotent.
170pub 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/// Test hook: force the active policy without touching disk.
178#[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        }
208    }
209
210    #[test]
211    fn deny_list_blocks_listed_tool() {
212        let p = ActivePolicy::from_resolved(rp(None, vec!["ctx_url_read"], &[]));
213        assert!(!p.tool_allowed("ctx_url_read"));
214        assert!(p.tool_allowed("ctx_read"));
215    }
216
217    #[test]
218    fn allow_list_is_exclusive() {
219        let p = ActivePolicy::from_resolved(rp(Some(vec!["ctx_read"]), vec![], &[]));
220        assert!(p.tool_allowed("ctx_read"));
221        assert!(!p.tool_allowed("ctx_shell"));
222    }
223
224    #[test]
225    fn compiles_redaction_patterns() {
226        let p = ActivePolicy::from_resolved(rp(None, vec![], &[("emp", r"EMP-\d{4}")]));
227        assert_eq!(p.redaction.len(), 1);
228        assert_eq!(p.redaction[0].0, "emp");
229    }
230}