1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::path::{Path, PathBuf};
11use uuid::Uuid;
12
13use crate::types::{ActionDecision, ActionType};
14
15#[derive(Debug, thiserror::Error)]
17pub enum AuditError {
18 #[error("Failed to read audit log: {0}")]
19 ReadError(#[from] std::io::Error),
20
21 #[error("Failed to parse audit log: {0}")]
22 ParseError(#[from] serde_json::Error),
23
24 #[error("Hash chain broken at entry {index}: expected {expected}, got {actual}")]
25 ChainBroken {
26 index: usize,
27 expected: String,
28 actual: String,
29 },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AuditEntry {
35 pub id: Uuid,
37 pub timestamp: DateTime<Utc>,
39 pub session_id: String,
41 pub workflow_id: Option<String>,
43 pub action_type: ActionType,
45 pub action_detail: String,
47 pub model_used: Option<String>,
49 pub cost: Option<f64>,
51 pub input_hash: String,
53 pub output_summary: String,
55 pub decision: ActionDecision,
57 pub approved_by: Option<String>,
59 pub duration_ms: u64,
61 pub success: bool,
63 pub error: Option<String>,
65 pub prev_hash: String,
67 pub entry_hash: String,
69}
70
71#[derive(Debug, Clone)]
73pub struct AuditLogParams {
74 pub session_id: String,
75 pub workflow_id: Option<String>,
76 pub action_type: ActionType,
77 pub action_detail: String,
78 pub model_used: Option<String>,
79 pub cost: Option<f64>,
80 pub input_hash: String,
81 pub output_summary: String,
82 pub decision: ActionDecision,
83 pub approved_by: Option<String>,
84 pub duration_ms: u64,
85 pub success: bool,
86 pub error: Option<String>,
87}
88
89impl AuditEntry {
90 #[allow(clippy::too_many_arguments)]
94 fn compute_hash(
95 id: &Uuid,
96 timestamp: &DateTime<Utc>,
97 session_id: &str,
98 action_type: &ActionType,
99 action_detail: &str,
100 decision: &ActionDecision,
101 success: bool,
102 prev_hash: &str,
103 ) -> String {
104 let mut hasher = Sha256::new();
105 hasher.update(id.to_string().as_bytes());
106 hasher.update(timestamp.to_rfc3339().as_bytes());
107 hasher.update(session_id.as_bytes());
108 hasher.update(serde_json::to_string(action_type).unwrap_or_default().as_bytes());
109 hasher.update(action_detail.as_bytes());
110 hasher.update(serde_json::to_string(decision).unwrap_or_default().as_bytes());
111 hasher.update(if success { &b"true"[..] } else { &b"false"[..] });
112 hasher.update(prev_hash.as_bytes());
113 let result = hasher.finalize();
114 result.iter().map(|b| format!("{:02x}", b)).collect()
115 }
116}
117
118pub struct AuditStore {
123 path: PathBuf,
125 last_hash: std::sync::Mutex<Option<String>>,
127}
128
129impl AuditStore {
130 pub fn new(path: &Path) -> Self {
132 Self {
133 path: path.to_path_buf(),
134 last_hash: std::sync::Mutex::new(None),
135 }
136 }
137
138 pub fn new_memory() -> Self {
140 let path = std::env::temp_dir()
141 .join(format!("mur-audit-{}.jsonl", uuid::Uuid::new_v4()));
142 Self {
143 path,
144 last_hash: std::sync::Mutex::new(None),
145 }
146 }
147
148 pub fn log_action(&self, action: &crate::types::Action, status: &str, output: &str) {
150 let params = AuditLogParams {
151 session_id: "workflow".into(),
152 workflow_id: None,
153 action_type: action.action_type.clone(),
154 action_detail: action.command.clone(),
155 model_used: None,
156 cost: None,
157 input_hash: String::new(),
158 output_summary: output.chars().take(200).collect(),
159 decision: if status == "blocked" {
160 crate::types::ActionDecision::Blocked {
161 reason: output.to_string(),
162 }
163 } else {
164 crate::types::ActionDecision::Allowed
165 },
166 approved_by: None,
167 duration_ms: 0,
168 success: status != "blocked",
169 error: if status == "blocked" {
170 Some(output.to_string())
171 } else {
172 None
173 },
174 };
175 if let Err(e) = self.log(params) {
176 tracing::warn!("Failed to write audit log: {}", e);
177 }
178 }
179
180 pub fn log(&self, params: AuditLogParams) -> Result<AuditEntry, AuditError> {
184 let prev_hash = {
186 let mut cached = self.last_hash.lock().unwrap_or_else(|e| e.into_inner());
187 if let Some(ref h) = *cached {
188 h.clone()
189 } else {
190 let h = self.read_last_hash().unwrap_or_else(|| "0".repeat(64));
192 *cached = Some(h.clone());
193 h
194 }
195 };
196
197 let id = Uuid::new_v4();
198 let timestamp = Utc::now();
199
200 let entry_hash = AuditEntry::compute_hash(
201 &id,
202 ×tamp,
203 ¶ms.session_id,
204 ¶ms.action_type,
205 ¶ms.action_detail,
206 ¶ms.decision,
207 params.success,
208 &prev_hash,
209 );
210
211 let entry = AuditEntry {
212 id,
213 timestamp,
214 session_id: params.session_id,
215 workflow_id: params.workflow_id,
216 action_type: params.action_type,
217 action_detail: params.action_detail,
218 model_used: params.model_used,
219 cost: params.cost,
220 input_hash: params.input_hash,
221 output_summary: params.output_summary,
222 decision: params.decision,
223 approved_by: params.approved_by,
224 duration_ms: params.duration_ms,
225 success: params.success,
226 error: params.error,
227 prev_hash,
228 entry_hash: entry_hash.clone(),
229 };
230
231 self.append_entry(&entry)?;
233
234 if let Ok(mut cached) = self.last_hash.lock() {
236 *cached = Some(entry_hash);
237 }
238
239 Ok(entry)
240 }
241
242 fn append_entry(&self, entry: &AuditEntry) -> Result<(), AuditError> {
244 use std::io::Write;
245 if let Some(parent) = self.path.parent() {
246 std::fs::create_dir_all(parent)?;
247 }
248 let mut file = std::fs::OpenOptions::new()
249 .create(true)
250 .append(true)
251 .open(&self.path)?;
252 let json = serde_json::to_string(entry)?;
253 writeln!(file, "{}", json)?;
254 Ok(())
255 }
256
257 fn read_last_hash(&self) -> Option<String> {
259 if !self.path.exists() {
260 return None;
261 }
262 let content = std::fs::read_to_string(&self.path).ok()?;
264 let last_line = content.lines().rev().find(|l| !l.trim().is_empty())?;
265 let entry: AuditEntry = serde_json::from_str(last_line).ok()?;
266 Some(entry.entry_hash)
267 }
268
269 pub fn load_all(&self) -> Result<Vec<AuditEntry>, AuditError> {
271 if !self.path.exists() {
272 return Ok(Vec::new());
273 }
274 let content = std::fs::read_to_string(&self.path)?;
275 if content.trim().is_empty() {
276 return Ok(Vec::new());
277 }
278 if content.trim_start().starts_with('[') {
280 let entries: Vec<AuditEntry> = serde_json::from_str(&content)?;
281 Ok(entries)
282 } else {
283 let mut entries = Vec::new();
284 for line in content.lines() {
285 let line = line.trim();
286 if line.is_empty() {
287 continue;
288 }
289 let entry: AuditEntry = serde_json::from_str(line)?;
290 entries.push(entry);
291 }
292 Ok(entries)
293 }
294 }
295
296 pub fn query_by_workflow(&self, workflow_id: &str) -> Result<Vec<AuditEntry>, AuditError> {
298 let entries = self.load_all()?;
299 Ok(entries
300 .into_iter()
301 .filter(|e| e.workflow_id.as_deref() == Some(workflow_id))
302 .collect())
303 }
304
305 pub fn query_by_time_range(
307 &self,
308 from: DateTime<Utc>,
309 to: DateTime<Utc>,
310 ) -> Result<Vec<AuditEntry>, AuditError> {
311 let entries = self.load_all()?;
312 Ok(entries
313 .into_iter()
314 .filter(|e| e.timestamp >= from && e.timestamp <= to)
315 .collect())
316 }
317
318 pub fn recent(&self, limit: usize) -> Result<Vec<AuditEntry>, AuditError> {
320 let entries = self.load_all()?;
321 let start = entries.len().saturating_sub(limit);
322 Ok(entries[start..].to_vec())
323 }
324
325 pub fn verify_chain(&self) -> Result<bool, AuditError> {
330 let entries = self.load_all()?;
331 verify_chain(&entries)
332 }
333
334 #[cfg(test)]
337 fn save_all(&self, entries: &[AuditEntry]) -> Result<(), AuditError> {
338 use std::io::Write;
339 if let Some(parent) = self.path.parent() {
340 std::fs::create_dir_all(parent)?;
341 }
342 let mut file = std::fs::File::create(&self.path)?;
343 for entry in entries {
344 let json = serde_json::to_string(entry)?;
345 writeln!(file, "{}", json)?;
346 }
347 Ok(())
348 }
349}
350
351pub fn verify_chain(entries: &[AuditEntry]) -> Result<bool, AuditError> {
355 let genesis_hash = "0".repeat(64);
356
357 for (i, entry) in entries.iter().enumerate() {
358 let expected_prev = if i == 0 {
360 &genesis_hash
361 } else {
362 &entries[i - 1].entry_hash
363 };
364
365 if entry.prev_hash != *expected_prev {
366 return Err(AuditError::ChainBroken {
367 index: i,
368 expected: expected_prev.clone(),
369 actual: entry.prev_hash.clone(),
370 });
371 }
372
373 let recomputed = AuditEntry::compute_hash(
375 &entry.id,
376 &entry.timestamp,
377 &entry.session_id,
378 &entry.action_type,
379 &entry.action_detail,
380 &entry.decision,
381 entry.success,
382 &entry.prev_hash,
383 );
384
385 if entry.entry_hash != recomputed {
386 return Err(AuditError::ChainBroken {
387 index: i,
388 expected: recomputed,
389 actual: entry.entry_hash.clone(),
390 });
391 }
392 }
393
394 Ok(true)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use tempfile::TempDir;
401
402 fn make_store() -> (AuditStore, TempDir) {
403 let dir = TempDir::new().unwrap();
404 let path = dir.path().join("audit.json");
405 let store = AuditStore::new(&path);
406 (store, dir)
407 }
408
409 fn log_test_entry(store: &AuditStore, detail: &str) -> AuditEntry {
410 store
411 .log(AuditLogParams {
412 session_id: "test-session".into(),
413 workflow_id: Some("test-workflow".into()),
414 action_type: ActionType::Execute,
415 action_detail: detail.to_string(),
416 model_used: None,
417 cost: None,
418 input_hash: "input-hash-placeholder".into(),
419 output_summary: "output summary".into(),
420 decision: ActionDecision::Allowed,
421 approved_by: Some("auto".into()),
422 duration_ms: 100,
423 success: true,
424 error: None,
425 })
426 .expect("Failed to log entry")
427 }
428
429 #[test]
430 fn test_log_single_entry() {
431 let (store, _dir) = make_store();
432 let entry = log_test_entry(&store, "test action");
433
434 assert_eq!(entry.session_id, "test-session");
435 assert_eq!(entry.action_detail, "test action");
436 assert!(entry.success);
437 assert_eq!(entry.prev_hash, "0".repeat(64));
439 }
440
441 #[test]
442 fn test_hash_chain_linkage() {
443 let (store, _dir) = make_store();
444
445 let entry1 = log_test_entry(&store, "first action");
446 let entry2 = log_test_entry(&store, "second action");
447 let entry3 = log_test_entry(&store, "third action");
448
449 assert_eq!(entry2.prev_hash, entry1.entry_hash);
451 assert_eq!(entry3.prev_hash, entry2.entry_hash);
452
453 assert_ne!(entry1.entry_hash, entry2.entry_hash);
455 assert_ne!(entry2.entry_hash, entry3.entry_hash);
456 }
457
458 #[test]
459 fn test_verify_chain_valid() {
460 let (store, _dir) = make_store();
461
462 log_test_entry(&store, "action 1");
463 log_test_entry(&store, "action 2");
464 log_test_entry(&store, "action 3");
465
466 assert!(store.verify_chain().unwrap());
467 }
468
469 #[test]
470 fn test_verify_chain_detects_tampered_entry() {
471 let (store, _dir) = make_store();
472
473 log_test_entry(&store, "action 1");
474 log_test_entry(&store, "action 2");
475 log_test_entry(&store, "action 3");
476
477 let mut entries = store.load_all().unwrap();
479 entries[1].action_detail = "TAMPERED ACTION".into();
480 store.save_all(&entries).unwrap();
481
482 let result = store.verify_chain();
484 assert!(result.is_err());
485 if let Err(AuditError::ChainBroken { index, .. }) = result {
486 assert_eq!(index, 1);
487 }
488 }
489
490 #[test]
491 fn test_verify_chain_detects_broken_linkage() {
492 let (store, _dir) = make_store();
493
494 log_test_entry(&store, "action 1");
495 log_test_entry(&store, "action 2");
496 log_test_entry(&store, "action 3");
497
498 let mut entries = store.load_all().unwrap();
500 entries[2].prev_hash = "0000000000000000000000000000000000000000000000000000000000000000".into();
501 store.save_all(&entries).unwrap();
502
503 let result = store.verify_chain();
504 assert!(result.is_err());
505 }
506
507 #[test]
508 fn test_verify_empty_chain() {
509 let (store, _dir) = make_store();
510 assert!(store.verify_chain().unwrap());
511 }
512
513 #[test]
514 fn test_query_by_workflow() {
515 let (store, _dir) = make_store();
516
517 store
518 .log(AuditLogParams {
519 session_id: "sess1".into(),
520 workflow_id: Some("workflow-a".into()),
521 action_type: ActionType::Execute,
522 action_detail: "action for A".into(),
523 model_used: None,
524 cost: None,
525 input_hash: String::new(),
526 output_summary: String::new(),
527 decision: ActionDecision::Allowed,
528 approved_by: None,
529 duration_ms: 100,
530 success: true,
531 error: None,
532 })
533 .unwrap();
534
535 store
536 .log(AuditLogParams {
537 session_id: "sess1".into(),
538 workflow_id: Some("workflow-b".into()),
539 action_type: ActionType::Read,
540 action_detail: "action for B".into(),
541 model_used: None,
542 cost: None,
543 input_hash: String::new(),
544 output_summary: String::new(),
545 decision: ActionDecision::Allowed,
546 approved_by: None,
547 duration_ms: 50,
548 success: true,
549 error: None,
550 })
551 .unwrap();
552
553 let results = store.query_by_workflow("workflow-a").unwrap();
554 assert_eq!(results.len(), 1);
555 assert_eq!(results[0].action_detail, "action for A");
556 }
557
558 #[test]
559 fn test_recent_entries() {
560 let (store, _dir) = make_store();
561
562 for i in 0..5 {
563 log_test_entry(&store, &format!("action {}", i));
564 }
565
566 let recent = store.recent(2).unwrap();
567 assert_eq!(recent.len(), 2);
568 assert_eq!(recent[0].action_detail, "action 3");
569 assert_eq!(recent[1].action_detail, "action 4");
570 }
571
572 #[test]
573 fn test_persistence() {
574 let dir = TempDir::new().unwrap();
575 let path = dir.path().join("audit.json");
576
577 {
579 let store = AuditStore::new(&path);
580 log_test_entry(&store, "persisted action");
581 }
582
583 {
585 let store = AuditStore::new(&path);
586 let entries = store.load_all().unwrap();
587 assert_eq!(entries.len(), 1);
588 assert_eq!(entries[0].action_detail, "persisted action");
589 assert!(store.verify_chain().unwrap());
590 }
591 }
592}