Skip to main content

lean_ctx/core/context_kernel/
policy_engine.rs

1//! Hierarchical policy decisions for Context Kernel requests.
2
3use serde::{Deserialize, Serialize};
4
5use super::types::SensitivityLevel;
6
7/// The 6-level policy hierarchy — higher levels take precedence for deny rules.
8#[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/// A single policy rule.
20#[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/// The action a matching policy rule takes.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum PolicyEffect {
33    Allow,
34    Deny,
35}
36
37/// Condition types for policy matching.
38#[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/// Outcome of evaluating a request against the policy set.
49#[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/// Request attributes used by the policy decision point.
58#[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
67/// Policy Decision Point — evaluates requests against a rule set.
68pub 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    /// Creates a decision point backed by the supplied rules.
80    pub fn new(rules: Vec<PolicyRule>) -> Self {
81        Self { rules }
82    }
83
84    /// Loads policy rules from the lean-ctx configuration directory.
85    ///
86    /// Missing or invalid configuration produces an empty, permissive rule set.
87    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    /// Evaluates matching rules at the highest governing hierarchy level.
100    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    /// Adds a rule to the decision point.
139    pub fn add_rule(&mut self, rule: PolicyRule) {
140        self.rules.push(rule);
141    }
142
143    /// Returns rules defined at one hierarchy level.
144    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}