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 #[serde(skip_serializing_if = "Option::is_none")]
67 pub caller_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
71 pub policy_match: Option<String>,
72}
73
74#[derive(serde::Serialize)]
75#[serde(tag = "type")]
76pub enum AuditResult {
77 #[serde(rename = "success")]
78 Success,
79 #[serde(rename = "blocked")]
80 Blocked { reason: String },
81 #[serde(rename = "error")]
82 Error { message: String },
83 #[serde(rename = "timeout")]
84 Timeout,
85 #[serde(rename = "rollback")]
86 Rollback { restored: usize, deleted: usize },
87}
88
89impl AuditLogger {
90 pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
96 let destination = if config.destination == "stdout" {
97 AuditDestination::Stdout
98 } else {
99 let file = tokio::fs::OpenOptions::new()
100 .create(true)
101 .append(true)
102 .open(Path::new(&config.destination))
103 .await?;
104 AuditDestination::File(tokio::sync::Mutex::new(file))
105 };
106
107 Ok(Self { destination })
108 }
109
110 pub async fn log(&self, entry: &AuditEntry) {
111 let json = match serde_json::to_string(entry) {
112 Ok(j) => j,
113 Err(err) => {
114 tracing::error!("audit entry serialization failed: {err}");
115 return;
116 }
117 };
118
119 match &self.destination {
120 AuditDestination::Stdout => {
121 tracing::info!(target: "audit", "{json}");
122 }
123 AuditDestination::File(file) => {
124 use tokio::io::AsyncWriteExt;
125 let mut f = file.lock().await;
126 let line = format!("{json}\n");
127 if let Err(e) = f.write_all(line.as_bytes()).await {
128 tracing::error!("failed to write audit log: {e}");
129 } else if let Err(e) = f.flush().await {
130 tracing::error!("failed to flush audit log: {e}");
131 }
132 }
133 }
134 }
135}
136
137pub fn log_tool_risk_summary(tool_ids: &[&str]) {
143 fn classify(id: &str) -> (&'static str, &'static str) {
147 if id.starts_with("shell") || id == "bash" || id == "exec" {
148 ("high", "env_blocklist + command_blocklist")
149 } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
150 ("medium", "validate_url + SSRF + domain_policy")
151 } else if id.starts_with("file_write")
152 || id.starts_with("file_read")
153 || id.starts_with("file")
154 {
155 ("medium", "path_sandbox")
156 } else {
157 ("low", "schema_only")
158 }
159 }
160
161 for &id in tool_ids {
162 let (privilege, sanitization) = classify(id);
163 tracing::info!(
164 tool = id,
165 privilege_level = privilege,
166 expected_sanitization = sanitization,
167 "tool risk summary"
168 );
169 }
170}
171
172#[must_use]
173pub fn chrono_now() -> String {
174 use std::time::{SystemTime, UNIX_EPOCH};
175 let secs = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .unwrap_or_default()
178 .as_secs();
179 format!("{secs}")
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn audit_entry_serialization() {
188 let entry = AuditEntry {
189 timestamp: "1234567890".into(),
190 tool: "shell".into(),
191 command: "echo hello".into(),
192 result: AuditResult::Success,
193 duration_ms: 42,
194 error_category: None,
195 error_domain: None,
196 error_phase: None,
197 claim_source: None,
198 mcp_server_id: None,
199 injection_flagged: false,
200 embedding_anomalous: false,
201 cross_boundary_mcp_to_acp: false,
202 adversarial_policy_decision: None,
203 exit_code: None,
204 truncated: false,
205 policy_match: None,
206 caller_id: None,
207 };
208 let json = serde_json::to_string(&entry).unwrap();
209 assert!(json.contains("\"type\":\"success\""));
210 assert!(json.contains("\"tool\":\"shell\""));
211 assert!(json.contains("\"duration_ms\":42"));
212 }
213
214 #[test]
215 fn audit_result_blocked_serialization() {
216 let entry = AuditEntry {
217 timestamp: "0".into(),
218 tool: "shell".into(),
219 command: "sudo rm".into(),
220 result: AuditResult::Blocked {
221 reason: "blocked command: sudo".into(),
222 },
223 duration_ms: 0,
224 error_category: Some("policy_blocked".to_owned()),
225 error_domain: Some("action".to_owned()),
226 error_phase: None,
227 claim_source: None,
228 mcp_server_id: None,
229 injection_flagged: false,
230 embedding_anomalous: false,
231 cross_boundary_mcp_to_acp: false,
232 adversarial_policy_decision: None,
233 exit_code: None,
234 truncated: false,
235 policy_match: None,
236 caller_id: None,
237 };
238 let json = serde_json::to_string(&entry).unwrap();
239 assert!(json.contains("\"type\":\"blocked\""));
240 assert!(json.contains("\"reason\""));
241 }
242
243 #[test]
244 fn audit_result_error_serialization() {
245 let entry = AuditEntry {
246 timestamp: "0".into(),
247 tool: "shell".into(),
248 command: "bad".into(),
249 result: AuditResult::Error {
250 message: "exec failed".into(),
251 },
252 duration_ms: 0,
253 error_category: None,
254 error_domain: None,
255 error_phase: None,
256 claim_source: None,
257 mcp_server_id: None,
258 injection_flagged: false,
259 embedding_anomalous: false,
260 cross_boundary_mcp_to_acp: false,
261 adversarial_policy_decision: None,
262 exit_code: None,
263 truncated: false,
264 policy_match: None,
265 caller_id: None,
266 };
267 let json = serde_json::to_string(&entry).unwrap();
268 assert!(json.contains("\"type\":\"error\""));
269 }
270
271 #[test]
272 fn audit_result_timeout_serialization() {
273 let entry = AuditEntry {
274 timestamp: "0".into(),
275 tool: "shell".into(),
276 command: "sleep 999".into(),
277 result: AuditResult::Timeout,
278 duration_ms: 30000,
279 error_category: Some("timeout".to_owned()),
280 error_domain: Some("system".to_owned()),
281 error_phase: None,
282 claim_source: None,
283 mcp_server_id: None,
284 injection_flagged: false,
285 embedding_anomalous: false,
286 cross_boundary_mcp_to_acp: false,
287 adversarial_policy_decision: None,
288 exit_code: None,
289 truncated: false,
290 policy_match: None,
291 caller_id: None,
292 };
293 let json = serde_json::to_string(&entry).unwrap();
294 assert!(json.contains("\"type\":\"timeout\""));
295 }
296
297 #[tokio::test]
298 async fn audit_logger_stdout() {
299 let config = AuditConfig {
300 enabled: true,
301 destination: "stdout".into(),
302 ..Default::default()
303 };
304 let logger = AuditLogger::from_config(&config).await.unwrap();
305 let entry = AuditEntry {
306 timestamp: "0".into(),
307 tool: "shell".into(),
308 command: "echo test".into(),
309 result: AuditResult::Success,
310 duration_ms: 1,
311 error_category: None,
312 error_domain: None,
313 error_phase: None,
314 claim_source: None,
315 mcp_server_id: None,
316 injection_flagged: false,
317 embedding_anomalous: false,
318 cross_boundary_mcp_to_acp: false,
319 adversarial_policy_decision: None,
320 exit_code: None,
321 truncated: false,
322 policy_match: None,
323 caller_id: None,
324 };
325 logger.log(&entry).await;
326 }
327
328 #[tokio::test]
329 async fn audit_logger_file() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = dir.path().join("audit.log");
332 let config = AuditConfig {
333 enabled: true,
334 destination: path.display().to_string(),
335 ..Default::default()
336 };
337 let logger = AuditLogger::from_config(&config).await.unwrap();
338 let entry = AuditEntry {
339 timestamp: "0".into(),
340 tool: "shell".into(),
341 command: "echo test".into(),
342 result: AuditResult::Success,
343 duration_ms: 1,
344 error_category: None,
345 error_domain: None,
346 error_phase: None,
347 claim_source: None,
348 mcp_server_id: None,
349 injection_flagged: false,
350 embedding_anomalous: false,
351 cross_boundary_mcp_to_acp: false,
352 adversarial_policy_decision: None,
353 exit_code: None,
354 truncated: false,
355 policy_match: None,
356 caller_id: None,
357 };
358 logger.log(&entry).await;
359
360 let content = tokio::fs::read_to_string(&path).await.unwrap();
361 assert!(content.contains("\"tool\":\"shell\""));
362 }
363
364 #[tokio::test]
365 async fn audit_logger_file_write_error_logged() {
366 let config = AuditConfig {
367 enabled: true,
368 destination: "/nonexistent/dir/audit.log".into(),
369 ..Default::default()
370 };
371 let result = AuditLogger::from_config(&config).await;
372 assert!(result.is_err());
373 }
374
375 #[test]
376 fn claim_source_serde_roundtrip() {
377 use crate::executor::ClaimSource;
378 let cases = [
379 (ClaimSource::Shell, "\"shell\""),
380 (ClaimSource::FileSystem, "\"file_system\""),
381 (ClaimSource::WebScrape, "\"web_scrape\""),
382 (ClaimSource::Mcp, "\"mcp\""),
383 (ClaimSource::A2a, "\"a2a\""),
384 (ClaimSource::CodeSearch, "\"code_search\""),
385 (ClaimSource::Diagnostics, "\"diagnostics\""),
386 (ClaimSource::Memory, "\"memory\""),
387 ];
388 for (variant, expected_json) in cases {
389 let serialized = serde_json::to_string(&variant).unwrap();
390 assert_eq!(serialized, expected_json, "serialize {variant:?}");
391 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
392 assert_eq!(deserialized, variant, "deserialize {variant:?}");
393 }
394 }
395
396 #[test]
397 fn audit_entry_claim_source_none_omitted() {
398 let entry = AuditEntry {
399 timestamp: "0".into(),
400 tool: "shell".into(),
401 command: "echo".into(),
402 result: AuditResult::Success,
403 duration_ms: 1,
404 error_category: None,
405 error_domain: None,
406 error_phase: None,
407 claim_source: None,
408 mcp_server_id: None,
409 injection_flagged: false,
410 embedding_anomalous: false,
411 cross_boundary_mcp_to_acp: false,
412 adversarial_policy_decision: None,
413 exit_code: None,
414 truncated: false,
415 policy_match: None,
416 caller_id: None,
417 };
418 let json = serde_json::to_string(&entry).unwrap();
419 assert!(
420 !json.contains("claim_source"),
421 "claim_source must be omitted when None: {json}"
422 );
423 }
424
425 #[test]
426 fn audit_entry_claim_source_some_present() {
427 use crate::executor::ClaimSource;
428 let entry = AuditEntry {
429 timestamp: "0".into(),
430 tool: "shell".into(),
431 command: "echo".into(),
432 result: AuditResult::Success,
433 duration_ms: 1,
434 error_category: None,
435 error_domain: None,
436 error_phase: None,
437 claim_source: Some(ClaimSource::Shell),
438 mcp_server_id: None,
439 injection_flagged: false,
440 embedding_anomalous: false,
441 cross_boundary_mcp_to_acp: false,
442 adversarial_policy_decision: None,
443 exit_code: None,
444 truncated: false,
445 policy_match: None,
446 caller_id: None,
447 };
448 let json = serde_json::to_string(&entry).unwrap();
449 assert!(
450 json.contains("\"claim_source\":\"shell\""),
451 "expected claim_source=shell in JSON: {json}"
452 );
453 }
454
455 #[tokio::test]
456 async fn audit_logger_multiple_entries() {
457 let dir = tempfile::tempdir().unwrap();
458 let path = dir.path().join("audit.log");
459 let config = AuditConfig {
460 enabled: true,
461 destination: path.display().to_string(),
462 ..Default::default()
463 };
464 let logger = AuditLogger::from_config(&config).await.unwrap();
465
466 for i in 0..5 {
467 let entry = AuditEntry {
468 timestamp: i.to_string(),
469 tool: "shell".into(),
470 command: format!("cmd{i}"),
471 result: AuditResult::Success,
472 duration_ms: i,
473 error_category: None,
474 error_domain: None,
475 error_phase: None,
476 claim_source: None,
477 mcp_server_id: None,
478 injection_flagged: false,
479 embedding_anomalous: false,
480 cross_boundary_mcp_to_acp: false,
481 adversarial_policy_decision: None,
482 exit_code: None,
483 truncated: false,
484 policy_match: None,
485 caller_id: None,
486 };
487 logger.log(&entry).await;
488 }
489
490 let content = tokio::fs::read_to_string(&path).await.unwrap();
491 assert_eq!(content.lines().count(), 5);
492 }
493
494 #[test]
495 fn audit_entry_exit_code_serialized() {
496 let entry = AuditEntry {
497 timestamp: "0".into(),
498 tool: "shell".into(),
499 command: "echo hi".into(),
500 result: AuditResult::Success,
501 duration_ms: 5,
502 error_category: None,
503 error_domain: None,
504 error_phase: None,
505 claim_source: None,
506 mcp_server_id: None,
507 injection_flagged: false,
508 embedding_anomalous: false,
509 cross_boundary_mcp_to_acp: false,
510 adversarial_policy_decision: None,
511 exit_code: Some(0),
512 truncated: false,
513 policy_match: None,
514 caller_id: None,
515 };
516 let json = serde_json::to_string(&entry).unwrap();
517 assert!(
518 json.contains("\"exit_code\":0"),
519 "exit_code must be serialized: {json}"
520 );
521 }
522
523 #[test]
524 fn audit_entry_exit_code_none_omitted() {
525 let entry = AuditEntry {
526 timestamp: "0".into(),
527 tool: "file".into(),
528 command: "read /tmp/x".into(),
529 result: AuditResult::Success,
530 duration_ms: 1,
531 error_category: None,
532 error_domain: None,
533 error_phase: None,
534 claim_source: None,
535 mcp_server_id: None,
536 injection_flagged: false,
537 embedding_anomalous: false,
538 cross_boundary_mcp_to_acp: false,
539 adversarial_policy_decision: None,
540 exit_code: None,
541 truncated: false,
542 policy_match: None,
543 caller_id: None,
544 };
545 let json = serde_json::to_string(&entry).unwrap();
546 assert!(
547 !json.contains("exit_code"),
548 "exit_code None must be omitted: {json}"
549 );
550 }
551
552 #[test]
553 fn log_tool_risk_summary_does_not_panic() {
554 log_tool_risk_summary(&[
555 "shell",
556 "bash",
557 "exec",
558 "web_scrape",
559 "fetch",
560 "scrape_page",
561 "file_write",
562 "file_read",
563 "file_delete",
564 "memory_search",
565 "unknown_tool",
566 ]);
567 }
568
569 #[test]
570 fn log_tool_risk_summary_empty_input_does_not_panic() {
571 log_tool_risk_summary(&[]);
572 }
573}