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