1use 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
17pub const DEFAULT_AUDIT_LOG_MAX_AGE_DAYS: u64 = 90;
19
20const PERMISSION_LOG_PREFIX: &str = "permissions-";
22
23const SECONDS_PER_DAY: u64 = 86400;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PermissionEvent {
29 pub timestamp: DateTime<Local>,
31
32 pub subject: String,
34
35 pub event_type: PermissionEventType,
37
38 pub decision: PermissionDecision,
40
41 pub reason: String,
43
44 pub resolved_path: Option<PathBuf>,
46
47 pub requested_by: String,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub agent_name: Option<String>,
53
54 pub is_subagent: bool,
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
60pub enum FileAccessPermission {
61 Read,
62 Write,
63 ReadWrite,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub enum PermissionEventType {
69 CommandExecution,
70 ToolUsage,
71 FileAccess(FileAccessPermission),
72 NetworkAccess { domain: String },
73 HookExecution,
74}
75
76#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
78pub enum PermissionDecision {
79 Allowed,
80 Denied,
81 Prompted,
82 Cached,
83}
84
85pub struct PermissionAuditLog {
87 log_path: PathBuf,
89
90 writer: Option<BufWriter<fs::File>>,
92
93 event_count: usize,
95}
96
97impl PermissionAuditLog {
98 pub fn new(audit_dir: PathBuf) -> Result<Self> {
100 VtCodePaths::ensure_user_dir(&audit_dir).context(ERR_CREATE_AUDIT_DIR)?;
102
103 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 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 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 pub fn event_count(&self) -> usize {
141 self.event_count
142 }
143
144 pub fn log_path(&self) -> &PathBuf {
146 &self.log_path
147 }
148
149 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
185fn 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}