Skip to main content

ravenclaws/
audit.rs

1//! RavenClaws
2//!
3//! Every tool call, policy decision, and approval action is recorded
4//! in a structured audit log with HMAC chaining for tamper detection.
5//!
6//! # Architecture
7//!
8//! Each audit entry contains:
9//! - A sequential index
10//! - A timestamp
11//! - The event type and details
12//! - An HMAC-SHA256 of the previous entry's HMAC + current entry data
13//!
14//! This creates a hash chain: any modification to an entry breaks the chain
15//! for all subsequent entries, making tampering detectable.
16//!
17//! # Security Properties
18//!
19//! - **Tamper-evident**: HMAC chain links each entry to the previous one
20//! - **Append-only**: Entries can only be added, never removed or modified
21//! - **Verifiable**: The chain can be verified at any time
22//! - **Keyed**: HMAC uses a secret key known only to the audit system
23
24use hmac::{Hmac, Mac};
25use serde::{Deserialize, Serialize};
26use sha2::Sha256;
27use std::path::PathBuf;
28use std::sync::Mutex;
29use thiserror::Error;
30use tracing::info;
31use zeroize::Zeroize;
32
33// ── Error types ────────────────────────────────────────────────────────────
34
35#[allow(dead_code)]
36#[derive(Error, Debug)]
37pub enum AuditError {
38    #[error("IO error: {0}")]
39    Io(#[from] std::io::Error),
40
41    #[error("Serialization error: {0}")]
42    Serialization(#[from] serde_json::Error),
43
44    #[error("HMAC verification failed at entry {0}")]
45    TamperDetected(u64),
46
47    #[error("Audit log is empty")]
48    EmptyLog,
49
50    #[error("Audit log not initialized")]
51    NotInitialized,
52}
53
54// ── Event types ────────────────────────────────────────────────────────────
55
56/// Types of audit events
57#[allow(dead_code)]
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59#[serde(rename_all = "snake_case")]
60pub enum AuditEventType {
61    /// A tool was called
62    ToolCall,
63    /// A tool execution completed
64    ToolResult,
65    /// A policy decision was made
66    PolicyDecision,
67    /// An approval was requested
68    ApprovalRequested,
69    /// An approval was granted
70    ApprovalGranted,
71    /// An approval was denied
72    ApprovalDenied,
73    /// A sandbox violation occurred
74    SandboxViolation,
75    /// A security violation occurred (e.g., prompt injection detected)
76    SecurityViolation,
77    /// The agent started
78    AgentStart,
79    /// The agent finished
80    AgentFinish,
81    /// An error occurred
82    Error,
83    /// A configuration change
84    ConfigChange,
85    /// A custom event
86    Custom(String),
87}
88
89/// A single audit log entry
90#[allow(dead_code)]
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AuditEntry {
93    /// Sequential index (0-based)
94    pub index: u64,
95    /// ISO 8601 timestamp
96    pub timestamp: String,
97    /// The type of event
98    pub event_type: AuditEventType,
99    /// The tool or component name
100    pub component: String,
101    /// A human-readable description
102    pub description: String,
103    /// Arbitrary metadata (JSON)
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub metadata: Option<serde_json::Value>,
106    /// The session ID
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub session_id: Option<String>,
109    /// HMAC-SHA256 of (previous_hmac || index || timestamp || event_type || component || description || metadata)
110    pub hmac: String,
111}
112
113/// The audit log — an append-only, tamper-evident log
114#[allow(dead_code)]
115pub struct AuditLog {
116    /// The secret key for HMAC
117    key: Vec<u8>,
118    /// The entries (protected by mutex for thread safety)
119    entries: Mutex<Vec<AuditEntry>>,
120    /// The session ID
121    session_id: String,
122    /// Whether to also log to tracing
123    trace_logging: bool,
124    /// Optional file path for persistence
125    file_path: Option<PathBuf>,
126}
127
128/// Zeroize the HMAC secret key on drop
129impl Drop for AuditLog {
130    fn drop(&mut self) {
131        self.key.zeroize();
132    }
133}
134
135#[allow(dead_code)]
136impl AuditLog {
137    /// Create a new audit log with a random key
138    pub fn new(session_id: String) -> Self {
139        use rand::RngCore;
140        let mut key = vec![0u8; 32];
141        rand::rngs::OsRng.fill_bytes(&mut key);
142
143        Self {
144            key,
145            entries: Mutex::new(Vec::new()),
146            session_id,
147            trace_logging: true,
148            file_path: None,
149        }
150    }
151
152    /// Create a new audit log with a specific key (for testing or recovery)
153    pub fn with_key(session_id: String, key: Vec<u8>) -> Self {
154        Self {
155            key,
156            entries: Mutex::new(Vec::new()),
157            session_id,
158            trace_logging: true,
159            file_path: None,
160        }
161    }
162
163    /// Enable or disable tracing logging
164    pub fn set_trace_logging(&mut self, enabled: bool) {
165        self.trace_logging = enabled;
166    }
167
168    /// Set the file path for persistence
169    pub fn set_file_path(&mut self, path: PathBuf) {
170        self.file_path = Some(path);
171    }
172
173    /// Append an entry to the audit log
174    pub fn append(
175        &self,
176        event_type: AuditEventType,
177        component: &str,
178        description: &str,
179        metadata: Option<serde_json::Value>,
180    ) -> Result<AuditEntry, AuditError> {
181        let mut entries = self.lock_entries()?;
182
183        let index = entries.len() as u64;
184        let timestamp = chrono::Utc::now().to_rfc3339();
185
186        // Compute the HMAC
187        let hmac = self.compute_hmac(
188            index,
189            &timestamp,
190            &event_type,
191            component,
192            description,
193            &metadata,
194            entries.last(),
195        )?;
196
197        let entry = AuditEntry {
198            index,
199            timestamp,
200            event_type: event_type.clone(),
201            component: component.to_string(),
202            description: description.to_string(),
203            metadata,
204            session_id: Some(self.session_id.clone()),
205            hmac,
206        };
207
208        if self.trace_logging {
209            info!(
210                audit.index = entry.index,
211                audit.event = ?entry.event_type,
212                audit.component = %entry.component,
213                "Audit: {}",
214                entry.description
215            );
216        }
217
218        entries.push(entry.clone());
219
220        // Persist to file if configured
221        if let Some(path) = &self.file_path {
222            self.persist_to_file(path, &entries)?;
223        }
224
225        Ok(entry)
226    }
227
228    /// Convenience method for logging a tool call
229    pub fn tool_call(
230        &self,
231        tool_name: &str,
232        args: &serde_json::Value,
233    ) -> Result<AuditEntry, AuditError> {
234        self.append(
235            AuditEventType::ToolCall,
236            tool_name,
237            &format!("Tool call: {}", tool_name),
238            Some(serde_json::json!({"arguments": args})),
239        )
240    }
241
242    /// Convenience method for logging a tool result
243    pub fn tool_result(
244        &self,
245        tool_name: &str,
246        result: &crate::tools::ToolResult,
247    ) -> Result<AuditEntry, AuditError> {
248        self.append(
249            AuditEventType::ToolResult,
250            tool_name,
251            &format!("Tool result: {} (success: {})", tool_name, result.success),
252            Some(serde_json::json!({
253                "success": result.success,
254                "exit_code": result.exit_code,
255                "duration_ms": result.duration_ms,
256                "output_length": result.output.len(),
257            })),
258        )
259    }
260
261    /// Convenience method for logging a policy decision
262    pub fn policy_decision(
263        &self,
264        tool_name: &str,
265        allowed: bool,
266        reason: Option<&str>,
267    ) -> Result<AuditEntry, AuditError> {
268        self.append(
269            AuditEventType::PolicyDecision,
270            "policy",
271            &format!(
272                "Policy decision for '{}': {}",
273                tool_name,
274                if allowed { "allowed" } else { "denied" }
275            ),
276            Some(serde_json::json!({
277                "tool": tool_name,
278                "allowed": allowed,
279                "reason": reason,
280            })),
281        )
282    }
283
284    /// Convenience method for logging an approval
285    pub fn approval(
286        &self,
287        tool_name: &str,
288        granted: bool,
289        reason: Option<&str>,
290    ) -> Result<AuditEntry, AuditError> {
291        let event_type = if granted {
292            AuditEventType::ApprovalGranted
293        } else {
294            AuditEventType::ApprovalDenied
295        };
296
297        self.append(
298            event_type,
299            "approval",
300            &format!(
301                "Approval for '{}': {}",
302                tool_name,
303                if granted { "granted" } else { "denied" }
304            ),
305            Some(serde_json::json!({
306                "tool": tool_name,
307                "granted": granted,
308                "reason": reason,
309            })),
310        )
311    }
312
313    /// Get all entries
314    pub fn entries(&self) -> Result<Vec<AuditEntry>, AuditError> {
315        Ok(self.lock_entries()?.clone())
316    }
317
318    /// Get the number of entries
319    pub fn len(&self) -> Result<usize, AuditError> {
320        Ok(self.lock_entries()?.len())
321    }
322
323    /// Check if the log is empty
324    pub fn is_empty(&self) -> Result<bool, AuditError> {
325        Ok(self.lock_entries()?.is_empty())
326    }
327
328    /// Verify the integrity of the entire audit log
329    pub fn verify(&self) -> Result<(), AuditError> {
330        let entries = self.lock_entries()?;
331
332        if entries.is_empty() {
333            return Err(AuditError::EmptyLog);
334        }
335
336        let mut prev_hmac = String::new();
337
338        for entry in entries.iter() {
339            let expected_hmac = self.compute_hmac_for_verification(
340                entry.index,
341                &entry.timestamp,
342                &entry.event_type,
343                &entry.component,
344                &entry.description,
345                &entry.metadata,
346                &prev_hmac,
347            )?;
348
349            if entry.hmac != expected_hmac {
350                return Err(AuditError::TamperDetected(entry.index));
351            }
352
353            prev_hmac = entry.hmac.clone();
354        }
355
356        Ok(())
357    }
358
359    /// Export the audit log as JSON
360    pub fn to_json(&self) -> Result<String, AuditError> {
361        let entries = self.lock_entries()?;
362        Ok(serde_json::to_string_pretty(&*entries)?)
363    }
364
365    /// Export the audit log as JSON lines (one entry per line)
366    pub fn to_json_lines(&self) -> Result<String, AuditError> {
367        let entries = self.lock_entries()?;
368        let mut lines = String::new();
369        for entry in entries.iter() {
370            lines.push_str(&serde_json::to_string(entry)?);
371            lines.push('\n');
372        }
373        Ok(lines)
374    }
375
376    // ── Private helpers ────────────────────────────────────────────────
377
378    /// Lock the entries mutex, returning an error if poisoned
379    fn lock_entries(&self) -> Result<std::sync::MutexGuard<'_, Vec<AuditEntry>>, AuditError> {
380        self.entries.lock().map_err(|_| AuditError::NotInitialized)
381    }
382
383    #[allow(clippy::too_many_arguments)]
384    fn compute_hmac(
385        &self,
386        index: u64,
387        timestamp: &str,
388        event_type: &AuditEventType,
389        component: &str,
390        description: &str,
391        metadata: &Option<serde_json::Value>,
392        prev_entry: Option<&AuditEntry>,
393    ) -> Result<String, AuditError> {
394        let prev_hmac = prev_entry.map(|e| e.hmac.as_str()).unwrap_or("");
395
396        self.compute_hmac_for_verification(
397            index,
398            timestamp,
399            event_type,
400            component,
401            description,
402            metadata,
403            prev_hmac,
404        )
405    }
406
407    #[allow(clippy::too_many_arguments)]
408    fn compute_hmac_for_verification(
409        &self,
410        index: u64,
411        timestamp: &str,
412        event_type: &AuditEventType,
413        component: &str,
414        description: &str,
415        metadata: &Option<serde_json::Value>,
416        prev_hmac: &str,
417    ) -> Result<String, AuditError> {
418        let mut mac = Hmac::<Sha256>::new_from_slice(&self.key).map_err(|e| {
419            AuditError::Serialization(serde_json::Error::io(std::io::Error::new(
420                std::io::ErrorKind::InvalidInput,
421                e.to_string(),
422            )))
423        })?;
424
425        mac.update(prev_hmac.as_bytes());
426        mac.update(&index.to_le_bytes());
427        mac.update(timestamp.as_bytes());
428        mac.update(serde_json::to_string(event_type)?.as_bytes());
429        mac.update(component.as_bytes());
430        mac.update(description.as_bytes());
431
432        if let Some(m) = metadata {
433            mac.update(serde_json::to_string(m)?.as_bytes());
434        }
435
436        Ok(hex::encode(mac.finalize().into_bytes()))
437    }
438
439    fn persist_to_file(&self, path: &PathBuf, entries: &[AuditEntry]) -> Result<(), AuditError> {
440        let json = serde_json::to_string_pretty(entries)?;
441        std::fs::write(path, json)?;
442        Ok(())
443    }
444}
445
446// ── Tests ──────────────────────────────────────────────────────────────────
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn create_test_log() -> AuditLog {
453        AuditLog::with_key("test-session".to_string(), vec![0u8; 32])
454    }
455
456    #[test]
457    fn test_audit_log_empty() {
458        let log = create_test_log();
459        assert!(log.is_empty().unwrap());
460        assert_eq!(log.len().unwrap(), 0);
461    }
462
463    #[test]
464    fn test_audit_log_append() {
465        let log = create_test_log();
466        let entry = log
467            .append(AuditEventType::AgentStart, "agent", "Agent started", None)
468            .unwrap();
469
470        assert_eq!(entry.index, 0);
471        assert_eq!(entry.component, "agent");
472        assert_eq!(entry.event_type, AuditEventType::AgentStart);
473        assert!(!entry.hmac.is_empty());
474        assert_eq!(log.len().unwrap(), 1);
475    }
476
477    #[test]
478    fn test_audit_log_multiple_entries() {
479        let log = create_test_log();
480
481        log.append(AuditEventType::AgentStart, "agent", "Agent started", None)
482            .unwrap();
483
484        log.append(
485            AuditEventType::ToolCall,
486            "shell_exec",
487            "Executing command",
488            Some(serde_json::json!({"command": "echo hello"})),
489        )
490        .unwrap();
491
492        assert_eq!(log.len().unwrap(), 2);
493    }
494
495    #[test]
496    fn test_audit_log_verify_valid() {
497        let log = create_test_log();
498
499        log.append(AuditEventType::AgentStart, "agent", "Agent started", None)
500            .unwrap();
501
502        log.append(
503            AuditEventType::ToolCall,
504            "shell_exec",
505            "Executing command",
506            None,
507        )
508        .unwrap();
509
510        log.append(
511            AuditEventType::ToolResult,
512            "shell_exec",
513            "Command completed",
514            None,
515        )
516        .unwrap();
517
518        assert!(log.verify().is_ok());
519    }
520
521    #[test]
522    fn test_audit_log_verify_tampered() {
523        let log = create_test_log();
524
525        log.append(AuditEventType::AgentStart, "agent", "Agent started", None)
526            .unwrap();
527
528        log.append(
529            AuditEventType::ToolCall,
530            "shell_exec",
531            "Executing command",
532            None,
533        )
534        .unwrap();
535
536        // Tamper with the second entry
537        {
538            let mut entries = log.entries.lock().unwrap();
539            entries[1].description = "Tampered!".to_string();
540        }
541
542        let result = log.verify();
543        assert!(result.is_err());
544        assert!(matches!(result.unwrap_err(), AuditError::TamperDetected(1)));
545    }
546
547    #[test]
548    fn test_audit_log_verify_empty() {
549        let log = create_test_log();
550        let result = log.verify();
551        assert!(matches!(result.unwrap_err(), AuditError::EmptyLog));
552    }
553
554    #[test]
555    fn test_audit_log_tool_call() {
556        let log = create_test_log();
557        let args = serde_json::json!({"command": "echo hello"});
558        let entry = log.tool_call("shell_exec", &args).unwrap();
559
560        assert_eq!(entry.event_type, AuditEventType::ToolCall);
561        assert_eq!(entry.component, "shell_exec");
562    }
563
564    #[test]
565    fn test_audit_log_policy_decision() {
566        let log = create_test_log();
567        let entry = log.policy_decision("shell_exec", true, None).unwrap();
568
569        assert_eq!(entry.event_type, AuditEventType::PolicyDecision);
570        assert_eq!(entry.component, "policy");
571    }
572
573    #[test]
574    fn test_audit_log_approval_granted() {
575        let log = create_test_log();
576        let entry = log
577            .approval("shell_exec", true, Some("User approved"))
578            .unwrap();
579
580        assert_eq!(entry.event_type, AuditEventType::ApprovalGranted);
581    }
582
583    #[test]
584    fn test_audit_log_approval_denied() {
585        let log = create_test_log();
586        let entry = log
587            .approval("shell_exec", false, Some("User denied"))
588            .unwrap();
589
590        assert_eq!(entry.event_type, AuditEventType::ApprovalDenied);
591    }
592
593    #[test]
594    fn test_audit_log_to_json() {
595        let log = create_test_log();
596
597        log.append(AuditEventType::AgentStart, "agent", "Agent started", None)
598            .unwrap();
599
600        let json = log.to_json().unwrap();
601        assert!(json.contains("Agent started"));
602        assert!(json.contains("agent_start"));
603    }
604
605    #[test]
606    fn test_audit_log_to_json_lines() {
607        let log = create_test_log();
608
609        log.append(AuditEventType::AgentStart, "agent", "Agent started", None)
610            .unwrap();
611
612        log.append(
613            AuditEventType::ToolCall,
614            "shell_exec",
615            "Executing command",
616            None,
617        )
618        .unwrap();
619
620        let lines = log.to_json_lines().unwrap();
621        let line_count = lines.lines().count();
622        assert_eq!(line_count, 2);
623    }
624
625    #[test]
626    fn test_audit_log_session_id() {
627        let log = AuditLog::new("my-session-123".to_string());
628        let entry = log
629            .append(AuditEventType::AgentStart, "agent", "Agent started", None)
630            .unwrap();
631
632        assert_eq!(entry.session_id.unwrap(), "my-session-123");
633    }
634
635    #[test]
636    fn test_audit_log_different_keys_produce_different_hmacs() {
637        let log1 = AuditLog::with_key("test".to_string(), vec![1u8; 32]);
638        let log2 = AuditLog::with_key("test".to_string(), vec![2u8; 32]);
639
640        let e1 = log1
641            .append(AuditEventType::AgentStart, "agent", "Agent started", None)
642            .unwrap();
643
644        let e2 = log2
645            .append(AuditEventType::AgentStart, "agent", "Agent started", None)
646            .unwrap();
647
648        assert_ne!(e1.hmac, e2.hmac);
649    }
650
651    #[test]
652    fn test_audit_entry_serialization() {
653        let entry = AuditEntry {
654            index: 0,
655            timestamp: "2026-01-01T00:00:00Z".to_string(),
656            event_type: AuditEventType::ToolCall,
657            component: "shell_exec".to_string(),
658            description: "Executed command".to_string(),
659            metadata: Some(serde_json::json!({"command": "echo hello"})),
660            session_id: Some("session-1".to_string()),
661            hmac: "abc123".to_string(),
662        };
663
664        let json = serde_json::to_string(&entry).unwrap();
665        assert!(json.contains("shell_exec"));
666        assert!(json.contains("tool_call"));
667        assert!(json.contains("echo hello"));
668    }
669
670    #[test]
671    fn test_audit_error_tamper_detected() {
672        let err = AuditError::TamperDetected(5);
673        assert_eq!(format!("{}", err), "HMAC verification failed at entry 5");
674    }
675
676    #[test]
677    fn test_audit_error_empty() {
678        let err = AuditError::EmptyLog;
679        assert_eq!(format!("{}", err), "Audit log is empty");
680    }
681
682    #[test]
683    fn test_audit_event_type_custom() {
684        let event = AuditEventType::Custom("my_event".to_string());
685        let json = serde_json::to_string(&event).unwrap();
686        assert_eq!(json, "{\"custom\":\"my_event\"}");
687    }
688
689    #[test]
690    fn test_audit_log_with_metadata() {
691        let log = create_test_log();
692        let metadata = serde_json::json!({
693            "key1": "value1",
694            "key2": 42,
695            "nested": {"a": 1}
696        });
697
698        let entry = log
699            .append(
700                AuditEventType::ConfigChange,
701                "config",
702                "Configuration changed",
703                Some(metadata),
704            )
705            .unwrap();
706
707        assert!(entry.metadata.is_some());
708        let meta = entry.metadata.unwrap();
709        assert_eq!(meta["key1"], "value1");
710        assert_eq!(meta["key2"], 42);
711    }
712
713    #[test]
714    fn test_audit_log_trace_logging_disabled() {
715        let mut log = create_test_log();
716        log.set_trace_logging(false);
717
718        let entry = log
719            .append(AuditEventType::AgentStart, "agent", "Agent started", None)
720            .unwrap();
721
722        assert_eq!(entry.index, 0);
723    }
724
725    #[test]
726    fn test_audit_log_chain_integrity() {
727        let log = create_test_log();
728
729        // Add several entries
730        for i in 0..10 {
731            log.append(
732                AuditEventType::Custom(format!("event_{}", i)),
733                "test",
734                &format!("Entry {}", i),
735                None,
736            )
737            .unwrap();
738        }
739
740        // Verify chain
741        assert!(log.verify().is_ok());
742
743        // Tamper with entry 3
744        {
745            let mut entries = log.entries.lock().unwrap();
746            entries[3].description = "MODIFIED".to_string();
747        }
748
749        // Verification should fail
750        assert!(log.verify().is_err());
751    }
752}