Skip to main content

vtcode_core/audit/
permission_log.rs

1//! Permission audit logging system
2//! Tracks all permission decisions (allow/deny/prompt) with context
3//! Writes to the user state directory's `audit/permissions-{date}.log` in JSON format.
4//! Old audit logs are pruned after DEFAULT_AUDIT_LOG_MAX_AGE_DAYS days.
5
6use crate::utils::error_messages::ERR_CREATE_AUDIT_DIR;
7use anyhow::{Context, Result};
8use chrono::{DateTime, Local};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::io::BufWriter;
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14use tracing::info;
15use vtcode_commons::VtCodePaths;
16
17/// Default maximum age in days for audit log files before they are pruned.
18pub const DEFAULT_AUDIT_LOG_MAX_AGE_DAYS: u64 = 90;
19
20/// Prefix for permission audit log files.
21const PERMISSION_LOG_PREFIX: &str = "permissions-";
22
23/// Seconds per day constant (avoiding dependency on core crate).
24const SECONDS_PER_DAY: u64 = 86400;
25
26/// Record of a single permission decision
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PermissionEvent {
29    /// When the decision was made
30    pub timestamp: DateTime<Local>,
31
32    /// What was being requested (command, tool, path, etc.)
33    pub subject: String,
34
35    /// Type of permission check
36    pub event_type: PermissionEventType,
37
38    /// The decision reached
39    pub decision: PermissionDecision,
40
41    /// Why the decision was made
42    pub reason: String,
43
44    /// Optional resolved path (if applicable)
45    pub resolved_path: Option<PathBuf>,
46
47    /// Tool or component that made the request
48    pub requested_by: String,
49
50    /// Name of the agent that triggered this permission check, if known
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub agent_name: Option<String>,
53
54    /// Whether the requesting agent is a subagent (false for primary agent)
55    pub is_subagent: bool,
56}
57
58/// Type of file access permission
59#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
60pub enum FileAccessPermission {
61    Read,
62    Write,
63    ReadWrite,
64}
65
66/// Type of permission event
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub enum PermissionEventType {
69    CommandExecution,
70    ToolUsage,
71    FileAccess(FileAccessPermission),
72    NetworkAccess { domain: String },
73    HookExecution,
74}
75
76/// The decision reached
77#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
78pub enum PermissionDecision {
79    Allowed,
80    Denied,
81    Prompted,
82    Cached,
83}
84
85/// Audit log for permission decisions
86pub struct PermissionAuditLog {
87    /// Path to the audit log file
88    log_path: PathBuf,
89
90    /// Writer for the log file
91    writer: Option<BufWriter<fs::File>>,
92
93    /// Count of events logged this session
94    event_count: usize,
95}
96
97impl PermissionAuditLog {
98    /// Create or open the audit log for today
99    pub fn new(audit_dir: PathBuf) -> Result<Self> {
100        // Create audit directory if needed
101        VtCodePaths::ensure_user_dir(&audit_dir).context(ERR_CREATE_AUDIT_DIR)?;
102
103        // Prune old audit logs
104        if let Err(err) = cleanup_old_audit_logs(&audit_dir, DEFAULT_AUDIT_LOG_MAX_AGE_DAYS) {
105            tracing::warn!(error = %err, "Failed to prune old audit logs");
106        }
107
108        // Use today's date in filename
109        let date = Local::now().format("%Y-%m-%d");
110        let log_path = audit_dir.join(format!("permissions-{date}.log"));
111
112        info!(?log_path, "Audit log initialized");
113
114        Ok(Self { log_path, writer: None, event_count: 0 })
115    }
116
117    /// Record a permission event
118    pub fn record(&mut self, event: PermissionEvent) -> Result<()> {
119        use std::io::Write;
120
121        let json = serde_json::to_string(&event).context("Failed to serialize permission event")?;
122
123        let writer = self.writer_mut()?;
124        writeln!(writer, "{json}").context("Failed to write to audit log")?;
125
126        writer.flush().context("Failed to flush audit log")?;
127
128        self.event_count += 1;
129
130        info!(
131            subject = &event.subject,
132            decision = ?event.decision,
133            "Permission event logged"
134        );
135
136        Ok(())
137    }
138
139    /// Get the number of events logged
140    pub fn event_count(&self) -> usize {
141        self.event_count
142    }
143
144    /// Get path to the log file
145    pub fn log_path(&self) -> &PathBuf {
146        &self.log_path
147    }
148
149    /// Helper to create and log a permission event
150    pub fn log_command_decision(
151        &mut self,
152        command: &str,
153        decision: PermissionDecision,
154        reason: &str,
155        resolved_path: Option<PathBuf>,
156        agent_name: Option<&str>,
157        is_subagent: bool,
158    ) -> Result<()> {
159        let event = PermissionEvent {
160            timestamp: Local::now(),
161            subject: command.to_owned(),
162            event_type: PermissionEventType::CommandExecution,
163            decision,
164            reason: reason.to_owned(),
165            resolved_path,
166            requested_by: "CommandPolicyEvaluator".into(),
167            agent_name: agent_name.map(String::from),
168            is_subagent,
169        };
170
171        self.record(event)
172    }
173
174    fn writer_mut(&mut self) -> Result<&mut BufWriter<fs::File>> {
175        if self.writer.is_none() {
176            let file = VtCodePaths::open_private_append_file(&self.log_path)
177                .with_context(|| format!("Failed to open audit log at {:?}", self.log_path))?;
178            self.writer = Some(BufWriter::new(file));
179        }
180
181        self.writer.as_mut().context("audit log writer was not initialized")
182    }
183}
184
185/// Prune audit log files older than `max_age_days` from the given directory.
186/// Only removes files matching the `permissions-YYYY-MM-DD.log` pattern.
187fn cleanup_old_audit_logs(audit_dir: &Path, max_age_days: u64) -> Result<()> {
188    if max_age_days == 0 {
189        return Ok(());
190    }
191
192    let cutoff = SystemTime::now()
193        .checked_sub(Duration::from_secs(max_age_days.saturating_mul(SECONDS_PER_DAY)))
194        .unwrap_or(UNIX_EPOCH);
195
196    let entries = match fs::read_dir(audit_dir) {
197        Ok(e) => e,
198        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
199        Err(err) => {
200            return Err(err).with_context(|| format!("Failed to read audit log directory {}", audit_dir.display()));
201        }
202    };
203
204    for entry in entries {
205        let entry = match entry {
206            Ok(e) => e,
207            Err(_) => continue,
208        };
209        let path = entry.path();
210        if !path.is_file() {
211            continue;
212        }
213        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
214            continue;
215        };
216        if !name.starts_with(PERMISSION_LOG_PREFIX) || !name.ends_with(".log") {
217            continue;
218        }
219        let metadata = match entry.metadata() {
220            Ok(m) => m,
221            Err(_) => continue,
222        };
223        let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
224        if modified <= cutoff {
225            if let Err(err) = fs::remove_file(&path) {
226                tracing::warn!(
227                    path = %path.display(),
228                    error = %err,
229                    "Failed to remove expired audit log"
230                );
231            }
232        }
233    }
234
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use tempfile::TempDir;
242
243    #[test]
244    fn test_audit_log_creation() -> Result<()> {
245        let dir = TempDir::new()?;
246        let log = PermissionAuditLog::new(dir.path().to_path_buf())?;
247        assert!(dir.path().exists());
248        assert!(!log.log_path().exists());
249        Ok(())
250    }
251
252    #[test]
253    fn test_log_permission_event() -> Result<()> {
254        let dir = TempDir::new()?;
255        let mut log = PermissionAuditLog::new(dir.path().to_path_buf())?;
256
257        log.log_command_decision(
258            "cargo fmt",
259            PermissionDecision::Allowed,
260            "Allow list match",
261            Some(PathBuf::from("/usr/local/cargo")),
262            Some("coder"),
263            false,
264        )?;
265
266        assert_eq!(log.event_count(), 1);
267        assert!(log.log_path().exists());
268        Ok(())
269    }
270
271    #[test]
272    fn test_cleanup_ignores_non_audit_files() -> Result<()> {
273        let dir = TempDir::new()?;
274
275        fs::write(dir.path().join("not-audit.log"), "noise")?;
276        fs::write(dir.path().join("other.txt"), "noise")?;
277
278        cleanup_old_audit_logs(dir.path(), 1)?;
279
280        assert!(dir.path().join("not-audit.log").exists());
281        assert!(dir.path().join("other.txt").exists());
282        Ok(())
283    }
284
285    #[test]
286    fn test_cleanup_noop_on_missing_dir() {
287        let result = cleanup_old_audit_logs(Path::new("/nonexistent/audit"), 90);
288        assert!(result.is_ok());
289    }
290}