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
59impl PolicySet {
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 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 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 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 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 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 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 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 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 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 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}