Skip to main content

vtcode_safety/command_safety/
audit.rs

1//! Audit logging for command safety decisions.
2//!
3//! Records all command safety checks for compliance and debugging.
4//! Can be used to generate security audit trails and identify patterns.
5
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use tokio::sync::Mutex;
9
10/// A single command safety audit entry
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AuditEntry {
13    /// The command that was evaluated
14    command: Vec<String>,
15    /// Whether the command was allowed
16    pub(crate) allowed: bool,
17    /// Reason for the decision
18    reason: String,
19    /// Decision type (Allow, Deny, Unknown)
20    pub(crate) decision_type: String,
21    /// Timestamp (ISO 8601 format)
22    timestamp: String,
23}
24
25impl AuditEntry {
26    /// Creates a new audit entry
27    pub(crate) fn new(command: Vec<String>, allowed: bool, reason: String, decision_type: String) -> Self {
28        // Use a simple timestamp (can be improved with chrono if available)
29        let timestamp = std::time::SystemTime::now()
30            .duration_since(std::time::UNIX_EPOCH)
31            .map(|d| d.as_secs().to_string())
32            .unwrap_or_else(|_| "unknown".to_string());
33
34        Self { command, allowed, reason, decision_type, timestamp }
35    }
36}
37
38/// Audit logger for command safety decisions
39pub struct SafetyAuditLogger {
40    entries: Arc<Mutex<Vec<AuditEntry>>>,
41    enabled: bool,
42}
43
44impl SafetyAuditLogger {
45    /// Creates a new audit logger
46    pub(crate) fn new(enabled: bool) -> Self {
47        Self { entries: Arc::new(Mutex::new(Vec::new())), enabled }
48    }
49
50    /// Logs an audit entry
51    pub(crate) async fn log(&self, entry: AuditEntry) {
52        if self.enabled {
53            let mut entries = self.entries.lock().await;
54            entries.push(entry);
55        }
56    }
57
58    /// Returns all logged entries
59    pub(crate) async fn entries(&self) -> Vec<AuditEntry> {
60        let entries = self.entries.lock().await;
61        entries.clone()
62    }
63
64    /// Returns entries for a specific command
65    pub async fn entries_for_command(&self, cmd: &str) -> Vec<AuditEntry> {
66        let entries = self.entries.lock().await;
67        entries.iter().filter(|e| e.command.join(" ").contains(cmd)).cloned().collect()
68    }
69
70    /// Returns denied entries only
71    async fn denied_entries(&self) -> Vec<AuditEntry> {
72        let entries = self.entries.lock().await;
73        entries.iter().filter(|e| !e.allowed).cloned().collect()
74    }
75
76    /// Clears all entries
77    pub async fn clear(&self) {
78        let mut entries = self.entries.lock().await;
79        entries.clear();
80    }
81
82    /// Returns count of entries
83    pub(crate) async fn count(&self) -> usize {
84        let entries = self.entries.lock().await;
85        entries.len()
86    }
87}
88
89impl Clone for SafetyAuditLogger {
90    fn clone(&self) -> Self {
91        Self {
92            entries: Arc::clone(&self.entries),
93            enabled: self.enabled,
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[tokio::test]
103    async fn creates_audit_entry() {
104        let cmd = vec!["git".to_string(), "status".to_string()];
105        let entry = AuditEntry::new(cmd, true, "git status allowed".to_string(), "Allow".to_string());
106        assert!(entry.allowed);
107        assert!(!entry.timestamp.is_empty());
108    }
109
110    #[tokio::test]
111    async fn logs_entries() {
112        let logger = SafetyAuditLogger::new(true);
113        let cmd = vec!["git".to_string(), "status".to_string()];
114        let entry = AuditEntry::new(cmd, true, "git status allowed".to_string(), "Allow".to_string());
115
116        logger.log(entry).await;
117        assert_eq!(logger.count().await, 1);
118    }
119
120    #[tokio::test]
121    async fn filters_denied_entries() {
122        let logger = SafetyAuditLogger::new(true);
123
124        let cmd1 = vec!["git".to_string(), "status".to_string()];
125        logger
126            .log(AuditEntry::new(cmd1, true, "allowed".to_string(), "Allow".to_string()))
127            .await;
128
129        let cmd2 = vec!["git".to_string(), "reset".to_string()];
130        logger
131            .log(AuditEntry::new(cmd2, false, "denied".to_string(), "Deny".to_string()))
132            .await;
133
134        let denied = logger.denied_entries().await;
135        assert_eq!(denied.len(), 1);
136        assert!(!denied[0].allowed);
137    }
138
139    #[tokio::test]
140    async fn disabled_logger_ignores_entries() {
141        let logger = SafetyAuditLogger::new(false);
142        let cmd = vec!["git".to_string(), "status".to_string()];
143        let entry = AuditEntry::new(cmd, true, "allowed".to_string(), "Allow".to_string());
144
145        logger.log(entry).await;
146        assert_eq!(logger.count().await, 0);
147    }
148
149    #[tokio::test]
150    async fn clones_share_same_entries() {
151        let logger1 = SafetyAuditLogger::new(true);
152        let logger2 = logger1.clone();
153
154        let cmd = vec!["git".to_string(), "status".to_string()];
155        let entry = AuditEntry::new(cmd, true, "allowed".to_string(), "Allow".to_string());
156
157        logger1.log(entry).await;
158
159        // Both loggers see the same entry
160        assert_eq!(logger1.count().await, 1);
161        assert_eq!(logger2.count().await, 1);
162    }
163}