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> {
263 self.entry_taints.get(index).copied()
264 }
265}
266
267impl Default for RegionTaint {
268 fn default() -> Self {
269 Self::new()
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SecurityConfig {
276 pub taint_tracking: bool,
278}
279
280impl Default for SecurityConfig {
281 fn default() -> Self {
282 Self {
290 taint_tracking: true,
291 }
292 }
293}
294
295pub fn resolve_taint_enabled(
305 global: bool,
306 agent: Option<&SecurityConfig>,
307 stage: Option<&SecurityConfig>,
308) -> bool {
309 let manifest = stage
310 .map(|s| s.taint_tracking)
311 .or_else(|| agent.map(|a| a.taint_tracking));
312 global || manifest.unwrap_or(false)
313}
314
315pub fn resolve_security(
322 global: bool,
323 agent: Option<&SecurityConfig>,
324 stage: Option<&SecurityConfig>,
325) -> SecurityConfig {
326 let mut resolved = match stage.or(agent) {
327 Some(c) => c.clone(),
328 None => SecurityConfig {
329 taint_tracking: global,
330 },
331 };
332 resolved.taint_tracking = resolve_taint_enabled(global, agent, stage);
333 resolved
334}
335
336fn resolve_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
342 stage.or(agent).unwrap_or(global)
343}
344
345pub fn resolve_batch_tool_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
349 resolve_hint(global, agent, stage)
350}
351
352pub fn resolve_shell_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
359 resolve_hint(global, agent, stage)
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
364pub enum GateDecision {
365 Allowed,
367 Blocked {
369 taint_level: TaintLevel,
371 clearance: TaintLevel,
373 source_regions: Vec<String>,
375 tool_name: String,
377 },
378}
379
380impl GateDecision {
381 pub fn is_allowed(&self) -> bool {
383 matches!(self, GateDecision::Allowed)
384 }
385
386 pub fn blocked_levels(&self) -> Option<(TaintLevel, TaintLevel)> {
389 match self {
390 GateDecision::Blocked {
391 taint_level,
392 clearance,
393 ..
394 } => Some((*taint_level, *clearance)),
395 GateDecision::Allowed => None,
396 }
397 }
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GateEvent {
403 pub timestamp: i64,
405 pub agent_id: String,
407 pub tool_name: String,
409 pub taint_level: TaintLevel,
411 pub clearance: TaintLevel,
413 pub allowed: bool,
415 pub decision_source: GateDecisionSource,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub enum GateDecisionSource {
422 AutoAllow,
424 AutoBlock,
426 AllowlistRule { rule_index: usize },
428 ScriptedRule { script_name: String },
430 UserAllowOnce,
432 UserAlwaysAllow,
434 UserDenied,
436 TaintDisabled,
438 YoloAutoApprove,
442}
443
444pub fn builtin_tool_classification(tool_name: &str) -> ToolClassification {
459 match tool_name {
460 "read_file" => ToolClassification::new(
461 TaintLevel::Internal,
462 ToolDirection::Inbound,
463 TaintLevel::Public,
464 ),
465 "write_file" => ToolClassification::new(
466 TaintLevel::Internal,
467 ToolDirection::Internal,
468 TaintLevel::Public,
469 ),
470 "edit_file" => ToolClassification::new(
471 TaintLevel::Internal,
472 ToolDirection::Internal,
473 TaintLevel::Public,
474 ),
475 "list_dir" => ToolClassification::new(
476 TaintLevel::Internal,
477 ToolDirection::Inbound,
478 TaintLevel::Public,
479 ),
480 "shell" | "bash" => ToolClassification::new(
481 TaintLevel::Public,
482 ToolDirection::Outbound,
483 TaintLevel::Public,
484 ),
485 "web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
489 TaintLevel::Public,
490 ToolDirection::Outbound,
491 TaintLevel::Public,
492 ),
493 "ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
494 ToolClassification::new(
495 TaintLevel::Internal,
496 ToolDirection::Internal,
497 TaintLevel::Public,
498 )
499 }
500 "spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
501 ToolClassification::new(
502 TaintLevel::Internal,
503 ToolDirection::Internal,
504 TaintLevel::Public,
505 )
506 }
507 _ => ToolClassification::new(
513 TaintLevel::Public,
514 ToolDirection::Outbound,
515 TaintLevel::Public,
516 ),
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
528 fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
529 let restored = RegionTaint::from_entry_taints(vec![
530 TaintLevel::Public,
531 TaintLevel::Private,
532 TaintLevel::Internal,
533 ]);
534 assert_eq!(restored.level(), TaintLevel::Private);
535 assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
536
537 assert_eq!(
540 RegionTaint::from_entry_taints(Vec::new()).level(),
541 TaintLevel::Public
542 );
543 }
544
545 #[test]
548 fn taint_level_ordering() {
549 assert!(TaintLevel::Public < TaintLevel::Internal);
550 assert!(TaintLevel::Internal < TaintLevel::Private);
551 assert!(TaintLevel::Public < TaintLevel::Private);
552 }
553
554 #[test]
555 fn taint_level_equality() {
556 assert_eq!(TaintLevel::Public, TaintLevel::Public);
557 assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
558 assert_eq!(TaintLevel::Private, TaintLevel::Private);
559 assert_ne!(TaintLevel::Public, TaintLevel::Private);
560 }
561
562 #[test]
563 fn taint_level_max() {
564 assert_eq!(
565 TaintLevel::Public.max(TaintLevel::Internal),
566 TaintLevel::Internal
567 );
568 assert_eq!(
569 TaintLevel::Private.max(TaintLevel::Public),
570 TaintLevel::Private
571 );
572 assert_eq!(
573 TaintLevel::Internal.max(TaintLevel::Internal),
574 TaintLevel::Internal
575 );
576 }
577
578 #[test]
579 fn taint_level_default_is_internal() {
580 assert_eq!(TaintLevel::default(), TaintLevel::Internal);
581 }
582
583 #[test]
584 fn taint_level_display() {
585 assert_eq!(format!("{}", TaintLevel::Public), "public");
586 assert_eq!(format!("{}", TaintLevel::Internal), "internal");
587 assert_eq!(format!("{}", TaintLevel::Private), "private");
588 }
589
590 #[test]
591 fn taint_level_from_str_loose() {
592 assert_eq!(
593 TaintLevel::from_str_loose("public"),
594 Some(TaintLevel::Public)
595 );
596 assert_eq!(
597 TaintLevel::from_str_loose("INTERNAL"),
598 Some(TaintLevel::Internal)
599 );
600 assert_eq!(
601 TaintLevel::from_str_loose("Private"),
602 Some(TaintLevel::Private)
603 );
604 assert_eq!(TaintLevel::from_str_loose("unknown"), None);
605 }
606
607 #[test]
608 fn taint_level_as_str() {
609 assert_eq!(TaintLevel::Public.as_str(), "public");
610 assert_eq!(TaintLevel::Internal.as_str(), "internal");
611 assert_eq!(TaintLevel::Private.as_str(), "private");
612 }
613
614 #[test]
615 fn taint_level_serde_roundtrip() {
616 for level in [
617 TaintLevel::Public,
618 TaintLevel::Internal,
619 TaintLevel::Private,
620 ] {
621 let json = serde_json::to_string(&level).unwrap();
622 let back: TaintLevel = serde_json::from_str(&json).unwrap();
623 assert_eq!(level, back);
624 }
625 }
626
627 #[test]
628 fn taint_level_hash() {
629 use std::collections::HashSet;
630 let mut set = HashSet::new();
631 set.insert(TaintLevel::Public);
632 set.insert(TaintLevel::Internal);
633 set.insert(TaintLevel::Private);
634 set.insert(TaintLevel::Public); assert_eq!(set.len(), 3);
636 }
637
638 #[test]
641 fn tool_direction_from_str_loose() {
642 assert_eq!(
643 ToolDirection::from_str_loose("inbound"),
644 Some(ToolDirection::Inbound)
645 );
646 assert_eq!(
647 ToolDirection::from_str_loose("OUTBOUND"),
648 Some(ToolDirection::Outbound)
649 );
650 assert_eq!(
651 ToolDirection::from_str_loose("Internal"),
652 Some(ToolDirection::Internal)
653 );
654 assert_eq!(ToolDirection::from_str_loose("nope"), None);
655 }
656
657 #[test]
658 fn tool_direction_default_is_internal() {
659 assert_eq!(ToolDirection::default(), ToolDirection::Internal);
660 }
661
662 #[test]
663 fn tool_direction_display() {
664 assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
665 assert_eq!(format!("{}", ToolDirection::Internal), "internal");
666 assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
667 }
668
669 #[test]
670 fn tool_direction_serde_roundtrip() {
671 for dir in [
672 ToolDirection::Inbound,
673 ToolDirection::Internal,
674 ToolDirection::Outbound,
675 ] {
676 let json = serde_json::to_string(&dir).unwrap();
677 let back: ToolDirection = serde_json::from_str(&json).unwrap();
678 assert_eq!(dir, back);
679 }
680 }
681
682 #[test]
685 fn tool_classification_default() {
686 let tc = ToolClassification::default();
687 assert_eq!(tc.sensitivity, TaintLevel::Internal);
688 assert_eq!(tc.direction, ToolDirection::Internal);
689 assert_eq!(tc.clearance, TaintLevel::Public);
690 }
691
692 #[test]
693 fn tool_classification_outbound_check() {
694 let tc = ToolClassification::new(
695 TaintLevel::Public,
696 ToolDirection::Outbound,
697 TaintLevel::Internal,
698 );
699 assert!(tc.is_outbound());
700 assert!(tc.check_clearance(TaintLevel::Public));
701 assert!(tc.check_clearance(TaintLevel::Internal));
702 assert!(!tc.check_clearance(TaintLevel::Private));
703 }
704
705 #[test]
706 fn tool_classification_non_outbound_always_passes() {
707 let tc = ToolClassification::new(
708 TaintLevel::Private,
709 ToolDirection::Inbound,
710 TaintLevel::Public, );
712 assert!(!tc.is_outbound());
713 assert!(tc.check_clearance(TaintLevel::Private));
714 }
715
716 #[test]
717 fn tool_classification_serde_roundtrip() {
718 let tc = ToolClassification::new(
719 TaintLevel::Private,
720 ToolDirection::Outbound,
721 TaintLevel::Internal,
722 );
723 let json = serde_json::to_string(&tc).unwrap();
724 let back: ToolClassification = serde_json::from_str(&json).unwrap();
725 assert_eq!(tc, back);
726 }
727
728 #[test]
731 fn region_taint_starts_public() {
732 let rt = RegionTaint::new();
733 assert_eq!(rt.level(), TaintLevel::Public);
734 assert_eq!(rt.entry_count(), 0);
735 }
736
737 #[test]
738 fn region_taint_add_entry_raises_level() {
739 let mut rt = RegionTaint::new();
740 rt.add_entry(TaintLevel::Internal);
741 assert_eq!(rt.level(), TaintLevel::Internal);
742 rt.add_entry(TaintLevel::Private);
743 assert_eq!(rt.level(), TaintLevel::Private);
744 }
745
746 #[test]
747 fn region_taint_add_public_doesnt_lower() {
748 let mut rt = RegionTaint::new();
749 rt.add_entry(TaintLevel::Private);
750 rt.add_entry(TaintLevel::Public);
751 assert_eq!(rt.level(), TaintLevel::Private);
752 }
753
754 #[test]
755 fn region_taint_remove_oldest_recovers() {
756 let mut rt = RegionTaint::new();
757 rt.add_entry(TaintLevel::Private);
758 rt.add_entry(TaintLevel::Public);
759 assert_eq!(rt.level(), TaintLevel::Private);
760
761 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
763 }
764
765 #[test]
766 fn region_taint_remove_oldest_empty() {
767 let mut rt = RegionTaint::new();
768 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
770 }
771
772 #[test]
773 fn region_taint_clear() {
774 let mut rt = RegionTaint::new();
775 rt.add_entry(TaintLevel::Private);
776 rt.add_entry(TaintLevel::Internal);
777 rt.clear();
778 assert_eq!(rt.level(), TaintLevel::Public);
779 assert_eq!(rt.entry_count(), 0);
780 }
781
782 #[test]
783 fn region_taint_recompute() {
784 let mut rt = RegionTaint::new();
785 rt.add_entry(TaintLevel::Private);
786 rt.add_entry(TaintLevel::Internal);
787 rt.add_entry(TaintLevel::Public);
788 assert_eq!(rt.entry_count(), 3);
789
790 rt.remove_oldest();
792 assert_eq!(rt.level(), TaintLevel::Internal);
793 assert_eq!(rt.entry_count(), 2);
794 }
795
796 #[test]
797 fn region_taint_entry_taint() {
798 let mut rt = RegionTaint::new();
799 rt.add_entry(TaintLevel::Public);
800 rt.add_entry(TaintLevel::Private);
801 assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
802 assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
803 assert_eq!(rt.entry_taint(2), None);
804 }
805
806 #[test]
807 fn region_taint_default() {
808 let rt = RegionTaint::default();
809 assert_eq!(rt.level(), TaintLevel::Public);
810 }
811
812 #[test]
813 fn region_taint_serde_roundtrip() {
814 let mut rt = RegionTaint::new();
815 rt.add_entry(TaintLevel::Internal);
816 rt.add_entry(TaintLevel::Private);
817 let json = serde_json::to_string(&rt).unwrap();
818 let back: RegionTaint = serde_json::from_str(&json).unwrap();
819 assert_eq!(back.level(), TaintLevel::Private);
820 assert_eq!(back.entry_count(), 2);
821 }
822
823 #[test]
826 fn security_config_default() {
827 let sc = SecurityConfig::default();
828 assert!(sc.taint_tracking);
829 }
830
831 #[test]
832 fn security_config_serde_roundtrip() {
833 let sc = SecurityConfig {
834 taint_tracking: false,
835 };
836 let json = serde_json::to_string(&sc).unwrap();
837 let back: SecurityConfig = serde_json::from_str(&json).unwrap();
838 assert!(!back.taint_tracking);
839 }
840
841 #[test]
844 fn gate_decision_allowed() {
845 let d = GateDecision::Allowed;
846 assert!(d.is_allowed());
847 }
848
849 #[test]
850 fn gate_decision_blocked() {
851 let d = GateDecision::Blocked {
852 taint_level: TaintLevel::Private,
853 clearance: TaintLevel::Public,
854 source_regions: vec!["conversation".into()],
855 tool_name: "send_email".into(),
856 };
857 assert!(!d.is_allowed());
858 }
859
860 #[test]
863 fn gate_event_serde_roundtrip() {
864 let event = GateEvent {
865 timestamp: 1234567890,
866 agent_id: "agent-1".into(),
867 tool_name: "send_email".into(),
868 taint_level: TaintLevel::Private,
869 clearance: TaintLevel::Public,
870 allowed: false,
871 decision_source: GateDecisionSource::UserDenied,
872 };
873 let json = serde_json::to_string(&event).unwrap();
874 let back: GateEvent = serde_json::from_str(&json).unwrap();
875 assert_eq!(back.agent_id, "agent-1");
876 assert!(!back.allowed);
877 }
878
879 #[test]
880 fn gate_decision_source_variants() {
881 let sources = vec![
882 GateDecisionSource::AutoAllow,
883 GateDecisionSource::AllowlistRule { rule_index: 0 },
884 GateDecisionSource::ScriptedRule {
885 script_name: "test.rhai".into(),
886 },
887 GateDecisionSource::UserAllowOnce,
888 GateDecisionSource::UserAlwaysAllow,
889 GateDecisionSource::UserDenied,
890 GateDecisionSource::TaintDisabled,
891 ];
892 for src in sources {
893 let json = serde_json::to_string(&src).unwrap();
894 let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
895 assert_eq!(src, back);
896 }
897 }
898
899 #[test]
902 fn builtin_read_file_classification() {
903 let tc = builtin_tool_classification("read_file");
904 assert_eq!(tc.sensitivity, TaintLevel::Internal);
905 assert_eq!(tc.direction, ToolDirection::Inbound);
906 }
907
908 #[test]
909 fn builtin_shell_classification() {
910 let tc = builtin_tool_classification("shell");
911 assert_eq!(tc.sensitivity, TaintLevel::Public);
912 assert_eq!(tc.direction, ToolDirection::Outbound);
913 assert_eq!(tc.clearance, TaintLevel::Public);
914
915 let tc2 = builtin_tool_classification("bash");
917 assert_eq!(tc2.direction, ToolDirection::Outbound);
918 }
919
920 #[test]
927 fn network_capable_tools_are_outbound() {
928 for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
929 let tc = builtin_tool_classification(name);
930 assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
931 assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
932 }
933 }
934
935 #[test]
936 fn builtin_ask_user_classification() {
937 for name in [
938 "ask_user_text",
939 "ask_user_choice",
940 "ask_user_confirm",
941 "present_for_review",
942 ] {
943 let tc = builtin_tool_classification(name);
944 assert_eq!(tc.direction, ToolDirection::Internal);
945 }
946 }
947
948 #[test]
949 fn builtin_subagent_classification() {
950 for name in [
951 "spawn_agent",
952 "check_agent",
953 "wait_for_agent",
954 "send_to_agent",
955 "kill_agent",
956 ] {
957 let tc = builtin_tool_classification(name);
958 assert_eq!(tc.direction, ToolDirection::Internal);
959 }
960 }
961
962 #[test]
963 fn builtin_write_file_classification() {
964 let tc = builtin_tool_classification("write_file");
965 assert_eq!(tc.direction, ToolDirection::Internal);
966 }
967
968 #[test]
973 fn unknown_tools_fail_closed_as_outbound() {
974 let tc = builtin_tool_classification("some_mcp_tool");
975 assert_eq!(tc.sensitivity, TaintLevel::Public);
976 assert_eq!(tc.direction, ToolDirection::Outbound);
977 assert_eq!(tc.clearance, TaintLevel::Public);
978 }
979
980 #[test]
981 fn builtin_edit_file_classification() {
982 let tc = builtin_tool_classification("edit_file");
983 assert_eq!(tc.sensitivity, TaintLevel::Internal);
984 assert_eq!(tc.direction, ToolDirection::Internal);
985 assert_eq!(tc.clearance, TaintLevel::Public);
986 }
987
988 #[test]
989 fn builtin_list_dir_classification() {
990 let tc = builtin_tool_classification("list_dir");
991 assert_eq!(tc.sensitivity, TaintLevel::Internal);
992 assert_eq!(tc.direction, ToolDirection::Inbound);
993 assert_eq!(tc.clearance, TaintLevel::Public);
994 }
995
996 fn sec(taint: bool) -> SecurityConfig {
999 SecurityConfig {
1000 taint_tracking: taint,
1001 }
1002 }
1003
1004 #[test]
1005 fn resolve_taint_enabled_inherits_global_when_unset() {
1006 assert!(!resolve_taint_enabled(false, None, None));
1007 assert!(resolve_taint_enabled(true, None, None));
1008 }
1009
1010 #[test]
1011 fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
1012 assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
1014 assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
1018 }
1019
1020 #[test]
1021 fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
1022 assert!(resolve_taint_enabled(
1024 false,
1025 Some(&sec(false)),
1026 Some(&sec(true))
1027 ));
1028 assert!(resolve_taint_enabled(
1030 true,
1031 Some(&sec(true)),
1032 Some(&sec(false))
1033 ));
1034 }
1035
1036 #[test]
1037 fn resolve_batch_tool_hint_cascade() {
1038 assert!(resolve_batch_tool_hint(true, None, None));
1040 assert!(!resolve_batch_tool_hint(false, None, None));
1041 assert!(!resolve_batch_tool_hint(true, Some(false), None));
1043 assert!(resolve_batch_tool_hint(false, Some(true), None));
1044 assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
1046 assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
1047 }
1048
1049 #[test]
1050 fn gate_decision_blocked_levels() {
1051 let blocked = GateDecision::Blocked {
1052 taint_level: TaintLevel::Private,
1053 clearance: TaintLevel::Public,
1054 source_regions: vec![],
1055 tool_name: "shell".into(),
1056 };
1057 assert_eq!(
1058 blocked.blocked_levels(),
1059 Some((TaintLevel::Private, TaintLevel::Public))
1060 );
1061 assert_eq!(GateDecision::Allowed.blocked_levels(), None);
1062 }
1063
1064 #[test]
1065 fn resolve_security_prefers_most_specific_but_clamps_taint() {
1066 assert!(resolve_security(true, None, None).taint_tracking);
1068 assert!(!resolve_security(false, None, None).taint_tracking);
1069 assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
1071 assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
1074 }
1075
1076 #[test]
1077 fn test_region_taint_remove_at_recomputes_level() {
1078 let mut rt = RegionTaint::new();
1079 rt.add_entry(TaintLevel::Public);
1080 rt.add_entry(TaintLevel::Private);
1081 rt.add_entry(TaintLevel::Public);
1082 assert_eq!(rt.level(), TaintLevel::Private);
1083
1084 rt.remove_at(1);
1086 assert_eq!(rt.entry_count(), 2);
1087 assert_eq!(rt.level(), TaintLevel::Public);
1088
1089 rt.remove_at(99);
1091 assert_eq!(rt.entry_count(), 2);
1092 }
1093}