lean_ctx/core/context_kernel/
policy.rs1use super::types::{ContextObjectV1, SensitivityLevel};
4
5#[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
15pub struct PolicyFilter {
17 policy: ContextPolicy,
18}
19
20impl PolicyFilter {
21 pub fn new(policy: ContextPolicy) -> Self {
23 Self { policy }
24 }
25
26 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 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 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 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 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}