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 classified_builtin(tool_name).unwrap_or_else(|| {
477 ToolClassification::new(
478 TaintLevel::Public,
479 ToolDirection::Outbound,
480 TaintLevel::Public,
481 )
482 })
483}
484
485pub fn classified_builtin(tool_name: &str) -> Option<ToolClassification> {
493 let classification = match tool_name {
494 "read_file" | "read_files" => ToolClassification::new(
496 TaintLevel::Internal,
497 ToolDirection::Inbound,
498 TaintLevel::Public,
499 ),
500 "write_file" => ToolClassification::new(
501 TaintLevel::Internal,
502 ToolDirection::Internal,
503 TaintLevel::Public,
504 ),
505 "edit_file" | "edit_document" => ToolClassification::new(
508 TaintLevel::Internal,
509 ToolDirection::Internal,
510 TaintLevel::Public,
511 ),
512 "context_write" | "context_append" | "context_read" | "context_delete" | "context_list"
516 | "todo_add" | "todo_done" | "todo_note" => ToolClassification::new(
517 TaintLevel::Internal,
518 ToolDirection::Internal,
519 TaintLevel::Public,
520 ),
521 "submit_output" => ToolClassification::new(
531 TaintLevel::Public,
532 ToolDirection::Outbound,
533 TaintLevel::Public,
534 ),
535 "list_dir" => ToolClassification::new(
536 TaintLevel::Internal,
537 ToolDirection::Inbound,
538 TaintLevel::Public,
539 ),
540 "shell" | "bash" => ToolClassification::new(
541 TaintLevel::Public,
542 ToolDirection::Outbound,
543 TaintLevel::Public,
544 ),
545 "web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
549 TaintLevel::Public,
550 ToolDirection::Outbound,
551 TaintLevel::Public,
552 ),
553 "current_time" | "locale_info" => ToolClassification::new(
562 TaintLevel::Public,
563 ToolDirection::Inbound,
564 TaintLevel::Public,
565 ),
566 "system_info" | "environment_info" | "which_command" | "runtime_info" => {
567 ToolClassification::new(
568 TaintLevel::Internal,
569 ToolDirection::Inbound,
570 TaintLevel::Public,
571 )
572 }
573 "ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
574 ToolClassification::new(
575 TaintLevel::Internal,
576 ToolDirection::Internal,
577 TaintLevel::Public,
578 )
579 }
580 "spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent"
582 | "fan_out" => ToolClassification::new(
583 TaintLevel::Internal,
584 ToolDirection::Internal,
585 TaintLevel::Public,
586 ),
587 _ => return None,
588 };
589 Some(classification)
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
600 fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
601 let restored = RegionTaint::from_entry_taints(vec![
602 TaintLevel::Public,
603 TaintLevel::Private,
604 TaintLevel::Internal,
605 ]);
606 assert_eq!(restored.level(), TaintLevel::Private);
607 assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
608
609 assert_eq!(
612 RegionTaint::from_entry_taints(Vec::new()).level(),
613 TaintLevel::Public
614 );
615 }
616
617 #[test]
620 fn taint_level_ordering() {
621 assert!(TaintLevel::Public < TaintLevel::Internal);
622 assert!(TaintLevel::Internal < TaintLevel::Private);
623 assert!(TaintLevel::Public < TaintLevel::Private);
624 }
625
626 #[test]
627 fn taint_level_equality() {
628 assert_eq!(TaintLevel::Public, TaintLevel::Public);
629 assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
630 assert_eq!(TaintLevel::Private, TaintLevel::Private);
631 assert_ne!(TaintLevel::Public, TaintLevel::Private);
632 }
633
634 #[test]
635 fn taint_level_max() {
636 assert_eq!(
637 TaintLevel::Public.max(TaintLevel::Internal),
638 TaintLevel::Internal
639 );
640 assert_eq!(
641 TaintLevel::Private.max(TaintLevel::Public),
642 TaintLevel::Private
643 );
644 assert_eq!(
645 TaintLevel::Internal.max(TaintLevel::Internal),
646 TaintLevel::Internal
647 );
648 }
649
650 #[test]
651 fn taint_level_default_is_internal() {
652 assert_eq!(TaintLevel::default(), TaintLevel::Internal);
653 }
654
655 #[test]
656 fn taint_level_display() {
657 assert_eq!(format!("{}", TaintLevel::Public), "public");
658 assert_eq!(format!("{}", TaintLevel::Internal), "internal");
659 assert_eq!(format!("{}", TaintLevel::Private), "private");
660 }
661
662 #[test]
663 fn taint_level_from_str_loose() {
664 assert_eq!(
665 TaintLevel::from_str_loose("public"),
666 Some(TaintLevel::Public)
667 );
668 assert_eq!(
669 TaintLevel::from_str_loose("INTERNAL"),
670 Some(TaintLevel::Internal)
671 );
672 assert_eq!(
673 TaintLevel::from_str_loose("Private"),
674 Some(TaintLevel::Private)
675 );
676 assert_eq!(TaintLevel::from_str_loose("unknown"), None);
677 }
678
679 #[test]
680 fn taint_level_as_str() {
681 assert_eq!(TaintLevel::Public.as_str(), "public");
682 assert_eq!(TaintLevel::Internal.as_str(), "internal");
683 assert_eq!(TaintLevel::Private.as_str(), "private");
684 }
685
686 #[test]
687 fn taint_level_serde_roundtrip() {
688 for level in [
689 TaintLevel::Public,
690 TaintLevel::Internal,
691 TaintLevel::Private,
692 ] {
693 let json = serde_json::to_string(&level).unwrap();
694 let back: TaintLevel = serde_json::from_str(&json).unwrap();
695 assert_eq!(level, back);
696 }
697 }
698
699 #[test]
700 fn taint_level_hash() {
701 use std::collections::HashSet;
702 let mut set = HashSet::new();
703 set.insert(TaintLevel::Public);
704 set.insert(TaintLevel::Internal);
705 set.insert(TaintLevel::Private);
706 set.insert(TaintLevel::Public); assert_eq!(set.len(), 3);
708 }
709
710 #[test]
713 fn tool_direction_from_str_loose() {
714 assert_eq!(
715 ToolDirection::from_str_loose("inbound"),
716 Some(ToolDirection::Inbound)
717 );
718 assert_eq!(
719 ToolDirection::from_str_loose("OUTBOUND"),
720 Some(ToolDirection::Outbound)
721 );
722 assert_eq!(
723 ToolDirection::from_str_loose("Internal"),
724 Some(ToolDirection::Internal)
725 );
726 assert_eq!(ToolDirection::from_str_loose("nope"), None);
727 }
728
729 #[test]
730 fn tool_direction_default_is_internal() {
731 assert_eq!(ToolDirection::default(), ToolDirection::Internal);
732 }
733
734 #[test]
735 fn tool_direction_display() {
736 assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
737 assert_eq!(format!("{}", ToolDirection::Internal), "internal");
738 assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
739 }
740
741 #[test]
742 fn tool_direction_serde_roundtrip() {
743 for dir in [
744 ToolDirection::Inbound,
745 ToolDirection::Internal,
746 ToolDirection::Outbound,
747 ] {
748 let json = serde_json::to_string(&dir).unwrap();
749 let back: ToolDirection = serde_json::from_str(&json).unwrap();
750 assert_eq!(dir, back);
751 }
752 }
753
754 #[test]
757 fn tool_classification_default() {
758 let tc = ToolClassification::default();
759 assert_eq!(tc.sensitivity, TaintLevel::Internal);
760 assert_eq!(tc.direction, ToolDirection::Internal);
761 assert_eq!(tc.clearance, TaintLevel::Public);
762 }
763
764 #[test]
765 fn tool_classification_outbound_check() {
766 let tc = ToolClassification::new(
767 TaintLevel::Public,
768 ToolDirection::Outbound,
769 TaintLevel::Internal,
770 );
771 assert!(tc.is_outbound());
772 assert!(tc.check_clearance(TaintLevel::Public));
773 assert!(tc.check_clearance(TaintLevel::Internal));
774 assert!(!tc.check_clearance(TaintLevel::Private));
775 }
776
777 #[test]
778 fn tool_classification_non_outbound_always_passes() {
779 let tc = ToolClassification::new(
780 TaintLevel::Private,
781 ToolDirection::Inbound,
782 TaintLevel::Public, );
784 assert!(!tc.is_outbound());
785 assert!(tc.check_clearance(TaintLevel::Private));
786 }
787
788 #[test]
789 fn tool_classification_serde_roundtrip() {
790 let tc = ToolClassification::new(
791 TaintLevel::Private,
792 ToolDirection::Outbound,
793 TaintLevel::Internal,
794 );
795 let json = serde_json::to_string(&tc).unwrap();
796 let back: ToolClassification = serde_json::from_str(&json).unwrap();
797 assert_eq!(tc, back);
798 }
799
800 #[test]
803 fn region_taint_starts_public() {
804 let rt = RegionTaint::new();
805 assert_eq!(rt.level(), TaintLevel::Public);
806 assert_eq!(rt.entry_count(), 0);
807 }
808
809 #[test]
810 fn region_taint_add_entry_raises_level() {
811 let mut rt = RegionTaint::new();
812 rt.add_entry(TaintLevel::Internal);
813 assert_eq!(rt.level(), TaintLevel::Internal);
814 rt.add_entry(TaintLevel::Private);
815 assert_eq!(rt.level(), TaintLevel::Private);
816 }
817
818 #[test]
819 fn region_taint_add_public_doesnt_lower() {
820 let mut rt = RegionTaint::new();
821 rt.add_entry(TaintLevel::Private);
822 rt.add_entry(TaintLevel::Public);
823 assert_eq!(rt.level(), TaintLevel::Private);
824 }
825
826 #[test]
827 fn region_taint_remove_oldest_recovers() {
828 let mut rt = RegionTaint::new();
829 rt.add_entry(TaintLevel::Private);
830 rt.add_entry(TaintLevel::Public);
831 assert_eq!(rt.level(), TaintLevel::Private);
832
833 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
835 }
836
837 #[test]
838 fn region_taint_remove_oldest_empty() {
839 let mut rt = RegionTaint::new();
840 rt.remove_oldest(); assert_eq!(rt.level(), TaintLevel::Public);
842 }
843
844 #[test]
845 fn region_taint_clear() {
846 let mut rt = RegionTaint::new();
847 rt.add_entry(TaintLevel::Private);
848 rt.add_entry(TaintLevel::Internal);
849 rt.clear();
850 assert_eq!(rt.level(), TaintLevel::Public);
851 assert_eq!(rt.entry_count(), 0);
852 }
853
854 #[test]
855 fn region_taint_recompute() {
856 let mut rt = RegionTaint::new();
857 rt.add_entry(TaintLevel::Private);
858 rt.add_entry(TaintLevel::Internal);
859 rt.add_entry(TaintLevel::Public);
860 assert_eq!(rt.entry_count(), 3);
861
862 rt.remove_oldest();
864 assert_eq!(rt.level(), TaintLevel::Internal);
865 assert_eq!(rt.entry_count(), 2);
866 }
867
868 #[test]
869 fn region_taint_entry_taint() {
870 let mut rt = RegionTaint::new();
871 rt.add_entry(TaintLevel::Public);
872 rt.add_entry(TaintLevel::Private);
873 assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
874 assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
875 assert_eq!(rt.entry_taint(2), None);
876 }
877
878 #[test]
879 fn region_taint_default() {
880 let rt = RegionTaint::default();
881 assert_eq!(rt.level(), TaintLevel::Public);
882 }
883
884 #[test]
885 fn region_taint_serde_roundtrip() {
886 let mut rt = RegionTaint::new();
887 rt.add_entry(TaintLevel::Internal);
888 rt.add_entry(TaintLevel::Private);
889 let json = serde_json::to_string(&rt).unwrap();
890 let back: RegionTaint = serde_json::from_str(&json).unwrap();
891 assert_eq!(back.level(), TaintLevel::Private);
892 assert_eq!(back.entry_count(), 2);
893 }
894
895 #[test]
898 fn security_config_default() {
899 let sc = SecurityConfig::default();
900 assert!(sc.taint_tracking);
901 }
902
903 #[test]
904 fn security_config_serde_roundtrip() {
905 let sc = SecurityConfig {
906 taint_tracking: false,
907 };
908 let json = serde_json::to_string(&sc).unwrap();
909 let back: SecurityConfig = serde_json::from_str(&json).unwrap();
910 assert!(!back.taint_tracking);
911 }
912
913 #[test]
916 fn gate_decision_allowed() {
917 let d = GateDecision::Allowed;
918 assert!(d.is_allowed());
919 }
920
921 #[test]
922 fn gate_decision_blocked() {
923 let d = GateDecision::Blocked {
924 taint_level: TaintLevel::Private,
925 clearance: TaintLevel::Public,
926 source_regions: vec!["conversation".into()],
927 tool_name: "send_email".into(),
928 };
929 assert!(!d.is_allowed());
930 }
931
932 #[test]
935 fn gate_event_serde_roundtrip() {
936 let event = GateEvent {
937 timestamp: 1234567890,
938 agent_id: "agent-1".into(),
939 tool_name: "send_email".into(),
940 taint_level: TaintLevel::Private,
941 clearance: TaintLevel::Public,
942 allowed: false,
943 decision_source: GateDecisionSource::UserDenied,
944 };
945 let json = serde_json::to_string(&event).unwrap();
946 let back: GateEvent = serde_json::from_str(&json).unwrap();
947 assert_eq!(back.agent_id, "agent-1");
948 assert!(!back.allowed);
949 }
950
951 #[test]
952 fn gate_decision_source_variants() {
953 let sources = vec![
954 GateDecisionSource::AutoAllow,
955 GateDecisionSource::AllowlistRule { rule_index: 0 },
956 GateDecisionSource::ScriptedRule {
957 script_name: "test.rhai".into(),
958 },
959 GateDecisionSource::UserAllowOnce,
960 GateDecisionSource::UserAlwaysAllow,
961 GateDecisionSource::UserDenied,
962 GateDecisionSource::TaintDisabled,
963 ];
964 for src in sources {
965 let json = serde_json::to_string(&src).unwrap();
966 let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
967 assert_eq!(src, back);
968 }
969 }
970
971 #[test]
974 fn builtin_read_file_classification() {
975 let tc = builtin_tool_classification("read_file");
976 assert_eq!(tc.sensitivity, TaintLevel::Internal);
977 assert_eq!(tc.direction, ToolDirection::Inbound);
978 }
979
980 #[test]
981 fn builtin_shell_classification() {
982 let tc = builtin_tool_classification("shell");
983 assert_eq!(tc.sensitivity, TaintLevel::Public);
984 assert_eq!(tc.direction, ToolDirection::Outbound);
985 assert_eq!(tc.clearance, TaintLevel::Public);
986
987 let tc2 = builtin_tool_classification("bash");
989 assert_eq!(tc2.direction, ToolDirection::Outbound);
990 }
991
992 #[test]
999 fn network_capable_tools_are_outbound() {
1000 for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
1001 let tc = builtin_tool_classification(name);
1002 assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
1003 assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
1004 }
1005 }
1006
1007 #[test]
1013 fn environment_tools_are_inbound_and_never_gated() {
1014 for name in [
1015 "current_time",
1016 "system_info",
1017 "locale_info",
1018 "environment_info",
1019 "which_command",
1020 "runtime_info",
1021 ] {
1022 let tc = builtin_tool_classification(name);
1023 assert_eq!(tc.direction, ToolDirection::Inbound, "{name}");
1024 assert_eq!(tc.clearance, TaintLevel::Public, "{name}");
1026 }
1027 }
1028
1029 #[test]
1034 fn environment_tools_are_graded_by_what_they_reveal() {
1035 for name in ["current_time", "locale_info"] {
1036 assert_eq!(
1037 builtin_tool_classification(name).sensitivity,
1038 TaintLevel::Public,
1039 "{name}"
1040 );
1041 }
1042 for name in [
1043 "system_info",
1044 "environment_info",
1045 "which_command",
1046 "runtime_info",
1047 ] {
1048 assert_eq!(
1049 builtin_tool_classification(name).sensitivity,
1050 TaintLevel::Internal,
1051 "{name}"
1052 );
1053 }
1054 }
1055
1056 #[test]
1057 fn builtin_ask_user_classification() {
1058 for name in [
1059 "ask_user_text",
1060 "ask_user_choice",
1061 "ask_user_confirm",
1062 "present_for_review",
1063 ] {
1064 let tc = builtin_tool_classification(name);
1065 assert_eq!(tc.direction, ToolDirection::Internal);
1066 }
1067 }
1068
1069 #[test]
1070 fn builtin_subagent_classification() {
1071 for name in [
1072 "spawn_agent",
1073 "check_agent",
1074 "wait_for_agent",
1075 "send_to_agent",
1076 "kill_agent",
1077 ] {
1078 let tc = builtin_tool_classification(name);
1079 assert_eq!(tc.direction, ToolDirection::Internal);
1080 }
1081 }
1082
1083 #[test]
1084 fn builtin_write_file_classification() {
1085 let tc = builtin_tool_classification("write_file");
1086 assert_eq!(tc.direction, ToolDirection::Internal);
1087 }
1088
1089 #[test]
1094 fn unknown_tools_fail_closed_as_outbound() {
1095 let tc = builtin_tool_classification("some_mcp_tool");
1096 assert_eq!(tc.sensitivity, TaintLevel::Public);
1097 assert_eq!(tc.direction, ToolDirection::Outbound);
1098 assert_eq!(tc.clearance, TaintLevel::Public);
1099 }
1100
1101 #[test]
1102 fn builtin_edit_file_classification() {
1103 let tc = builtin_tool_classification("edit_file");
1104 assert_eq!(tc.sensitivity, TaintLevel::Internal);
1105 assert_eq!(tc.direction, ToolDirection::Internal);
1106 assert_eq!(tc.clearance, TaintLevel::Public);
1107 }
1108
1109 #[test]
1110 fn builtin_list_dir_classification() {
1111 let tc = builtin_tool_classification("list_dir");
1112 assert_eq!(tc.sensitivity, TaintLevel::Internal);
1113 assert_eq!(tc.direction, ToolDirection::Inbound);
1114 assert_eq!(tc.clearance, TaintLevel::Public);
1115 }
1116
1117 #[test]
1125 fn the_remaining_builtins_are_classified_like_their_siblings() {
1126 assert_eq!(
1127 classified_builtin("read_files"),
1128 classified_builtin("read_file"),
1129 "read_files"
1130 );
1131 assert_eq!(
1132 classified_builtin("fan_out"),
1133 classified_builtin("spawn_agent"),
1134 "fan_out"
1135 );
1136 assert_eq!(
1137 classified_builtin("edit_document"),
1138 classified_builtin("edit_file"),
1139 "edit_document"
1140 );
1141 for name in [
1142 "context_write",
1143 "context_append",
1144 "context_read",
1145 "context_delete",
1146 "context_list",
1147 "todo_add",
1148 "todo_done",
1149 "todo_note",
1150 ] {
1151 let tc = classified_builtin(name);
1152 assert!(tc.is_some(), "{name} has no arm");
1153 let tc = tc.unwrap();
1154 assert_eq!(tc.direction, ToolDirection::Internal, "{name}");
1155 assert_eq!(tc.sensitivity, TaintLevel::Internal, "{name}");
1156 assert_eq!(tc.clearance, TaintLevel::Public, "{name}");
1157 }
1158 }
1159
1160 #[test]
1165 fn submit_output_is_an_outbound_channel() {
1166 assert_eq!(
1167 classified_builtin("submit_output"),
1168 classified_builtin("shell"),
1169 "submit_output"
1170 );
1171 }
1172
1173 #[test]
1175 fn a_third_party_name_has_no_arm_of_its_own() {
1176 assert_eq!(classified_builtin("some_mcp_tool"), None);
1177 assert_eq!(
1178 classified_builtin("shell"),
1179 Some(builtin_tool_classification("shell"))
1180 );
1181 }
1182
1183 fn sec(taint: bool) -> SecurityConfig {
1186 SecurityConfig {
1187 taint_tracking: taint,
1188 }
1189 }
1190
1191 #[test]
1192 fn resolve_taint_enabled_inherits_global_when_unset() {
1193 assert!(!resolve_taint_enabled(false, None, None));
1194 assert!(resolve_taint_enabled(true, None, None));
1195 }
1196
1197 #[test]
1198 fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
1199 assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
1201 assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
1205 }
1206
1207 #[test]
1208 fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
1209 assert!(resolve_taint_enabled(
1211 false,
1212 Some(&sec(false)),
1213 Some(&sec(true))
1214 ));
1215 assert!(resolve_taint_enabled(
1217 true,
1218 Some(&sec(true)),
1219 Some(&sec(false))
1220 ));
1221 }
1222
1223 #[test]
1224 fn resolve_batch_tool_hint_cascade() {
1225 assert!(resolve_batch_tool_hint(true, None, None));
1227 assert!(!resolve_batch_tool_hint(false, None, None));
1228 assert!(!resolve_batch_tool_hint(true, Some(false), None));
1230 assert!(resolve_batch_tool_hint(false, Some(true), None));
1231 assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
1233 assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
1234 }
1235
1236 #[test]
1237 fn gate_decision_blocked_levels() {
1238 let blocked = GateDecision::Blocked {
1239 taint_level: TaintLevel::Private,
1240 clearance: TaintLevel::Public,
1241 source_regions: vec![],
1242 tool_name: "shell".into(),
1243 };
1244 assert_eq!(
1245 blocked.blocked_levels(),
1246 Some((TaintLevel::Private, TaintLevel::Public))
1247 );
1248 assert_eq!(GateDecision::Allowed.blocked_levels(), None);
1249 }
1250
1251 #[test]
1252 fn resolve_security_prefers_most_specific_but_clamps_taint() {
1253 assert!(resolve_security(true, None, None).taint_tracking);
1255 assert!(!resolve_security(false, None, None).taint_tracking);
1256 assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
1258 assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
1261 }
1262
1263 #[test]
1264 fn test_region_taint_remove_at_recomputes_level() {
1265 let mut rt = RegionTaint::new();
1266 rt.add_entry(TaintLevel::Public);
1267 rt.add_entry(TaintLevel::Private);
1268 rt.add_entry(TaintLevel::Public);
1269 assert_eq!(rt.level(), TaintLevel::Private);
1270
1271 rt.remove_at(1);
1273 assert_eq!(rt.entry_count(), 2);
1274 assert_eq!(rt.level(), TaintLevel::Public);
1275
1276 rt.remove_at(99);
1278 assert_eq!(rt.entry_count(), 2);
1279 }
1280}