1use std::path::Path;
21
22use zeph_common::ToolName;
23
24use crate::config::AuditConfig;
25
26#[allow(clippy::trivially_copy_pass_by_ref)]
27fn is_zero_u8(v: &u8) -> bool {
28 *v == 0
29}
30
31#[derive(Debug, Clone, serde::Serialize)]
45pub struct EgressEvent {
46 pub timestamp: String,
48 pub kind: &'static str,
51 pub correlation_id: String,
53 pub tool: ToolName,
55 pub url: String,
57 pub host: String,
59 pub method: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub status: Option<u16>,
64 pub duration_ms: u64,
66 pub response_bytes: usize,
69 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
71 pub blocked: bool,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub block_reason: Option<&'static str>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub caller_id: Option<String>,
78 #[serde(default, skip_serializing_if = "is_zero_u8")]
81 pub hop: u8,
82}
83
84impl EgressEvent {
85 #[must_use]
87 pub fn new_correlation_id() -> String {
88 uuid::Uuid::new_v4().to_string()
89 }
90}
91
92#[derive(Debug)]
103pub struct AuditLogger {
104 destination: AuditDestination,
105}
106
107#[derive(Debug)]
108enum AuditDestination {
109 Stdout,
110 File(tokio::sync::Mutex<tokio::fs::File>),
111}
112
113#[derive(serde::Serialize)]
125#[allow(clippy::struct_excessive_bools)]
126pub struct AuditEntry {
127 pub timestamp: String,
129 pub tool: ToolName,
131 pub command: String,
133 pub result: AuditResult,
135 pub duration_ms: u64,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub error_category: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub error_domain: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
146 pub error_phase: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub claim_source: Option<crate::executor::ClaimSource>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub mcp_server_id: Option<String>,
153 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155 pub injection_flagged: bool,
156 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
159 pub embedding_anomalous: bool,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162 pub cross_boundary_mcp_to_acp: bool,
163 #[serde(skip_serializing_if = "Option::is_none")]
168 pub adversarial_policy_decision: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub exit_code: Option<i32>,
172 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
174 pub truncated: bool,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub caller_id: Option<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
181 pub policy_match: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
186 pub correlation_id: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
190 pub vigil_risk: Option<VigilRiskLevel>,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum VigilRiskLevel {
200 Low,
202 Medium,
204 High,
206}
207
208#[derive(serde::Serialize)]
223#[serde(tag = "type")]
224pub enum AuditResult {
225 #[serde(rename = "success")]
227 Success,
228 #[serde(rename = "blocked")]
230 Blocked {
231 reason: String,
233 },
234 #[serde(rename = "error")]
236 Error {
237 message: String,
239 },
240 #[serde(rename = "timeout")]
242 Timeout,
243 #[serde(rename = "rollback")]
245 Rollback {
246 restored: usize,
248 deleted: usize,
250 },
251}
252
253impl AuditLogger {
254 #[allow(clippy::unused_async)]
264 pub async fn from_config(config: &AuditConfig, tui_mode: bool) -> Result<Self, std::io::Error> {
265 let effective_dest = if tui_mode && config.destination == "stdout" {
266 tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
267 "audit.jsonl".to_owned()
268 } else {
269 config.destination.clone()
270 };
271
272 let destination = if effective_dest == "stdout" {
273 AuditDestination::Stdout
274 } else {
275 let std_file = zeph_common::fs_secure::append_private(Path::new(&effective_dest))?;
276 let file = tokio::fs::File::from_std(std_file);
277 AuditDestination::File(tokio::sync::Mutex::new(file))
278 };
279
280 Ok(Self { destination })
281 }
282
283 pub async fn log(&self, entry: &AuditEntry) {
288 let json = match serde_json::to_string(entry) {
289 Ok(j) => j,
290 Err(err) => {
291 tracing::error!("audit entry serialization failed: {err}");
292 return;
293 }
294 };
295
296 match &self.destination {
297 AuditDestination::Stdout => {
298 tracing::info!(target: "audit", "{json}");
299 }
300 AuditDestination::File(file) => {
301 use tokio::io::AsyncWriteExt;
302 let mut f = file.lock().await;
303 let line = format!("{json}\n");
304 if let Err(e) = f.write_all(line.as_bytes()).await {
305 tracing::error!("failed to write audit log: {e}");
306 } else if let Err(e) = f.flush().await {
307 tracing::error!("failed to flush audit log: {e}");
308 }
309 }
310 }
311 }
312
313 pub async fn log_egress(&self, event: &EgressEvent) {
321 let json = match serde_json::to_string(event) {
322 Ok(j) => j,
323 Err(err) => {
324 tracing::error!("egress event serialization failed: {err}");
325 return;
326 }
327 };
328
329 match &self.destination {
330 AuditDestination::Stdout => {
331 tracing::info!(target: "audit", "{json}");
332 }
333 AuditDestination::File(file) => {
334 use tokio::io::AsyncWriteExt;
335 let mut f = file.lock().await;
336 let line = format!("{json}\n");
337 if let Err(e) = f.write_all(line.as_bytes()).await {
338 tracing::error!("failed to write egress log: {e}");
339 } else if let Err(e) = f.flush().await {
340 tracing::error!("failed to flush egress log: {e}");
341 }
342 }
343 }
344 }
345}
346
347pub fn log_tool_risk_summary(tool_ids: &[&str]) {
353 fn classify(id: &str) -> (&'static str, &'static str) {
357 if id.starts_with("shell") || id == "bash" || id == "exec" {
358 ("high", "env_blocklist + command_blocklist")
359 } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
360 ("medium", "validate_url + SSRF + domain_policy")
361 } else if id.starts_with("file_write")
362 || id.starts_with("file_read")
363 || id.starts_with("file")
364 {
365 ("medium", "path_sandbox")
366 } else {
367 ("low", "schema_only")
368 }
369 }
370
371 for &id in tool_ids {
372 let (privilege, sanitization) = classify(id);
373 tracing::info!(
374 tool = id,
375 privilege_level = privilege,
376 expected_sanitization = sanitization,
377 "tool risk summary"
378 );
379 }
380}
381
382#[must_use]
387pub fn chrono_now() -> String {
388 use std::time::{SystemTime, UNIX_EPOCH};
389 let secs = SystemTime::now()
390 .duration_since(UNIX_EPOCH)
391 .unwrap_or_default()
392 .as_secs();
393 format!("{secs}")
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn audit_entry_serialization() {
402 let entry = AuditEntry {
403 timestamp: "1234567890".into(),
404 tool: "shell".into(),
405 command: "echo hello".into(),
406 result: AuditResult::Success,
407 duration_ms: 42,
408 error_category: None,
409 error_domain: None,
410 error_phase: None,
411 claim_source: None,
412 mcp_server_id: None,
413 injection_flagged: false,
414 embedding_anomalous: false,
415 cross_boundary_mcp_to_acp: false,
416 adversarial_policy_decision: None,
417 exit_code: None,
418 truncated: false,
419 policy_match: None,
420 correlation_id: None,
421 caller_id: None,
422 vigil_risk: None,
423 };
424 let json = serde_json::to_string(&entry).unwrap();
425 assert!(json.contains("\"type\":\"success\""));
426 assert!(json.contains("\"tool\":\"shell\""));
427 assert!(json.contains("\"duration_ms\":42"));
428 }
429
430 #[test]
431 fn audit_result_blocked_serialization() {
432 let entry = AuditEntry {
433 timestamp: "0".into(),
434 tool: "shell".into(),
435 command: "sudo rm".into(),
436 result: AuditResult::Blocked {
437 reason: "blocked command: sudo".into(),
438 },
439 duration_ms: 0,
440 error_category: Some("policy_blocked".to_owned()),
441 error_domain: Some("action".to_owned()),
442 error_phase: None,
443 claim_source: None,
444 mcp_server_id: None,
445 injection_flagged: false,
446 embedding_anomalous: false,
447 cross_boundary_mcp_to_acp: false,
448 adversarial_policy_decision: None,
449 exit_code: None,
450 truncated: false,
451 policy_match: None,
452 correlation_id: None,
453 caller_id: None,
454 vigil_risk: None,
455 };
456 let json = serde_json::to_string(&entry).unwrap();
457 assert!(json.contains("\"type\":\"blocked\""));
458 assert!(json.contains("\"reason\""));
459 }
460
461 #[test]
462 fn audit_result_error_serialization() {
463 let entry = AuditEntry {
464 timestamp: "0".into(),
465 tool: "shell".into(),
466 command: "bad".into(),
467 result: AuditResult::Error {
468 message: "exec failed".into(),
469 },
470 duration_ms: 0,
471 error_category: None,
472 error_domain: None,
473 error_phase: None,
474 claim_source: None,
475 mcp_server_id: None,
476 injection_flagged: false,
477 embedding_anomalous: false,
478 cross_boundary_mcp_to_acp: false,
479 adversarial_policy_decision: None,
480 exit_code: None,
481 truncated: false,
482 policy_match: None,
483 correlation_id: None,
484 caller_id: None,
485 vigil_risk: None,
486 };
487 let json = serde_json::to_string(&entry).unwrap();
488 assert!(json.contains("\"type\":\"error\""));
489 }
490
491 #[test]
492 fn audit_result_timeout_serialization() {
493 let entry = AuditEntry {
494 timestamp: "0".into(),
495 tool: "shell".into(),
496 command: "sleep 999".into(),
497 result: AuditResult::Timeout,
498 duration_ms: 30000,
499 error_category: Some("timeout".to_owned()),
500 error_domain: Some("system".to_owned()),
501 error_phase: None,
502 claim_source: None,
503 mcp_server_id: None,
504 injection_flagged: false,
505 embedding_anomalous: false,
506 cross_boundary_mcp_to_acp: false,
507 adversarial_policy_decision: None,
508 exit_code: None,
509 truncated: false,
510 policy_match: None,
511 correlation_id: None,
512 caller_id: None,
513 vigil_risk: None,
514 };
515 let json = serde_json::to_string(&entry).unwrap();
516 assert!(json.contains("\"type\":\"timeout\""));
517 }
518
519 #[tokio::test]
520 async fn audit_logger_stdout() {
521 let config = AuditConfig {
522 enabled: true,
523 destination: "stdout".into(),
524 ..Default::default()
525 };
526 let logger = AuditLogger::from_config(&config, false).await.unwrap();
527 let entry = AuditEntry {
528 timestamp: "0".into(),
529 tool: "shell".into(),
530 command: "echo test".into(),
531 result: AuditResult::Success,
532 duration_ms: 1,
533 error_category: None,
534 error_domain: None,
535 error_phase: None,
536 claim_source: None,
537 mcp_server_id: None,
538 injection_flagged: false,
539 embedding_anomalous: false,
540 cross_boundary_mcp_to_acp: false,
541 adversarial_policy_decision: None,
542 exit_code: None,
543 truncated: false,
544 policy_match: None,
545 correlation_id: None,
546 caller_id: None,
547 vigil_risk: None,
548 };
549 logger.log(&entry).await;
550 }
551
552 #[tokio::test]
553 async fn audit_logger_file() {
554 let dir = tempfile::tempdir().unwrap();
555 let path = dir.path().join("audit.log");
556 let config = AuditConfig {
557 enabled: true,
558 destination: path.display().to_string(),
559 ..Default::default()
560 };
561 let logger = AuditLogger::from_config(&config, false).await.unwrap();
562 let entry = AuditEntry {
563 timestamp: "0".into(),
564 tool: "shell".into(),
565 command: "echo test".into(),
566 result: AuditResult::Success,
567 duration_ms: 1,
568 error_category: None,
569 error_domain: None,
570 error_phase: None,
571 claim_source: None,
572 mcp_server_id: None,
573 injection_flagged: false,
574 embedding_anomalous: false,
575 cross_boundary_mcp_to_acp: false,
576 adversarial_policy_decision: None,
577 exit_code: None,
578 truncated: false,
579 policy_match: None,
580 correlation_id: None,
581 caller_id: None,
582 vigil_risk: None,
583 };
584 logger.log(&entry).await;
585
586 let content = tokio::fs::read_to_string(&path).await.unwrap();
587 assert!(content.contains("\"tool\":\"shell\""));
588 }
589
590 #[tokio::test]
591 async fn audit_logger_file_write_error_logged() {
592 let config = AuditConfig {
593 enabled: true,
594 destination: "/nonexistent/dir/audit.log".into(),
595 ..Default::default()
596 };
597 let result = AuditLogger::from_config(&config, false).await;
598 assert!(result.is_err());
599 }
600
601 #[test]
602 fn claim_source_serde_roundtrip() {
603 use crate::executor::ClaimSource;
604 let cases = [
605 (ClaimSource::Shell, "\"shell\""),
606 (ClaimSource::FileSystem, "\"file_system\""),
607 (ClaimSource::WebScrape, "\"web_scrape\""),
608 (ClaimSource::Mcp, "\"mcp\""),
609 (ClaimSource::A2a, "\"a2a\""),
610 (ClaimSource::CodeSearch, "\"code_search\""),
611 (ClaimSource::Diagnostics, "\"diagnostics\""),
612 (ClaimSource::Memory, "\"memory\""),
613 ];
614 for (variant, expected_json) in cases {
615 let serialized = serde_json::to_string(&variant).unwrap();
616 assert_eq!(serialized, expected_json, "serialize {variant:?}");
617 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
618 assert_eq!(deserialized, variant, "deserialize {variant:?}");
619 }
620 }
621
622 #[test]
623 fn audit_entry_claim_source_none_omitted() {
624 let entry = AuditEntry {
625 timestamp: "0".into(),
626 tool: "shell".into(),
627 command: "echo".into(),
628 result: AuditResult::Success,
629 duration_ms: 1,
630 error_category: None,
631 error_domain: None,
632 error_phase: None,
633 claim_source: None,
634 mcp_server_id: None,
635 injection_flagged: false,
636 embedding_anomalous: false,
637 cross_boundary_mcp_to_acp: false,
638 adversarial_policy_decision: None,
639 exit_code: None,
640 truncated: false,
641 policy_match: None,
642 correlation_id: None,
643 caller_id: None,
644 vigil_risk: None,
645 };
646 let json = serde_json::to_string(&entry).unwrap();
647 assert!(
648 !json.contains("claim_source"),
649 "claim_source must be omitted when None: {json}"
650 );
651 }
652
653 #[test]
654 fn audit_entry_claim_source_some_present() {
655 use crate::executor::ClaimSource;
656 let entry = AuditEntry {
657 timestamp: "0".into(),
658 tool: "shell".into(),
659 command: "echo".into(),
660 result: AuditResult::Success,
661 duration_ms: 1,
662 error_category: None,
663 error_domain: None,
664 error_phase: None,
665 claim_source: Some(ClaimSource::Shell),
666 mcp_server_id: None,
667 injection_flagged: false,
668 embedding_anomalous: false,
669 cross_boundary_mcp_to_acp: false,
670 adversarial_policy_decision: None,
671 exit_code: None,
672 truncated: false,
673 policy_match: None,
674 correlation_id: None,
675 caller_id: None,
676 vigil_risk: None,
677 };
678 let json = serde_json::to_string(&entry).unwrap();
679 assert!(
680 json.contains("\"claim_source\":\"shell\""),
681 "expected claim_source=shell in JSON: {json}"
682 );
683 }
684
685 #[tokio::test]
686 async fn audit_logger_multiple_entries() {
687 let dir = tempfile::tempdir().unwrap();
688 let path = dir.path().join("audit.log");
689 let config = AuditConfig {
690 enabled: true,
691 destination: path.display().to_string(),
692 ..Default::default()
693 };
694 let logger = AuditLogger::from_config(&config, false).await.unwrap();
695
696 for i in 0..5 {
697 let entry = AuditEntry {
698 timestamp: i.to_string(),
699 tool: "shell".into(),
700 command: format!("cmd{i}"),
701 result: AuditResult::Success,
702 duration_ms: i,
703 error_category: None,
704 error_domain: None,
705 error_phase: None,
706 claim_source: None,
707 mcp_server_id: None,
708 injection_flagged: false,
709 embedding_anomalous: false,
710 cross_boundary_mcp_to_acp: false,
711 adversarial_policy_decision: None,
712 exit_code: None,
713 truncated: false,
714 policy_match: None,
715 correlation_id: None,
716 caller_id: None,
717 vigil_risk: None,
718 };
719 logger.log(&entry).await;
720 }
721
722 let content = tokio::fs::read_to_string(&path).await.unwrap();
723 assert_eq!(content.lines().count(), 5);
724 }
725
726 #[test]
727 fn audit_entry_exit_code_serialized() {
728 let entry = AuditEntry {
729 timestamp: "0".into(),
730 tool: "shell".into(),
731 command: "echo hi".into(),
732 result: AuditResult::Success,
733 duration_ms: 5,
734 error_category: None,
735 error_domain: None,
736 error_phase: None,
737 claim_source: None,
738 mcp_server_id: None,
739 injection_flagged: false,
740 embedding_anomalous: false,
741 cross_boundary_mcp_to_acp: false,
742 adversarial_policy_decision: None,
743 exit_code: Some(0),
744 truncated: false,
745 policy_match: None,
746 correlation_id: None,
747 caller_id: None,
748 vigil_risk: None,
749 };
750 let json = serde_json::to_string(&entry).unwrap();
751 assert!(
752 json.contains("\"exit_code\":0"),
753 "exit_code must be serialized: {json}"
754 );
755 }
756
757 #[test]
758 fn audit_entry_exit_code_none_omitted() {
759 let entry = AuditEntry {
760 timestamp: "0".into(),
761 tool: "file".into(),
762 command: "read /tmp/x".into(),
763 result: AuditResult::Success,
764 duration_ms: 1,
765 error_category: None,
766 error_domain: None,
767 error_phase: None,
768 claim_source: None,
769 mcp_server_id: None,
770 injection_flagged: false,
771 embedding_anomalous: false,
772 cross_boundary_mcp_to_acp: false,
773 adversarial_policy_decision: None,
774 exit_code: None,
775 truncated: false,
776 policy_match: None,
777 correlation_id: None,
778 caller_id: None,
779 vigil_risk: None,
780 };
781 let json = serde_json::to_string(&entry).unwrap();
782 assert!(
783 !json.contains("exit_code"),
784 "exit_code None must be omitted: {json}"
785 );
786 }
787
788 #[test]
789 fn log_tool_risk_summary_does_not_panic() {
790 log_tool_risk_summary(&[
791 "shell",
792 "bash",
793 "exec",
794 "web_scrape",
795 "fetch",
796 "scrape_page",
797 "file_write",
798 "file_read",
799 "file_delete",
800 "memory_search",
801 "unknown_tool",
802 ]);
803 }
804
805 #[test]
806 fn log_tool_risk_summary_empty_input_does_not_panic() {
807 log_tool_risk_summary(&[]);
808 }
809}