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