Skip to main content

lean_ctx/core/context_kernel/
enforce.rs

1//! Kernel policy enforcement modes.
2
3use serde::Deserialize;
4
5use super::policy::ContextPolicy;
6use super::types::{ContextPlanV1, PlanEntry};
7
8/// Determines whether policy violations are observed or enforced.
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum KernelMode {
12    #[default]
13    Shadow,
14    Enforce,
15    Explain,
16}
17
18#[derive(Debug, Default, Deserialize)]
19struct KernelModeConfig {
20    kernel_mode: Option<KernelMode>,
21}
22
23/// Resolves the kernel mode from the environment, then the global config.
24pub fn resolve_mode(_project_root: &str) -> KernelMode {
25    if let Ok(value) = std::env::var("LEANCTX_KERNEL_MODE")
26        && let Some(mode) = parse_mode(&value)
27    {
28        return mode;
29    }
30
31    crate::core::paths::config_dir_member("config.toml")
32        .ok()
33        .and_then(|path| std::fs::read_to_string(path).ok())
34        .and_then(|raw| toml::from_str::<KernelModeConfig>(&raw).ok())
35        .and_then(|config| config.kernel_mode)
36        .unwrap_or_default()
37}
38
39fn parse_mode(value: &str) -> Option<KernelMode> {
40    match value.trim().to_ascii_lowercase().as_str() {
41        "shadow" => Some(KernelMode::Shadow),
42        "enforce" => Some(KernelMode::Enforce),
43        "explain" => Some(KernelMode::Explain),
44        _ => None,
45    }
46}
47
48/// Result of applying a policy to a context plan.
49#[derive(Debug, Clone)]
50pub struct EnforceResult {
51    pub mode: KernelMode,
52    pub allowed: Vec<PlanEntry>,
53    pub blocked: Vec<BlockedEntry>,
54    pub explanation: Option<String>,
55}
56
57/// A selected plan entry rejected by policy.
58#[derive(Debug, Clone)]
59pub struct BlockedEntry {
60    pub object_id: String,
61    pub reason: String,
62}
63
64/// Applies policy decisions according to the requested kernel mode.
65pub fn enforce_plan(
66    plan: &ContextPlanV1,
67    policy: &ContextPolicy,
68    mode: KernelMode,
69) -> EnforceResult {
70    let mut allowed: Vec<PlanEntry> = Vec::with_capacity(plan.selected.len());
71    let mut blocked: Vec<BlockedEntry> = Vec::new();
72    let mut details: Vec<String> = Vec::new();
73
74    for entry in &plan.selected {
75        if let Some(reason) = entry_violation(entry, policy) {
76            blocked.push(BlockedEntry {
77                object_id: entry.object_id.clone(),
78                reason: reason.clone(),
79            });
80
81            if mode == KernelMode::Shadow {
82                allowed.push(entry.clone());
83            }
84            if mode == KernelMode::Explain {
85                details.push(format!("{}: blocked — {reason}", entry.object_id));
86            }
87        } else {
88            allowed.push(entry.clone());
89            if mode == KernelMode::Explain {
90                details.push(format!("{}: allowed — policy compliant", entry.object_id));
91            }
92        }
93    }
94
95    EnforceResult {
96        mode,
97        allowed,
98        blocked,
99        explanation: (mode == KernelMode::Explain).then(|| details.join("\n")),
100    }
101}
102
103fn entry_violation(entry: &PlanEntry, policy: &ContextPolicy) -> Option<String> {
104    if entry.object_id.trim().is_empty() {
105        return Some("object id is empty".to_owned());
106    }
107    if entry.provider.trim().is_empty() {
108        return Some("provider is empty".to_owned());
109    }
110    if entry.view.trim().is_empty() {
111        return Some("view is empty".to_owned());
112    }
113    if !entry.phi.is_finite() {
114        return Some("phi is not finite".to_owned());
115    }
116
117    policy.violation_reason(entry)
118}
119
120#[cfg(test)]
121mod tests {
122    use std::collections::HashMap;
123
124    use super::{KernelMode, enforce_plan};
125    use crate::core::context_kernel::policy::ContextPolicy;
126    use crate::core::context_kernel::types::{ContextPlanV1, PlanBudget, PlanEntry};
127
128    fn entry(object_id: &str, provider: &str) -> PlanEntry {
129        PlanEntry {
130            object_id: object_id.to_owned(),
131            provider: provider.to_owned(),
132            view: "summary".to_owned(),
133            tokens: 20,
134            phi: 1.0,
135            reason: "relevant".to_owned(),
136        }
137    }
138
139    fn plan(selected: Vec<PlanEntry>) -> ContextPlanV1 {
140        ContextPlanV1 {
141            plan_id: "plan:test".to_owned(),
142            intent: "test enforcement".to_owned(),
143            budget: PlanBudget {
144                total_tokens: 100,
145                used_tokens: 40,
146                remaining_tokens: 60,
147            },
148            selected,
149            excluded: Vec::new(),
150            deferred: Vec::new(),
151            provider_stats: HashMap::new(),
152        }
153    }
154
155    #[test]
156    fn shadow_mode_allows_all() {
157        let plan = plan(vec![entry("valid", "files"), entry("invalid", "")]);
158        let result = enforce_plan(&plan, &ContextPolicy::default(), KernelMode::Shadow);
159
160        assert_eq!(result.allowed.len(), 2);
161        assert_eq!(result.blocked.len(), 1);
162        assert!(result.explanation.is_none());
163    }
164
165    #[test]
166    fn enforce_mode_blocks_violations() {
167        let plan = plan(vec![entry("valid", "files"), entry("invalid", "")]);
168        let result = enforce_plan(&plan, &ContextPolicy::default(), KernelMode::Enforce);
169
170        assert_eq!(result.allowed.len(), 1);
171        assert_eq!(result.allowed[0].object_id, "valid");
172        assert_eq!(result.blocked[0].object_id, "invalid");
173    }
174
175    #[test]
176    fn explain_mode_includes_reasoning() {
177        let plan = plan(vec![entry("valid", "files"), entry("invalid", "")]);
178        let result = enforce_plan(&plan, &ContextPolicy::default(), KernelMode::Explain);
179
180        let explanation = result.explanation.expect("explanation should be present");
181        assert!(explanation.contains("valid: allowed"));
182        assert!(explanation.contains("invalid: blocked"));
183        assert!(explanation.contains("provider is empty"));
184    }
185}