1use serde::{Deserialize, Serialize};
10use std::fmt;
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum TaintLevel {
18 Public,
20 #[default]
22 Internal,
23 Private,
25}
26
27impl TaintLevel {
28 fn rank(self) -> u8 {
30 match self {
31 TaintLevel::Public => 0,
32 TaintLevel::Internal => 1,
33 TaintLevel::Private => 2,
34 }
35 }
36
37 pub fn max(self, other: TaintLevel) -> TaintLevel {
39 if self >= other { self } else { other }
40 }
41
42 pub fn from_str_loose(s: &str) -> Option<TaintLevel> {
44 match s.to_lowercase().as_str() {
45 "public" => Some(TaintLevel::Public),
46 "internal" => Some(TaintLevel::Internal),
47 "private" => Some(TaintLevel::Private),
48 _ => None,
49 }
50 }
51
52 pub fn as_str(self) -> &'static str {
54 match self {
55 TaintLevel::Public => "public",
56 TaintLevel::Internal => "internal",
57 TaintLevel::Private => "private",
58 }
59 }
60}
61
62impl PartialOrd for TaintLevel {
63 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
64 Some(self.cmp(other))
65 }
66}
67
68impl Ord for TaintLevel {
69 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
70 self.rank().cmp(&other.rank())
71 }
72}
73
74impl fmt::Display for TaintLevel {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum ToolDirection {
83 Inbound,
85 #[default]
87 Internal,
88 Outbound,
90}
91
92impl ToolDirection {
93 pub fn from_str_loose(s: &str) -> Option<ToolDirection> {
95 match s.to_lowercase().as_str() {
96 "inbound" => Some(ToolDirection::Inbound),
97 "internal" => Some(ToolDirection::Internal),
98 "outbound" => Some(ToolDirection::Outbound),
99 _ => None,
100 }
101 }
102
103 pub fn as_str(self) -> &'static str {
105 match self {
106 ToolDirection::Inbound => "inbound",
107 ToolDirection::Internal => "internal",
108 ToolDirection::Outbound => "outbound",
109 }
110 }
111}
112
113impl fmt::Display for ToolDirection {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.write_str(self.as_str())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ToolClassification {
126 pub sensitivity: TaintLevel,
128 pub direction: ToolDirection,
130 pub clearance: TaintLevel,
133}
134
135impl ToolClassification {
136 pub fn new(sensitivity: TaintLevel, direction: ToolDirection, clearance: TaintLevel) -> Self {
138 Self {
139 sensitivity,
140 direction,
141 clearance,
142 }
143 }
144
145 pub fn is_outbound(&self) -> bool {
147 self.direction == ToolDirection::Outbound
148 }
149
150 pub fn check_clearance(&self, taint: TaintLevel) -> bool {
154 if !self.is_outbound() {
155 return true;
156 }
157 taint <= self.clearance
158 }
159}
160
161impl Default for ToolClassification {
162 fn default() -> Self {
163 Self {
164 sensitivity: TaintLevel::Internal,
165 direction: ToolDirection::Internal,
166 clearance: TaintLevel::Public,
167 }
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct RegionTaint {
177 current_level: TaintLevel,
179 entry_taints: Vec<TaintLevel>,
181}
182
183impl RegionTaint {
184 pub fn new() -> Self {
186 Self {
187 current_level: TaintLevel::Public,
188 entry_taints: Vec::new(),
189 }
190 }
191
192 pub fn level(&self) -> TaintLevel {
194 self.current_level
195 }
196
197 pub fn add_entry(&mut self, taint: TaintLevel) {
200 self.entry_taints.push(taint);
201 self.current_level = self.current_level.max(taint);
202 }
203
204 pub fn remove_oldest(&mut self) {
207 if !self.entry_taints.is_empty() {
208 self.entry_taints.remove(0);
209 self.recompute();
210 }
211 }
212
213 pub fn remove_at(&mut self, idx: usize) {
216 if idx < self.entry_taints.len() {
217 self.entry_taints.remove(idx);
218 self.recompute();
219 }
220 }
221
222 pub fn clear(&mut self) {
224 self.entry_taints.clear();
225 self.current_level = TaintLevel::Public;
226 }
227
228 pub fn recompute(&mut self) {
231 self.current_level = self
232 .entry_taints
233 .iter()
234 .copied()
235 .max()
236 .unwrap_or(TaintLevel::Public);
237 }
238
239 pub fn entry_count(&self) -> usize {
241 self.entry_taints.len()
242 }
243
244 pub fn from_entry_taints(entry_taints: Vec<TaintLevel>) -> Self {
251 let current_level = entry_taints
252 .iter()
253 .copied()
254 .max()
255 .unwrap_or(TaintLevel::Public);
256 Self {
257 current_level,
258 entry_taints,
259 }
260 }
261
262 pub fn entry_taint(&self, index: usize) -> Option<TaintLevel> {
268 self.entry_taints.get(index).copied()
269 }
270}
271
272impl Default for RegionTaint {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SecurityConfig {
281 pub taint_tracking: bool,
283}
284
285impl Default for SecurityConfig {
286 fn default() -> Self {
287 Self {
295 taint_tracking: true,
296 }
297 }
298}
299
300pub fn resolve_taint_enabled(
310 global: bool,
311 agent: Option<&SecurityConfig>,
312 stage: Option<&SecurityConfig>,
313) -> bool {
314 let manifest = stage
315 .map(|s| s.taint_tracking)
316 .or_else(|| agent.map(|a| a.taint_tracking));
317 global || manifest.unwrap_or(false)
318}
319
320pub fn resolve_security(
327 global: bool,
328 agent: Option<&SecurityConfig>,
329 stage: Option<&SecurityConfig>,
330) -> SecurityConfig {
331 let mut resolved = match stage.or(agent) {
332 Some(c) => c.clone(),
333 None => SecurityConfig {
334 taint_tracking: global,
335 },
336 };
337 resolved.taint_tracking = resolve_taint_enabled(global, agent, stage);
338 resolved
339}
340
341fn resolve_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
347 stage.or(agent).unwrap_or(global)
348}
349
350pub fn resolve_batch_tool_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
354 resolve_hint(global, agent, stage)
355}
356
357pub fn resolve_shell_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
364 resolve_hint(global, agent, stage)
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum GateDecision {
370 Allowed,
372 Blocked {
374 taint_level: TaintLevel,
376 clearance: TaintLevel,
378 source_regions: Vec<String>,
380 tool_name: String,
382 },
383}
384
385impl GateDecision {
386 pub fn is_allowed(&self) -> bool {
388 matches!(self, GateDecision::Allowed)
389 }
390
391 pub fn blocked_levels(&self) -> Option<(TaintLevel, TaintLevel)> {
394 match self {
395 GateDecision::Blocked {
396 taint_level,
397 clearance,
398 ..
399 } => Some((*taint_level, *clearance)),
400 GateDecision::Allowed => None,
401 }
402 }
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct GateEvent {
408 pub timestamp: i64,
410 pub agent_id: String,
412 pub tool_name: String,
414 pub taint_level: TaintLevel,
416 pub clearance: TaintLevel,
418 pub allowed: bool,
420 pub decision_source: GateDecisionSource,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub enum GateDecisionSource {
427 AutoAllow,
429 AutoBlock,
431 AllowlistRule {
433 rule_index: usize,
436 },
437 ScriptedRule {
439 script_name: String,
441 },
442 UserAllowOnce,
444 UserAlwaysAllow,
446 UserDenied,
448 TaintDisabled,
450 YoloAutoApprove,
454}
455
456pub fn builtin_tool_classification(tool_name: &str) -> ToolClassification {
471 match tool_name {
472 "read_file" => ToolClassification::new(
473 TaintLevel::Internal,
474 ToolDirection::Inbound,
475 TaintLevel::Public,
476 ),
477 "write_file" => ToolClassification::new(
478 TaintLevel::Internal,
479 ToolDirection::Internal,
480 TaintLevel::Public,
481 ),
482 "edit_file" => ToolClassification::new(
483 TaintLevel::Internal,
484 ToolDirection::Internal,
485 TaintLevel::Public,
486 ),
487 "list_dir" => ToolClassification::new(
488 TaintLevel::Internal,
489 ToolDirection::Inbound,
490 TaintLevel::Public,
491 ),
492 "shell" | "bash" => ToolClassification::new(
493 TaintLevel::Public,
494 ToolDirection::Outbound,
495 TaintLevel::Public,
496 ),
497 "web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
501 TaintLevel::Public,
502 ToolDirection::Outbound,
503 TaintLevel::Public,
504 ),
505 "ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
506 ToolClassification::new(
507 TaintLevel::Internal,
508 ToolDirection::Internal,
509 TaintLevel::Public,
510 )
511 }
512 "spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
513 ToolClassification::new(
514 TaintLevel::Internal,
515 ToolDirection::Internal,
516 TaintLevel::Public,
517 )
518 }
519 _ => ToolClassification::new(
525 TaintLevel::Public,
526 ToolDirection::Outbound,
527 TaintLevel::Public,
528 ),
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
540 fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
541 let restored = RegionTaint::from_entry_taints(vec![
542 TaintLevel::Public,
543 TaintLevel::Private,
544 TaintLevel::Internal,
545 ]);
546 assert_eq!(restored.level(), TaintLevel::Private);
547 assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
548
549 assert_eq!(
552 RegionTaint::from_entry_taints(Vec::new()).level(),
553 TaintLevel::Public
554 );
555 }
556
557 #[test]
560 fn taint_level_ordering() {
561 assert!(TaintLevel::Public < TaintLevel::Internal);
562 assert!(TaintLevel::Internal < TaintLevel::Private);
563 assert!(TaintLevel::Public < TaintLevel::Private);
564 }
565
566 #[test]
567 fn taint_level_equality() {
568 assert_eq!(TaintLevel::Public, TaintLevel::Public);
569 assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
570 assert_eq!(TaintLevel::Private, TaintLevel::Private);
571 assert_ne!(TaintLevel::Public, TaintLevel::Private);
572 }
573
574 #[test]
575 fn taint_level_max() {
576 assert_eq!(
577 TaintLevel::Public.max(TaintLevel::Internal),
578 TaintLevel::Internal
579 );
580 assert_eq!(
581 TaintLevel::Private.max(TaintLevel::Public),
582 TaintLevel::Private
583 );
584 assert_eq!(
585 TaintLevel::Internal.max(TaintLevel::Internal),
586 TaintLevel::Internal
587 );
588 }
589
590 #[test]
591 fn taint_level_default_is_internal() {
592 assert_eq!(TaintLevel::default(), TaintLevel::Internal);
593 }
594
595 #[test]
596 fn taint_level_display() {
597 assert_eq!(format!("{}", TaintLevel::Public), "public");
598 assert_eq!(format!("{}", TaintLevel::Internal), "internal");
599 assert_eq!(format!("{}", TaintLevel::Private), "private");
600 }
601
602 #[test]
603 fn taint_level_from_str_loose() {
604 assert_eq!(
605 TaintLevel::from_str_loose("public"),
606 Some(TaintLevel::Public)
607 );
608 assert_eq!(
609 TaintLevel::from_str_loose("INTERNAL"),
610 Some(TaintLevel::Internal)
611 );
612 assert_eq!(
613 TaintLevel::from_str_loose("Private"),
614 Some(TaintLevel::Private)
615 );
616 assert_eq!(TaintLevel::from_str_loose("unknown"), None);
617 }
618
619 #[test]
620 fn taint_level_as_str() {
621 assert_eq!(TaintLevel::Public.as_str(), "public");
622 assert_eq!(TaintLevel::Internal.as_str(), "internal");
623 assert_eq!(TaintLevel::Private.as_str(), "private");
624 }
625
626 #[test]
627 fn taint_level_serde_roundtrip() {
628 for level in [
629 TaintLevel::Public,
630 TaintLevel::Internal,
631 TaintLevel::Private,
632 ] {
633 let json = serde_json::to_string(&level).unwrap();
634 let back: TaintLevel = serde_json::from_str(&json).unwrap();
635 assert_eq!(level, back);
636 }
637 }
638
639 #[test]
640 fn taint_level_hash() {
641 use std::collections::HashSet;
642 let mut set = HashSet::new();
643 set.insert(TaintLevel::Public);
644 set.insert(TaintLevel::Internal);
645 set.insert(TaintLevel::Private);
646 set.insert(TaintLevel::Public); assert_eq!(set.len(), 3);
648 }
649
650 #[test]
653 fn tool_direction_from_str_loose() {
654 assert_eq!(
655 ToolDirection::from_str_loose("inbound"),
656 Some(ToolDirection::Inbound)
657 );
658 assert_eq!(
659 ToolDirection::from_str_loose("OUTBOUND"),
660 Some(ToolDirection::Outbound)
661 );
662 assert_eq!(
663 ToolDirection::from_str_loose("Internal"),
664 Some(ToolDirection::Internal)
665 );
666 assert_eq!(ToolDirection::from_str_loose("nope"), None);
667 }
668
669 #[test]
670 fn tool_direction_default_is_internal() {
671 assert_eq!(ToolDirection::default(), ToolDirection::Internal);
672 }
673
674 #[test]
675 fn tool_direction_display() {
676 assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
677 assert_eq!(format!("{}", ToolDirection::Internal), "internal");
678 assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
679 }
680
681 #[test]
682 fn tool_direction_serde_roundtrip() {
683 for dir in [
684 ToolDirection::Inbound,
685 ToolDirection::Internal,
686 ToolDirection::Outbound,
687 ] {
688 let json = serde_json::to_string(&dir).unwrap();
689 let back: ToolDirection = serde_json::from_str(&json).unwrap();
690 assert_eq!(dir, back);
691 }
692 }
693
694 #[test]
697 fn tool_classification_default() {
698 let tc = ToolClassification::default();
699 assert_eq!(tc.sensitivity, TaintLevel::Internal);
700 assert_eq!(tc.direction, ToolDirection::Internal);
701 assert_eq!(tc.clearance, TaintLevel::Public);
702 }
703
704 #[test]
705 fn tool_classification_outbound_check() {
706 let tc = ToolClassification::new(
707 TaintLevel::Public,
708 ToolDirection::Outbound,
709 TaintLevel::Internal,
710 );
711 assert!(tc.is_outbound());
712 assert!(tc.check_clearance(TaintLevel::Public));
713 assert!(tc.check_clearance(TaintLevel::Internal));
714 assert!(!tc.check_clearance(TaintLevel::Private));
715 }
716
717 #[test]
718 fn tool_classification_non_outbound_always_passes() {
719 let tc = ToolClassification::new(
720 TaintLevel::Private,
721 ToolDirection::Inbound,
722 TaintLevel::Public, );
724 assert!(!tc.is_outbound());
725 assert!(tc.check_clearance(TaintLevel::Private));
726 }
727
728 #[test]
729 fn tool_classification_serde_roundtrip() {
730 let tc = ToolClassification::new(
731 TaintLevel::Private,
732 ToolDirection::Outbound,
733 TaintLevel::Internal,
734 );
735 let json = serde_json::to_string(&tc).unwrap();
736 let back: ToolClassification = serde_json::from_str(&json).unwrap();
737 assert_eq!(tc, back);
738 }
739
740 #[test]
743 fn region_taint_starts_public() {
744 let rt = RegionTaint::new();
745 assert_eq!(rt.level(), TaintLevel::Public);
746 assert_eq!(rt.entry_count(), 0);
747 }
748
749 #[test]
750 fn region_taint_add_entry_raises_level() {
751 let mut rt = RegionTaint::new();
752 rt.add_entry(TaintLevel::Internal);
753 assert_eq!(rt.level(), TaintLevel::Internal);
754 rt.add_entry(TaintLevel::Private);
755 assert_eq!(rt.level(), TaintLevel::Private);
756 }
757
758 #[test]
759 fn region_taint_add_public_doesnt_lower() {
760 let mut rt = RegionTaint::new();
761 rt.add_entry(TaintLevel::Private);
762 rt.add_entry(TaintLevel::Public);
763 assert_eq!(rt.level(), TaintLevel::Private);
764 }
765
766 #[test]
767 fn region_taint_remove_oldest_recovers() {
768 let mut rt = RegionTaint::new();
769 rt.add_entry(TaintLevel::Private);
770 rt.add_entry(TaintLevel::Public);
771 assert_eq!(rt.level(), TaintLevel::Private);
772
773 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
775 }
776
777 #[test]
778 fn region_taint_remove_oldest_empty() {
779 let mut rt = RegionTaint::new();
780 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
782 }
783
784 #[test]
785 fn region_taint_clear() {
786 let mut rt = RegionTaint::new();
787 rt.add_entry(TaintLevel::Private);
788 rt.add_entry(TaintLevel::Internal);
789 rt.clear();
790 assert_eq!(rt.level(), TaintLevel::Public);
791 assert_eq!(rt.entry_count(), 0);
792 }
793
794 #[test]
795 fn region_taint_recompute() {
796 let mut rt = RegionTaint::new();
797 rt.add_entry(TaintLevel::Private);
798 rt.add_entry(TaintLevel::Internal);
799 rt.add_entry(TaintLevel::Public);
800 assert_eq!(rt.entry_count(), 3);
801
802 rt.remove_oldest();
804 assert_eq!(rt.level(), TaintLevel::Internal);
805 assert_eq!(rt.entry_count(), 2);
806 }
807
808 #[test]
809 fn region_taint_entry_taint() {
810 let mut rt = RegionTaint::new();
811 rt.add_entry(TaintLevel::Public);
812 rt.add_entry(TaintLevel::Private);
813 assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
814 assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
815 assert_eq!(rt.entry_taint(2), None);
816 }
817
818 #[test]
819 fn region_taint_default() {
820 let rt = RegionTaint::default();
821 assert_eq!(rt.level(), TaintLevel::Public);
822 }
823
824 #[test]
825 fn region_taint_serde_roundtrip() {
826 let mut rt = RegionTaint::new();
827 rt.add_entry(TaintLevel::Internal);
828 rt.add_entry(TaintLevel::Private);
829 let json = serde_json::to_string(&rt).unwrap();
830 let back: RegionTaint = serde_json::from_str(&json).unwrap();
831 assert_eq!(back.level(), TaintLevel::Private);
832 assert_eq!(back.entry_count(), 2);
833 }
834
835 #[test]
838 fn security_config_default() {
839 let sc = SecurityConfig::default();
840 assert!(sc.taint_tracking);
841 }
842
843 #[test]
844 fn security_config_serde_roundtrip() {
845 let sc = SecurityConfig {
846 taint_tracking: false,
847 };
848 let json = serde_json::to_string(&sc).unwrap();
849 let back: SecurityConfig = serde_json::from_str(&json).unwrap();
850 assert!(!back.taint_tracking);
851 }
852
853 #[test]
856 fn gate_decision_allowed() {
857 let d = GateDecision::Allowed;
858 assert!(d.is_allowed());
859 }
860
861 #[test]
862 fn gate_decision_blocked() {
863 let d = GateDecision::Blocked {
864 taint_level: TaintLevel::Private,
865 clearance: TaintLevel::Public,
866 source_regions: vec!["conversation".into()],
867 tool_name: "send_email".into(),
868 };
869 assert!(!d.is_allowed());
870 }
871
872 #[test]
875 fn gate_event_serde_roundtrip() {
876 let event = GateEvent {
877 timestamp: 1234567890,
878 agent_id: "agent-1".into(),
879 tool_name: "send_email".into(),
880 taint_level: TaintLevel::Private,
881 clearance: TaintLevel::Public,
882 allowed: false,
883 decision_source: GateDecisionSource::UserDenied,
884 };
885 let json = serde_json::to_string(&event).unwrap();
886 let back: GateEvent = serde_json::from_str(&json).unwrap();
887 assert_eq!(back.agent_id, "agent-1");
888 assert!(!back.allowed);
889 }
890
891 #[test]
892 fn gate_decision_source_variants() {
893 let sources = vec![
894 GateDecisionSource::AutoAllow,
895 GateDecisionSource::AllowlistRule { rule_index: 0 },
896 GateDecisionSource::ScriptedRule {
897 script_name: "test.rhai".into(),
898 },
899 GateDecisionSource::UserAllowOnce,
900 GateDecisionSource::UserAlwaysAllow,
901 GateDecisionSource::UserDenied,
902 GateDecisionSource::TaintDisabled,
903 ];
904 for src in sources {
905 let json = serde_json::to_string(&src).unwrap();
906 let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
907 assert_eq!(src, back);
908 }
909 }
910
911 #[test]
914 fn builtin_read_file_classification() {
915 let tc = builtin_tool_classification("read_file");
916 assert_eq!(tc.sensitivity, TaintLevel::Internal);
917 assert_eq!(tc.direction, ToolDirection::Inbound);
918 }
919
920 #[test]
921 fn builtin_shell_classification() {
922 let tc = builtin_tool_classification("shell");
923 assert_eq!(tc.sensitivity, TaintLevel::Public);
924 assert_eq!(tc.direction, ToolDirection::Outbound);
925 assert_eq!(tc.clearance, TaintLevel::Public);
926
927 let tc2 = builtin_tool_classification("bash");
929 assert_eq!(tc2.direction, ToolDirection::Outbound);
930 }
931
932 #[test]
939 fn network_capable_tools_are_outbound() {
940 for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
941 let tc = builtin_tool_classification(name);
942 assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
943 assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
944 }
945 }
946
947 #[test]
948 fn builtin_ask_user_classification() {
949 for name in [
950 "ask_user_text",
951 "ask_user_choice",
952 "ask_user_confirm",
953 "present_for_review",
954 ] {
955 let tc = builtin_tool_classification(name);
956 assert_eq!(tc.direction, ToolDirection::Internal);
957 }
958 }
959
960 #[test]
961 fn builtin_subagent_classification() {
962 for name in [
963 "spawn_agent",
964 "check_agent",
965 "wait_for_agent",
966 "send_to_agent",
967 "kill_agent",
968 ] {
969 let tc = builtin_tool_classification(name);
970 assert_eq!(tc.direction, ToolDirection::Internal);
971 }
972 }
973
974 #[test]
975 fn builtin_write_file_classification() {
976 let tc = builtin_tool_classification("write_file");
977 assert_eq!(tc.direction, ToolDirection::Internal);
978 }
979
980 #[test]
985 fn unknown_tools_fail_closed_as_outbound() {
986 let tc = builtin_tool_classification("some_mcp_tool");
987 assert_eq!(tc.sensitivity, TaintLevel::Public);
988 assert_eq!(tc.direction, ToolDirection::Outbound);
989 assert_eq!(tc.clearance, TaintLevel::Public);
990 }
991
992 #[test]
993 fn builtin_edit_file_classification() {
994 let tc = builtin_tool_classification("edit_file");
995 assert_eq!(tc.sensitivity, TaintLevel::Internal);
996 assert_eq!(tc.direction, ToolDirection::Internal);
997 assert_eq!(tc.clearance, TaintLevel::Public);
998 }
999
1000 #[test]
1001 fn builtin_list_dir_classification() {
1002 let tc = builtin_tool_classification("list_dir");
1003 assert_eq!(tc.sensitivity, TaintLevel::Internal);
1004 assert_eq!(tc.direction, ToolDirection::Inbound);
1005 assert_eq!(tc.clearance, TaintLevel::Public);
1006 }
1007
1008 fn sec(taint: bool) -> SecurityConfig {
1011 SecurityConfig {
1012 taint_tracking: taint,
1013 }
1014 }
1015
1016 #[test]
1017 fn resolve_taint_enabled_inherits_global_when_unset() {
1018 assert!(!resolve_taint_enabled(false, None, None));
1019 assert!(resolve_taint_enabled(true, None, None));
1020 }
1021
1022 #[test]
1023 fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
1024 assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
1026 assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
1030 }
1031
1032 #[test]
1033 fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
1034 assert!(resolve_taint_enabled(
1036 false,
1037 Some(&sec(false)),
1038 Some(&sec(true))
1039 ));
1040 assert!(resolve_taint_enabled(
1042 true,
1043 Some(&sec(true)),
1044 Some(&sec(false))
1045 ));
1046 }
1047
1048 #[test]
1049 fn resolve_batch_tool_hint_cascade() {
1050 assert!(resolve_batch_tool_hint(true, None, None));
1052 assert!(!resolve_batch_tool_hint(false, None, None));
1053 assert!(!resolve_batch_tool_hint(true, Some(false), None));
1055 assert!(resolve_batch_tool_hint(false, Some(true), None));
1056 assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
1058 assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
1059 }
1060
1061 #[test]
1062 fn gate_decision_blocked_levels() {
1063 let blocked = GateDecision::Blocked {
1064 taint_level: TaintLevel::Private,
1065 clearance: TaintLevel::Public,
1066 source_regions: vec![],
1067 tool_name: "shell".into(),
1068 };
1069 assert_eq!(
1070 blocked.blocked_levels(),
1071 Some((TaintLevel::Private, TaintLevel::Public))
1072 );
1073 assert_eq!(GateDecision::Allowed.blocked_levels(), None);
1074 }
1075
1076 #[test]
1077 fn resolve_security_prefers_most_specific_but_clamps_taint() {
1078 assert!(resolve_security(true, None, None).taint_tracking);
1080 assert!(!resolve_security(false, None, None).taint_tracking);
1081 assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
1083 assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
1086 }
1087
1088 #[test]
1089 fn test_region_taint_remove_at_recomputes_level() {
1090 let mut rt = RegionTaint::new();
1091 rt.add_entry(TaintLevel::Public);
1092 rt.add_entry(TaintLevel::Private);
1093 rt.add_entry(TaintLevel::Public);
1094 assert_eq!(rt.level(), TaintLevel::Private);
1095
1096 rt.remove_at(1);
1098 assert_eq!(rt.entry_count(), 2);
1099 assert_eq!(rt.level(), TaintLevel::Public);
1100
1101 rt.remove_at(99);
1103 assert_eq!(rt.entry_count(), 2);
1104 }
1105}