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 #[serde(skip_serializing_if = "Option::is_none")]
227 pub source_kind: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
234 pub trust_level: Option<String>,
235}
236
237impl AuditEntry {
238 #[must_use]
259 pub fn memory_write(
260 caller_id: impl Into<String>,
261 preview: impl Into<String>,
262 source_kind: Option<&str>,
263 trust_level: Option<&str>,
264 ) -> Self {
265 Self {
266 timestamp: chrono_now(),
267 tool: zeph_common::ToolName::new("memory_write"),
268 command: preview.into(),
269 result: AuditResult::Success,
270 duration_ms: 0,
271 error_category: None,
272 error_domain: None,
273 error_phase: None,
274 claim_source: Some(crate::executor::ClaimSource::Memory),
275 mcp_server_id: None,
276 injection_flagged: false,
277 embedding_anomalous: false,
278 cross_boundary_mcp_to_acp: false,
279 adversarial_policy_decision: None,
280 exit_code: None,
281 truncated: false,
282 caller_id: Some(caller_id.into()),
283 policy_match: None,
284 correlation_id: None,
285 vigil_risk: None,
286 execution_env: None,
287 resolved_cwd: None,
288 scope_at_definition: None,
289 scope_at_dispatch: None,
290 skill_name: None,
291 source_kind: source_kind.map(str::to_owned),
292 trust_level: trust_level.map(str::to_owned),
293 }
294 }
295}
296
297#[non_exhaustive]
298#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
303#[serde(rename_all = "lowercase")]
304pub enum VigilRiskLevel {
305 Low,
307 Medium,
309 High,
311}
312
313#[derive(Debug, serde::Serialize)]
328#[serde(tag = "type")]
329#[non_exhaustive]
330pub enum AuditResult {
331 #[serde(rename = "success")]
333 Success,
334 #[serde(rename = "blocked")]
336 Blocked {
337 reason: String,
339 },
340 #[serde(rename = "error")]
342 Error {
343 message: String,
345 },
346 #[serde(rename = "timeout")]
348 Timeout,
349 #[serde(rename = "rollback")]
351 Rollback {
352 restored: usize,
354 deleted: usize,
356 },
357}
358
359impl AuditLogger {
360 #[allow(clippy::unused_async)]
370 pub async fn from_config(config: &AuditConfig, tui_mode: bool) -> Result<Self, std::io::Error> {
371 use zeph_config::AuditDestination as CfgDest;
372
373 let destination = match &config.destination {
374 CfgDest::Stdout if tui_mode => {
375 tracing::warn!("TUI mode: audit stdout redirected to file audit.jsonl");
376 let std_file = zeph_common::fs_secure::append_private(Path::new("audit.jsonl"))?;
377 let file = tokio::fs::File::from_std(std_file);
378 AuditDestination::File(tokio::sync::Mutex::new(file))
379 }
380 CfgDest::File(path) => {
381 let std_file = zeph_common::fs_secure::append_private(path)?;
382 let file = tokio::fs::File::from_std(std_file);
383 AuditDestination::File(tokio::sync::Mutex::new(file))
384 }
385 _ => AuditDestination::Stdout,
386 };
387
388 Ok(Self { destination })
389 }
390
391 pub async fn log(&self, entry: &AuditEntry) {
396 let json = match serde_json::to_string(entry) {
397 Ok(j) => j,
398 Err(err) => {
399 tracing::error!("audit entry serialization failed: {err}");
400 return;
401 }
402 };
403
404 match &self.destination {
405 AuditDestination::Stdout => {
406 tracing::info!(target: "audit", "{json}");
407 }
408 AuditDestination::File(file) => {
409 use tokio::io::AsyncWriteExt;
410 let mut f = file.lock().await;
411 let line = format!("{json}\n");
412 if let Err(e) = f.write_all(line.as_bytes()).await {
413 tracing::error!("failed to write audit log: {e}");
414 } else if let Err(e) = f.flush().await {
415 tracing::error!("failed to flush audit log: {e}");
416 }
417 }
418 }
419 }
420
421 pub async fn log_egress(&self, event: &EgressEvent) {
429 let json = match serde_json::to_string(event) {
430 Ok(j) => j,
431 Err(err) => {
432 tracing::error!("egress event serialization failed: {err}");
433 return;
434 }
435 };
436
437 match &self.destination {
438 AuditDestination::Stdout => {
439 tracing::info!(target: "audit", "{json}");
440 }
441 AuditDestination::File(file) => {
442 use tokio::io::AsyncWriteExt;
443 let mut f = file.lock().await;
444 let line = format!("{json}\n");
445 if let Err(e) = f.write_all(line.as_bytes()).await {
446 tracing::error!("failed to write egress log: {e}");
447 } else if let Err(e) = f.flush().await {
448 tracing::error!("failed to flush egress log: {e}");
449 }
450 }
451 }
452 }
453}
454
455pub fn log_tool_risk_summary(tool_ids: &[&str]) {
461 fn classify(id: &str) -> (&'static str, &'static str) {
465 if id.starts_with("shell") || id == "bash" || id == "exec" {
466 ("high", "env_blocklist + command_blocklist")
467 } else if id.starts_with("web_scrape") || id == "fetch" || id.starts_with("scrape") {
468 ("medium", "validate_url + SSRF + domain_policy")
469 } else if id.starts_with("file_write")
470 || id.starts_with("file_read")
471 || id.starts_with("file")
472 {
473 ("medium", "path_sandbox")
474 } else {
475 ("low", "schema_only")
476 }
477 }
478
479 for &id in tool_ids {
480 let (privilege, sanitization) = classify(id);
481 tracing::info!(
482 tool = id,
483 privilege_level = privilege,
484 expected_sanitization = sanitization,
485 "tool risk summary"
486 );
487 }
488}
489
490#[must_use]
495pub fn chrono_now() -> String {
496 use std::time::{SystemTime, UNIX_EPOCH};
497 let secs = SystemTime::now()
498 .duration_since(UNIX_EPOCH)
499 .unwrap_or_default()
500 .as_secs();
501 format!("{secs}")
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn audit_entry_serialization() {
510 let entry = AuditEntry {
511 source_kind: None,
512 trust_level: None,
513 timestamp: "1234567890".into(),
514 tool: "shell".into(),
515 command: "echo hello".into(),
516 result: AuditResult::Success,
517 duration_ms: 42,
518 error_category: None,
519 error_domain: None,
520 error_phase: None,
521 claim_source: None,
522 mcp_server_id: None,
523 injection_flagged: false,
524 embedding_anomalous: false,
525 cross_boundary_mcp_to_acp: false,
526 adversarial_policy_decision: None,
527 exit_code: None,
528 truncated: false,
529 policy_match: None,
530 correlation_id: None,
531 caller_id: None,
532 vigil_risk: None,
533 execution_env: None,
534 resolved_cwd: None,
535 scope_at_definition: None,
536 scope_at_dispatch: None,
537 skill_name: None,
538 };
539 let json = serde_json::to_string(&entry).unwrap();
540 assert!(json.contains("\"type\":\"success\""));
541 assert!(json.contains("\"tool\":\"shell\""));
542 assert!(json.contains("\"duration_ms\":42"));
543 }
544
545 #[test]
546 fn audit_result_blocked_serialization() {
547 let entry = AuditEntry {
548 source_kind: None,
549 trust_level: None,
550 timestamp: "0".into(),
551 tool: "shell".into(),
552 command: "sudo rm".into(),
553 result: AuditResult::Blocked {
554 reason: "blocked command: sudo".into(),
555 },
556 duration_ms: 0,
557 error_category: Some("policy_blocked".to_owned()),
558 error_domain: Some("action".to_owned()),
559 error_phase: None,
560 claim_source: None,
561 mcp_server_id: None,
562 injection_flagged: false,
563 embedding_anomalous: false,
564 cross_boundary_mcp_to_acp: false,
565 adversarial_policy_decision: None,
566 exit_code: None,
567 truncated: false,
568 policy_match: None,
569 correlation_id: None,
570 caller_id: None,
571 vigil_risk: None,
572 execution_env: None,
573 resolved_cwd: None,
574 scope_at_definition: None,
575 scope_at_dispatch: None,
576 skill_name: None,
577 };
578 let json = serde_json::to_string(&entry).unwrap();
579 assert!(json.contains("\"type\":\"blocked\""));
580 assert!(json.contains("\"reason\""));
581 }
582
583 #[test]
584 fn audit_result_error_serialization() {
585 let entry = AuditEntry {
586 source_kind: None,
587 trust_level: None,
588 timestamp: "0".into(),
589 tool: "shell".into(),
590 command: "bad".into(),
591 result: AuditResult::Error {
592 message: "exec failed".into(),
593 },
594 duration_ms: 0,
595 error_category: None,
596 error_domain: None,
597 error_phase: None,
598 claim_source: None,
599 mcp_server_id: None,
600 injection_flagged: false,
601 embedding_anomalous: false,
602 cross_boundary_mcp_to_acp: false,
603 adversarial_policy_decision: None,
604 exit_code: None,
605 truncated: false,
606 policy_match: None,
607 correlation_id: None,
608 caller_id: None,
609 vigil_risk: None,
610 execution_env: None,
611 resolved_cwd: None,
612 scope_at_definition: None,
613 scope_at_dispatch: None,
614 skill_name: None,
615 };
616 let json = serde_json::to_string(&entry).unwrap();
617 assert!(json.contains("\"type\":\"error\""));
618 }
619
620 #[test]
621 fn audit_result_timeout_serialization() {
622 let entry = AuditEntry {
623 source_kind: None,
624 trust_level: None,
625 timestamp: "0".into(),
626 tool: "shell".into(),
627 command: "sleep 999".into(),
628 result: AuditResult::Timeout,
629 duration_ms: 30000,
630 error_category: Some("timeout".to_owned()),
631 error_domain: Some("system".to_owned()),
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 execution_env: None,
646 resolved_cwd: None,
647 scope_at_definition: None,
648 scope_at_dispatch: None,
649 skill_name: None,
650 };
651 let json = serde_json::to_string(&entry).unwrap();
652 assert!(json.contains("\"type\":\"timeout\""));
653 }
654
655 #[tokio::test]
656 async fn audit_logger_stdout() {
657 let config = AuditConfig {
658 enabled: true,
659 destination: crate::config::AuditDestination::Stdout,
660 ..Default::default()
661 };
662 let logger = AuditLogger::from_config(&config, false).await.unwrap();
663 let entry = AuditEntry {
664 source_kind: None,
665 trust_level: None,
666 timestamp: "0".into(),
667 tool: "shell".into(),
668 command: "echo test".into(),
669 result: AuditResult::Success,
670 duration_ms: 1,
671 error_category: None,
672 error_domain: None,
673 error_phase: None,
674 claim_source: None,
675 mcp_server_id: None,
676 injection_flagged: false,
677 embedding_anomalous: false,
678 cross_boundary_mcp_to_acp: false,
679 adversarial_policy_decision: None,
680 exit_code: None,
681 truncated: false,
682 policy_match: None,
683 correlation_id: None,
684 caller_id: None,
685 vigil_risk: None,
686 execution_env: None,
687 resolved_cwd: None,
688 scope_at_definition: None,
689 scope_at_dispatch: None,
690 skill_name: None,
691 };
692 logger.log(&entry).await;
693 }
694
695 #[tokio::test]
696 async fn audit_logger_file() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("audit.log");
699 let config = AuditConfig {
700 enabled: true,
701 destination: crate::config::AuditDestination::File(path.clone()),
702 ..Default::default()
703 };
704 let logger = AuditLogger::from_config(&config, false).await.unwrap();
705 let entry = AuditEntry {
706 source_kind: None,
707 trust_level: None,
708 timestamp: "0".into(),
709 tool: "shell".into(),
710 command: "echo test".into(),
711 result: AuditResult::Success,
712 duration_ms: 1,
713 error_category: None,
714 error_domain: None,
715 error_phase: None,
716 claim_source: None,
717 mcp_server_id: None,
718 injection_flagged: false,
719 embedding_anomalous: false,
720 cross_boundary_mcp_to_acp: false,
721 adversarial_policy_decision: None,
722 exit_code: None,
723 truncated: false,
724 policy_match: None,
725 correlation_id: None,
726 caller_id: None,
727 vigil_risk: None,
728 execution_env: None,
729 resolved_cwd: None,
730 scope_at_definition: None,
731 scope_at_dispatch: None,
732 skill_name: None,
733 };
734 logger.log(&entry).await;
735
736 let content = tokio::fs::read_to_string(&path).await.unwrap();
737 assert!(content.contains("\"tool\":\"shell\""));
738 }
739
740 #[tokio::test]
741 async fn audit_logger_file_write_error_logged() {
742 let config = AuditConfig {
743 enabled: true,
744 destination: crate::config::AuditDestination::File("/nonexistent/dir/audit.log".into()),
745 ..Default::default()
746 };
747 let result = AuditLogger::from_config(&config, false).await;
748 assert!(result.is_err());
749 }
750
751 #[test]
752 fn claim_source_serde_roundtrip() {
753 use crate::executor::ClaimSource;
754 let cases = [
755 (ClaimSource::Shell, "\"shell\""),
756 (ClaimSource::FileSystem, "\"file_system\""),
757 (ClaimSource::WebScrape, "\"web_scrape\""),
758 (ClaimSource::Mcp, "\"mcp\""),
759 (ClaimSource::A2a, "\"a2a\""),
760 (ClaimSource::CodeSearch, "\"code_search\""),
761 (ClaimSource::Diagnostics, "\"diagnostics\""),
762 (ClaimSource::Memory, "\"memory\""),
763 ];
764 for (variant, expected_json) in cases {
765 let serialized = serde_json::to_string(&variant).unwrap();
766 assert_eq!(serialized, expected_json, "serialize {variant:?}");
767 let deserialized: ClaimSource = serde_json::from_str(&serialized).unwrap();
768 assert_eq!(deserialized, variant, "deserialize {variant:?}");
769 }
770 }
771
772 #[test]
773 fn audit_entry_claim_source_none_omitted() {
774 let entry = AuditEntry {
775 source_kind: None,
776 trust_level: None,
777 timestamp: "0".into(),
778 tool: "shell".into(),
779 command: "echo".into(),
780 result: AuditResult::Success,
781 duration_ms: 1,
782 error_category: None,
783 error_domain: None,
784 error_phase: None,
785 claim_source: None,
786 mcp_server_id: None,
787 injection_flagged: false,
788 embedding_anomalous: false,
789 cross_boundary_mcp_to_acp: false,
790 adversarial_policy_decision: None,
791 exit_code: None,
792 truncated: false,
793 policy_match: None,
794 correlation_id: None,
795 caller_id: None,
796 vigil_risk: None,
797 execution_env: None,
798 resolved_cwd: None,
799 scope_at_definition: None,
800 scope_at_dispatch: None,
801 skill_name: None,
802 };
803 let json = serde_json::to_string(&entry).unwrap();
804 assert!(
805 !json.contains("claim_source"),
806 "claim_source must be omitted when None: {json}"
807 );
808 }
809
810 #[test]
811 fn audit_entry_claim_source_some_present() {
812 use crate::executor::ClaimSource;
813 let entry = AuditEntry {
814 source_kind: None,
815 trust_level: None,
816 timestamp: "0".into(),
817 tool: "shell".into(),
818 command: "echo".into(),
819 result: AuditResult::Success,
820 duration_ms: 1,
821 error_category: None,
822 error_domain: None,
823 error_phase: None,
824 claim_source: Some(ClaimSource::Shell),
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 skill_name: None,
841 };
842 let json = serde_json::to_string(&entry).unwrap();
843 assert!(
844 json.contains("\"claim_source\":\"shell\""),
845 "expected claim_source=shell in JSON: {json}"
846 );
847 }
848
849 #[tokio::test]
850 async fn audit_logger_multiple_entries() {
851 let dir = tempfile::tempdir().unwrap();
852 let path = dir.path().join("audit.log");
853 let config = AuditConfig {
854 enabled: true,
855 destination: crate::config::AuditDestination::File(path.clone()),
856 ..Default::default()
857 };
858 let logger = AuditLogger::from_config(&config, false).await.unwrap();
859
860 for i in 0..5 {
861 let entry = AuditEntry {
862 source_kind: None,
863 trust_level: None,
864 timestamp: i.to_string(),
865 tool: "shell".into(),
866 command: format!("cmd{i}"),
867 result: AuditResult::Success,
868 duration_ms: i,
869 error_category: None,
870 error_domain: None,
871 error_phase: None,
872 claim_source: None,
873 mcp_server_id: None,
874 injection_flagged: false,
875 embedding_anomalous: false,
876 cross_boundary_mcp_to_acp: false,
877 adversarial_policy_decision: None,
878 exit_code: None,
879 truncated: false,
880 policy_match: None,
881 correlation_id: None,
882 caller_id: None,
883 vigil_risk: None,
884 execution_env: None,
885 resolved_cwd: None,
886 scope_at_definition: None,
887 scope_at_dispatch: None,
888 skill_name: None,
889 };
890 logger.log(&entry).await;
891 }
892
893 let content = tokio::fs::read_to_string(&path).await.unwrap();
894 assert_eq!(content.lines().count(), 5);
895 }
896
897 #[test]
898 fn audit_entry_exit_code_serialized() {
899 let entry = AuditEntry {
900 source_kind: None,
901 trust_level: None,
902 timestamp: "0".into(),
903 tool: "shell".into(),
904 command: "echo hi".into(),
905 result: AuditResult::Success,
906 duration_ms: 5,
907 error_category: None,
908 error_domain: None,
909 error_phase: None,
910 claim_source: None,
911 mcp_server_id: None,
912 injection_flagged: false,
913 embedding_anomalous: false,
914 cross_boundary_mcp_to_acp: false,
915 adversarial_policy_decision: None,
916 exit_code: Some(0),
917 truncated: false,
918 policy_match: None,
919 correlation_id: None,
920 caller_id: None,
921 vigil_risk: None,
922 execution_env: None,
923 resolved_cwd: None,
924 scope_at_definition: None,
925 scope_at_dispatch: None,
926 skill_name: None,
927 };
928 let json = serde_json::to_string(&entry).unwrap();
929 assert!(
930 json.contains("\"exit_code\":0"),
931 "exit_code must be serialized: {json}"
932 );
933 }
934
935 #[test]
936 fn audit_entry_exit_code_none_omitted() {
937 let entry = AuditEntry {
938 source_kind: None,
939 trust_level: None,
940 timestamp: "0".into(),
941 tool: "file".into(),
942 command: "read /tmp/x".into(),
943 result: AuditResult::Success,
944 duration_ms: 1,
945 error_category: None,
946 error_domain: None,
947 error_phase: None,
948 claim_source: None,
949 mcp_server_id: None,
950 injection_flagged: false,
951 embedding_anomalous: false,
952 cross_boundary_mcp_to_acp: false,
953 adversarial_policy_decision: None,
954 exit_code: None,
955 truncated: false,
956 policy_match: None,
957 correlation_id: None,
958 caller_id: None,
959 vigil_risk: None,
960 execution_env: None,
961 resolved_cwd: None,
962 scope_at_definition: None,
963 scope_at_dispatch: None,
964 skill_name: None,
965 };
966 let json = serde_json::to_string(&entry).unwrap();
967 assert!(
968 !json.contains("exit_code"),
969 "exit_code None must be omitted: {json}"
970 );
971 }
972
973 #[test]
974 fn log_tool_risk_summary_does_not_panic() {
975 log_tool_risk_summary(&[
976 "shell",
977 "bash",
978 "exec",
979 "web_scrape",
980 "fetch",
981 "scrape_page",
982 "file_write",
983 "file_read",
984 "file_delete",
985 "memory_search",
986 "unknown_tool",
987 ]);
988 }
989
990 #[test]
991 fn log_tool_risk_summary_empty_input_does_not_panic() {
992 log_tool_risk_summary(&[]);
993 }
994}