1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, Write};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct AuditEntry {
9 pub timestamp: String,
10 pub agent_id: String,
11 pub tool: String,
12 pub action: Option<String>,
13 pub input_hash: String,
14 pub output_tokens: u32,
15 pub role: String,
16 pub event_type: AuditEventType,
17 pub prev_hash: String,
18 pub entry_hash: String,
19 #[serde(skip_serializing_if = "Option::is_none", default)]
22 pub signature: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AuditEventType {
28 ToolCall,
29 ToolDenied,
30 PathJailViolation,
31 BudgetExceeded,
32 CrossProjectAccess,
33 RateLimited,
34 SecurityViolation,
35 RoleChanged,
36 SecretDetected,
37 AgentRegistered,
39 AgentSuspended,
40 AgentResumed,
41 AgentDecommissioned,
42}
43
44pub struct AuditEntryData {
45 pub agent_id: String,
46 pub tool: String,
47 pub action: Option<String>,
48 pub input_hash: String,
49 pub output_tokens: u32,
50 pub role: String,
51 pub event_type: AuditEventType,
52}
53
54pub struct ChainVerifyResult {
55 pub total_entries: usize,
56 pub valid: bool,
57 pub first_invalid_at: Option<usize>,
58}
59
60fn trail_path() -> Option<PathBuf> {
61 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
62 let audit_dir = dir.join("audit");
63 fs::create_dir_all(&audit_dir).ok()?;
64 Some(audit_dir.join("trail.jsonl"))
65}
66
67fn read_last_hash_tail(file: &fs::File) -> String {
72 use std::io::{Read, Seek, SeekFrom};
73 const TAIL: i64 = 64 * 1024;
74
75 let mut f = file;
76 let Ok(len) = f.seek(SeekFrom::End(0)) else {
77 return "genesis".to_string();
78 };
79 if len == 0 {
80 return "genesis".to_string();
81 }
82 let start = if (len as i64) > TAIL {
83 -TAIL
84 } else {
85 -(len as i64)
86 };
87 if f.seek(SeekFrom::End(start)).is_err() {
88 return "genesis".to_string();
89 }
90 let mut buf = String::new();
91 if f.read_to_string(&mut buf).is_err() {
92 return "genesis".to_string();
93 }
94 for line in buf.lines().rev() {
97 if line.trim().is_empty() {
98 continue;
99 }
100 let mut last: Option<String> = None;
101 for v in serde_json::Deserializer::from_str(line)
102 .into_iter::<serde_json::Value>()
103 .flatten()
104 {
105 if let Some(h) = v.get("entry_hash").and_then(|h| h.as_str()) {
106 last = Some(h.to_string());
107 }
108 }
109 if let Some(h) = last {
110 return h;
111 }
112 }
113 "genesis".to_string()
114}
115
116fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
117 let mut hasher = Sha256::new();
118 hasher.update(prev_hash.as_bytes());
119 hasher.update(data_json.as_bytes());
120 format!("{:x}", hasher.finalize())
121}
122
123pub fn record(data: AuditEntryData) {
124 use fs2::FileExt;
125
126 let Some(path) = trail_path() else { return };
127 let Ok(mut file) = OpenOptions::new()
128 .create(true)
129 .append(true)
130 .read(true)
131 .open(&path)
132 else {
133 return;
134 };
135 if file.lock_exclusive().is_err() {
138 return;
139 }
140 let prev_hash = read_last_hash_tail(&file);
141
142 let partial = serde_json::json!({
143 "agent_id": data.agent_id,
144 "tool": data.tool,
145 "action": data.action,
146 "input_hash": data.input_hash,
147 "output_tokens": data.output_tokens,
148 "role": data.role,
149 "event_type": data.event_type,
150 });
151 let data_json = serde_json::to_string(&partial).unwrap_or_default();
152 let entry_hash = compute_entry_hash(&prev_hash, &data_json);
153
154 let signature = crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
155 .map(|sig| crate::core::agent_identity::hex_encode(&sig))
156 .ok();
157
158 let entry = AuditEntry {
159 timestamp: chrono::Utc::now().to_rfc3339(),
160 agent_id: data.agent_id,
161 tool: data.tool,
162 action: data.action,
163 input_hash: data.input_hash,
164 output_tokens: data.output_tokens,
165 role: data.role,
166 event_type: data.event_type,
167 prev_hash,
168 entry_hash,
169 signature,
170 };
171
172 if let Ok(line) = serde_json::to_string(&entry) {
173 let _ = writeln!(file, "{line}");
174 }
175 let _ = FileExt::unlock(&file);
176}
177
178pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
179 let Some(path) = trail_path() else {
180 return Vec::new();
181 };
182 let Ok(file) = fs::File::open(&path) else {
183 return Vec::new();
184 };
185 let reader = std::io::BufReader::new(file);
186 let entries: Vec<AuditEntry> = reader
187 .lines()
188 .map_while(Result::ok)
189 .filter_map(|line| serde_json::from_str(&line).ok())
190 .collect();
191 let skip = entries.len().saturating_sub(limit);
192 entries.into_iter().skip(skip).collect()
193}
194
195pub fn verify_chain() -> ChainVerifyResult {
196 let Some(path) = trail_path() else {
197 return ChainVerifyResult {
198 total_entries: 0,
199 valid: true,
200 first_invalid_at: None,
201 };
202 };
203 let Ok(file) = fs::File::open(&path) else {
204 return ChainVerifyResult {
205 total_entries: 0,
206 valid: true,
207 first_invalid_at: None,
208 };
209 };
210 let reader = std::io::BufReader::new(file);
211 let mut prev_hash = "genesis".to_string();
212 let mut total = 0usize;
213
214 for line in reader.lines().map_while(Result::ok) {
215 let entry: AuditEntry = match serde_json::from_str(&line) {
216 Ok(e) => e,
217 Err(_) => {
218 return ChainVerifyResult {
219 total_entries: total,
220 valid: false,
221 first_invalid_at: Some(total),
222 }
223 }
224 };
225
226 if entry.prev_hash != prev_hash {
227 return ChainVerifyResult {
228 total_entries: total,
229 valid: false,
230 first_invalid_at: Some(total),
231 };
232 }
233
234 let partial = serde_json::json!({
235 "agent_id": entry.agent_id,
236 "tool": entry.tool,
237 "action": entry.action,
238 "input_hash": entry.input_hash,
239 "output_tokens": entry.output_tokens,
240 "role": entry.role,
241 "event_type": entry.event_type,
242 });
243 let data_json = serde_json::to_string(&partial).unwrap_or_default();
244 let expected = compute_entry_hash(&prev_hash, &data_json);
245
246 if entry.entry_hash != expected {
247 return ChainVerifyResult {
248 total_entries: total,
249 valid: false,
250 first_invalid_at: Some(total),
251 };
252 }
253
254 prev_hash = entry.entry_hash;
255 total += 1;
256 }
257
258 ChainVerifyResult {
259 total_entries: total,
260 valid: true,
261 first_invalid_at: None,
262 }
263}
264
265pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
266 let serialized = serde_json::to_string(args).unwrap_or_default();
267 let mut hasher = Sha256::new();
268 hasher.update(serialized.as_bytes());
269 format!("{:x}", hasher.finalize())
270}