Skip to main content

oxios_kernel/
audit_trail.rs

1//! Tamper-evident audit trail with cryptographic hash chain.
2//!
3//! Provides a Merkle-chain style audit log for all kernel events.
4//! Each entry is cryptographically linked to the previous entry,
5//! making tampering detectable.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use crate::state_store::StateStore;
12
13/// Type alias for hash digest (blake3 hex output).
14pub type HashDigest = String;
15
16/// Unique identifier for an agent (String for flexibility).
17pub type AgentId = String;
18
19// ─── Error Types ─────────────────────────────────────────────────────────────
20
21/// Errors that can occur during audit trail operations.
22#[derive(Debug, Clone)]
23pub enum AuditError {
24    /// Chain link broken at given sequence number.
25    ChainBroken {
26        /// Sequence number where the chain broke.
27        seq: u64,
28        /// Expected hash value.
29        expected: String,
30        /// Actual hash value found.
31        found: String,
32    },
33    /// Invalid timestamp detected.
34    InvalidTimestamp {
35        /// Sequence number with the bad timestamp.
36        seq: u64,
37    },
38    /// Failed to export audit log.
39    ExportFailed(String),
40}
41
42impl std::fmt::Display for AuditError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            AuditError::ChainBroken {
46                seq,
47                expected,
48                found,
49            } => {
50                write!(
51                    f,
52                    "chain broken at seq {}: expected hash '{}', found '{}'",
53                    seq, expected, found
54                )
55            }
56            AuditError::InvalidTimestamp { seq } => {
57                write!(f, "invalid timestamp at seq {}", seq)
58            }
59            AuditError::ExportFailed(msg) => {
60                write!(f, "export failed: {}", msg)
61            }
62        }
63    }
64}
65
66impl std::error::Error for AuditError {}
67
68// ─── Audit Action ─────────────────────────────────────────────────────────────
69
70/// Types of actions that can be audited.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(tag = "type", content = "data")]
73pub enum AuditAction {
74    /// Agent spawned with task type.
75    AgentSpawn {
76        /// Type of task the agent was spawned for.
77        task_type: String,
78    },
79    /// Agent exited with reason.
80    AgentExit {
81        /// Reason for agent exit.
82        reason: String,
83    },
84    /// Tool was called.
85    ToolCall {
86        /// Name of the tool invoked.
87        tool: String,
88        /// JSON-encoded arguments passed to the tool.
89        args_json: String,
90    },
91    /// Tool returned a result.
92    ToolResult {
93        /// Name of the tool that produced the result.
94        tool: String,
95        /// Whether the tool call succeeded.
96        success: bool,
97    },
98    /// Memory entry written.
99    MemoryWrite {
100        /// ID of the written memory entry.
101        entry_id: String,
102    },
103    /// Memory entry read.
104    MemoryRead {
105        /// ID of the read memory entry.
106        entry_id: String,
107    },
108    /// Configuration changed.
109    ConfigChange {
110        /// Configuration key that changed.
111        key: String,
112    },
113    /// Program installed.
114    ProgramInstall {
115        /// Name of the installed program.
116        program: String,
117        /// Version of the installed program.
118        version: String,
119    },
120    /// Cron job triggered.
121    CronTrigger {
122        /// ID of the triggered cron job.
123        job_id: String,
124    },
125    /// Git commit created.
126    GitCommit {
127        /// Commit message.
128        message: String,
129    },
130    /// Access was denied.
131    AccessDenied {
132        /// Permission that was denied.
133        permission: String,
134    },
135    /// Other/unclassified action.
136    Other {
137        /// Free-form detail string.
138        detail: String,
139    },
140}
141
142// ─── Audit Entry ─────────────────────────────────────────────────────────────
143
144/// A single entry in the audit trail.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct AuditEntry {
147    /// Sequential entry number.
148    pub seq: u64,
149    /// Timestamp of the entry.
150    pub timestamp: DateTime<Utc>,
151    /// Agent ID that performed the action.
152    pub actor: AgentId,
153    /// The action that was performed.
154    pub action: AuditAction,
155    /// Resource affected by the action.
156    pub resource: String,
157    /// Hash of the previous entry (empty string for genesis).
158    pub prev_hash: HashDigest,
159    /// Hash of this entry.
160    pub hash: HashDigest,
161    /// Optional arbitrary metadata.
162    pub metadata: Option<serde_json::Value>,
163}
164
165// ─── Hash Computation ──────────────────────────────────────────────────────────
166
167/// Compute the hash for an audit entry.
168/// Uses blake3 to hash all entry fields in a deterministic way.
169fn compute_entry_hash(
170    seq: u64,
171    ts: &DateTime<Utc>,
172    actor: &str,
173    action: &AuditAction,
174    resource: &str,
175    prev: &str,
176) -> HashDigest {
177    use blake3::Hasher;
178
179    let mut h = Hasher::new();
180    h.update(b"oxios-audit-v1");
181    h.update(&seq.to_be_bytes());
182    h.update(ts.to_rfc3339().as_bytes());
183    h.update(actor.as_bytes());
184
185    // Serialize action to bytes for hashing
186    let action_bytes = serde_json::to_vec(action).unwrap_or_default();
187    h.update(&action_bytes);
188    h.update(prev.as_bytes());
189    h.update(resource.as_bytes());
190
191    h.finalize().to_hex().to_string()
192}
193
194// ─── Audit Trail ─────────────────────────────────────────────────────────────
195
196/// A tamper-evident audit trail with cryptographic hash chain.
197///
198/// Each entry is cryptographically linked to the previous entry using
199/// blake3 hashing. This makes it possible to detect any tampering with
200/// historical entries.
201pub struct AuditTrail {
202    /// All audit entries in order.
203    entries: parking_lot::RwLock<Vec<AuditEntry>>,
204    /// Sequence number counter for next entry.
205    seq_counter: AtomicU64,
206    /// Chain hasher for computing hashes (mutex for interior mutability).
207    #[allow(dead_code)]
208    chain_hasher: parking_lot::Mutex<blake3::Hasher>,
209    /// Maximum number of entries before auto-pruning.
210    max_entries: usize,
211}
212
213impl AuditTrail {
214    /// Create a new audit trail.
215    pub fn new(max_entries: usize) -> Self {
216        Self {
217            entries: parking_lot::RwLock::new(Vec::new()),
218            seq_counter: AtomicU64::new(1), // Start at 1, 0 is genesis marker
219            chain_hasher: parking_lot::Mutex::new(blake3::Hasher::new()),
220            max_entries,
221        }
222    }
223
224    /// Get the current number of entries.
225    pub fn len(&self) -> usize {
226        self.entries.read().len()
227    }
228
229    /// Check if the trail is empty.
230    pub fn is_empty(&self) -> bool {
231        self.len() == 0
232    }
233
234    /// Get the last hash in the chain.
235    fn last_hash(&self) -> HashDigest {
236        let entries = self.entries.read();
237        entries
238            .last()
239            .map(|e| e.hash.clone())
240            .unwrap_or_else(|| "genesis".to_string())
241    }
242
243    /// Append an audit entry. Computes hash chain automatically.
244    pub fn append(&self, actor: AgentId, action: AuditAction, resource: String) -> HashDigest {
245        self.append_with_meta(actor, action, resource, None)
246    }
247
248    /// Append an audit entry with optional metadata.
249    pub fn append_with_meta(
250        &self,
251        actor: AgentId,
252        action: AuditAction,
253        resource: String,
254        metadata: Option<serde_json::Value>,
255    ) -> HashDigest {
256        let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst);
257        let timestamp = Utc::now();
258        let prev_hash = self.last_hash();
259        let hash = compute_entry_hash(seq, &timestamp, &actor, &action, &resource, &prev_hash);
260
261        let entry = AuditEntry {
262            seq,
263            timestamp,
264            actor,
265            action,
266            resource,
267            prev_hash,
268            hash,
269            metadata,
270        };
271
272        let entry_hash = entry.hash.clone();
273
274        {
275            let mut entries = self.entries.write();
276            entries.push(entry);
277
278            // Auto-prune if over limit
279            if entries.len() > self.max_entries {
280                let excess = entries.len() - self.max_entries;
281                entries.drain(0..excess);
282                // Fix the chain: mark the first remaining entry as a new chain root.
283                // We only update prev_hash to "pruned" — we do NOT recompute the hash.
284                // Remaining entries still link to each other correctly since their
285                // hashes are unchanged, so no cascade is needed. O(1) instead of O(N).
286                if let Some(first) = entries.first_mut() {
287                    first.prev_hash = "pruned".to_string();
288                }
289            }
290        }
291
292        entry_hash
293    }
294
295    /// Verify the integrity of the hash chain.
296    ///
297    /// The chain is valid if:
298    /// - The first entry has prev_hash "genesis" or "pruned" (after auto-pruning)
299    /// - Every subsequent entry's prev_hash matches the previous entry's hash
300    /// - Every entry's hash can be independently recomputed
301    pub fn verify(&self) -> Result<bool, AuditError> {
302        let entries = self.entries.read();
303        let mut prev_hash = "genesis".to_string();
304
305        for (i, entry) in entries.iter().enumerate() {
306            // Check sequence is correct
307            if entry.seq == 0 {
308                return Err(AuditError::ChainBroken {
309                    seq: 0,
310                    expected: "non-zero sequence".to_string(),
311                    found: "0".to_string(),
312                });
313            }
314
315            // First entry after pruning gets a free pass on prev_hash matching.
316            // We also skip hash recomputation since the stored hash was computed
317            // with the original prev_hash, not "pruned". We trust the stored hash.
318            if i == 0 && entry.prev_hash == "pruned" {
319                // Accept "pruned" as a valid starting point
320                prev_hash = entry.hash.clone();
321                continue;
322            } else if entry.prev_hash != prev_hash {
323                return Err(AuditError::ChainBroken {
324                    seq: entry.seq,
325                    expected: prev_hash,
326                    found: entry.prev_hash.clone(),
327                });
328            }
329
330            // Verify timestamp is not in the future
331            let now = Utc::now();
332            if entry.timestamp > now {
333                return Err(AuditError::InvalidTimestamp { seq: entry.seq });
334            }
335
336            // Recompute hash and verify
337            let computed = compute_entry_hash(
338                entry.seq,
339                &entry.timestamp,
340                &entry.actor,
341                &entry.action,
342                &entry.resource,
343                &entry.prev_hash,
344            );
345
346            if computed != entry.hash {
347                return Err(AuditError::ChainBroken {
348                    seq: entry.seq,
349                    expected: computed,
350                    found: entry.hash.clone(),
351                });
352            }
353
354            prev_hash = entry.hash.clone();
355        }
356
357        Ok(true)
358    }
359
360    /// Get entries within a sequence range (inclusive).
361    pub fn entries(&self, from_seq: u64, to_seq: u64) -> Vec<AuditEntry> {
362        let entries = self.entries.read();
363        entries
364            .iter()
365            .filter(|e| e.seq >= from_seq && e.seq <= to_seq)
366            .cloned()
367            .collect()
368    }
369
370    /// Get all entries.
371    pub fn all_entries(&self) -> Vec<AuditEntry> {
372        self.entries.read().clone()
373    }
374
375    /// Query entries by agent ID.
376    pub fn by_agent(&self, agent_id: &str) -> Vec<AuditEntry> {
377        let entries = self.entries.read();
378        entries
379            .iter()
380            .filter(|e| e.actor == agent_id)
381            .cloned()
382            .collect()
383    }
384
385    /// Query entries by action type.
386    pub fn by_action(&self, action: &AuditAction) -> Vec<AuditEntry> {
387        let entries = self.entries.read();
388        entries
389            .iter()
390            .filter(|e| &e.action == action)
391            .cloned()
392            .collect()
393    }
394
395    /// Query entries by action discriminant (for faster lookup).
396    pub fn by_action_type(&self, type_name: &str) -> Vec<AuditEntry> {
397        let entries = self.entries.read();
398        entries
399            .iter()
400            .filter(|e| {
401                let action_name = match &e.action {
402                    AuditAction::AgentSpawn { .. } => "AgentSpawn",
403                    AuditAction::AgentExit { .. } => "AgentExit",
404                    AuditAction::ToolCall { .. } => "ToolCall",
405                    AuditAction::ToolResult { .. } => "ToolResult",
406                    AuditAction::MemoryWrite { .. } => "MemoryWrite",
407                    AuditAction::MemoryRead { .. } => "MemoryRead",
408                    AuditAction::ConfigChange { .. } => "ConfigChange",
409                    AuditAction::ProgramInstall { .. } => "ProgramInstall",
410                    AuditAction::CronTrigger { .. } => "CronTrigger",
411                    AuditAction::GitCommit { .. } => "GitCommit",
412                    AuditAction::AccessDenied { .. } => "AccessDenied",
413                    AuditAction::Other { .. } => "Other",
414                };
415                action_name == type_name
416            })
417            .cloned()
418            .collect()
419    }
420
421    /// Export entries from a sequence number as JSON.
422    pub fn export_json(&self, from_seq: u64) -> Result<String, AuditError> {
423        let entries = self.entries.read();
424        let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.seq >= from_seq).collect();
425
426        serde_json::to_string_pretty(&filtered).map_err(|e| AuditError::ExportFailed(e.to_string()))
427    }
428
429    /// Export all entries as JSON.
430    pub fn export_all_json(&self) -> Result<String, AuditError> {
431        let entries = self.entries.read();
432        serde_json::to_string_pretty(&*entries).map_err(|e| AuditError::ExportFailed(e.to_string()))
433    }
434
435    /// Flush entries to the state store for persistence.
436    pub fn flush(&self, state_store: &StateStore) -> Result<(), AuditError> {
437        let entries = self.entries.read();
438        state_store
439            .save_audit_entries(&entries)
440            .map_err(|e| AuditError::ExportFailed(e.to_string()))
441    }
442
443    /// Restore previously persisted entries.
444    ///
445    /// Sets `seq_counter` to `max(entries.seq) + 1` so new entries
446    /// don't collide with restored ones. Trims to `max_entries` if
447    /// the restored set is larger, re-linking the hash chain.
448    pub fn restore_from(&self, entries: Vec<AuditEntry>) {
449        if entries.is_empty() {
450            return;
451        }
452
453        // Advance seq_counter past the highest restored seq.
454        let max_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0);
455        self.seq_counter.store(max_seq + 1, Ordering::SeqCst);
456
457        let mut current = self.entries.write();
458        *current = entries;
459
460        // Trim if restored set exceeds max_entries.
461        if current.len() > self.max_entries {
462            let excess = current.len() - self.max_entries;
463            current.drain(0..excess);
464
465            // Mark the first remaining entry as pruned root.
466            // Do NOT recompute hashes — remaining entries still link
467            // to each other correctly. O(1) instead of O(N).
468            if let Some(first) = current.first_mut() {
469                first.prev_hash = "pruned".to_string();
470            }
471        }
472
473        tracing::info!(
474            restored = current.len(),
475            next_seq = max_seq + 1,
476            "Audit trail restored from persistence"
477        );
478    }
479}
480
481impl Default for AuditTrail {
482    fn default() -> Self {
483        Self::new(100_000)
484    }
485}
486
487impl std::fmt::Debug for AuditTrail {
488    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        f.debug_struct("AuditTrail")
490            .field("entries", &self.len())
491            .field("seq_counter", &self.seq_counter)
492            .field("max_entries", &self.max_entries)
493            .finish()
494    }
495}
496
497// ─── StateStore Extension ─────────────────────────────────────────────────────
498
499use anyhow::Result;
500
501impl StateStore {
502    /// Save audit entries to the state store.
503    pub fn save_audit_entries(&self, entries: &[AuditEntry]) -> Result<()> {
504        let path = self.audit_path();
505        if let Some(parent) = path.parent() {
506            std::fs::create_dir_all(parent)?;
507        }
508        let json = serde_json::to_string_pretty(entries)?;
509        std::fs::write(&path, json)?;
510        Ok(())
511    }
512
513    /// Load audit entries from the state store.
514    pub fn load_audit_entries(&self) -> Result<Vec<AuditEntry>> {
515        let path = self.audit_path();
516        if !path.exists() {
517            return Ok(Vec::new());
518        }
519        let json = std::fs::read_to_string(&path)?;
520        let entries: Vec<AuditEntry> = serde_json::from_str(&json)?;
521        Ok(entries)
522    }
523
524    /// Get the path to the audit trail file.
525    fn audit_path(&self) -> std::path::PathBuf {
526        self.base_path.join("audit").join("trail.json")
527    }
528}
529
530// ─── Tests ────────────────────────────────────────────────────────────────────
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    fn create_test_trail() -> AuditTrail {
537        AuditTrail::new(1000)
538    }
539
540    #[test]
541    fn test_append_generates_hash() {
542        let trail = create_test_trail();
543        let hash = trail.append(
544            "agent-001".to_string(),
545            AuditAction::AgentSpawn {
546                task_type: "test".to_string(),
547            },
548            "/test/resource".to_string(),
549        );
550
551        assert!(!hash.is_empty());
552        assert_eq!(hash.len(), 64); // blake3 hex is 64 chars
553    }
554
555    #[test]
556    fn test_append_increments_seq() {
557        let trail = create_test_trail();
558
559        let h1 = trail.append(
560            "agent-001".to_string(),
561            AuditAction::AgentSpawn {
562                task_type: "test".to_string(),
563            },
564            "/test/resource".to_string(),
565        );
566
567        let h2 = trail.append(
568            "agent-002".to_string(),
569            AuditAction::ToolCall {
570                tool: "bash".to_string(),
571                args_json: "{}".to_string(),
572            },
573            "/test/resource2".to_string(),
574        );
575
576        assert_ne!(h1, h2);
577
578        let entries = trail.all_entries();
579        assert_eq!(entries.len(), 2);
580        assert_eq!(entries[0].seq, 1);
581        assert_eq!(entries[1].seq, 2);
582    }
583
584    #[test]
585    fn test_hash_chain_linked() {
586        let trail = create_test_trail();
587
588        trail.append(
589            "agent-001".to_string(),
590            AuditAction::AgentSpawn {
591                task_type: "test".to_string(),
592            },
593            "/test/resource".to_string(),
594        );
595
596        trail.append(
597            "agent-001".to_string(),
598            AuditAction::AgentExit {
599                reason: "done".to_string(),
600            },
601            "/test/resource".to_string(),
602        );
603
604        let entries = trail.all_entries();
605        assert_eq!(entries[0].prev_hash, "genesis");
606        assert_eq!(entries[1].prev_hash, entries[0].hash);
607    }
608
609    #[test]
610    fn test_verify_passes_clean_chain() {
611        let trail = create_test_trail();
612
613        trail.append(
614            "agent-001".to_string(),
615            AuditAction::AgentSpawn {
616                task_type: "test".to_string(),
617            },
618            "/test/resource".to_string(),
619        );
620
621        trail.append(
622            "agent-001".to_string(),
623            AuditAction::ToolCall {
624                tool: "bash".to_string(),
625                args_json: "{}".to_string(),
626            },
627            "/test/resource".to_string(),
628        );
629
630        trail.append(
631            "agent-001".to_string(),
632            AuditAction::ToolResult {
633                tool: "bash".to_string(),
634                success: true,
635            },
636            "/test/resource".to_string(),
637        );
638
639        assert!(trail.verify().is_ok());
640    }
641
642    #[test]
643    fn test_verify_detects_tampering() {
644        let trail = create_test_trail();
645
646        trail.append(
647            "agent-001".to_string(),
648            AuditAction::AgentSpawn {
649                task_type: "test".to_string(),
650            },
651            "/test/resource".to_string(),
652        );
653
654        trail.append(
655            "agent-001".to_string(),
656            AuditAction::ToolCall {
657                tool: "bash".to_string(),
658                args_json: "{}".to_string(),
659            },
660            "/test/resource".to_string(),
661        );
662
663        // Tamper with an entry (change actor, which changes its hash)
664        {
665            let mut entries = trail.entries.write();
666            entries[0].actor = "hacker-001".to_string();
667        }
668
669        // Verification should fail - entry 1's stored hash no longer matches recomputed hash
670        let result = trail.verify();
671        assert!(result.is_err());
672        match result {
673            Err(AuditError::ChainBroken { seq, .. }) => {
674                // First entry's stored hash doesn't match its recomputed hash after tampering
675                assert_eq!(seq, 1);
676            }
677            _ => panic!("expected ChainBroken error"),
678        }
679    }
680
681    #[test]
682    fn test_verify_detects_prev_hash_tampering() {
683        let trail = create_test_trail();
684
685        trail.append(
686            "agent-001".to_string(),
687            AuditAction::AgentSpawn {
688                task_type: "test".to_string(),
689            },
690            "/test/resource".to_string(),
691        );
692
693        trail.append(
694            "agent-001".to_string(),
695            AuditAction::ToolCall {
696                tool: "bash".to_string(),
697                args_json: "{}".to_string(),
698            },
699            "/test/resource".to_string(),
700        );
701
702        // Tamper with prev_hash
703        {
704            let mut entries = trail.entries.write();
705            entries[1].prev_hash = "fake-hash".to_string();
706        }
707
708        let result = trail.verify();
709        assert!(result.is_err());
710    }
711
712    #[test]
713    fn test_export_json_format() {
714        let trail = create_test_trail();
715
716        trail.append(
717            "agent-001".to_string(),
718            AuditAction::AgentSpawn {
719                task_type: "test".to_string(),
720            },
721            "/test/resource".to_string(),
722        );
723
724        let json = trail.export_json(0).unwrap();
725
726        // Should be valid JSON
727        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
728        assert_eq!(parsed.len(), 1);
729
730        // Should have expected fields
731        let entry = &parsed[0];
732        assert!(entry.get("seq").is_some());
733        assert!(entry.get("timestamp").is_some());
734        assert!(entry.get("actor").is_some());
735        assert!(entry.get("action").is_some());
736        assert!(entry.get("resource").is_some());
737        assert!(entry.get("prev_hash").is_some());
738        assert!(entry.get("hash").is_some());
739    }
740
741    #[test]
742    fn test_by_agent_query() {
743        let trail = create_test_trail();
744
745        trail.append(
746            "agent-001".to_string(),
747            AuditAction::AgentSpawn {
748                task_type: "test".to_string(),
749            },
750            "/test/resource".to_string(),
751        );
752
753        trail.append(
754            "agent-002".to_string(),
755            AuditAction::AgentSpawn {
756                task_type: "test".to_string(),
757            },
758            "/test/resource".to_string(),
759        );
760
761        trail.append(
762            "agent-001".to_string(),
763            AuditAction::AgentExit {
764                reason: "done".to_string(),
765            },
766            "/test/resource".to_string(),
767        );
768
769        let agent_001_entries = trail.by_agent("agent-001");
770        assert_eq!(agent_001_entries.len(), 2);
771
772        let agent_002_entries = trail.by_agent("agent-002");
773        assert_eq!(agent_002_entries.len(), 1);
774    }
775
776    #[test]
777    fn test_by_action_query() {
778        let trail = create_test_trail();
779
780        trail.append(
781            "agent-001".to_string(),
782            AuditAction::AgentSpawn {
783                task_type: "test".to_string(),
784            },
785            "/test/resource".to_string(),
786        );
787
788        trail.append(
789            "agent-001".to_string(),
790            AuditAction::ToolCall {
791                tool: "bash".to_string(),
792                args_json: "{}".to_string(),
793            },
794            "/test/resource".to_string(),
795        );
796
797        trail.append(
798            "agent-001".to_string(),
799            AuditAction::ToolCall {
800                tool: "grep".to_string(),
801                args_json: "{}".to_string(),
802            },
803            "/test/resource".to_string(),
804        );
805
806        let spawn_entries = trail.by_action(&AuditAction::AgentSpawn {
807            task_type: "test".to_string(),
808        });
809        assert_eq!(spawn_entries.len(), 1);
810
811        let tool_calls = trail.by_action_type("ToolCall");
812        assert_eq!(tool_calls.len(), 2);
813    }
814
815    #[test]
816    fn test_entries_range() {
817        let trail = create_test_trail();
818
819        for i in 0..10 {
820            trail.append(
821                "agent-001".to_string(),
822                AuditAction::Other {
823                    detail: format!("action-{}", i),
824                },
825                "/test/resource".to_string(),
826            );
827        }
828
829        let range = trail.entries(3, 7);
830        assert_eq!(range.len(), 5);
831        assert_eq!(range[0].seq, 3);
832        assert_eq!(range[4].seq, 7);
833    }
834
835    #[test]
836    fn test_auto_prune() {
837        let trail = AuditTrail::new(5);
838
839        for i in 0..10 {
840            trail.append(
841                "agent-001".to_string(),
842                AuditAction::Other {
843                    detail: format!("action-{}", i),
844                },
845                "/test/resource".to_string(),
846            );
847        }
848
849        // Should only have 5 entries (oldest pruned)
850        assert_eq!(trail.len(), 5);
851
852        let entries = trail.all_entries();
853        // First entry should be seq 6 (after pruning 1-5)
854        assert_eq!(entries[0].seq, 6);
855        assert_eq!(entries[4].seq, 10);
856
857        // After pruning, the chain should still be verifiable.
858        assert!(trail.verify().is_ok(), "Pruned trail should still verify");
859    }
860
861    #[test]
862    fn test_append_with_metadata() {
863        let trail = create_test_trail();
864        let metadata = serde_json::json!({
865            "duration_ms": 150,
866            "memory_mb": 32
867        });
868
869        let hash = trail.append_with_meta(
870            "agent-001".to_string(),
871            AuditAction::MemoryWrite {
872                entry_id: "mem-001".to_string(),
873            },
874            "/memory/entries".to_string(),
875            Some(metadata.clone()),
876        );
877
878        assert!(!hash.is_empty());
879
880        let entries = trail.all_entries();
881        assert!(entries[0].metadata.is_some());
882        assert_eq!(entries[0].metadata.as_ref().unwrap(), &metadata);
883    }
884
885    #[test]
886    fn test_genesis_hash() {
887        let trail = create_test_trail();
888
889        // First entry should have prev_hash = "genesis"
890        trail.append(
891            "agent-001".to_string(),
892            AuditAction::AgentSpawn {
893                task_type: "test".to_string(),
894            },
895            "/test/resource".to_string(),
896        );
897
898        let entries = trail.all_entries();
899        assert_eq!(entries[0].prev_hash, "genesis");
900    }
901
902    #[test]
903    fn test_deterministic_hash() {
904        let trail1 = create_test_trail();
905        let trail2 = create_test_trail();
906
907        let action = AuditAction::AgentSpawn {
908            task_type: "test".to_string(),
909        };
910
911        trail1.append(
912            "agent-001".to_string(),
913            action.clone(),
914            "/test/resource".to_string(),
915        );
916
917        // Same input should produce same hash
918        let hash = compute_entry_hash(
919            1,
920            &trail1.all_entries()[0].timestamp,
921            "agent-001",
922            &action,
923            "/test/resource",
924            "genesis",
925        );
926
927        assert_eq!(hash, trail1.all_entries()[0].hash);
928    }
929
930    #[test]
931    fn test_empty_trail_verify() {
932        let trail = create_test_trail();
933        assert!(trail.verify().is_ok());
934    }
935
936    #[test]
937    fn test_all_action_types() {
938        let trail = create_test_trail();
939
940        let actions = vec![
941            AuditAction::AgentSpawn {
942                task_type: "test".to_string(),
943            },
944            AuditAction::AgentExit {
945                reason: "done".to_string(),
946            },
947            AuditAction::ToolCall {
948                tool: "bash".to_string(),
949                args_json: "{}".to_string(),
950            },
951            AuditAction::ToolResult {
952                tool: "bash".to_string(),
953                success: true,
954            },
955            AuditAction::MemoryWrite {
956                entry_id: "mem-001".to_string(),
957            },
958            AuditAction::MemoryRead {
959                entry_id: "mem-001".to_string(),
960            },
961            AuditAction::ConfigChange {
962                key: "max_agents".to_string(),
963            },
964            AuditAction::ProgramInstall {
965                program: "test-program".to_string(),
966                version: "1.0.0".to_string(),
967            },
968            AuditAction::CronTrigger {
969                job_id: "job-001".to_string(),
970            },
971            AuditAction::GitCommit {
972                message: "test commit".to_string(),
973            },
974            AuditAction::AccessDenied {
975                permission: "write".to_string(),
976            },
977            AuditAction::Other {
978                detail: "misc".to_string(),
979            },
980        ];
981
982        for (i, action) in actions.into_iter().enumerate() {
983            trail.append("agent-001".to_string(), action, format!("/resource/{}", i));
984        }
985
986        assert_eq!(trail.len(), 12);
987        assert!(trail.verify().is_ok());
988    }
989
990    #[test]
991    fn test_hash_different_for_different_inputs() {
992        let ts = Utc::now();
993
994        let hash1 = compute_entry_hash(
995            1,
996            &ts,
997            "agent-001",
998            &AuditAction::AgentSpawn {
999                task_type: "test".to_string(),
1000            },
1001            "/resource",
1002            "genesis",
1003        );
1004
1005        let hash2 = compute_entry_hash(
1006            2,
1007            &ts,
1008            "agent-001",
1009            &AuditAction::AgentSpawn {
1010                task_type: "test".to_string(),
1011            },
1012            "/resource",
1013            "genesis",
1014        );
1015
1016        assert_ne!(hash1, hash2);
1017
1018        let hash3 = compute_entry_hash(
1019            1,
1020            &ts,
1021            "agent-002",
1022            &AuditAction::AgentSpawn {
1023                task_type: "test".to_string(),
1024            },
1025            "/resource",
1026            "genesis",
1027        );
1028
1029        assert_ne!(hash1, hash3);
1030    }
1031
1032    #[test]
1033    fn test_restore_from_empty() {
1034        let trail = create_test_trail();
1035        trail.restore_from(Vec::new());
1036        assert!(trail.is_empty());
1037        // seq_counter should remain at 1 (default)
1038        assert_eq!(trail.all_entries().len(), 0);
1039    }
1040
1041    #[test]
1042    fn test_restore_from_advances_seq_counter() {
1043        let trail = create_test_trail();
1044
1045        // Simulate persisted entries with seq 1..5
1046        let ts = Utc::now();
1047        let mut entries = Vec::new();
1048        let mut prev = "genesis".to_string();
1049        for i in 1..=5 {
1050            let hash = compute_entry_hash(
1051                i,
1052                &ts,
1053                "agent-001",
1054                &AuditAction::Other {
1055                    detail: format!("action-{}", i),
1056                },
1057                "/resource",
1058                &prev,
1059            );
1060            entries.push(AuditEntry {
1061                seq: i,
1062                timestamp: ts,
1063                actor: "agent-001".to_string(),
1064                action: AuditAction::Other {
1065                    detail: format!("action-{}", i),
1066                },
1067                resource: "/resource".to_string(),
1068                prev_hash: prev.clone(),
1069                hash: hash.clone(),
1070                metadata: None,
1071            });
1072            prev = hash;
1073        }
1074
1075        trail.restore_from(entries);
1076        assert_eq!(trail.len(), 5);
1077
1078        // Next append should get seq 6
1079        let new_hash = trail.append(
1080            "agent-001".to_string(),
1081            AuditAction::Other {
1082                detail: "new".to_string(),
1083            },
1084            "/resource".to_string(),
1085        );
1086        assert!(!new_hash.is_empty());
1087        assert_eq!(trail.len(), 6);
1088
1089        let all = trail.all_entries();
1090        assert_eq!(all[5].seq, 6);
1091    }
1092
1093    #[test]
1094    fn test_restore_from_trims_to_max() {
1095        let trail = AuditTrail::new(3);
1096
1097        let ts = Utc::now();
1098        let mut entries = Vec::new();
1099        let mut prev = "genesis".to_string();
1100        for i in 1..=5 {
1101            let hash = compute_entry_hash(
1102                i,
1103                &ts,
1104                "agent-001",
1105                &AuditAction::Other {
1106                    detail: format!("action-{}", i),
1107                },
1108                "/resource",
1109                &prev,
1110            );
1111            entries.push(AuditEntry {
1112                seq: i,
1113                timestamp: ts,
1114                actor: "agent-001".to_string(),
1115                action: AuditAction::Other {
1116                    detail: format!("action-{}", i),
1117                },
1118                resource: "/resource".to_string(),
1119                prev_hash: prev.clone(),
1120                hash: hash.clone(),
1121                metadata: None,
1122            });
1123            prev = hash;
1124        }
1125
1126        trail.restore_from(entries);
1127        assert_eq!(trail.len(), 3);
1128        // Should have trimmed to last 3 (seq 3, 4, 5)
1129        let all = trail.all_entries();
1130        assert_eq!(all[0].seq, 3);
1131        assert_eq!(all[2].seq, 5);
1132        // Pruned chain should verify
1133        assert!(trail.verify().is_ok());
1134    }
1135}