1use leviath_core::taint::{
8 GateDecision, GateDecisionSource, GateEvent, SecurityConfig, TaintLevel, ToolClassification,
9 builtin_tool_classification,
10};
11use std::collections::HashMap;
12
13use crate::components::ContextWindow;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum GateResolution {
18 AllowOnce,
20 AlwaysAllow,
22 Deny,
24}
25
26#[async_trait::async_trait]
32pub trait GatePrompt: Send + Sync {
33 async fn resolve(&self, decision: &GateDecision) -> GateResolution;
36}
37
38pub type ScriptRuleChecker = dyn Fn(&str, Option<&str>, TaintLevel) -> Option<String> + Send + Sync;
42
43#[derive(Debug, Clone, bevy_ecs::component::Component)]
47pub struct TaintGate {
48 config: SecurityConfig,
50 tool_overrides: HashMap<String, ToolClassification>,
52 audit_log: Vec<GateEvent>,
54}
55
56impl TaintGate {
57 pub fn new(config: SecurityConfig) -> Self {
59 Self {
60 config,
61 tool_overrides: HashMap::new(),
62 audit_log: Vec::new(),
63 }
64 }
65
66 pub fn disabled() -> Self {
68 Self {
69 config: SecurityConfig {
70 taint_tracking: false,
71 },
72 tool_overrides: HashMap::new(),
73 audit_log: Vec::new(),
74 }
75 }
76
77 pub fn config(&self) -> &SecurityConfig {
79 &self.config
80 }
81
82 pub fn set_tool_classification(
84 &mut self,
85 tool_name: String,
86 classification: ToolClassification,
87 ) {
88 self.tool_overrides.insert(tool_name, classification);
89 }
90
91 pub fn tool_classification(&self, tool_name: &str) -> ToolClassification {
93 self.tool_overrides
94 .get(tool_name)
95 .cloned()
96 .unwrap_or_else(|| builtin_tool_classification(tool_name))
97 }
98
99 pub fn apply_mcp_overrides(
111 &mut self,
112 overrides: &HashMap<String, leviath_core::policy::McpToolOverride>,
113 ) {
114 for (tool_name, over) in overrides {
115 let mut classification = self.tool_classification(tool_name);
116 if let Some(sensitivity) = over.sensitivity {
117 classification.sensitivity = sensitivity;
118 }
119 if let Some(clearance) = over.clearance {
120 classification.clearance = clearance;
121 }
122 if let Some(direction) = over.direction.as_deref() {
123 match leviath_core::taint::ToolDirection::from_str_loose(direction) {
124 Some(parsed) => classification.direction = parsed,
125 None => tracing::warn!(
126 tool = %tool_name,
127 direction = %direction,
128 "ignoring unrecognized direction in [mcp_overrides]"
129 ),
130 }
131 }
132 self.set_tool_classification(tool_name.clone(), classification);
133 }
134 }
135
136 pub fn check_traditional(
141 &mut self,
142 agent_id: &str,
143 tool_name: &str,
144 window: &ContextWindow,
145 ) -> GateDecision {
146 if !self.config.taint_tracking {
147 self.log_event(
148 agent_id,
149 tool_name,
150 TaintLevel::Public,
151 TaintLevel::Public,
152 true,
153 GateDecisionSource::TaintDisabled,
154 );
155 return GateDecision::Allowed;
156 }
157
158 let classification = self.tool_classification(tool_name);
159
160 if !classification.is_outbound() {
162 self.log_event(
163 agent_id,
164 tool_name,
165 TaintLevel::Public,
166 classification.clearance,
167 true,
168 GateDecisionSource::AutoAllow,
169 );
170 return GateDecision::Allowed;
171 }
172
173 let taint = window.overall_taint().unwrap_or(TaintLevel::Public);
175
176 if classification.check_clearance(taint) {
177 self.log_event(
178 agent_id,
179 tool_name,
180 taint,
181 classification.clearance,
182 true,
183 GateDecisionSource::AutoAllow,
184 );
185 GateDecision::Allowed
186 } else {
187 let source_regions: Vec<String> = window
189 .taint_summary()
190 .into_iter()
191 .filter(|(_, level)| *level > classification.clearance)
192 .map(|(name, _)| name)
193 .collect();
194
195 self.log_event(
196 agent_id,
197 tool_name,
198 taint,
199 classification.clearance,
200 false,
201 GateDecisionSource::AutoBlock,
202 );
203
204 GateDecision::Blocked {
205 taint_level: taint,
206 clearance: classification.clearance,
207 source_regions,
208 tool_name: tool_name.to_string(),
209 }
210 }
211 }
212
213 pub fn check_with_policy(
221 &mut self,
222 agent_id: &str,
223 tool_name: &str,
224 window: &ContextWindow,
225 target: Option<&str>,
226 policy: &leviath_core::PolicyConfig,
227 script_checker: Option<&ScriptRuleChecker>,
228 ) -> GateDecision {
229 let decision = self.check_traditional(agent_id, tool_name, window);
230
231 if decision.is_allowed() {
232 return decision;
233 }
234
235 let (taint, clearance) = decision
239 .blocked_levels()
240 .expect("infallible: a non-Allowed GateDecision is always Blocked");
241
242 if let Some(rule_idx) = policy.check_allowlist(tool_name, target, taint) {
244 self.log_event(
245 agent_id,
246 tool_name,
247 taint,
248 clearance,
249 true,
250 GateDecisionSource::AllowlistRule {
251 rule_index: rule_idx,
252 },
253 );
254 return GateDecision::Allowed;
255 }
256
257 if let Some(checker) = script_checker
259 && let Some(script_name) = checker(tool_name, target, taint)
260 {
261 self.log_event(
262 agent_id,
263 tool_name,
264 taint,
265 clearance,
266 true,
267 GateDecisionSource::ScriptedRule { script_name },
268 );
269 return GateDecision::Allowed;
270 }
271
272 decision
273 }
274
275 pub fn record_allow(
277 &mut self,
278 agent_id: &str,
279 tool_name: &str,
280 taint: TaintLevel,
281 clearance: TaintLevel,
282 source: GateDecisionSource,
283 ) {
284 self.log_event(agent_id, tool_name, taint, clearance, true, source);
285 }
286
287 pub fn record_deny(
289 &mut self,
290 agent_id: &str,
291 tool_name: &str,
292 taint: TaintLevel,
293 clearance: TaintLevel,
294 source: GateDecisionSource,
295 ) {
296 self.log_event(agent_id, tool_name, taint, clearance, false, source);
297 }
298
299 pub fn apply_resolution(
307 &mut self,
308 agent_id: &str,
309 tool_name: &str,
310 tool_id: &str,
311 taint: TaintLevel,
312 clearance: TaintLevel,
313 resolution: GateResolution,
314 ) -> Option<(String, String)> {
315 match resolution {
316 GateResolution::AllowOnce => {
317 self.record_allow(
318 agent_id,
319 tool_name,
320 taint,
321 clearance,
322 GateDecisionSource::UserAllowOnce,
323 );
324 None
325 }
326 GateResolution::AlwaysAllow => {
327 self.record_allow(
328 agent_id,
329 tool_name,
330 taint,
331 clearance,
332 GateDecisionSource::UserAlwaysAllow,
333 );
334 let mut cls = self.tool_classification(tool_name);
335 cls.clearance = TaintLevel::Private;
336 self.set_tool_classification(tool_name.to_string(), cls);
337 None
338 }
339 GateResolution::Deny => {
340 self.record_deny(
341 agent_id,
342 tool_name,
343 taint,
344 clearance,
345 GateDecisionSource::UserDenied,
346 );
347 Some((
348 tool_id.to_string(),
349 format!(
350 "[blocked] Tool '{}' would send data at {} sensitivity, above its {} \
351 clearance. Denied by user.",
352 tool_name, taint, clearance
353 ),
354 ))
355 }
356 }
357 }
358
359 pub fn audit_log(&self) -> &[GateEvent] {
361 &self.audit_log
362 }
363
364 #[allow(clippy::too_many_arguments)]
365 fn log_event(
366 &mut self,
367 agent_id: &str,
368 tool_name: &str,
369 taint_level: TaintLevel,
370 clearance: TaintLevel,
371 allowed: bool,
372 decision_source: GateDecisionSource,
373 ) {
374 self.audit_log.push(GateEvent {
375 timestamp: chrono::Utc::now().timestamp(),
376 agent_id: agent_id.to_string(),
377 tool_name: tool_name.to_string(),
378 taint_level,
379 clearance,
380 allowed,
381 decision_source,
382 });
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use leviath_core::taint::ToolDirection;
390 use leviath_core::{Region, RegionKind};
391
392 fn make_window_with_taint(taint: TaintLevel) -> ContextWindow {
393 let mut window = ContextWindow::new(10000);
394 let region =
395 Region::new("conv".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
396 window.add_region(region);
397 if taint != TaintLevel::Public {
398 window
399 .add_tainted_to_region("conv", "data".to_string(), 10, taint)
400 .unwrap();
401 }
402 window
403 }
404
405 #[test]
406 fn gate_disabled_always_allows() {
407 let mut gate = TaintGate::disabled();
408 assert!(!gate.config().taint_tracking);
409
410 let window = make_window_with_taint(TaintLevel::Private);
411 let decision = gate.check_traditional("agent-1", "shell", &window);
412 assert!(decision.is_allowed());
413 assert_eq!(gate.audit_log().len(), 1);
414 assert_eq!(
415 gate.audit_log()[0].decision_source,
416 GateDecisionSource::TaintDisabled
417 );
418 }
419
420 #[test]
421 fn gate_allows_non_outbound_tool() {
422 let mut gate = TaintGate::new(SecurityConfig::default());
423 let window = make_window_with_taint(TaintLevel::Private);
424 let decision = gate.check_traditional("agent-1", "read_file", &window);
425 assert!(decision.is_allowed());
426 }
427
428 #[test]
429 fn gate_allows_outbound_when_taint_within_clearance() {
430 let mut gate = TaintGate::new(SecurityConfig::default());
431 let window = make_window_with_taint(TaintLevel::Public);
432 let decision = gate.check_traditional("agent-1", "shell", &window);
433 assert!(decision.is_allowed());
434 }
435
436 #[test]
437 fn gate_blocks_outbound_when_taint_exceeds_clearance() {
438 let mut gate = TaintGate::new(SecurityConfig::default());
439 let window = make_window_with_taint(TaintLevel::Private);
440 let decision = gate.check_traditional("agent-1", "shell", &window);
441 assert!(!decision.is_allowed());
442 assert_eq!(
443 decision,
444 GateDecision::Blocked {
445 taint_level: TaintLevel::Private,
446 clearance: TaintLevel::Public,
447 source_regions: vec!["conv".to_string()],
448 tool_name: "shell".to_string(),
449 }
450 );
451 }
452
453 #[test]
454 fn gate_uses_tool_override() {
455 let mut gate = TaintGate::new(SecurityConfig::default());
456 gate.set_tool_classification(
457 "shell".to_string(),
458 ToolClassification::new(
459 TaintLevel::Public,
460 ToolDirection::Outbound,
461 TaintLevel::Private, ),
463 );
464 let window = make_window_with_taint(TaintLevel::Private);
465 let decision = gate.check_traditional("agent-1", "shell", &window);
466 assert!(decision.is_allowed());
467 }
468
469 #[test]
470 fn apply_mcp_overrides_replaces_only_the_set_fields() {
471 let mut gate = TaintGate::new(SecurityConfig::default());
472 let before = gate.tool_classification("srv.notify");
473 let overrides = std::collections::HashMap::from([(
474 "srv.notify".to_string(),
475 leviath_core::policy::McpToolOverride {
476 sensitivity: Some(TaintLevel::Private),
477 direction: None,
478 clearance: None,
479 },
480 )]);
481 gate.apply_mcp_overrides(&overrides);
482 let after = gate.tool_classification("srv.notify");
483 assert_eq!(after.sensitivity, TaintLevel::Private);
484 assert_eq!(after.direction, before.direction);
485 assert_eq!(after.clearance, before.clearance);
486 }
487
488 #[test]
489 fn apply_mcp_overrides_parses_direction_and_clearance() {
490 let mut gate = TaintGate::new(SecurityConfig::default());
491 let overrides = std::collections::HashMap::from([(
492 "srv.post".to_string(),
493 leviath_core::policy::McpToolOverride {
494 sensitivity: None,
495 direction: Some("outbound".to_string()),
496 clearance: Some(TaintLevel::Internal),
497 },
498 )]);
499 gate.apply_mcp_overrides(&overrides);
500 let after = gate.tool_classification("srv.post");
501 assert_eq!(after.direction, ToolDirection::Outbound);
502 assert_eq!(after.clearance, TaintLevel::Internal);
503 }
504
505 #[test]
506 fn apply_mcp_overrides_keeps_direction_on_unrecognized_string() {
507 let mut gate = TaintGate::new(SecurityConfig::default());
508 let before = gate.tool_classification("srv.odd");
509 let overrides = std::collections::HashMap::from([(
510 "srv.odd".to_string(),
511 leviath_core::policy::McpToolOverride {
512 sensitivity: None,
513 direction: Some("sideways".to_string()),
514 clearance: None,
515 },
516 )]);
517 gate.apply_mcp_overrides(&overrides);
518 assert_eq!(
520 gate.tool_classification("srv.odd").direction,
521 before.direction
522 );
523 }
524
525 #[test]
526 fn session_approval_still_wins_over_an_mcp_override() {
527 let mut gate = TaintGate::new(SecurityConfig::default());
528 let overrides = std::collections::HashMap::from([(
529 "srv.send".to_string(),
530 leviath_core::policy::McpToolOverride {
531 sensitivity: None,
532 direction: Some("outbound".to_string()),
533 clearance: Some(TaintLevel::Public),
534 },
535 )]);
536 gate.apply_mcp_overrides(&overrides);
537 gate.set_tool_classification(
540 "srv.send".to_string(),
541 ToolClassification::new(
542 TaintLevel::Public,
543 ToolDirection::Outbound,
544 TaintLevel::Private,
545 ),
546 );
547 assert_eq!(
548 gate.tool_classification("srv.send").clearance,
549 TaintLevel::Private
550 );
551 }
552
553 #[test]
554 fn gate_blocked_identifies_source_regions() {
555 let mut gate = TaintGate::new(SecurityConfig::default());
556 let mut window = ContextWindow::new(10000);
557 let r1 =
558 Region::new("clean".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
559 let r2 =
560 Region::new("dirty".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
561 window.add_region(r1);
562 window.add_region(r2);
563
564 window
565 .add_tainted_to_region("clean", "ok".to_string(), 5, TaintLevel::Public)
566 .unwrap();
567 window
568 .add_tainted_to_region("dirty", "secret".to_string(), 5, TaintLevel::Private)
569 .unwrap();
570
571 let decision = gate.check_traditional("agent-1", "shell", &window);
572 assert_eq!(
575 decision,
576 GateDecision::Blocked {
577 taint_level: TaintLevel::Private,
578 clearance: TaintLevel::Public,
579 source_regions: vec!["dirty".to_string()],
580 tool_name: "shell".to_string(),
581 }
582 );
583 }
584
585 #[test]
586 fn gate_audit_log_records_events() {
587 let mut gate = TaintGate::new(SecurityConfig::default());
588 let window = make_window_with_taint(TaintLevel::Public);
589
590 gate.check_traditional("agent-1", "shell", &window);
591 gate.check_traditional("agent-1", "read_file", &window);
592
593 assert_eq!(gate.audit_log().len(), 2);
594 assert!(gate.audit_log()[0].allowed);
595 assert!(gate.audit_log()[1].allowed);
596 }
597
598 #[test]
599 fn gate_record_allow() {
600 let mut gate = TaintGate::new(SecurityConfig::default());
601 gate.record_allow(
602 "agent-1",
603 "shell",
604 TaintLevel::Private,
605 TaintLevel::Public,
606 GateDecisionSource::UserAllowOnce,
607 );
608 assert_eq!(gate.audit_log().len(), 1);
609 assert!(gate.audit_log()[0].allowed);
610 assert_eq!(
611 gate.audit_log()[0].decision_source,
612 GateDecisionSource::UserAllowOnce
613 );
614 }
615
616 #[test]
617 fn gate_tool_classification_returns_override() {
618 let mut gate = TaintGate::new(SecurityConfig::default());
619 let custom = ToolClassification::new(
620 TaintLevel::Private,
621 ToolDirection::Outbound,
622 TaintLevel::Private,
623 );
624 gate.set_tool_classification("my_tool".to_string(), custom.clone());
625 assert_eq!(gate.tool_classification("my_tool"), custom);
626 }
627
628 #[test]
629 fn gate_tool_classification_falls_back_to_builtin() {
630 let gate = TaintGate::new(SecurityConfig::default());
631 let tc = gate.tool_classification("read_file");
632 assert_eq!(tc.direction, ToolDirection::Inbound);
633 }
634
635 #[test]
636 fn gate_new_and_config() {
637 let config = SecurityConfig {
638 taint_tracking: true,
639 };
640 let gate = TaintGate::new(config.clone());
641 assert!(gate.config().taint_tracking);
642 assert!(gate.config().taint_tracking);
643 }
644
645 #[test]
648 fn gate_with_policy_allows_via_allowlist() {
649 let mut gate = TaintGate::new(SecurityConfig::default());
650 let window = make_window_with_taint(TaintLevel::Private);
651 let policy = leviath_core::PolicyConfig {
652 allowlist: vec![leviath_core::AllowlistRule {
653 tool: "shell".into(),
654 to: vec![],
655 channel: vec![],
656 max_sensitivity: TaintLevel::Private,
657 }],
658 mcp_overrides: Default::default(),
659 };
660
661 let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
662 assert!(decision.is_allowed());
663
664 let last = gate.audit_log().last().unwrap();
666 assert!(last.allowed);
667 assert_eq!(
669 last.decision_source,
670 GateDecisionSource::AllowlistRule { rule_index: 0 }
671 );
672 }
673
674 #[test]
675 fn gate_with_policy_allows_via_scripted_rule() {
676 let mut gate = TaintGate::new(SecurityConfig::default());
677 let window = make_window_with_taint(TaintLevel::Private);
678 let policy = leviath_core::PolicyConfig::default(); let checker = |tool: &str, _target: Option<&str>, _taint: TaintLevel| -> Option<String> {
681 (tool == "shell").then(|| "company_rule.rhai".to_string())
682 };
683
684 let decision =
685 gate.check_with_policy("agent-1", "shell", &window, None, &policy, Some(&checker));
686 assert!(decision.is_allowed());
687
688 let last = gate.audit_log().last().unwrap();
689 assert_eq!(
690 last.decision_source,
691 GateDecisionSource::ScriptedRule {
692 script_name: "company_rule.rhai".to_string()
693 }
694 );
695 }
696
697 #[test]
698 fn gate_with_policy_blocks_when_no_rule_matches() {
699 let mut gate = TaintGate::new(SecurityConfig::default());
700 let window = make_window_with_taint(TaintLevel::Private);
701 let policy = leviath_core::PolicyConfig::default();
702
703 let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
704 assert!(!decision.is_allowed());
705 }
706
707 #[test]
708 fn gate_with_policy_passes_through_when_already_allowed() {
709 let mut gate = TaintGate::new(SecurityConfig::default());
710 let window = make_window_with_taint(TaintLevel::Public);
711 let policy = leviath_core::PolicyConfig::default();
712
713 let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
714 assert!(decision.is_allowed());
715 }
716
717 #[test]
718 fn gate_with_policy_target_pattern_matching() {
719 let mut gate = TaintGate::new(SecurityConfig::default());
720 gate.set_tool_classification(
721 "send_email".to_string(),
722 ToolClassification::new(
723 TaintLevel::Public,
724 ToolDirection::Outbound,
725 TaintLevel::Public,
726 ),
727 );
728 let window = make_window_with_taint(TaintLevel::Private);
729 let policy = leviath_core::PolicyConfig {
730 allowlist: vec![leviath_core::AllowlistRule {
731 tool: "send_email".into(),
732 to: vec!["megan@*".into()],
733 channel: vec![],
734 max_sensitivity: TaintLevel::Private,
735 }],
736 mcp_overrides: Default::default(),
737 };
738
739 let decision = gate.check_with_policy(
741 "agent-1",
742 "send_email",
743 &window,
744 Some("megan@work.com"),
745 &policy,
746 None,
747 );
748 assert!(decision.is_allowed());
749
750 let decision2 = gate.check_with_policy(
752 "agent-1",
753 "send_email",
754 &window,
755 Some("bob@work.com"),
756 &policy,
757 None,
758 );
759 assert!(!decision2.is_allowed());
760 }
761
762 #[test]
765 fn gate_check_traditional_with_internal_taint() {
766 let mut gate = TaintGate::new(SecurityConfig::default());
767 let window = make_window_with_taint(TaintLevel::Internal);
768 let decision = gate.check_traditional("agent-1", "shell", &window);
769 assert!(!decision.is_allowed());
771 assert_eq!(
772 decision,
773 GateDecision::Blocked {
774 taint_level: TaintLevel::Internal,
775 clearance: TaintLevel::Public,
776 source_regions: vec!["conv".to_string()],
777 tool_name: "shell".to_string(),
778 }
779 );
780 }
781
782 #[test]
783 fn gate_audit_log_records_blocked_events() {
784 let mut gate = TaintGate::new(SecurityConfig::default());
785 let window = make_window_with_taint(TaintLevel::Private);
786
787 gate.check_traditional("agent-1", "shell", &window);
788 assert_eq!(gate.audit_log().len(), 1);
789 assert!(!gate.audit_log()[0].allowed);
790 assert_eq!(gate.audit_log()[0].tool_name, "shell");
791 assert_eq!(gate.audit_log()[0].taint_level, TaintLevel::Private);
792 assert_eq!(
795 gate.audit_log()[0].decision_source,
796 GateDecisionSource::AutoBlock,
797 );
798 }
799
800 #[test]
801 fn gate_with_policy_scripted_rule_non_matching_tool() {
802 let mut gate = TaintGate::new(SecurityConfig::default());
803 let window = make_window_with_taint(TaintLevel::Private);
804 let policy = leviath_core::PolicyConfig::default();
805
806 let checker = |_tool: &str, _target: Option<&str>, _taint: TaintLevel| -> Option<String> {
807 None };
809
810 let decision =
811 gate.check_with_policy("agent-1", "shell", &window, None, &policy, Some(&checker));
812 assert!(!decision.is_allowed());
813 }
814
815 #[test]
816 fn gate_with_policy_non_outbound_skips_policy_check() {
817 let mut gate = TaintGate::new(SecurityConfig::default());
818 let window = make_window_with_taint(TaintLevel::Private);
819 let policy = leviath_core::PolicyConfig::default();
820
821 let decision = gate.check_with_policy("agent-1", "read_file", &window, None, &policy, None);
823 assert!(decision.is_allowed());
824 }
825
826 #[test]
827 fn gate_multiple_tool_overrides() {
828 let mut gate = TaintGate::new(SecurityConfig::default());
829 gate.set_tool_classification(
830 "tool_a".to_string(),
831 ToolClassification::new(
832 TaintLevel::Public,
833 ToolDirection::Outbound,
834 TaintLevel::Internal,
835 ),
836 );
837 gate.set_tool_classification(
838 "tool_b".to_string(),
839 ToolClassification::new(
840 TaintLevel::Public,
841 ToolDirection::Outbound,
842 TaintLevel::Private,
843 ),
844 );
845
846 let window = make_window_with_taint(TaintLevel::Private);
847
848 let decision_a = gate.check_traditional("agent-1", "tool_a", &window);
850 assert!(!decision_a.is_allowed());
851
852 let decision_b = gate.check_traditional("agent-1", "tool_b", &window);
854 assert!(decision_b.is_allowed());
855 }
856
857 #[test]
860 fn apply_resolution_allow_once_records_and_executes() {
861 let mut gate = TaintGate::new(SecurityConfig::default());
862 let out = gate.apply_resolution(
863 "a",
864 "shell",
865 "call1",
866 TaintLevel::Private,
867 TaintLevel::Public,
868 GateResolution::AllowOnce,
869 );
870 assert!(out.is_none()); let allow = gate
872 .audit_log()
873 .iter()
874 .find(|e| e.allowed)
875 .expect("an allowed event should be logged");
876 assert_eq!(allow.decision_source, GateDecisionSource::UserAllowOnce);
877 }
878
879 #[test]
880 fn apply_resolution_always_allow_raises_clearance() {
881 let mut gate = TaintGate::new(SecurityConfig::default());
882 let out = gate.apply_resolution(
883 "a",
884 "shell",
885 "call1",
886 TaintLevel::Private,
887 TaintLevel::Public,
888 GateResolution::AlwaysAllow,
889 );
890 assert!(out.is_none());
891 assert_eq!(
893 gate.tool_classification("shell").clearance,
894 TaintLevel::Private
895 );
896 let allow = gate
897 .audit_log()
898 .iter()
899 .find(|e| e.allowed)
900 .expect("an allowed event should be logged");
901 assert_eq!(allow.decision_source, GateDecisionSource::UserAlwaysAllow);
902 }
903
904 #[test]
905 fn apply_resolution_deny_returns_blocked_result() {
906 let mut gate = TaintGate::new(SecurityConfig::default());
907 let out = gate.apply_resolution(
908 "a",
909 "shell",
910 "call1",
911 TaintLevel::Private,
912 TaintLevel::Public,
913 GateResolution::Deny,
914 );
915 let (id, msg) = out.expect("deny yields a blocked result");
916 assert_eq!(id, "call1");
917 assert!(msg.contains("[blocked]") && msg.contains("shell"));
918 let deny = gate
919 .audit_log()
920 .iter()
921 .find(|e| !e.allowed)
922 .expect("a denied event should be logged");
923 assert_eq!(deny.decision_source, GateDecisionSource::UserDenied);
924 }
925}