Skip to main content

mur_core/
audit.rs

1//! Audit logging with hash chain for tamper detection.
2//!
3//! Every action taken by MUR Commander is recorded in an append-only audit log.
4//! Each entry is linked to the previous via SHA-256, forming a hash chain that
5//! makes any tampering immediately detectable.
6
7use 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/// Errors specific to the audit system.
16#[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/// A single audit log entry with hash chain linkage.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AuditEntry {
35    /// Unique ID for this audit entry.
36    pub id: Uuid,
37    /// Timestamp of this entry.
38    pub timestamp: DateTime<Utc>,
39    /// Session that generated this entry.
40    pub session_id: String,
41    /// Workflow being executed (if any).
42    pub workflow_id: Option<String>,
43    /// Type of action performed.
44    pub action_type: ActionType,
45    /// Human-readable description of the action.
46    pub action_detail: String,
47    /// AI model used (if any).
48    pub model_used: Option<String>,
49    /// API cost incurred (if any).
50    pub cost: Option<f64>,
51    /// SHA-256 hash of the input (privacy: don't store raw input).
52    pub input_hash: String,
53    /// Summary of the output.
54    pub output_summary: String,
55    /// Constitution decision for this action.
56    pub decision: ActionDecision,
57    /// Who approved this action ("user", "auto", "constitution").
58    pub approved_by: Option<String>,
59    /// Duration in milliseconds.
60    pub duration_ms: u64,
61    /// Whether the action succeeded.
62    pub success: bool,
63    /// Error message if the action failed.
64    pub error: Option<String>,
65    /// SHA-256 hash of the previous entry (forms the hash chain).
66    pub prev_hash: String,
67    /// SHA-256 hash of this entry's content + prev_hash.
68    pub entry_hash: String,
69}
70
71/// Parameters for creating a new audit log entry.
72#[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    /// Compute the hash for this entry based on its content and the previous hash.
91    ///
92    /// The hash covers all meaningful fields to ensure any modification is detected.
93    #[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
118/// Append-only audit store backed by a JSON file.
119///
120/// In a future phase, this will be backed by LanceDB for vector search
121/// capabilities. For now, JSON file storage is sufficient and simple.
122pub struct AuditStore {
123    /// Path to the JSON audit log file.
124    path: PathBuf,
125    /// Cached last hash for append-only writes (avoids full reload).
126    last_hash: std::sync::Mutex<Option<String>>,
127}
128
129impl AuditStore {
130    /// Create a new audit store at the given path.
131    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    /// Create an audit store using a temporary file (for tests).
139    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    /// Convenience: log an action with minimal params.
149    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    /// Append a new entry to the audit log.
181    ///
182    /// Automatically computes the hash chain linkage from the last entry.
183    pub fn log(&self, params: AuditLogParams) -> Result<AuditEntry, AuditError> {
184        // Get prev_hash from cache or read last line from file
185        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                // Read last line to get prev_hash (avoid loading entire file)
191                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            &timestamp,
203            &params.session_id,
204            &params.action_type,
205            &params.action_detail,
206            &params.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        // Append-only: write single JSON line
232        self.append_entry(&entry)?;
233
234        // Update cached last hash
235        if let Ok(mut cached) = self.last_hash.lock() {
236            *cached = Some(entry_hash);
237        }
238
239        Ok(entry)
240    }
241
242    /// Append a single entry as a JSON line (append-only, O(1)).
243    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    /// Read the last entry's hash from the file without loading everything.
258    fn read_last_hash(&self) -> Option<String> {
259        if !self.path.exists() {
260            return None;
261        }
262        // Read file and get last non-empty line
263        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    /// Load all audit entries from the log file.
270    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        // Support both JSONL (one entry per line) and legacy JSON array format
279        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    /// Query entries by workflow ID.
297    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    /// Query entries within a time range.
306    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    /// Get the most recent N entries.
319    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    /// Verify the integrity of the entire hash chain.
326    ///
327    /// Returns Ok(true) if the chain is valid, or an error describing
328    /// where the chain was broken.
329    pub fn verify_chain(&self) -> Result<bool, AuditError> {
330        let entries = self.load_all()?;
331        verify_chain(&entries)
332    }
333
334    /// Save all entries to the JSON file.
335    /// Overwrite entire file (used for tamper simulation in tests).
336    #[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
351/// Verify a hash chain given a slice of audit entries.
352///
353/// This is a pure function that can be used independently of the store.
354pub 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        // Check prev_hash linkage
359        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        // Recompute entry hash and verify
374        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        // Genesis entry links to all-zero hash
438        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        // Each entry's prev_hash should point to the previous entry's hash
450        assert_eq!(entry2.prev_hash, entry1.entry_hash);
451        assert_eq!(entry3.prev_hash, entry2.entry_hash);
452
453        // All hashes should be unique
454        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        // Tamper with an entry
478        let mut entries = store.load_all().unwrap();
479        entries[1].action_detail = "TAMPERED ACTION".into();
480        store.save_all(&entries).unwrap();
481
482        // Verify should fail
483        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        // Break the chain by modifying prev_hash
499        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        // Write with one store instance
578        {
579            let store = AuditStore::new(&path);
580            log_test_entry(&store, "persisted action");
581        }
582
583        // Read with a new store instance
584        {
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}