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