1use serde::{Deserialize, Serialize};
12
13use super::context_field::{ContextState, ViewKind};
14
15#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct PolicySet {
56 pub policies: Vec<ContextPolicy>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum PolicySource {
63 SecurityDefault,
65 Default,
67 Project,
69}
70
71impl PolicySet {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 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 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 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 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 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 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 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 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 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 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 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}