lean_ctx/core/context_kernel/
enforce.rs1use serde::Deserialize;
4
5use super::policy::ContextPolicy;
6use super::types::{ContextPlanV1, PlanEntry};
7
8#[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
23pub 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#[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#[derive(Debug, Clone)]
59pub struct BlockedEntry {
60 pub object_id: String,
61 pub reason: String,
62}
63
64pub 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}