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
336pub fn resolve_batch_tool_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
342 stage.or(agent).unwrap_or(global)
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum GateDecision {
348 Allowed,
350 Blocked {
352 taint_level: TaintLevel,
354 clearance: TaintLevel,
356 source_regions: Vec<String>,
358 tool_name: String,
360 },
361}
362
363impl GateDecision {
364 pub fn is_allowed(&self) -> bool {
366 matches!(self, GateDecision::Allowed)
367 }
368
369 pub fn blocked_levels(&self) -> Option<(TaintLevel, TaintLevel)> {
372 match self {
373 GateDecision::Blocked {
374 taint_level,
375 clearance,
376 ..
377 } => Some((*taint_level, *clearance)),
378 GateDecision::Allowed => None,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct GateEvent {
386 pub timestamp: i64,
388 pub agent_id: String,
390 pub tool_name: String,
392 pub taint_level: TaintLevel,
394 pub clearance: TaintLevel,
396 pub allowed: bool,
398 pub decision_source: GateDecisionSource,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub enum GateDecisionSource {
405 AutoAllow,
407 AutoBlock,
409 AllowlistRule { rule_index: usize },
411 ScriptedRule { script_name: String },
413 UserAllowOnce,
415 UserAlwaysAllow,
417 UserDenied,
419 TaintDisabled,
421 YoloAutoApprove,
425}
426
427pub fn builtin_tool_classification(tool_name: &str) -> ToolClassification {
442 match tool_name {
443 "read_file" => ToolClassification::new(
444 TaintLevel::Internal,
445 ToolDirection::Inbound,
446 TaintLevel::Public,
447 ),
448 "write_file" => ToolClassification::new(
449 TaintLevel::Internal,
450 ToolDirection::Internal,
451 TaintLevel::Public,
452 ),
453 "edit_file" => ToolClassification::new(
454 TaintLevel::Internal,
455 ToolDirection::Internal,
456 TaintLevel::Public,
457 ),
458 "list_dir" => ToolClassification::new(
459 TaintLevel::Internal,
460 ToolDirection::Inbound,
461 TaintLevel::Public,
462 ),
463 "shell" | "bash" => ToolClassification::new(
464 TaintLevel::Public,
465 ToolDirection::Outbound,
466 TaintLevel::Public,
467 ),
468 "web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
472 TaintLevel::Public,
473 ToolDirection::Outbound,
474 TaintLevel::Public,
475 ),
476 "ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
477 ToolClassification::new(
478 TaintLevel::Internal,
479 ToolDirection::Internal,
480 TaintLevel::Public,
481 )
482 }
483 "spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
484 ToolClassification::new(
485 TaintLevel::Internal,
486 ToolDirection::Internal,
487 TaintLevel::Public,
488 )
489 }
490 _ => ToolClassification::new(
496 TaintLevel::Public,
497 ToolDirection::Outbound,
498 TaintLevel::Public,
499 ),
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
511 fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
512 let restored = RegionTaint::from_entry_taints(vec![
513 TaintLevel::Public,
514 TaintLevel::Private,
515 TaintLevel::Internal,
516 ]);
517 assert_eq!(restored.level(), TaintLevel::Private);
518 assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
519
520 assert_eq!(
523 RegionTaint::from_entry_taints(Vec::new()).level(),
524 TaintLevel::Public
525 );
526 }
527
528 #[test]
531 fn taint_level_ordering() {
532 assert!(TaintLevel::Public < TaintLevel::Internal);
533 assert!(TaintLevel::Internal < TaintLevel::Private);
534 assert!(TaintLevel::Public < TaintLevel::Private);
535 }
536
537 #[test]
538 fn taint_level_equality() {
539 assert_eq!(TaintLevel::Public, TaintLevel::Public);
540 assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
541 assert_eq!(TaintLevel::Private, TaintLevel::Private);
542 assert_ne!(TaintLevel::Public, TaintLevel::Private);
543 }
544
545 #[test]
546 fn taint_level_max() {
547 assert_eq!(
548 TaintLevel::Public.max(TaintLevel::Internal),
549 TaintLevel::Internal
550 );
551 assert_eq!(
552 TaintLevel::Private.max(TaintLevel::Public),
553 TaintLevel::Private
554 );
555 assert_eq!(
556 TaintLevel::Internal.max(TaintLevel::Internal),
557 TaintLevel::Internal
558 );
559 }
560
561 #[test]
562 fn taint_level_default_is_internal() {
563 assert_eq!(TaintLevel::default(), TaintLevel::Internal);
564 }
565
566 #[test]
567 fn taint_level_display() {
568 assert_eq!(format!("{}", TaintLevel::Public), "public");
569 assert_eq!(format!("{}", TaintLevel::Internal), "internal");
570 assert_eq!(format!("{}", TaintLevel::Private), "private");
571 }
572
573 #[test]
574 fn taint_level_from_str_loose() {
575 assert_eq!(
576 TaintLevel::from_str_loose("public"),
577 Some(TaintLevel::Public)
578 );
579 assert_eq!(
580 TaintLevel::from_str_loose("INTERNAL"),
581 Some(TaintLevel::Internal)
582 );
583 assert_eq!(
584 TaintLevel::from_str_loose("Private"),
585 Some(TaintLevel::Private)
586 );
587 assert_eq!(TaintLevel::from_str_loose("unknown"), None);
588 }
589
590 #[test]
591 fn taint_level_as_str() {
592 assert_eq!(TaintLevel::Public.as_str(), "public");
593 assert_eq!(TaintLevel::Internal.as_str(), "internal");
594 assert_eq!(TaintLevel::Private.as_str(), "private");
595 }
596
597 #[test]
598 fn taint_level_serde_roundtrip() {
599 for level in [
600 TaintLevel::Public,
601 TaintLevel::Internal,
602 TaintLevel::Private,
603 ] {
604 let json = serde_json::to_string(&level).unwrap();
605 let back: TaintLevel = serde_json::from_str(&json).unwrap();
606 assert_eq!(level, back);
607 }
608 }
609
610 #[test]
611 fn taint_level_hash() {
612 use std::collections::HashSet;
613 let mut set = HashSet::new();
614 set.insert(TaintLevel::Public);
615 set.insert(TaintLevel::Internal);
616 set.insert(TaintLevel::Private);
617 set.insert(TaintLevel::Public); assert_eq!(set.len(), 3);
619 }
620
621 #[test]
624 fn tool_direction_from_str_loose() {
625 assert_eq!(
626 ToolDirection::from_str_loose("inbound"),
627 Some(ToolDirection::Inbound)
628 );
629 assert_eq!(
630 ToolDirection::from_str_loose("OUTBOUND"),
631 Some(ToolDirection::Outbound)
632 );
633 assert_eq!(
634 ToolDirection::from_str_loose("Internal"),
635 Some(ToolDirection::Internal)
636 );
637 assert_eq!(ToolDirection::from_str_loose("nope"), None);
638 }
639
640 #[test]
641 fn tool_direction_default_is_internal() {
642 assert_eq!(ToolDirection::default(), ToolDirection::Internal);
643 }
644
645 #[test]
646 fn tool_direction_display() {
647 assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
648 assert_eq!(format!("{}", ToolDirection::Internal), "internal");
649 assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
650 }
651
652 #[test]
653 fn tool_direction_serde_roundtrip() {
654 for dir in [
655 ToolDirection::Inbound,
656 ToolDirection::Internal,
657 ToolDirection::Outbound,
658 ] {
659 let json = serde_json::to_string(&dir).unwrap();
660 let back: ToolDirection = serde_json::from_str(&json).unwrap();
661 assert_eq!(dir, back);
662 }
663 }
664
665 #[test]
668 fn tool_classification_default() {
669 let tc = ToolClassification::default();
670 assert_eq!(tc.sensitivity, TaintLevel::Internal);
671 assert_eq!(tc.direction, ToolDirection::Internal);
672 assert_eq!(tc.clearance, TaintLevel::Public);
673 }
674
675 #[test]
676 fn tool_classification_outbound_check() {
677 let tc = ToolClassification::new(
678 TaintLevel::Public,
679 ToolDirection::Outbound,
680 TaintLevel::Internal,
681 );
682 assert!(tc.is_outbound());
683 assert!(tc.check_clearance(TaintLevel::Public));
684 assert!(tc.check_clearance(TaintLevel::Internal));
685 assert!(!tc.check_clearance(TaintLevel::Private));
686 }
687
688 #[test]
689 fn tool_classification_non_outbound_always_passes() {
690 let tc = ToolClassification::new(
691 TaintLevel::Private,
692 ToolDirection::Inbound,
693 TaintLevel::Public, );
695 assert!(!tc.is_outbound());
696 assert!(tc.check_clearance(TaintLevel::Private));
697 }
698
699 #[test]
700 fn tool_classification_serde_roundtrip() {
701 let tc = ToolClassification::new(
702 TaintLevel::Private,
703 ToolDirection::Outbound,
704 TaintLevel::Internal,
705 );
706 let json = serde_json::to_string(&tc).unwrap();
707 let back: ToolClassification = serde_json::from_str(&json).unwrap();
708 assert_eq!(tc, back);
709 }
710
711 #[test]
714 fn region_taint_starts_public() {
715 let rt = RegionTaint::new();
716 assert_eq!(rt.level(), TaintLevel::Public);
717 assert_eq!(rt.entry_count(), 0);
718 }
719
720 #[test]
721 fn region_taint_add_entry_raises_level() {
722 let mut rt = RegionTaint::new();
723 rt.add_entry(TaintLevel::Internal);
724 assert_eq!(rt.level(), TaintLevel::Internal);
725 rt.add_entry(TaintLevel::Private);
726 assert_eq!(rt.level(), TaintLevel::Private);
727 }
728
729 #[test]
730 fn region_taint_add_public_doesnt_lower() {
731 let mut rt = RegionTaint::new();
732 rt.add_entry(TaintLevel::Private);
733 rt.add_entry(TaintLevel::Public);
734 assert_eq!(rt.level(), TaintLevel::Private);
735 }
736
737 #[test]
738 fn region_taint_remove_oldest_recovers() {
739 let mut rt = RegionTaint::new();
740 rt.add_entry(TaintLevel::Private);
741 rt.add_entry(TaintLevel::Public);
742 assert_eq!(rt.level(), TaintLevel::Private);
743
744 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
746 }
747
748 #[test]
749 fn region_taint_remove_oldest_empty() {
750 let mut rt = RegionTaint::new();
751 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
753 }
754
755 #[test]
756 fn region_taint_clear() {
757 let mut rt = RegionTaint::new();
758 rt.add_entry(TaintLevel::Private);
759 rt.add_entry(TaintLevel::Internal);
760 rt.clear();
761 assert_eq!(rt.level(), TaintLevel::Public);
762 assert_eq!(rt.entry_count(), 0);
763 }
764
765 #[test]
766 fn region_taint_recompute() {
767 let mut rt = RegionTaint::new();
768 rt.add_entry(TaintLevel::Private);
769 rt.add_entry(TaintLevel::Internal);
770 rt.add_entry(TaintLevel::Public);
771 assert_eq!(rt.entry_count(), 3);
772
773 rt.remove_oldest();
775 assert_eq!(rt.level(), TaintLevel::Internal);
776 assert_eq!(rt.entry_count(), 2);
777 }
778
779 #[test]
780 fn region_taint_entry_taint() {
781 let mut rt = RegionTaint::new();
782 rt.add_entry(TaintLevel::Public);
783 rt.add_entry(TaintLevel::Private);
784 assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
785 assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
786 assert_eq!(rt.entry_taint(2), None);
787 }
788
789 #[test]
790 fn region_taint_default() {
791 let rt = RegionTaint::default();
792 assert_eq!(rt.level(), TaintLevel::Public);
793 }
794
795 #[test]
796 fn region_taint_serde_roundtrip() {
797 let mut rt = RegionTaint::new();
798 rt.add_entry(TaintLevel::Internal);
799 rt.add_entry(TaintLevel::Private);
800 let json = serde_json::to_string(&rt).unwrap();
801 let back: RegionTaint = serde_json::from_str(&json).unwrap();
802 assert_eq!(back.level(), TaintLevel::Private);
803 assert_eq!(back.entry_count(), 2);
804 }
805
806 #[test]
809 fn security_config_default() {
810 let sc = SecurityConfig::default();
811 assert!(sc.taint_tracking);
812 }
813
814 #[test]
815 fn security_config_serde_roundtrip() {
816 let sc = SecurityConfig {
817 taint_tracking: false,
818 };
819 let json = serde_json::to_string(&sc).unwrap();
820 let back: SecurityConfig = serde_json::from_str(&json).unwrap();
821 assert!(!back.taint_tracking);
822 }
823
824 #[test]
827 fn gate_decision_allowed() {
828 let d = GateDecision::Allowed;
829 assert!(d.is_allowed());
830 }
831
832 #[test]
833 fn gate_decision_blocked() {
834 let d = GateDecision::Blocked {
835 taint_level: TaintLevel::Private,
836 clearance: TaintLevel::Public,
837 source_regions: vec!["conversation".into()],
838 tool_name: "send_email".into(),
839 };
840 assert!(!d.is_allowed());
841 }
842
843 #[test]
846 fn gate_event_serde_roundtrip() {
847 let event = GateEvent {
848 timestamp: 1234567890,
849 agent_id: "agent-1".into(),
850 tool_name: "send_email".into(),
851 taint_level: TaintLevel::Private,
852 clearance: TaintLevel::Public,
853 allowed: false,
854 decision_source: GateDecisionSource::UserDenied,
855 };
856 let json = serde_json::to_string(&event).unwrap();
857 let back: GateEvent = serde_json::from_str(&json).unwrap();
858 assert_eq!(back.agent_id, "agent-1");
859 assert!(!back.allowed);
860 }
861
862 #[test]
863 fn gate_decision_source_variants() {
864 let sources = vec![
865 GateDecisionSource::AutoAllow,
866 GateDecisionSource::AllowlistRule { rule_index: 0 },
867 GateDecisionSource::ScriptedRule {
868 script_name: "test.rhai".into(),
869 },
870 GateDecisionSource::UserAllowOnce,
871 GateDecisionSource::UserAlwaysAllow,
872 GateDecisionSource::UserDenied,
873 GateDecisionSource::TaintDisabled,
874 ];
875 for src in sources {
876 let json = serde_json::to_string(&src).unwrap();
877 let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
878 assert_eq!(src, back);
879 }
880 }
881
882 #[test]
885 fn builtin_read_file_classification() {
886 let tc = builtin_tool_classification("read_file");
887 assert_eq!(tc.sensitivity, TaintLevel::Internal);
888 assert_eq!(tc.direction, ToolDirection::Inbound);
889 }
890
891 #[test]
892 fn builtin_shell_classification() {
893 let tc = builtin_tool_classification("shell");
894 assert_eq!(tc.sensitivity, TaintLevel::Public);
895 assert_eq!(tc.direction, ToolDirection::Outbound);
896 assert_eq!(tc.clearance, TaintLevel::Public);
897
898 let tc2 = builtin_tool_classification("bash");
900 assert_eq!(tc2.direction, ToolDirection::Outbound);
901 }
902
903 #[test]
910 fn network_capable_tools_are_outbound() {
911 for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
912 let tc = builtin_tool_classification(name);
913 assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
914 assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
915 }
916 }
917
918 #[test]
919 fn builtin_ask_user_classification() {
920 for name in [
921 "ask_user_text",
922 "ask_user_choice",
923 "ask_user_confirm",
924 "present_for_review",
925 ] {
926 let tc = builtin_tool_classification(name);
927 assert_eq!(tc.direction, ToolDirection::Internal);
928 }
929 }
930
931 #[test]
932 fn builtin_subagent_classification() {
933 for name in [
934 "spawn_agent",
935 "check_agent",
936 "wait_for_agent",
937 "send_to_agent",
938 "kill_agent",
939 ] {
940 let tc = builtin_tool_classification(name);
941 assert_eq!(tc.direction, ToolDirection::Internal);
942 }
943 }
944
945 #[test]
946 fn builtin_write_file_classification() {
947 let tc = builtin_tool_classification("write_file");
948 assert_eq!(tc.direction, ToolDirection::Internal);
949 }
950
951 #[test]
956 fn unknown_tools_fail_closed_as_outbound() {
957 let tc = builtin_tool_classification("some_mcp_tool");
958 assert_eq!(tc.sensitivity, TaintLevel::Public);
959 assert_eq!(tc.direction, ToolDirection::Outbound);
960 assert_eq!(tc.clearance, TaintLevel::Public);
961 }
962
963 #[test]
964 fn builtin_edit_file_classification() {
965 let tc = builtin_tool_classification("edit_file");
966 assert_eq!(tc.sensitivity, TaintLevel::Internal);
967 assert_eq!(tc.direction, ToolDirection::Internal);
968 assert_eq!(tc.clearance, TaintLevel::Public);
969 }
970
971 #[test]
972 fn builtin_list_dir_classification() {
973 let tc = builtin_tool_classification("list_dir");
974 assert_eq!(tc.sensitivity, TaintLevel::Internal);
975 assert_eq!(tc.direction, ToolDirection::Inbound);
976 assert_eq!(tc.clearance, TaintLevel::Public);
977 }
978
979 fn sec(taint: bool) -> SecurityConfig {
982 SecurityConfig {
983 taint_tracking: taint,
984 }
985 }
986
987 #[test]
988 fn resolve_taint_enabled_inherits_global_when_unset() {
989 assert!(!resolve_taint_enabled(false, None, None));
990 assert!(resolve_taint_enabled(true, None, None));
991 }
992
993 #[test]
994 fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
995 assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
997 assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
1001 }
1002
1003 #[test]
1004 fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
1005 assert!(resolve_taint_enabled(
1007 false,
1008 Some(&sec(false)),
1009 Some(&sec(true))
1010 ));
1011 assert!(resolve_taint_enabled(
1013 true,
1014 Some(&sec(true)),
1015 Some(&sec(false))
1016 ));
1017 }
1018
1019 #[test]
1020 fn resolve_batch_tool_hint_cascade() {
1021 assert!(resolve_batch_tool_hint(true, None, None));
1023 assert!(!resolve_batch_tool_hint(false, None, None));
1024 assert!(!resolve_batch_tool_hint(true, Some(false), None));
1026 assert!(resolve_batch_tool_hint(false, Some(true), None));
1027 assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
1029 assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
1030 }
1031
1032 #[test]
1033 fn gate_decision_blocked_levels() {
1034 let blocked = GateDecision::Blocked {
1035 taint_level: TaintLevel::Private,
1036 clearance: TaintLevel::Public,
1037 source_regions: vec![],
1038 tool_name: "shell".into(),
1039 };
1040 assert_eq!(
1041 blocked.blocked_levels(),
1042 Some((TaintLevel::Private, TaintLevel::Public))
1043 );
1044 assert_eq!(GateDecision::Allowed.blocked_levels(), None);
1045 }
1046
1047 #[test]
1048 fn resolve_security_prefers_most_specific_but_clamps_taint() {
1049 assert!(resolve_security(true, None, None).taint_tracking);
1051 assert!(!resolve_security(false, None, None).taint_tracking);
1052 assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
1054 assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
1057 }
1058
1059 #[test]
1060 fn test_region_taint_remove_at_recomputes_level() {
1061 let mut rt = RegionTaint::new();
1062 rt.add_entry(TaintLevel::Public);
1063 rt.add_entry(TaintLevel::Private);
1064 rt.add_entry(TaintLevel::Public);
1065 assert_eq!(rt.level(), TaintLevel::Private);
1066
1067 rt.remove_at(1);
1069 assert_eq!(rt.entry_count(), 2);
1070 assert_eq!(rt.level(), TaintLevel::Public);
1071
1072 rt.remove_at(99);
1074 assert_eq!(rt.entry_count(), 2);
1075 }
1076}