Skip to main content

lean_ctx/core/
context_policies.rs

1//! Context Policy Engine -- declarative rules for context governance.
2//!
3//! Extends the existing profile/role system with match-based policies
4//! that automatically include/exclude/pin/transform context items.
5//!
6//! Integrates with:
7//!   - io_boundary.rs (secret path detection)
8//!   - profiles.rs (compression/routing config)
9//!   - roles.rs (role-based access control)
10
11use serde::{Deserialize, Serialize};
12
13use super::context_field::{ContextState, ViewKind};
14
15/// A declarative context policy rule.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ContextPolicy {
18    pub name: String,
19    #[serde(rename = "match")]
20    pub match_pattern: String,
21    pub action: PolicyAction,
22    #[serde(default)]
23    pub condition: Option<PolicyCondition>,
24    #[serde(default)]
25    pub reason: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PolicyAction {
31    Exclude,
32    Include,
33    Pin,
34    SetView { view: String },
35    MaxTokens { limit: usize },
36    MarkOutdated,
37    Redact,
38    Audit,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum PolicyCondition {
44    SourceSeenBefore,
45    SourceModifiedRecently,
46    TokensAbove { threshold: usize },
47    Always,
48    AgentIs { agent_id: String },
49    AgentRoleIs { role: String },
50    ContentContainsSecret,
51}
52
53/// A set of loaded policies.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct PolicySet {
56    pub policies: Vec<ContextPolicy>,
57}
58
59impl PolicySet {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Built-in default policies that align with existing LeanCTX behavior.
65    pub fn defaults() -> Self {
66        let mut policies = Self::security_defaults().policies;
67        policies.extend(Self::attach_compat_defaults().policies);
68        Self { policies }
69    }
70
71    /// Open Engine safety defaults. These remain mechanism-level security policy.
72    pub fn security_defaults() -> Self {
73        Self {
74            policies: vec![
75                ContextPolicy {
76                    name: "never_include_secrets".to_string(),
77                    match_pattern: "**/.env*".to_string(),
78                    action: PolicyAction::Exclude,
79                    condition: None,
80                    reason: Some("secrets".to_string()),
81                },
82                ContextPolicy {
83                    name: "exclude_private_keys".to_string(),
84                    match_pattern: "**/*private_key*".to_string(),
85                    action: PolicyAction::Exclude,
86                    condition: None,
87                    reason: Some("private key material".to_string()),
88                },
89                ContextPolicy {
90                    name: "exclude_credentials".to_string(),
91                    match_pattern: "**/credentials*".to_string(),
92                    action: PolicyAction::Exclude,
93                    condition: None,
94                    reason: Some("credentials".to_string()),
95                },
96            ],
97        }
98    }
99
100    /// Frozen OSS Attach compatibility heuristics.
101    ///
102    /// These preserve the supported Coding-Agent experience but are not the
103    /// canonical Product policy surface and must not gain new lifecycle rules.
104    pub fn attach_compat_defaults() -> Self {
105        Self {
106            policies: vec![
107                ContextPolicy {
108                    name: "delta_after_first_read".to_string(),
109                    match_pattern: "src/**".to_string(),
110                    action: PolicyAction::SetView {
111                        view: "diff".to_string(),
112                    },
113                    condition: Some(PolicyCondition::SourceSeenBefore),
114                    reason: Some("predictive coding: only send prediction errors".to_string()),
115                },
116                ContextPolicy {
117                    name: "compress_large_files".to_string(),
118                    match_pattern: "**/*".to_string(),
119                    action: PolicyAction::SetView {
120                        view: "signatures".to_string(),
121                    },
122                    condition: Some(PolicyCondition::TokensAbove { threshold: 8000 }),
123                    reason: Some("large file budget protection".to_string()),
124                },
125            ],
126        }
127    }
128
129    /// Evaluate all policies against a path, returning applicable actions.
130    pub fn evaluate(
131        &self,
132        path: &str,
133        seen_before: bool,
134        token_count: usize,
135    ) -> Vec<PolicyEvalResult> {
136        self.evaluate_full(path, seen_before, token_count, None, None, None)
137    }
138
139    /// Evaluate with full context including agent/role/content dimensions.
140    pub fn evaluate_full(
141        &self,
142        path: &str,
143        seen_before: bool,
144        token_count: usize,
145        agent_id: Option<&str>,
146        role: Option<&str>,
147        content: Option<&str>,
148    ) -> Vec<PolicyEvalResult> {
149        let mut results = Vec::new();
150        for policy in &self.policies {
151            if !path_matches(&policy.match_pattern, path) {
152                continue;
153            }
154            if let Some(ref condition) = policy.condition
155                && !check_condition(
156                    condition,
157                    seen_before,
158                    token_count,
159                    path,
160                    agent_id,
161                    role,
162                    content,
163                )
164            {
165                continue;
166            }
167            results.push(PolicyEvalResult {
168                policy_name: policy.name.clone(),
169                action: policy.action.clone(),
170                reason: policy.reason.clone().unwrap_or_else(|| policy.name.clone()),
171            });
172        }
173        results
174    }
175
176    /// Determine the effective state for an item after policy evaluation.
177    ///
178    /// Actions fold in order (the last match wins), except that a matching
179    /// security-default `Exclude` always wins: no later `Pin`, `Include` or
180    /// `MarkOutdated` re-admits a secret, key or credential file.
181    pub fn effective_state(
182        &self,
183        path: &str,
184        current: ContextState,
185        seen_before: bool,
186        token_count: usize,
187    ) -> ContextState {
188        let evals = self.evaluate(path, seen_before, token_count);
189        let mut state = current;
190        for eval in &evals {
191            match &eval.action {
192                PolicyAction::Exclude => state = ContextState::Excluded,
193                PolicyAction::Pin => state = ContextState::Pinned,
194                PolicyAction::Include => {
195                    if state == ContextState::Candidate {
196                        state = ContextState::Included;
197                    }
198                }
199                PolicyAction::MarkOutdated => state = ContextState::Stale,
200                PolicyAction::MaxTokens { limit } => {
201                    if token_count > *limit {
202                        state = ContextState::Excluded;
203                    }
204                }
205                PolicyAction::SetView { .. } | PolicyAction::Redact | PolicyAction::Audit => {}
206            }
207        }
208        let security = Self::security_defaults();
209        if evals.iter().any(|eval| {
210            matches!(eval.action, PolicyAction::Exclude)
211                && security.policies.iter().any(|p| p.name == eval.policy_name)
212        }) {
213            return ContextState::Excluded;
214        }
215        state
216    }
217
218    /// Determine the recommended view for an item after policy evaluation.
219    pub fn recommended_view(
220        &self,
221        path: &str,
222        seen_before: bool,
223        token_count: usize,
224    ) -> Option<ViewKind> {
225        let evals = self.evaluate(path, seen_before, token_count);
226        for eval in evals.iter().rev() {
227            if let PolicyAction::SetView { view } = &eval.action {
228                return Some(ViewKind::parse(view));
229            }
230        }
231        None
232    }
233
234    /// Load policies from a project's .lean-ctx/policies.json file.
235    ///
236    /// The security defaults always come first and cannot be removed or
237    /// redefined by the project file: a project policy reusing one of their
238    /// names is dropped.
239    pub fn load_project(project_root: &std::path::Path) -> Self {
240        if crate::core::pathutil::is_data_dir_collision(project_root) {
241            return Self::defaults();
242        }
243        let path = project_root.join(".lean-ctx").join("policies.json");
244        let Some(project) = std::fs::read_to_string(&path)
245            .ok()
246            .and_then(|s| serde_json::from_str::<Self>(&s).ok())
247        else {
248            return Self::defaults();
249        };
250        let mut policies = Self::security_defaults().policies;
251        let reserved: Vec<String> = policies.iter().map(|p| p.name.clone()).collect();
252        policies.extend(
253            project
254                .policies
255                .into_iter()
256                .filter(|p| !reserved.contains(&p.name)),
257        );
258        Self { policies }
259    }
260
261    /// Save policies to a project's .lean-ctx/policies.json file.
262    pub fn save_project(&self, project_root: &std::path::Path) -> Result<(), String> {
263        let dir = crate::core::pathutil::safe_project_data_dir(project_root)?;
264        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
265        let path = dir.join("policies.json");
266        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
267        crate::config_io::write_atomic(&path, &json)
268    }
269}
270
271#[derive(Debug, Clone)]
272pub struct PolicyEvalResult {
273    pub policy_name: String,
274    pub action: PolicyAction,
275    pub reason: String,
276}
277
278fn path_matches(pattern: &str, path: &str) -> bool {
279    if pattern == "**/*" {
280        return true;
281    }
282
283    if let Some(suffix) = pattern.strip_prefix("**/") {
284        if suffix.contains('*') {
285            let inner = suffix.replace('*', "");
286            return path.contains(&inner);
287        }
288        return path.contains(suffix) || path.ends_with(suffix);
289    }
290
291    if let Some(prefix) = pattern.strip_suffix("/**") {
292        return path.starts_with(prefix);
293    }
294
295    if pattern.contains("**") {
296        let parts: Vec<&str> = pattern.split("**").collect();
297        if parts.len() == 2 {
298            return path.starts_with(parts[0]) && path.ends_with(parts[1]);
299        }
300    }
301
302    if let Some(prefix) = pattern.strip_suffix('*') {
303        return path.starts_with(prefix);
304    }
305
306    path == pattern || path.ends_with(pattern)
307}
308
309fn check_condition(
310    condition: &PolicyCondition,
311    seen_before: bool,
312    token_count: usize,
313    path: &str,
314    agent_id: Option<&str>,
315    role: Option<&str>,
316    content: Option<&str>,
317) -> bool {
318    match condition {
319        PolicyCondition::SourceSeenBefore => seen_before,
320        PolicyCondition::TokensAbove { threshold } => token_count > *threshold,
321        PolicyCondition::SourceModifiedRecently => {
322            const RECENT_SECS: u64 = 3600;
323            std::fs::metadata(path)
324                .and_then(|m| m.modified())
325                .ok()
326                .and_then(|t| t.elapsed().ok())
327                .is_some_and(|elapsed| elapsed.as_secs() < RECENT_SECS)
328        }
329        PolicyCondition::Always => true,
330        PolicyCondition::AgentIs { agent_id: expected } => {
331            agent_id.is_some_and(|id| id == expected)
332        }
333        PolicyCondition::AgentRoleIs { role: expected } => role.is_some_and(|r| r == expected),
334        PolicyCondition::ContentContainsSecret => {
335            content.is_some_and(|c| !crate::core::secret_detection::detect_secrets(c).is_empty())
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn default_policies_exclude_env_files() {
346        let ps = PolicySet::defaults();
347        let results = ps.evaluate(".env", false, 100);
348        assert!(
349            results
350                .iter()
351                .any(|r| matches!(r.action, PolicyAction::Exclude)),
352            "should exclude .env files"
353        );
354    }
355
356    #[test]
357    fn default_policy_partitions_preserve_order_and_ownership() {
358        let security = PolicySet::security_defaults();
359        let attach = PolicySet::attach_compat_defaults();
360        let defaults = PolicySet::defaults();
361
362        let security_names: Vec<_> = security
363            .policies
364            .iter()
365            .map(|policy| policy.name.as_str())
366            .collect();
367        let attach_names: Vec<_> = attach
368            .policies
369            .iter()
370            .map(|policy| policy.name.as_str())
371            .collect();
372        let default_names: Vec<_> = defaults
373            .policies
374            .iter()
375            .map(|policy| policy.name.as_str())
376            .collect();
377
378        assert_eq!(
379            security_names,
380            [
381                "never_include_secrets",
382                "exclude_private_keys",
383                "exclude_credentials",
384            ]
385        );
386        assert_eq!(
387            attach_names,
388            ["delta_after_first_read", "compress_large_files"]
389        );
390        assert_eq!(
391            default_names,
392            security_names
393                .into_iter()
394                .chain(attach_names)
395                .collect::<Vec<_>>()
396        );
397        assert!(
398            security
399                .policies
400                .iter()
401                .all(|policy| matches!(policy.action, PolicyAction::Exclude))
402        );
403        assert!(
404            attach
405                .policies
406                .iter()
407                .all(|policy| matches!(policy.action, PolicyAction::SetView { .. }))
408        );
409    }
410
411    #[test]
412    fn default_policies_exclude_private_keys() {
413        let ps = PolicySet::defaults();
414        let results = ps.evaluate("secrets/private_key.pem", false, 100);
415        assert!(
416            results
417                .iter()
418                .any(|r| matches!(r.action, PolicyAction::Exclude)),
419            "should exclude private key files"
420        );
421    }
422
423    #[test]
424    fn delta_policy_only_when_seen_before() {
425        let ps = PolicySet::defaults();
426        let first = ps.evaluate("src/main.rs", false, 500);
427        let second = ps.evaluate("src/main.rs", true, 500);
428        assert!(
429            !first
430                .iter()
431                .any(|r| matches!(&r.action, PolicyAction::SetView { view } if view == "diff")),
432            "should NOT suggest diff on first read"
433        );
434        assert!(
435            second
436                .iter()
437                .any(|r| matches!(&r.action, PolicyAction::SetView { view } if view == "diff")),
438            "should suggest diff on subsequent read"
439        );
440    }
441
442    #[test]
443    fn large_file_policy_triggers_above_threshold() {
444        let ps = PolicySet::defaults();
445        let small = ps.evaluate("src/main.rs", false, 500);
446        let large = ps.evaluate("src/main.rs", false, 10000);
447        assert!(
448            !small.iter().any(
449                |r| matches!(&r.action, PolicyAction::SetView { view } if view == "signatures")
450            ),
451        );
452        assert!(
453            large.iter().any(
454                |r| matches!(&r.action, PolicyAction::SetView { view } if view == "signatures")
455            ),
456        );
457    }
458
459    #[test]
460    fn effective_state_excludes_secrets() {
461        let ps = PolicySet::defaults();
462        let state = ps.effective_state(".env.local", ContextState::Candidate, false, 100);
463        assert_eq!(state, ContextState::Excluded);
464    }
465
466    #[test]
467    fn recommended_view_for_seen_file() {
468        let ps = PolicySet::defaults();
469        let view = ps.recommended_view("src/main.rs", true, 500);
470        assert_eq!(view, Some(ViewKind::Diff));
471    }
472
473    #[test]
474    fn recommended_view_none_for_new_file() {
475        let ps = PolicySet::defaults();
476        let view = ps.recommended_view("src/main.rs", false, 500);
477        assert!(view.is_none() || view == Some(ViewKind::Diff),);
478    }
479
480    #[test]
481    fn path_matches_glob_patterns() {
482        assert!(path_matches("**/.env*", ".env"));
483        assert!(path_matches("**/.env*", ".env.local"));
484        assert!(path_matches("**/.env*", "config/.env.prod"));
485        assert!(path_matches("src/**", "src/main.rs"));
486        assert!(path_matches("src/**", "src/core/mod.rs"));
487        assert!(path_matches("**/*", "anything.txt"));
488        assert!(!path_matches("src/**", "tests/test.rs"));
489    }
490
491    #[test]
492    fn empty_policy_set_changes_nothing() {
493        let ps = PolicySet::new();
494        let state = ps.effective_state("src/main.rs", ContextState::Included, false, 100);
495        assert_eq!(state, ContextState::Included);
496    }
497
498    #[test]
499    fn project_file_cannot_drop_or_override_security_defaults() {
500        let tmp = tempfile::tempdir().unwrap();
501        let dir = tmp.path().join(".lean-ctx");
502        std::fs::create_dir_all(&dir).unwrap();
503        // Omits every security default, redefines one of them as Include, and
504        // pins everything after them.
505        std::fs::write(
506            dir.join("policies.json"),
507            r#"{"policies":[
508                {"name":"never_include_secrets","match":"**/.env*","action":"include"},
509                {"name":"pin_all","match":"**/*","action":"pin"}
510            ]}"#,
511        )
512        .unwrap();
513        let ps = PolicySet::load_project(tmp.path());
514        for secret in [".env", "config/private_key.pem", "aws/credentials"] {
515            assert_eq!(
516                ps.effective_state(secret, ContextState::Candidate, false, 100),
517                ContextState::Excluded,
518                "{secret}"
519            );
520        }
521        assert_eq!(
522            ps.effective_state("src/main.rs", ContextState::Candidate, false, 100),
523            ContextState::Pinned,
524            "project policies still apply elsewhere"
525        );
526        assert_eq!(
527            ps.policies
528                .iter()
529                .filter(|p| p.name == "never_include_secrets")
530                .count(),
531            1
532        );
533    }
534
535    #[test]
536    fn security_exclude_wins_over_a_later_pin() {
537        let mut ps = PolicySet::defaults();
538        ps.policies.push(ContextPolicy {
539            name: "pin_all".to_string(),
540            match_pattern: "**/*".to_string(),
541            action: PolicyAction::Pin,
542            condition: None,
543            reason: None,
544        });
545        assert_eq!(
546            ps.effective_state(".env", ContextState::Candidate, false, 100),
547            ContextState::Excluded
548        );
549    }
550
551    #[test]
552    fn custom_policy_works() {
553        let ps = PolicySet {
554            policies: vec![ContextPolicy {
555                name: "pin_readme".to_string(),
556                match_pattern: "README.md".to_string(),
557                action: PolicyAction::Pin,
558                condition: None,
559                reason: None,
560            }],
561        };
562        let state = ps.effective_state("README.md", ContextState::Candidate, false, 100);
563        assert_eq!(state, ContextState::Pinned);
564    }
565}