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