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 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
80const 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 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 std::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 std::env::remove_var("LEAN_CTX_DATA_DIR");
240 }
241}