lean_ctx/core/context_kernel/
policy_engine.rs1use serde::{Deserialize, Serialize};
4
5use super::types::SensitivityLevel;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum PolicyLevel {
11 Request,
12 Workload,
13 Project,
14 Team,
15 Org,
16 Platform,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PolicyRule {
22 pub id: String,
23 pub level: PolicyLevel,
24 pub effect: PolicyEffect,
25 pub conditions: Vec<PolicyCondition>,
26 pub priority: u32,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum PolicyEffect {
33 Allow,
34 Deny,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum PolicyCondition {
41 MaxTokens { limit: usize },
42 MaxSensitivity { level: SensitivityLevel },
43 SourcePattern { pattern: String },
44 ModelAllowlist { models: Vec<String> },
45 CostCap { max_micros: u64 },
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PolicyDecision {
51 pub effect: PolicyEffect,
52 pub matched_rules: Vec<String>,
53 pub denied_reasons: Vec<String>,
54 pub evaluation_level: PolicyLevel,
55}
56
57#[derive(Debug, Clone)]
59pub struct PolicyEvalRequest {
60 pub source: String,
61 pub model: Option<String>,
62 pub tokens: usize,
63 pub sensitivity: SensitivityLevel,
64 pub cost_micros: Option<u64>,
65}
66
67pub struct PolicyDecisionPoint {
69 rules: Vec<PolicyRule>,
70}
71
72#[derive(Debug, Deserialize)]
73struct PolicyRulesConfig {
74 #[serde(default)]
75 rules: Vec<PolicyRule>,
76}
77
78impl PolicyDecisionPoint {
79 pub fn new(rules: Vec<PolicyRule>) -> Self {
81 Self { rules }
82 }
83
84 pub fn from_config() -> Self {
88 let rules = crate::core::paths::config_dir()
89 .ok()
90 .map(|directory| directory.join("policy-rules.toml"))
91 .and_then(|path| std::fs::read_to_string(path).ok())
92 .and_then(|contents| toml::from_str::<PolicyRulesConfig>(&contents).ok())
93 .map(|config| config.rules)
94 .unwrap_or_default();
95
96 Self::new(rules)
97 }
98
99 pub fn evaluate(&self, request: &PolicyEvalRequest) -> PolicyDecision {
101 let matching: Vec<&PolicyRule> = self
102 .rules
103 .iter()
104 .filter(|rule| rule_matches(rule, request))
105 .collect();
106 let evaluation_level = matching
107 .iter()
108 .map(|rule| rule.level)
109 .max()
110 .unwrap_or(PolicyLevel::Request);
111 let governing: Vec<&PolicyRule> = matching
112 .iter()
113 .copied()
114 .filter(|rule| rule.level == evaluation_level)
115 .collect();
116 let denied: Vec<&PolicyRule> = governing
117 .iter()
118 .copied()
119 .filter(|rule| rule.effect == PolicyEffect::Deny)
120 .collect();
121 let effect = if denied.is_empty() {
122 PolicyEffect::Allow
123 } else {
124 PolicyEffect::Deny
125 };
126
127 PolicyDecision {
128 effect,
129 matched_rules: matching.iter().map(|rule| rule.id.clone()).collect(),
130 denied_reasons: denied
131 .iter()
132 .map(|rule| format!("policy rule '{}' denied request", rule.id))
133 .collect(),
134 evaluation_level,
135 }
136 }
137
138 pub fn add_rule(&mut self, rule: PolicyRule) {
140 self.rules.push(rule);
141 }
142
143 pub fn rules_at_level(&self, level: PolicyLevel) -> Vec<&PolicyRule> {
145 self.rules
146 .iter()
147 .filter(|rule| rule.level == level)
148 .collect()
149 }
150}
151
152fn rule_matches(rule: &PolicyRule, request: &PolicyEvalRequest) -> bool {
153 rule.conditions
154 .iter()
155 .all(|condition| condition_matches(condition, request))
156}
157
158fn condition_matches(condition: &PolicyCondition, request: &PolicyEvalRequest) -> bool {
159 match condition {
160 PolicyCondition::MaxTokens { limit } => request.tokens > *limit,
161 PolicyCondition::MaxSensitivity { level } => {
162 sensitivity_rank(request.sensitivity) > sensitivity_rank(*level)
163 }
164 PolicyCondition::SourcePattern { pattern } => wildcard_matches(pattern, &request.source),
165 PolicyCondition::ModelAllowlist { models } => request
166 .model
167 .as_ref()
168 .is_none_or(|model| !models.iter().any(|allowed| allowed == model)),
169 PolicyCondition::CostCap { max_micros } => {
170 request.cost_micros.is_some_and(|cost| cost > *max_micros)
171 }
172 }
173}
174
175fn sensitivity_rank(level: SensitivityLevel) -> u8 {
176 match level {
177 SensitivityLevel::Public => 0,
178 SensitivityLevel::Internal => 1,
179 SensitivityLevel::Confidential => 2,
180 SensitivityLevel::Restricted => 3,
181 }
182}
183
184fn wildcard_matches(pattern: &str, value: &str) -> bool {
185 let pattern = pattern.as_bytes();
186 let value = value.as_bytes();
187 let (mut pattern_index, mut value_index) = (0, 0);
188 let (mut star_index, mut star_value_index) = (None, 0);
189
190 while value_index < value.len() {
191 if pattern_index < pattern.len()
192 && (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index])
193 {
194 pattern_index += 1;
195 value_index += 1;
196 } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
197 star_index = Some(pattern_index);
198 pattern_index += 1;
199 star_value_index = value_index;
200 } else if let Some(star) = star_index {
201 pattern_index = star + 1;
202 star_value_index += 1;
203 value_index = star_value_index;
204 } else {
205 return false;
206 }
207 }
208
209 while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
210 pattern_index += 1;
211 }
212 pattern_index == pattern.len()
213}
214
215#[cfg(test)]
216mod tests {
217 use super::{
218 PolicyCondition, PolicyDecisionPoint, PolicyEffect, PolicyEvalRequest, PolicyLevel,
219 PolicyRule,
220 };
221 use crate::core::context_kernel::types::SensitivityLevel;
222
223 fn request() -> PolicyEvalRequest {
224 PolicyEvalRequest {
225 source: "github:issue".to_owned(),
226 model: Some("gpt-5".to_owned()),
227 tokens: 100,
228 sensitivity: SensitivityLevel::Internal,
229 cost_micros: Some(500),
230 }
231 }
232
233 fn rule(
234 id: &str,
235 level: PolicyLevel,
236 effect: PolicyEffect,
237 conditions: Vec<PolicyCondition>,
238 ) -> PolicyRule {
239 PolicyRule {
240 id: id.to_owned(),
241 level,
242 effect,
243 conditions,
244 priority: 0,
245 }
246 }
247
248 #[test]
249 fn empty_rules_allow_requests() {
250 let decision = PolicyDecisionPoint::new(Vec::new()).evaluate(&request());
251
252 assert_eq!(decision.effect, PolicyEffect::Allow);
253 assert!(decision.matched_rules.is_empty());
254 assert!(decision.denied_reasons.is_empty());
255 assert_eq!(decision.evaluation_level, PolicyLevel::Request);
256 }
257
258 #[test]
259 fn matching_deny_rule_denies_with_reason() {
260 let policy = PolicyDecisionPoint::new(vec![rule(
261 "block-github",
262 PolicyLevel::Project,
263 PolicyEffect::Deny,
264 vec![PolicyCondition::SourcePattern {
265 pattern: "github:*".to_owned(),
266 }],
267 )]);
268
269 let decision = policy.evaluate(&request());
270
271 assert_eq!(decision.effect, PolicyEffect::Deny);
272 assert_eq!(decision.matched_rules, ["block-github"]);
273 assert!(decision.denied_reasons[0].contains("block-github"));
274 }
275
276 #[test]
277 fn deny_wins_over_allow_at_same_level() {
278 let policy = PolicyDecisionPoint::new(vec![
279 rule("allow", PolicyLevel::Team, PolicyEffect::Allow, Vec::new()),
280 rule("deny", PolicyLevel::Team, PolicyEffect::Deny, Vec::new()),
281 ]);
282
283 let decision = policy.evaluate(&request());
284
285 assert_eq!(decision.effect, PolicyEffect::Deny);
286 assert_eq!(decision.evaluation_level, PolicyLevel::Team);
287 }
288
289 #[test]
290 fn higher_level_deny_overrides_lower_level_allow() {
291 let policy = PolicyDecisionPoint::new(vec![
292 rule(
293 "project-allow",
294 PolicyLevel::Project,
295 PolicyEffect::Allow,
296 Vec::new(),
297 ),
298 rule(
299 "platform-deny",
300 PolicyLevel::Platform,
301 PolicyEffect::Deny,
302 Vec::new(),
303 ),
304 ]);
305
306 let decision = policy.evaluate(&request());
307
308 assert_eq!(decision.effect, PolicyEffect::Deny);
309 assert_eq!(decision.evaluation_level, PolicyLevel::Platform);
310 }
311
312 #[test]
313 fn limit_source_and_model_conditions_match_violations() {
314 let policy = PolicyDecisionPoint::new(vec![
315 rule(
316 "token-cap",
317 PolicyLevel::Request,
318 PolicyEffect::Deny,
319 vec![PolicyCondition::MaxTokens { limit: 50 }],
320 ),
321 rule(
322 "source-cap",
323 PolicyLevel::Request,
324 PolicyEffect::Deny,
325 vec![PolicyCondition::SourcePattern {
326 pattern: "github:*".to_owned(),
327 }],
328 ),
329 rule(
330 "model-cap",
331 PolicyLevel::Request,
332 PolicyEffect::Deny,
333 vec![PolicyCondition::ModelAllowlist {
334 models: vec!["gpt-5-mini".to_owned()],
335 }],
336 ),
337 ]);
338
339 let decision = policy.evaluate(&request());
340
341 assert_eq!(decision.effect, PolicyEffect::Deny);
342 assert_eq!(decision.matched_rules.len(), 3);
343 }
344
345 #[test]
346 fn rules_at_level_and_add_rule_preserve_level_filtering() {
347 let mut policy = PolicyDecisionPoint::new(Vec::new());
348 policy.add_rule(rule(
349 "org",
350 PolicyLevel::Org,
351 PolicyEffect::Allow,
352 Vec::new(),
353 ));
354
355 assert_eq!(policy.rules_at_level(PolicyLevel::Org).len(), 1);
356 assert!(policy.rules_at_level(PolicyLevel::Project).is_empty());
357 }
358}