Skip to main content

lean_ctx/core/
memory_boundary.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::io::{BufRead, BufReader};
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
80/// Cap for the operational cross-project audit mirror. Kept >= the largest limit served
81/// by the /v1/audit/events endpoint (capped at 1000) so no reader observes truncation.
82/// The hash-chained compliance trail is separate (core::audit_trail) and not rotated here.
83const MAX_AUDIT_LINES: usize = 2000;
84
85pub fn record_audit_event(event: &CrossProjectAuditEvent) {
86    let dir = match lean_ctx_data_dir() {
87        Ok(d) => d.join("audit"),
88        Err(e) => {
89            tracing::warn!("cannot resolve data dir for audit: {e}");
90            return;
91        }
92    };
93    if let Err(e) = fs::create_dir_all(&dir) {
94        tracing::warn!("cannot create audit dir {}: {e}", dir.display());
95        return;
96    }
97    let path = dir.join("cross-project.jsonl");
98    let line = match serde_json::to_string(event) {
99        Ok(l) => l,
100        Err(e) => {
101            tracing::warn!("cannot serialize audit event: {e}");
102            return;
103        }
104    };
105    // Rotating cap (mirrors server_metrics::append_tool_call_log): bound the jsonl to
106    // MAX_AUDIT_LINES so disk growth is finite. This is the best-effort operational
107    // mirror — the tamper-evident, hash-chained compliance trail lives in
108    // core::audit_trail and is intentionally not rotated here.
109    let mut lines: Vec<String> = fs::read_to_string(&path)
110        .unwrap_or_default()
111        .lines()
112        .map(std::string::ToString::to_string)
113        .collect();
114    lines.push(line);
115    if lines.len() > MAX_AUDIT_LINES {
116        let excess = lines.len() - MAX_AUDIT_LINES;
117        lines.drain(0..excess);
118    }
119    if let Err(e) = fs::write(&path, lines.join("\n") + "\n") {
120        tracing::warn!("cannot write audit log {}: {e}", path.display());
121    }
122}
123
124pub fn load_audit_events(limit: usize) -> Vec<CrossProjectAuditEvent> {
125    let path = match lean_ctx_data_dir() {
126        Ok(d) => d.join("audit").join("cross-project.jsonl"),
127        Err(_) => return Vec::new(),
128    };
129    let Ok(file) = fs::File::open(&path) else {
130        return Vec::new();
131    };
132    let reader = BufReader::new(file);
133    let mut events: Vec<CrossProjectAuditEvent> = reader
134        .lines()
135        .filter_map(|line| {
136            let line = line.ok()?;
137            serde_json::from_str(&line).ok()
138        })
139        .collect();
140    if events.len() > limit {
141        events = events.split_off(events.len() - limit);
142    }
143    events
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn boundary_check_same_project_always_allowed() {
152        let policy = BoundaryPolicy::default();
153        assert!(check_boundary(
154            "abc123",
155            "abc123",
156            &policy,
157            &CrossProjectEventType::Search,
158        ));
159        assert!(check_boundary(
160            "abc123",
161            "abc123",
162            &policy,
163            &CrossProjectEventType::Import,
164        ));
165    }
166
167    #[test]
168    fn boundary_check_cross_project_respects_policy() {
169        let deny_all = BoundaryPolicy::default();
170        assert!(!check_boundary(
171            "proj_a",
172            "proj_b",
173            &deny_all,
174            &CrossProjectEventType::Search,
175        ));
176        assert!(!check_boundary(
177            "proj_a",
178            "proj_b",
179            &deny_all,
180            &CrossProjectEventType::Import,
181        ));
182
183        let allow_search = BoundaryPolicy {
184            cross_project_search: true,
185            ..Default::default()
186        };
187        assert!(check_boundary(
188            "proj_a",
189            "proj_b",
190            &allow_search,
191            &CrossProjectEventType::Search,
192        ));
193        assert!(!check_boundary(
194            "proj_a",
195            "proj_b",
196            &allow_search,
197            &CrossProjectEventType::Import,
198        ));
199    }
200
201    #[test]
202    fn same_identity_detection() {
203        assert!(is_same_project_identity("hash1", "hash1"));
204        assert!(!is_same_project_identity("hash1", "hash2"));
205        assert!(!is_same_project_identity("", ""));
206        assert!(!is_same_project_identity("hash1", ""));
207    }
208
209    #[test]
210    fn audit_event_roundtrip() {
211        let _guard = crate::core::data_dir::test_env_lock();
212        let tmp = tempfile::tempdir().unwrap();
213        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
214
215        let event = CrossProjectAuditEvent {
216            timestamp: chrono::Utc::now().to_rfc3339(),
217            event_type: CrossProjectEventType::Search,
218            source_project_hash: "src_hash".into(),
219            target_project_hash: "tgt_hash".into(),
220            tool: "ctx_knowledge".into(),
221            action: "recall".into(),
222            facts_accessed: 3,
223            allowed: false,
224            policy_reason: "cross_project_search disabled".into(),
225        };
226
227        record_audit_event(&event);
228        record_audit_event(&event);
229
230        let loaded = load_audit_events(10);
231        assert_eq!(loaded.len(), 2);
232        assert_eq!(loaded[0].source_project_hash, "src_hash");
233        assert_eq!(loaded[0].facts_accessed, 3);
234        assert!(!loaded[0].allowed);
235
236        let limited = load_audit_events(1);
237        assert_eq!(limited.len(), 1);
238
239        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
240    }
241}