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 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
79const 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 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}