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)] pub 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 #[serde(skip_serializing_if = "Option::is_none")]
194 pub execution_env: Option<String>,
195 #[serde(skip_serializing_if = "Option::is_none")]
198 pub resolved_cwd: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
202 pub scope_at_definition: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
206 pub scope_at_dispatch: Option<String>,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
214#[serde(rename_all = "lowercase")]
215pub enum VigilRiskLevel {
216 Low,
218 Medium,
220 High,
222}
223
224#[derive(serde::Serialize)]
239#[serde(tag = "type")]
240pub enum AuditResult {
241 #[serde(rename = "success")]
243 Success,
244 #[serde(rename = "blocked")]
246 Blocked {
247 reason: String,
249 },
250 #[serde(rename = "error")]
252 Error {
253 message: String,
255 },
256 #[serde(rename = "timeout")]
258 Timeout,
259 #[serde(rename = "rollback")]
261 Rollback {
262 restored: usize,
264 deleted: usize,
266 },
267}
268
269impl AuditLogger {
270 #[allow(clippy::unused_async)]
280 pub async fn from_config(config: &AuditConfig, tui_mode: bool) -> Result<Self, std::io::Error> {
281 use zeph_config::AuditDestination as CfgDest;
282
283 let destination = match &config.destination {
284 CfgDest::Stdout if tui_mode => {
285 tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
286 let std_file = zeph_common::fs_secure::append_private(Path::new("audit.jsonl"))?;
287 let file = tokio::fs::File::from_std(std_file);
288 AuditDestination::File(tokio::sync::Mutex::new(file))
289 }
290 CfgDest::Stdout | CfgDest::Stderr => AuditDestination::Stdout,
291 CfgDest::File(path) => {
292 let std_file = zeph_common::fs_secure::append_private(path)?;
293 let file = tokio::fs::File::from_std(std_file);
294 AuditDestination::File(tokio::sync::Mutex::new(file))
295 }
296 };
297
298 Ok(Self { destination })
299 }
300
301 pub async fn log(&self, entry: &AuditEntry) {
306 let json = match serde_json::to_string(entry) {
307 Ok(j) => j,
308 Err(err) => {
309 tracing::error!("audit entry serialization failed: {err}");
310 return;
311 }
312 };
313
314 match &self.destination {
315 AuditDestination::Stdout => {
316 tracing::info!(target: "audit", "{json}");
317 }
318 AuditDestination::File(file) => {
319 use tokio::io::AsyncWriteExt;
320 let mut f = file.lock().await;
321 let line = format!("{json}\n");
322 if let Err(e) = f.write_all(line.as_bytes()).await {
323 tracing::error!("failed to write audit log: {e}");
324 } else if let Err(e) = f.flush().await {
325 tracing::error!("failed to flush audit log: {e}");
326 }
327 }
328 }
329 }
330
331 pub async fn log_egress(&self, event: &EgressEvent) {
339 let json = match serde_json::to_string(event) {
340 Ok(j) => j,
341 Err(err) => {
342 tracing::error!("egress event serialization failed: {err}");
343 return;
344 }
345 };
346
347 match &self.destination {
348 AuditDestination::Stdout => {
349 tracing::info!(target: "audit", "{json}");
350 }
351 AuditDestination::File(file) => {
352 use tokio::io::AsyncWriteExt;
353 let mut f = file.lock().await;
354 let line = format!("{json}\n");
355 if let Err(e) = f.write_all(line.as_bytes()).await {
356 tracing::error!("failed to write egress log: {e}");
357 } else if let Err(e) = f.flush().await {
358 tracing::error!("failed to flush egress log: {e}");
359 }
360 }
361 }
362 }
363}
364
365pub fn log_tool_risk_summary(tool_ids: &[&str]) {
371 fn classify(id: &str) -> (&'static str, &'static str) {
375 if id.starts_with("shell") || id == "bash" || id == "exec" {
376 ("high", "env_blocklist + command_blocklist")
377 } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
378 ("medium", "validate_url + SSRF + domain_policy")
379 } else if id.starts_with("file_write")
380 || id.starts_with("file_read")
381 || id.starts_with("file")
382 {
383 ("medium", "path_sandbox")
384 } else {
385 ("low", "schema_only")
386 }
387 }
388
389 for &id in tool_ids {
390 let (privilege, sanitization) = classify(id);
391 tracing::info!(
392 tool = id,
393 privilege_level = privilege,
394 expected_sanitization = sanitization,
395 "tool risk summary"
396 );
397 }
398}
399
400#[must_use]
405pub fn chrono_now() -> String {
406 use std::time::{SystemTime, UNIX_EPOCH};
407 let secs = SystemTime::now()
408 .duration_since(UNIX_EPOCH)
409 .unwrap_or_default()
410 .as_secs();
411 format!("{secs}")
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn audit_entry_serialization() {
420 let entry = AuditEntry {
421 timestamp: "1234567890".into(),
422 tool: "shell".into(),
423 command: "echo hello".into(),
424 result: AuditResult::Success,
425 duration_ms: 42,
426 error_category: None,
427 error_domain: None,
428 error_phase: None,
429 claim_source: None,
430 mcp_server_id: None,
431 injection_flagged: false,
432 embedding_anomalous: false,
433 cross_boundary_mcp_to_acp: false,
434 adversarial_policy_decision: None,
435 exit_code: None,
436 truncated: false,
437 policy_match: None,
438 correlation_id: None,
439 caller_id: None,
440 vigil_risk: None,
441 execution_env: None,
442 resolved_cwd: None,
443 scope_at_definition: None,
444 scope_at_dispatch: None,
445 };
446 let json = serde_json::to_string(&entry).unwrap();
447 assert!(json.contains("\"type\":\"success\""));
448 assert!(json.contains("\"tool\":\"shell\""));
449 assert!(json.contains("\"duration_ms\":42"));
450 }
451
452 #[test]
453 fn audit_result_blocked_serialization() {
454 let entry = AuditEntry {
455 timestamp: "0".into(),
456 tool: "shell".into(),
457 command: "sudo rm".into(),
458 result: AuditResult::Blocked {
459 reason: "blocked command: sudo".into(),
460 },
461 duration_ms: 0,
462 error_category: Some("policy_blocked".to_owned()),
463 error_domain: Some("action".to_owned()),
464 error_phase: None,
465 claim_source: None,
466 mcp_server_id: None,
467 injection_flagged: false,
468 embedding_anomalous: false,
469 cross_boundary_mcp_to_acp: false,
470 adversarial_policy_decision: None,
471 exit_code: None,
472 truncated: false,
473 policy_match: None,
474 correlation_id: None,
475 caller_id: None,
476 vigil_risk: None,
477 execution_env: None,
478 resolved_cwd: None,
479 scope_at_definition: None,
480 scope_at_dispatch: None,
481 };
482 let json = serde_json::to_string(&entry).unwrap();
483 assert!(json.contains("\"type\":\"blocked\""));
484 assert!(json.contains("\"reason\""));
485 }
486
487 #[test]
488 fn audit_result_error_serialization() {
489 let entry = AuditEntry {
490 timestamp: "0".into(),
491 tool: "shell".into(),
492 command: "bad".into(),
493 result: AuditResult::Error {
494 message: "exec failed".into(),
495 },
496 duration_ms: 0,
497 error_category: None,
498 error_domain: None,
499 error_phase: None,
500 claim_source: None,
501 mcp_server_id: None,
502 injection_flagged: false,
503 embedding_anomalous: false,
504 cross_boundary_mcp_to_acp: false,
505 adversarial_policy_decision: None,
506 exit_code: None,
507 truncated: false,
508 policy_match: None,
509 correlation_id: None,
510 caller_id: None,
511 vigil_risk: None,
512 execution_env: None,
513 resolved_cwd: None,
514 scope_at_definition: None,
515 scope_at_dispatch: None,
516 };
517 let json = serde_json::to_string(&entry).unwrap();
518 assert!(json.contains("\"type\":\"error\""));
519 }
520
521 #[test]
522 fn audit_result_timeout_serialization() {
523 let entry = AuditEntry {
524 timestamp: "0".into(),
525 tool: "shell".into(),
526 command: "sleep 999".into(),
527 result: AuditResult::Timeout,
528 duration_ms: 30000,
529 error_category: Some("timeout".to_owned()),
530 error_domain: Some("system".to_owned()),
531 error_phase: None,
532 claim_source: None,
533 mcp_server_id: None,
534 injection_flagged: false,
535 embedding_anomalous: false,
536 cross_boundary_mcp_to_acp: false,
537 adversarial_policy_decision: None,
538 exit_code: None,
539 truncated: false,
540 policy_match: None,
541 correlation_id: None,
542 caller_id: None,
543 vigil_risk: None,
544 execution_env: None,
545 resolved_cwd: None,
546 scope_at_definition: None,
547 scope_at_dispatch: None,
548 };
549 let json = serde_json::to_string(&entry).unwrap();
550 assert!(json.contains("\"type\":\"timeout\""));
551 }
552
553 #[tokio::test]
554 async fn audit_logger_stdout() {
555 let config = AuditConfig {
556 enabled: true,
557 destination: crate::config::AuditDestination::Stdout,
558 ..Default::default()
559 };
560 let logger = AuditLogger::from_config(&config, false).await.unwrap();
561 let entry = AuditEntry {
562 timestamp: "0".into(),
563 tool: "shell".into(),
564 command: "echo test".into(),
565 result: AuditResult::Success,
566 duration_ms: 1,
567 error_category: None,
568 error_domain: None,
569 error_phase: None,
570 claim_source: None,
571 mcp_server_id: None,
572 injection_flagged: false,
573 embedding_anomalous: false,
574 cross_boundary_mcp_to_acp: false,
575 adversarial_policy_decision: None,
576 exit_code: None,
577 truncated: false,
578 policy_match: None,
579 correlation_id: None,
580 caller_id: None,
581 vigil_risk: None,
582 execution_env: None,
583 resolved_cwd: None,
584 scope_at_definition: None,
585 scope_at_dispatch: None,
586 };
587 logger.log(&entry).await;
588 }
589
590 #[tokio::test]
591 async fn audit_logger_file() {
592 let dir = tempfile::tempdir().unwrap();
593 let path = dir.path().join("audit.log");
594 let config = AuditConfig {
595 enabled: true,
596 destination: crate::config::AuditDestination::File(path.clone()),
597 ..Default::default()
598 };
599 let logger = AuditLogger::from_config(&config, false).await.unwrap();
600 let entry = AuditEntry {
601 timestamp: "0".into(),
602 tool: "shell".into(),
603 command: "echo test".into(),
604 result: AuditResult::Success,
605 duration_ms: 1,
606 error_category: None,
607 error_domain: None,
608 error_phase: None,
609 claim_source: None,
610 mcp_server_id: None,
611 injection_flagged: false,
612 embedding_anomalous: false,
613 cross_boundary_mcp_to_acp: false,
614 adversarial_policy_decision: None,
615 exit_code: None,
616 truncated: false,
617 policy_match: None,
618 correlation_id: None,
619 caller_id: None,
620 vigil_risk: None,
621 execution_env: None,
622 resolved_cwd: None,
623 scope_at_definition: None,
624 scope_at_dispatch: None,
625 };
626 logger.log(&entry).await;
627
628 let content = tokio::fs::read_to_string(&path).await.unwrap();
629 assert!(content.contains("\"tool\":\"shell\""));
630 }
631
632 #[tokio::test]
633 async fn audit_logger_file_write_error_logged() {
634 let config = AuditConfig {
635 enabled: true,
636 destination: crate::config::AuditDestination::File("/nonexistent/dir/audit.log".into()),
637 ..Default::default()
638 };
639 let result = AuditLogger::from_config(&config, false).await;
640 assert!(result.is_err());
641 }
642
643 #[test]
644 fn claim_source_serde_roundtrip() {
645 use crate::executor::ClaimSource;
646 let cases = [
647 (ClaimSource::Shell, "\"shell\""),
648 (ClaimSource::FileSystem, "\"file_system\""),
649 (ClaimSource::WebScrape, "\"web_scrape\""),
650 (ClaimSource::Mcp, "\"mcp\""),
651 (ClaimSource::A2a, "\"a2a\""),
652 (ClaimSource::CodeSearch, "\"code_search\""),
653 (ClaimSource::Diagnostics, "\"diagnostics\""),
654 (ClaimSource::Memory, "\"memory\""),
655 ];
656 for (variant, expected_json) in cases {
657 let serialized = serde_json::to_string(&variant).unwrap();
658 assert_eq!(serialized, expected_json, "serialize {variant:?}");
659 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
660 assert_eq!(deserialized, variant, "deserialize {variant:?}");
661 }
662 }
663
664 #[test]
665 fn audit_entry_claim_source_none_omitted() {
666 let entry = AuditEntry {
667 timestamp: "0".into(),
668 tool: "shell".into(),
669 command: "echo".into(),
670 result: AuditResult::Success,
671 duration_ms: 1,
672 error_category: None,
673 error_domain: None,
674 error_phase: None,
675 claim_source: None,
676 mcp_server_id: None,
677 injection_flagged: false,
678 embedding_anomalous: false,
679 cross_boundary_mcp_to_acp: false,
680 adversarial_policy_decision: None,
681 exit_code: None,
682 truncated: false,
683 policy_match: None,
684 correlation_id: None,
685 caller_id: None,
686 vigil_risk: None,
687 execution_env: None,
688 resolved_cwd: None,
689 scope_at_definition: None,
690 scope_at_dispatch: None,
691 };
692 let json = serde_json::to_string(&entry).unwrap();
693 assert!(
694 !json.contains("claim_source"),
695 "claim_source must be omitted when None: {json}"
696 );
697 }
698
699 #[test]
700 fn audit_entry_claim_source_some_present() {
701 use crate::executor::ClaimSource;
702 let entry = AuditEntry {
703 timestamp: "0".into(),
704 tool: "shell".into(),
705 command: "echo".into(),
706 result: AuditResult::Success,
707 duration_ms: 1,
708 error_category: None,
709 error_domain: None,
710 error_phase: None,
711 claim_source: Some(ClaimSource::Shell),
712 mcp_server_id: None,
713 injection_flagged: false,
714 embedding_anomalous: false,
715 cross_boundary_mcp_to_acp: false,
716 adversarial_policy_decision: None,
717 exit_code: None,
718 truncated: false,
719 policy_match: None,
720 correlation_id: None,
721 caller_id: None,
722 vigil_risk: None,
723 execution_env: None,
724 resolved_cwd: None,
725 scope_at_definition: None,
726 scope_at_dispatch: None,
727 };
728 let json = serde_json::to_string(&entry).unwrap();
729 assert!(
730 json.contains("\"claim_source\":\"shell\""),
731 "expected claim_source=shell in JSON: {json}"
732 );
733 }
734
735 #[tokio::test]
736 async fn audit_logger_multiple_entries() {
737 let dir = tempfile::tempdir().unwrap();
738 let path = dir.path().join("audit.log");
739 let config = AuditConfig {
740 enabled: true,
741 destination: crate::config::AuditDestination::File(path.clone()),
742 ..Default::default()
743 };
744 let logger = AuditLogger::from_config(&config, false).await.unwrap();
745
746 for i in 0..5 {
747 let entry = AuditEntry {
748 timestamp: i.to_string(),
749 tool: "shell".into(),
750 command: format!("cmd{i}"),
751 result: AuditResult::Success,
752 duration_ms: i,
753 error_category: None,
754 error_domain: None,
755 error_phase: None,
756 claim_source: None,
757 mcp_server_id: None,
758 injection_flagged: false,
759 embedding_anomalous: false,
760 cross_boundary_mcp_to_acp: false,
761 adversarial_policy_decision: None,
762 exit_code: None,
763 truncated: false,
764 policy_match: None,
765 correlation_id: None,
766 caller_id: None,
767 vigil_risk: None,
768 execution_env: None,
769 resolved_cwd: None,
770 scope_at_definition: None,
771 scope_at_dispatch: None,
772 };
773 logger.log(&entry).await;
774 }
775
776 let content = tokio::fs::read_to_string(&path).await.unwrap();
777 assert_eq!(content.lines().count(), 5);
778 }
779
780 #[test]
781 fn audit_entry_exit_code_serialized() {
782 let entry = AuditEntry {
783 timestamp: "0".into(),
784 tool: "shell".into(),
785 command: "echo hi".into(),
786 result: AuditResult::Success,
787 duration_ms: 5,
788 error_category: None,
789 error_domain: None,
790 error_phase: None,
791 claim_source: None,
792 mcp_server_id: None,
793 injection_flagged: false,
794 embedding_anomalous: false,
795 cross_boundary_mcp_to_acp: false,
796 adversarial_policy_decision: None,
797 exit_code: Some(0),
798 truncated: false,
799 policy_match: None,
800 correlation_id: None,
801 caller_id: None,
802 vigil_risk: None,
803 execution_env: None,
804 resolved_cwd: None,
805 scope_at_definition: None,
806 scope_at_dispatch: None,
807 };
808 let json = serde_json::to_string(&entry).unwrap();
809 assert!(
810 json.contains("\"exit_code\":0"),
811 "exit_code must be serialized: {json}"
812 );
813 }
814
815 #[test]
816 fn audit_entry_exit_code_none_omitted() {
817 let entry = AuditEntry {
818 timestamp: "0".into(),
819 tool: "file".into(),
820 command: "read /tmp/x".into(),
821 result: AuditResult::Success,
822 duration_ms: 1,
823 error_category: None,
824 error_domain: None,
825 error_phase: None,
826 claim_source: None,
827 mcp_server_id: None,
828 injection_flagged: false,
829 embedding_anomalous: false,
830 cross_boundary_mcp_to_acp: false,
831 adversarial_policy_decision: None,
832 exit_code: None,
833 truncated: false,
834 policy_match: None,
835 correlation_id: None,
836 caller_id: None,
837 vigil_risk: None,
838 execution_env: None,
839 resolved_cwd: None,
840 scope_at_definition: None,
841 scope_at_dispatch: None,
842 };
843 let json = serde_json::to_string(&entry).unwrap();
844 assert!(
845 !json.contains("exit_code"),
846 "exit_code None must be omitted: {json}"
847 );
848 }
849
850 #[test]
851 fn log_tool_risk_summary_does_not_panic() {
852 log_tool_risk_summary(&[
853 "shell",
854 "bash",
855 "exec",
856 "web_scrape",
857 "fetch",
858 "scrape_page",
859 "file_write",
860 "file_read",
861 "file_delete",
862 "memory_search",
863 "unknown_tool",
864 ]);
865 }
866
867 #[test]
868 fn log_tool_risk_summary_empty_input_does_not_panic() {
869 log_tool_risk_summary(&[]);
870 }
871}