Skip to main content

lean_ctx/core/
memory_boundary.rs

1use serde::{Deserialize, Serialize};
2use std::fs::{self, OpenOptions};
3use std::io::{BufRead, BufReader, Write};
4
5use crate::core::data_dir::lean_ctx_data_dir;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(rename_all = "snake_case")]
9pub enum FactPrivacy {
10    #[default]
11    ProjectOnly,
12    LinkedProjects,
13    Team,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default)]
18pub struct BoundaryPolicy {
19    pub cross_project_search: bool,
20    pub cross_project_import: bool,
21    pub audit_cross_access: bool,
22    /// Controls whether universal (cross-project) gotchas are loaded.
23    /// When false, only project-scoped gotchas are used.
24    pub universal_gotchas_enabled: bool,
25}
26
27impl Default for BoundaryPolicy {
28    fn default() -> Self {
29        Self {
30            cross_project_search: false,
31            cross_project_import: false,
32            audit_cross_access: true,
33            universal_gotchas_enabled: true,
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum CrossProjectEventType {
41    Search,
42    Import,
43    Recall,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CrossProjectAuditEvent {
48    pub timestamp: String,
49    pub event_type: CrossProjectEventType,
50    pub source_project_hash: String,
51    pub target_project_hash: String,
52    pub tool: String,
53    pub action: String,
54    pub facts_accessed: usize,
55    pub allowed: bool,
56    pub policy_reason: String,
57}
58
59pub fn check_boundary(
60    source_hash: &str,
61    target_hash: &str,
62    policy: &BoundaryPolicy,
63    event_type: &CrossProjectEventType,
64) -> bool {
65    if is_same_project_identity(source_hash, target_hash) {
66        return true;
67    }
68    match event_type {
69        CrossProjectEventType::Import => policy.cross_project_import,
70        CrossProjectEventType::Search | CrossProjectEventType::Recall => {
71            policy.cross_project_search
72        }
73    }
74}
75
76pub fn is_same_project_identity(hash_a: &str, hash_b: &str) -> bool {
77    !hash_a.is_empty() && !hash_b.is_empty() && hash_a == hash_b
78}
79
80pub fn record_audit_event(event: &CrossProjectAuditEvent) {
81    let dir = match lean_ctx_data_dir() {
82        Ok(d) => d.join("audit"),
83        Err(e) => {
84            tracing::warn!("cannot resolve data dir for audit: {e}");
85            return;
86        }
87    };
88    if let Err(e) = fs::create_dir_all(&dir) {
89        tracing::warn!("cannot create audit dir {}: {e}", dir.display());
90        return;
91    }
92    let path = dir.join("cross-project.jsonl");
93    let line = match serde_json::to_string(event) {
94        Ok(l) => l,
95        Err(e) => {
96            tracing::warn!("cannot serialize audit event: {e}");
97            return;
98        }
99    };
100    let file = OpenOptions::new().create(true).append(true).open(&path);
101    match file {
102        Ok(mut f) => {
103            if let Err(e) = writeln!(f, "{line}") {
104                tracing::warn!("cannot write audit event to {}: {e}", path.display());
105            }
106        }
107        Err(e) => {
108            tracing::warn!("cannot open audit log {}: {e}", path.display());
109        }
110    }
111}
112
113pub fn load_audit_events(limit: usize) -> Vec<CrossProjectAuditEvent> {
114    let path = match lean_ctx_data_dir() {
115        Ok(d) => d.join("audit").join("cross-project.jsonl"),
116        Err(_) => return Vec::new(),
117    };
118    let Ok(file) = fs::File::open(&path) else {
119        return Vec::new();
120    };
121    let reader = BufReader::new(file);
122    let mut events: Vec<CrossProjectAuditEvent> = reader
123        .lines()
124        .filter_map(|line| {
125            let line = line.ok()?;
126            serde_json::from_str(&line).ok()
127        })
128        .collect();
129    if events.len() > limit {
130        events = events.split_off(events.len() - limit);
131    }
132    events
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn boundary_check_same_project_always_allowed() {
141        let policy = BoundaryPolicy::default();
142        assert!(check_boundary(
143            "abc123",
144            "abc123",
145            &policy,
146            &CrossProjectEventType::Search,
147        ));
148        assert!(check_boundary(
149            "abc123",
150            "abc123",
151            &policy,
152            &CrossProjectEventType::Import,
153        ));
154    }
155
156    #[test]
157    fn boundary_check_cross_project_respects_policy() {
158        let deny_all = BoundaryPolicy::default();
159        assert!(!check_boundary(
160            "proj_a",
161            "proj_b",
162            &deny_all,
163            &CrossProjectEventType::Search,
164        ));
165        assert!(!check_boundary(
166            "proj_a",
167            "proj_b",
168            &deny_all,
169            &CrossProjectEventType::Import,
170        ));
171
172        let allow_search = BoundaryPolicy {
173            cross_project_search: true,
174            ..Default::default()
175        };
176        assert!(check_boundary(
177            "proj_a",
178            "proj_b",
179            &allow_search,
180            &CrossProjectEventType::Search,
181        ));
182        assert!(!check_boundary(
183            "proj_a",
184            "proj_b",
185            &allow_search,
186            &CrossProjectEventType::Import,
187        ));
188    }
189
190    #[test]
191    fn same_identity_detection() {
192        assert!(is_same_project_identity("hash1", "hash1"));
193        assert!(!is_same_project_identity("hash1", "hash2"));
194        assert!(!is_same_project_identity("", ""));
195        assert!(!is_same_project_identity("hash1", ""));
196    }
197
198    #[test]
199    fn audit_event_roundtrip() {
200        let _guard = crate::core::data_dir::test_env_lock();
201        let tmp = tempfile::tempdir().unwrap();
202        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
203
204        let event = CrossProjectAuditEvent {
205            timestamp: chrono::Utc::now().to_rfc3339(),
206            event_type: CrossProjectEventType::Search,
207            source_project_hash: "src_hash".into(),
208            target_project_hash: "tgt_hash".into(),
209            tool: "ctx_knowledge".into(),
210            action: "recall".into(),
211            facts_accessed: 3,
212            allowed: false,
213            policy_reason: "cross_project_search disabled".into(),
214        };
215
216        record_audit_event(&event);
217        record_audit_event(&event);
218
219        let loaded = load_audit_events(10);
220        assert_eq!(loaded.len(), 2);
221        assert_eq!(loaded[0].source_project_hash, "src_hash");
222        assert_eq!(loaded[0].facts_accessed, 3);
223        assert!(!loaded[0].allowed);
224
225        let limited = load_audit_events(1);
226        assert_eq!(limited.len(), 1);
227
228        std::env::remove_var("LEAN_CTX_DATA_DIR");
229    }
230}