Skip to main content

lean_ctx/core/context_kernel/
policy.rs

1//! Context policy filtering for the Context Kernel.
2
3use super::types::{ContextObjectV1, SensitivityLevel};
4
5/// Restrictions applied to candidates before kernel selection.
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7pub struct ContextPolicy {
8    pub max_sensitivity: SensitivityLevel,
9    pub allowed_sources: Option<Vec<String>>,
10    pub blocked_sources: Vec<String>,
11    pub budget_cap_tokens: Option<usize>,
12    pub retention_days: Option<u32>,
13}
14
15/// Applies a [`ContextPolicy`] to context candidates.
16pub struct PolicyFilter {
17    policy: ContextPolicy,
18}
19
20impl PolicyFilter {
21    /// Creates a filter backed by the supplied policy.
22    pub fn new(policy: ContextPolicy) -> Self {
23        Self { policy }
24    }
25
26    /// Loads the kernel policy from the lean-ctx configuration directory.
27    ///
28    /// Missing and invalid configuration gracefully falls back to the default
29    /// policy so candidate retrieval remains available.
30    pub fn from_config(project_root: &str) -> Self {
31        let _ = project_root;
32        let policy = crate::core::paths::config_dir()
33            .ok()
34            .map(|directory| directory.join("kernel-policy.toml"))
35            .and_then(|path| std::fs::read_to_string(path).ok())
36            .and_then(|contents| toml::from_str::<ContextPolicy>(&contents).ok())
37            .unwrap_or_else(Self::default_policy);
38
39        Self::new(policy)
40    }
41
42    /// Returns the permissive default for ordinary internal context.
43    pub fn default_policy() -> ContextPolicy {
44        ContextPolicy {
45            max_sensitivity: SensitivityLevel::Internal,
46            allowed_sources: None,
47            blocked_sources: Vec::new(),
48            budget_cap_tokens: None,
49            retention_days: None,
50        }
51    }
52
53    /// Filters candidates and applies the optional prefix token budget.
54    pub fn apply(&self, candidates: Vec<ContextObjectV1>) -> Vec<ContextObjectV1> {
55        let allowed: Vec<ContextObjectV1> = candidates
56            .into_iter()
57            .filter(|candidate| self.is_allowed(candidate))
58            .collect();
59
60        let Some(cap) = self.policy.budget_cap_tokens else {
61            return allowed;
62        };
63
64        let mut used: usize = 0;
65        allowed
66            .into_iter()
67            .take_while(|candidate| {
68                if candidate.token_estimate > cap.saturating_sub(used) {
69                    return false;
70                }
71                used = used.saturating_add(candidate.token_estimate);
72                true
73            })
74            .collect()
75    }
76
77    /// Returns whether a candidate satisfies sensitivity and source rules.
78    pub fn is_allowed(&self, candidate: &ContextObjectV1) -> bool {
79        if sensitivity_rank(&candidate.sensitivity) > sensitivity_rank(&self.policy.max_sensitivity)
80        {
81            return false;
82        }
83
84        if self
85            .policy
86            .allowed_sources
87            .as_ref()
88            .is_some_and(|sources| !sources.contains(&candidate.source))
89        {
90            return false;
91        }
92
93        !self.policy.blocked_sources.contains(&candidate.source)
94    }
95}
96
97impl ContextPolicy {
98    /// Returns the reason a plan entry violates this policy, or `None` if compliant.
99    pub fn violation_reason(&self, entry: &super::types::PlanEntry) -> Option<String> {
100        if let Some(ref allowed) = self.allowed_sources
101            && !allowed.contains(&entry.provider)
102        {
103            return Some(format!(
104                "provider '{}' not in allowed sources",
105                entry.provider
106            ));
107        }
108        if self.blocked_sources.contains(&entry.provider) {
109            return Some(format!("provider '{}' is blocked", entry.provider));
110        }
111        None
112    }
113}
114
115impl Default for ContextPolicy {
116    fn default() -> Self {
117        PolicyFilter::default_policy()
118    }
119}
120fn sensitivity_rank(level: &SensitivityLevel) -> u8 {
121    match level {
122        SensitivityLevel::Public => 0,
123        SensitivityLevel::Internal => 1,
124        SensitivityLevel::Confidential => 2,
125        SensitivityLevel::Restricted => 3,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::{ContextPolicy, PolicyFilter};
132    use crate::core::context_kernel::types::{ContextObjectV1, SensitivityLevel};
133
134    fn candidate(source: &str, sensitivity: SensitivityLevel, tokens: usize) -> ContextObjectV1 {
135        ContextObjectV1 {
136            source: source.to_owned(),
137            sensitivity,
138            token_estimate: tokens,
139            ..ContextObjectV1::default()
140        }
141    }
142
143    fn policy(max_sensitivity: SensitivityLevel) -> ContextPolicy {
144        ContextPolicy {
145            max_sensitivity,
146            allowed_sources: None,
147            blocked_sources: Vec::new(),
148            budget_cap_tokens: None,
149            retention_days: None,
150        }
151    }
152
153    #[test]
154    fn sensitivity_filter_removes_restricted() {
155        let filter = PolicyFilter::new(policy(SensitivityLevel::Internal));
156        let candidates: Vec<ContextObjectV1> = vec![
157            candidate("public", SensitivityLevel::Public, 10),
158            candidate("restricted", SensitivityLevel::Restricted, 10),
159        ];
160
161        let filtered = filter.apply(candidates);
162
163        assert_eq!(filtered.len(), 1);
164        assert_eq!(filtered[0].source, "public");
165    }
166
167    #[test]
168    fn allowed_sources_filters_correctly() {
169        let mut context_policy = policy(SensitivityLevel::Internal);
170        context_policy.allowed_sources = Some(vec!["knowledge".to_owned()]);
171        let filter = PolicyFilter::new(context_policy);
172        let candidates: Vec<ContextObjectV1> = vec![
173            candidate("knowledge", SensitivityLevel::Internal, 10),
174            candidate("file", SensitivityLevel::Internal, 10),
175        ];
176
177        let filtered = filter.apply(candidates);
178
179        assert_eq!(filtered.len(), 1);
180        assert_eq!(filtered[0].source, "knowledge");
181    }
182
183    #[test]
184    fn blocked_sources_removed() {
185        let mut context_policy = policy(SensitivityLevel::Internal);
186        context_policy.blocked_sources = vec!["episodic".to_owned()];
187        let filter = PolicyFilter::new(context_policy);
188        let candidates: Vec<ContextObjectV1> = vec![
189            candidate("episodic", SensitivityLevel::Internal, 10),
190            candidate("file", SensitivityLevel::Internal, 10),
191        ];
192
193        let filtered = filter.apply(candidates);
194
195        assert_eq!(filtered.len(), 1);
196        assert_eq!(filtered[0].source, "file");
197    }
198
199    #[test]
200    fn budget_cap_truncates() {
201        let mut context_policy = policy(SensitivityLevel::Internal);
202        context_policy.budget_cap_tokens = Some(250);
203        let filter = PolicyFilter::new(context_policy);
204        let candidates: Vec<ContextObjectV1> = vec![
205            candidate("first", SensitivityLevel::Internal, 100),
206            candidate("second", SensitivityLevel::Internal, 150),
207            candidate("third", SensitivityLevel::Internal, 1),
208        ];
209
210        let filtered = filter.apply(candidates);
211
212        assert_eq!(filtered.len(), 2);
213        assert_eq!(filtered[1].source, "second");
214    }
215
216    #[test]
217    fn blocked_source_overrides_allowed_source() {
218        let mut context_policy = policy(SensitivityLevel::Internal);
219        context_policy.allowed_sources = Some(vec!["knowledge".to_owned()]);
220        context_policy.blocked_sources = vec!["knowledge".to_owned()];
221        let filter = PolicyFilter::new(context_policy);
222
223        assert!(!filter.is_allowed(&candidate("knowledge", SensitivityLevel::Internal, 10,)));
224    }
225}