1use anyhow::Result;
6use chrono::{DateTime, Utc};
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9use std::fs::OpenOptions;
10use std::io::Write;
11use std::path::PathBuf;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AuditEventType {
18 CommandExecution,
19 FileAccess,
20 ConfigChange,
21 AuthSuccess,
22 AuthFailure,
23 PolicyViolation,
24 SecurityEvent,
25 ToolInvocation,
26 AgentAction,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Actor {
32 pub channel: String,
33 pub user_id: Option<String>,
34 pub username: Option<String>,
35 pub session_id: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Action {
41 pub command: Option<String>,
42 pub tool_name: Option<String>,
43 pub risk_level: Option<String>,
44 pub approved: bool,
45 pub allowed: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ExecutionResult {
51 pub success: bool,
52 pub exit_code: Option<i32>,
53 pub duration_ms: Option<u64>,
54 pub error: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct SecurityContext {
60 pub policy_violation: bool,
61 pub rate_limit_remaining: Option<u32>,
62 pub autonomy_level: Option<String>,
63 pub sandbox_backend: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct AuditEvent {
69 pub timestamp: DateTime<Utc>,
70 pub event_id: String,
71 pub event_type: AuditEventType,
72 pub actor: Option<Actor>,
73 pub action: Option<Action>,
74 pub result: Option<ExecutionResult>,
75 pub security: SecurityContext,
76}
77
78impl AuditEvent {
79 pub fn new(event_type: AuditEventType) -> Self {
81 Self {
82 timestamp: Utc::now(),
83 event_id: Uuid::new_v4().to_string(),
84 event_type,
85 actor: None,
86 action: None,
87 result: None,
88 security: SecurityContext::default(),
89 }
90 }
91
92 pub fn with_actor(mut self, actor: Actor) -> Self {
94 self.actor = Some(actor);
95 self
96 }
97
98 pub fn with_action(mut self, action: Action) -> Self {
100 self.action = Some(action);
101 self
102 }
103
104 pub fn with_result(mut self, result: ExecutionResult) -> Self {
106 self.result = Some(result);
107 self
108 }
109
110 pub fn with_security(mut self, security: SecurityContext) -> Self {
112 self.security = security;
113 self
114 }
115
116 pub fn as_violation(mut self) -> Self {
118 self.security.policy_violation = true;
119 self
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AuditConfig {
126 pub enabled: bool,
127 pub log_path: String,
128 pub max_size_mb: u32,
129 pub retain_days: u32,
130}
131
132impl Default for AuditConfig {
133 fn default() -> Self {
134 Self {
135 enabled: true,
136 log_path: "audit.log".to_string(),
137 max_size_mb: 100,
138 retain_days: 90,
139 }
140 }
141}
142
143pub struct AuditLogger {
145 log_path: PathBuf,
146 config: AuditConfig,
147 #[allow(dead_code)] buffer: Mutex<Vec<AuditEvent>>,
149}
150
151impl AuditLogger {
152 pub fn new(config: AuditConfig, workspace_dir: PathBuf) -> Result<Self> {
154 let log_path = workspace_dir.join(&config.log_path);
155 Ok(Self {
156 log_path,
157 config,
158 buffer: Mutex::new(Vec::new()),
159 })
160 }
161
162 pub fn log(&self, event: &AuditEvent) -> Result<()> {
164 if !self.config.enabled {
165 return Ok(());
166 }
167
168 self.rotate_if_needed()?;
169
170 let line = serde_json::to_string(event)?;
171 let mut file = OpenOptions::new()
172 .create(true)
173 .append(true)
174 .open(&self.log_path)?;
175
176 writeln!(file, "{}", line)?;
177 file.sync_all()?;
178
179 Ok(())
180 }
181
182 #[allow(clippy::too_many_arguments)]
184 pub fn log_command(
185 &self,
186 channel: &str,
187 command: &str,
188 risk_level: &str,
189 approved: bool,
190 allowed: bool,
191 success: bool,
192 duration_ms: u64,
193 ) -> Result<()> {
194 let event = AuditEvent::new(AuditEventType::CommandExecution)
195 .with_actor(Actor {
196 channel: channel.to_string(),
197 user_id: None,
198 username: None,
199 session_id: None,
200 })
201 .with_action(Action {
202 command: Some(command.to_string()),
203 tool_name: None,
204 risk_level: Some(risk_level.to_string()),
205 approved,
206 allowed,
207 })
208 .with_result(ExecutionResult {
209 success,
210 exit_code: None,
211 duration_ms: Some(duration_ms),
212 error: None,
213 });
214
215 self.log(&event)
216 }
217
218 pub fn log_tool(
220 &self,
221 channel: &str,
222 tool_name: &str,
223 allowed: bool,
224 success: bool,
225 duration_ms: u64,
226 error: Option<&str>,
227 ) -> Result<()> {
228 let event = AuditEvent::new(AuditEventType::ToolInvocation)
229 .with_actor(Actor {
230 channel: channel.to_string(),
231 user_id: None,
232 username: None,
233 session_id: None,
234 })
235 .with_action(Action {
236 command: None,
237 tool_name: Some(tool_name.to_string()),
238 risk_level: None,
239 approved: true,
240 allowed,
241 })
242 .with_result(ExecutionResult {
243 success,
244 exit_code: None,
245 duration_ms: Some(duration_ms),
246 error: error.map(String::from),
247 });
248
249 self.log(&event)
250 }
251
252 pub fn log_violation(&self, channel: &str, description: &str) -> Result<()> {
254 let event = AuditEvent::new(AuditEventType::PolicyViolation)
255 .with_actor(Actor {
256 channel: channel.to_string(),
257 user_id: None,
258 username: None,
259 session_id: None,
260 })
261 .with_action(Action {
262 command: Some(description.to_string()),
263 tool_name: None,
264 risk_level: Some("high".to_string()),
265 approved: false,
266 allowed: false,
267 })
268 .as_violation();
269
270 self.log(&event)
271 }
272
273 fn rotate_if_needed(&self) -> Result<()> {
275 if let Ok(metadata) = std::fs::metadata(&self.log_path) {
276 let current_size_mb = metadata.len() / (1024 * 1024);
277 if current_size_mb >= u64::from(self.config.max_size_mb) {
278 self.rotate()?;
279 }
280 }
281 Ok(())
282 }
283
284 fn rotate(&self) -> Result<()> {
286 for i in (1..10).rev() {
287 let old_name = format!("{}.{}.log", self.log_path.display(), i);
288 let new_name = format!("{}.{}.log", self.log_path.display(), i + 1);
289 let _ = std::fs::rename(&old_name, &new_name);
290 }
291
292 let rotated = format!("{}.1.log", self.log_path.display());
293 std::fs::rename(&self.log_path, &rotated)?;
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use tempfile::TempDir;
302
303 #[test]
304 fn audit_event_new_creates_unique_id() {
305 let event1 = AuditEvent::new(AuditEventType::CommandExecution);
306 let event2 = AuditEvent::new(AuditEventType::CommandExecution);
307 assert_ne!(event1.event_id, event2.event_id);
308 }
309
310 #[test]
311 fn audit_event_serializes_to_json() {
312 let event = AuditEvent::new(AuditEventType::CommandExecution)
313 .with_actor(Actor {
314 channel: "telegram".to_string(),
315 user_id: None,
316 username: None,
317 session_id: None,
318 })
319 .with_action(Action {
320 command: Some("ls".to_string()),
321 tool_name: None,
322 risk_level: Some("low".to_string()),
323 approved: false,
324 allowed: true,
325 });
326
327 let json = serde_json::to_string(&event);
328 assert!(json.is_ok());
329 }
330
331 #[test]
332 fn audit_logger_disabled_does_not_create_file() -> Result<()> {
333 let tmp = TempDir::new()?;
334 let config = AuditConfig {
335 enabled: false,
336 ..Default::default()
337 };
338 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
339 let event = AuditEvent::new(AuditEventType::CommandExecution);
340
341 logger.log(&event)?;
342
343 assert!(!tmp.path().join("audit.log").exists());
344 Ok(())
345 }
346
347 #[test]
348 fn audit_logger_writes_event_when_enabled() -> Result<()> {
349 let tmp = TempDir::new()?;
350 let config = AuditConfig {
351 enabled: true,
352 max_size_mb: 10,
353 ..Default::default()
354 };
355 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
356
357 logger.log_command("cli", "ls", "low", false, true, true, 15)?;
358
359 let log_path = tmp.path().join("audit.log");
360 assert!(log_path.exists());
361
362 let content = std::fs::read_to_string(&log_path)?;
363 assert!(!content.is_empty());
364 Ok(())
365 }
366}