Skip to main content

lean_ctx/core/
memory_boundary.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3#[cfg(feature = "http-server")]
4use std::io::{BufRead, BufReader};
5
6use crate::core::data_dir::lean_ctx_data_dir;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "snake_case")]
10pub enum FactPrivacy {
11    #[default]
12    ProjectOnly,
13    LinkedProjects,
14    Team,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct BoundaryPolicy {
20    pub cross_project_search: bool,
21    pub cross_project_import: 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            universal_gotchas_enabled: true,
33        }
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum CrossProjectEventType {
40    Search,
41    Import,
42    Recall,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CrossProjectAuditEvent {
47    pub timestamp: String,
48    pub event_type: CrossProjectEventType,
49    pub source_project_hash: String,
50    pub target_project_hash: String,
51    pub tool: String,
52    pub action: String,
53    pub facts_accessed: usize,
54    pub allowed: bool,
55    pub policy_reason: String,
56}
57
58pub fn check_boundary(
59    source_hash: &str,
60    target_hash: &str,
61    policy: &BoundaryPolicy,
62    event_type: &CrossProjectEventType,
63) -> bool {
64    if is_same_project_identity(source_hash, target_hash) {
65        return true;
66    }
67    match event_type {
68        CrossProjectEventType::Import => policy.cross_project_import,
69        CrossProjectEventType::Search | CrossProjectEventType::Recall => {
70            policy.cross_project_search
71        }
72    }
73}
74
75pub(crate) fn is_same_project_identity(hash_a: &str, hash_b: &str) -> bool {
76    !hash_a.is_empty() && !hash_b.is_empty() && hash_a == hash_b
77}
78
79/// Cap for the operational cross-project audit mirror. Kept >= the largest limit served
80/// by the /v1/audit/events endpoint (capped at 1000) so no reader observes truncation.
81/// The hash-chained compliance trail is separate (core::audit_trail) and not rotated here.
82const MAX_AUDIT_LINES: usize = 2000;
83
84pub fn record_audit_event(event: &CrossProjectAuditEvent) {
85    let dir = match lean_ctx_data_dir() {
86        Ok(d) => d.join("audit"),
87        Err(e) => {
88            tracing::warn!("cannot resolve data dir for audit: {e}");
89            return;
90        }
91    };
92    if let Err(e) = fs::create_dir_all(&dir) {
93        tracing::warn!("cannot create audit dir {}: {e}", dir.display());
94        return;
95    }
96    let path = dir.join("cross-project.jsonl");
97    let line = match serde_json::to_string(event) {
98        Ok(l) => l,
99        Err(e) => {
100            tracing::warn!("cannot serialize audit event: {e}");
101            return;
102        }
103    };
104    // Rotating cap (mirrors server_metrics::append_tool_call_log): bound the jsonl to
105    // MAX_AUDIT_LINES so disk growth is finite. This is the best-effort operational
106    // mirror — the tamper-evident, hash-chained compliance trail lives in
107    // core::audit_trail and is intentionally not rotated here.
108    let mut lines: Vec<String> = fs::read_to_string(&path)
109        .unwrap_or_default()
110        .lines()
111        .map(std::string::ToString::to_string)
112        .collect();
113    lines.push(line);
114    if lines.len() > MAX_AUDIT_LINES {
115        let excess = lines.len() - MAX_AUDIT_LINES;
116        lines.drain(0..excess);
117    }
118    if let Err(e) = fs::write(&path, lines.join("\n") + "\n") {
119        tracing::warn!("cannot write audit log {}: {e}", path.display());
120    }
121}
122
123#[cfg(feature = "http-server")]
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    #[cfg(feature = "http-server")]
210    #[test]
211    fn audit_event_roundtrip() {
212        let _guard = crate::core::data_dir::test_env_lock();
213        let tmp = tempfile::tempdir().unwrap();
214        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
215
216        let event = CrossProjectAuditEvent {
217            timestamp: chrono::Utc::now().to_rfc3339(),
218            event_type: CrossProjectEventType::Search,
219            source_project_hash: "src_hash".into(),
220            target_project_hash: "tgt_hash".into(),
221            tool: "ctx_knowledge".into(),
222            action: "recall".into(),
223            facts_accessed: 3,
224            allowed: false,
225            policy_reason: "cross_project_search disabled".into(),
226        };
227
228        record_audit_event(&event);
229        record_audit_event(&event);
230
231        let loaded = load_audit_events(10);
232        assert_eq!(loaded.len(), 2);
233        assert_eq!(loaded[0].source_project_hash, "src_hash");
234        assert_eq!(loaded[0].facts_accessed, 3);
235        assert!(!loaded[0].allowed);
236
237        let limited = load_audit_events(1);
238        assert_eq!(limited.len(), 1);
239
240        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
241    }
242}