1use std::path::Path;
5
6use crate::config::AuditConfig;
7
8#[derive(Debug)]
9pub struct AuditLogger {
10 destination: AuditDestination,
11}
12
13#[derive(Debug)]
14enum AuditDestination {
15 Stdout,
16 File(tokio::sync::Mutex<tokio::fs::File>),
17}
18
19#[derive(serde::Serialize)]
20#[allow(clippy::struct_excessive_bools)]
21pub struct AuditEntry {
22 pub timestamp: String,
23 pub tool: String,
24 pub command: String,
25 pub result: AuditResult,
26 pub duration_ms: u64,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub error_category: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub error_domain: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
36 pub error_phase: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub claim_source: Option<crate::executor::ClaimSource>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub mcp_server_id: Option<String>,
43 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
45 pub injection_flagged: bool,
46 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
49 pub embedding_anomalous: bool,
50 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
52 pub cross_boundary_mcp_to_acp: bool,
53 #[serde(skip_serializing_if = "Option::is_none")]
58 pub adversarial_policy_decision: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub exit_code: Option<i32>,
62 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64 pub truncated: bool,
65}
66
67#[derive(serde::Serialize)]
68#[serde(tag = "type")]
69pub enum AuditResult {
70 #[serde(rename = "success")]
71 Success,
72 #[serde(rename = "blocked")]
73 Blocked { reason: String },
74 #[serde(rename = "error")]
75 Error { message: String },
76 #[serde(rename = "timeout")]
77 Timeout,
78 #[serde(rename = "rollback")]
79 Rollback { restored: usize, deleted: usize },
80}
81
82impl AuditLogger {
83 pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
89 let destination = if config.destination == "stdout" {
90 AuditDestination::Stdout
91 } else {
92 let file = tokio::fs::OpenOptions::new()
93 .create(true)
94 .append(true)
95 .open(Path::new(&config.destination))
96 .await?;
97 AuditDestination::File(tokio::sync::Mutex::new(file))
98 };
99
100 Ok(Self { destination })
101 }
102
103 pub async fn log(&self, entry: &AuditEntry) {
104 let json = match serde_json::to_string(entry) {
105 Ok(j) => j,
106 Err(err) => {
107 tracing::error!("audit entry serialization failed: {err}");
108 return;
109 }
110 };
111
112 match &self.destination {
113 AuditDestination::Stdout => {
114 tracing::info!(target: "audit", "{json}");
115 }
116 AuditDestination::File(file) => {
117 use tokio::io::AsyncWriteExt;
118 let mut f = file.lock().await;
119 let line = format!("{json}\n");
120 if let Err(e) = f.write_all(line.as_bytes()).await {
121 tracing::error!("failed to write audit log: {e}");
122 } else if let Err(e) = f.flush().await {
123 tracing::error!("failed to flush audit log: {e}");
124 }
125 }
126 }
127 }
128}
129
130#[must_use]
131pub fn chrono_now() -> String {
132 use std::time::{SystemTime, UNIX_EPOCH};
133 let secs = SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .unwrap_or_default()
136 .as_secs();
137 format!("{secs}")
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn audit_entry_serialization() {
146 let entry = AuditEntry {
147 timestamp: "1234567890".into(),
148 tool: "shell".into(),
149 command: "echo hello".into(),
150 result: AuditResult::Success,
151 duration_ms: 42,
152 error_category: None,
153 error_domain: None,
154 error_phase: None,
155 claim_source: None,
156 mcp_server_id: None,
157 injection_flagged: false,
158 embedding_anomalous: false,
159 cross_boundary_mcp_to_acp: false,
160 adversarial_policy_decision: None,
161 exit_code: None,
162 truncated: false,
163 };
164 let json = serde_json::to_string(&entry).unwrap();
165 assert!(json.contains("\"type\":\"success\""));
166 assert!(json.contains("\"tool\":\"shell\""));
167 assert!(json.contains("\"duration_ms\":42"));
168 }
169
170 #[test]
171 fn audit_result_blocked_serialization() {
172 let entry = AuditEntry {
173 timestamp: "0".into(),
174 tool: "shell".into(),
175 command: "sudo rm".into(),
176 result: AuditResult::Blocked {
177 reason: "blocked command: sudo".into(),
178 },
179 duration_ms: 0,
180 error_category: Some("policy_blocked".to_owned()),
181 error_domain: Some("action".to_owned()),
182 error_phase: None,
183 claim_source: None,
184 mcp_server_id: None,
185 injection_flagged: false,
186 embedding_anomalous: false,
187 cross_boundary_mcp_to_acp: false,
188 adversarial_policy_decision: None,
189 exit_code: None,
190 truncated: false,
191 };
192 let json = serde_json::to_string(&entry).unwrap();
193 assert!(json.contains("\"type\":\"blocked\""));
194 assert!(json.contains("\"reason\""));
195 }
196
197 #[test]
198 fn audit_result_error_serialization() {
199 let entry = AuditEntry {
200 timestamp: "0".into(),
201 tool: "shell".into(),
202 command: "bad".into(),
203 result: AuditResult::Error {
204 message: "exec failed".into(),
205 },
206 duration_ms: 0,
207 error_category: None,
208 error_domain: None,
209 error_phase: None,
210 claim_source: None,
211 mcp_server_id: None,
212 injection_flagged: false,
213 embedding_anomalous: false,
214 cross_boundary_mcp_to_acp: false,
215 adversarial_policy_decision: None,
216 exit_code: None,
217 truncated: false,
218 };
219 let json = serde_json::to_string(&entry).unwrap();
220 assert!(json.contains("\"type\":\"error\""));
221 }
222
223 #[test]
224 fn audit_result_timeout_serialization() {
225 let entry = AuditEntry {
226 timestamp: "0".into(),
227 tool: "shell".into(),
228 command: "sleep 999".into(),
229 result: AuditResult::Timeout,
230 duration_ms: 30000,
231 error_category: Some("timeout".to_owned()),
232 error_domain: Some("system".to_owned()),
233 error_phase: None,
234 claim_source: None,
235 mcp_server_id: None,
236 injection_flagged: false,
237 embedding_anomalous: false,
238 cross_boundary_mcp_to_acp: false,
239 adversarial_policy_decision: None,
240 exit_code: None,
241 truncated: false,
242 };
243 let json = serde_json::to_string(&entry).unwrap();
244 assert!(json.contains("\"type\":\"timeout\""));
245 }
246
247 #[tokio::test]
248 async fn audit_logger_stdout() {
249 let config = AuditConfig {
250 enabled: true,
251 destination: "stdout".into(),
252 };
253 let logger = AuditLogger::from_config(&config).await.unwrap();
254 let entry = AuditEntry {
255 timestamp: "0".into(),
256 tool: "shell".into(),
257 command: "echo test".into(),
258 result: AuditResult::Success,
259 duration_ms: 1,
260 error_category: None,
261 error_domain: None,
262 error_phase: None,
263 claim_source: None,
264 mcp_server_id: None,
265 injection_flagged: false,
266 embedding_anomalous: false,
267 cross_boundary_mcp_to_acp: false,
268 adversarial_policy_decision: None,
269 exit_code: None,
270 truncated: false,
271 };
272 logger.log(&entry).await;
273 }
274
275 #[tokio::test]
276 async fn audit_logger_file() {
277 let dir = tempfile::tempdir().unwrap();
278 let path = dir.path().join("audit.log");
279 let config = AuditConfig {
280 enabled: true,
281 destination: path.display().to_string(),
282 };
283 let logger = AuditLogger::from_config(&config).await.unwrap();
284 let entry = AuditEntry {
285 timestamp: "0".into(),
286 tool: "shell".into(),
287 command: "echo test".into(),
288 result: AuditResult::Success,
289 duration_ms: 1,
290 error_category: None,
291 error_domain: None,
292 error_phase: None,
293 claim_source: None,
294 mcp_server_id: None,
295 injection_flagged: false,
296 embedding_anomalous: false,
297 cross_boundary_mcp_to_acp: false,
298 adversarial_policy_decision: None,
299 exit_code: None,
300 truncated: false,
301 };
302 logger.log(&entry).await;
303
304 let content = tokio::fs::read_to_string(&path).await.unwrap();
305 assert!(content.contains("\"tool\":\"shell\""));
306 }
307
308 #[tokio::test]
309 async fn audit_logger_file_write_error_logged() {
310 let config = AuditConfig {
311 enabled: true,
312 destination: "/nonexistent/dir/audit.log".into(),
313 };
314 let result = AuditLogger::from_config(&config).await;
315 assert!(result.is_err());
316 }
317
318 #[test]
319 fn claim_source_serde_roundtrip() {
320 use crate::executor::ClaimSource;
321 let cases = [
322 (ClaimSource::Shell, "\"shell\""),
323 (ClaimSource::FileSystem, "\"file_system\""),
324 (ClaimSource::WebScrape, "\"web_scrape\""),
325 (ClaimSource::Mcp, "\"mcp\""),
326 (ClaimSource::A2a, "\"a2a\""),
327 (ClaimSource::CodeSearch, "\"code_search\""),
328 (ClaimSource::Diagnostics, "\"diagnostics\""),
329 (ClaimSource::Memory, "\"memory\""),
330 ];
331 for (variant, expected_json) in cases {
332 let serialized = serde_json::to_string(&variant).unwrap();
333 assert_eq!(serialized, expected_json, "serialize {variant:?}");
334 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
335 assert_eq!(deserialized, variant, "deserialize {variant:?}");
336 }
337 }
338
339 #[test]
340 fn audit_entry_claim_source_none_omitted() {
341 let entry = AuditEntry {
342 timestamp: "0".into(),
343 tool: "shell".into(),
344 command: "echo".into(),
345 result: AuditResult::Success,
346 duration_ms: 1,
347 error_category: None,
348 error_domain: None,
349 error_phase: None,
350 claim_source: None,
351 mcp_server_id: None,
352 injection_flagged: false,
353 embedding_anomalous: false,
354 cross_boundary_mcp_to_acp: false,
355 adversarial_policy_decision: None,
356 exit_code: None,
357 truncated: false,
358 };
359 let json = serde_json::to_string(&entry).unwrap();
360 assert!(
361 !json.contains("claim_source"),
362 "claim_source must be omitted when None: {json}"
363 );
364 }
365
366 #[test]
367 fn audit_entry_claim_source_some_present() {
368 use crate::executor::ClaimSource;
369 let entry = AuditEntry {
370 timestamp: "0".into(),
371 tool: "shell".into(),
372 command: "echo".into(),
373 result: AuditResult::Success,
374 duration_ms: 1,
375 error_category: None,
376 error_domain: None,
377 error_phase: None,
378 claim_source: Some(ClaimSource::Shell),
379 mcp_server_id: None,
380 injection_flagged: false,
381 embedding_anomalous: false,
382 cross_boundary_mcp_to_acp: false,
383 adversarial_policy_decision: None,
384 exit_code: None,
385 truncated: false,
386 };
387 let json = serde_json::to_string(&entry).unwrap();
388 assert!(
389 json.contains("\"claim_source\":\"shell\""),
390 "expected claim_source=shell in JSON: {json}"
391 );
392 }
393
394 #[tokio::test]
395 async fn audit_logger_multiple_entries() {
396 let dir = tempfile::tempdir().unwrap();
397 let path = dir.path().join("audit.log");
398 let config = AuditConfig {
399 enabled: true,
400 destination: path.display().to_string(),
401 };
402 let logger = AuditLogger::from_config(&config).await.unwrap();
403
404 for i in 0..5 {
405 let entry = AuditEntry {
406 timestamp: i.to_string(),
407 tool: "shell".into(),
408 command: format!("cmd{i}"),
409 result: AuditResult::Success,
410 duration_ms: i,
411 error_category: None,
412 error_domain: None,
413 error_phase: None,
414 claim_source: None,
415 mcp_server_id: None,
416 injection_flagged: false,
417 embedding_anomalous: false,
418 cross_boundary_mcp_to_acp: false,
419 adversarial_policy_decision: None,
420 exit_code: None,
421 truncated: false,
422 };
423 logger.log(&entry).await;
424 }
425
426 let content = tokio::fs::read_to_string(&path).await.unwrap();
427 assert_eq!(content.lines().count(), 5);
428 }
429
430 #[test]
431 fn audit_entry_exit_code_serialized() {
432 let entry = AuditEntry {
433 timestamp: "0".into(),
434 tool: "shell".into(),
435 command: "echo hi".into(),
436 result: AuditResult::Success,
437 duration_ms: 5,
438 error_category: None,
439 error_domain: None,
440 error_phase: None,
441 claim_source: None,
442 mcp_server_id: None,
443 injection_flagged: false,
444 embedding_anomalous: false,
445 cross_boundary_mcp_to_acp: false,
446 adversarial_policy_decision: None,
447 exit_code: Some(0),
448 truncated: false,
449 };
450 let json = serde_json::to_string(&entry).unwrap();
451 assert!(
452 json.contains("\"exit_code\":0"),
453 "exit_code must be serialized: {json}"
454 );
455 }
456
457 #[test]
458 fn audit_entry_exit_code_none_omitted() {
459 let entry = AuditEntry {
460 timestamp: "0".into(),
461 tool: "file".into(),
462 command: "read /tmp/x".into(),
463 result: AuditResult::Success,
464 duration_ms: 1,
465 error_category: None,
466 error_domain: None,
467 error_phase: None,
468 claim_source: None,
469 mcp_server_id: None,
470 injection_flagged: false,
471 embedding_anomalous: false,
472 cross_boundary_mcp_to_acp: false,
473 adversarial_policy_decision: None,
474 exit_code: None,
475 truncated: false,
476 };
477 let json = serde_json::to_string(&entry).unwrap();
478 assert!(
479 !json.contains("exit_code"),
480 "exit_code None must be omitted: {json}"
481 );
482 }
483}