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