Skip to main content

leviath_runtime/
taint.rs

1//! Taint gate checking for tool execution.
2//!
3//! Implements the gate check logic that runs before outbound tool calls.
4//! When taint tracking is enabled, the gate compares the context window's
5//! overall taint level against the tool's clearance level.
6
7use 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/// The user's resolution of a blocked outbound tool call.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum GateResolution {
18    /// Allow this one call.
19    AllowOnce,
20    /// Allow this tool for the rest of the run (session allow).
21    AlwaysAllow,
22    /// Deny the call - it is not executed; the model gets a blocked result.
23    Deny,
24}
25
26/// Injected resolver used when the gate blocks an outbound tool call.
27///
28/// The runtime cannot prompt the user itself (no stdin/IPC), so the CLI
29/// provides an implementation that asks via the dashboard/stdin and returns
30/// the user's decision. Mirrors how tool execution is injected as a closure.
31#[async_trait::async_trait]
32pub trait GatePrompt: Send + Sync {
33    /// Ask the user how to resolve a blocked outbound call. Implementations
34    /// should default to [`GateResolution::Deny`] when no answer is available.
35    async fn resolve(&self, decision: &GateDecision) -> GateResolution;
36}
37
38/// Type alias for a scripted rule checker function.
39/// Takes (tool_name, target, taint_level) and returns Some(script_name) if the rule allows.
40/// `Send + Sync` so a boxed checker can live in a shared-world resource.
41pub type ScriptRuleChecker = dyn Fn(&str, Option<&str>, TaintLevel) -> Option<String> + Send + Sync;
42
43/// Taint gate - checks whether a tool invocation is allowed given the
44/// current taint state of the context window. Attached per-agent (as an ECS
45/// component) when the agent's blueprint opts into taint tracking.
46#[derive(Debug, Clone, bevy_ecs::component::Component)]
47pub struct TaintGate {
48    /// Security configuration.
49    config: SecurityConfig,
50    /// Per-tool classification overrides (from agent.leviath or user policy).
51    tool_overrides: HashMap<String, ToolClassification>,
52    /// Audit log of gate events.
53    audit_log: Vec<GateEvent>,
54}
55
56impl TaintGate {
57    /// Create a new taint gate with the given security config.
58    pub fn new(config: SecurityConfig) -> Self {
59        Self {
60            config,
61            tool_overrides: HashMap::new(),
62            audit_log: Vec::new(),
63        }
64    }
65
66    /// Create a disabled taint gate (no tracking, no gating).
67    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    /// Get the security config.
78    pub fn config(&self) -> &SecurityConfig {
79        &self.config
80    }
81
82    /// Register a tool classification override.
83    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    /// Get the classification for a tool (override first, then built-in default).
92    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    /// Apply the `[mcp_overrides]` section of policy.toml to this gate.
100    ///
101    /// Each override starts from the tool's current classification and
102    /// replaces only the fields it sets, keyed the same `server.tool` way MCP
103    /// tools are named at dispatch. An unrecognized `direction` string keeps
104    /// the existing direction and warns, rather than silently reclassifying
105    /// a security property.
106    ///
107    /// Called at gate construction. A later session-scoped "always allow"
108    /// writes the same map through [`Self::set_tool_classification`], so the
109    /// user's runtime decision still wins over the config file.
110    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    /// Check the gate for a traditional-mode tool invocation.
137    ///
138    /// In traditional mode, the overall taint (max across all regions) is
139    /// compared against the tool's clearance.
140    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        // Non-outbound tools always pass
161        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        // Get overall taint level
174        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            // Identify source regions contributing to the taint
188            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    /// Check the gate with allowlist and scripted rule support.
214    ///
215    /// This is the full gate check that runs:
216    /// 1. Basic taint vs clearance check
217    /// 2. If blocked, check static allowlist rules
218    /// 3. If still blocked, check scripted rules (if checker provided)
219    /// 4. Return final decision
220    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        // Extract taint level from the blocked decision. `decision` is
236        // necessarily `Blocked` here: `GateDecision` has only two variants and
237        // the `Allowed` case already returned above.
238        let (taint, clearance) = decision
239            .blocked_levels()
240            .expect("infallible: a non-Allowed GateDecision is always Blocked");
241
242        // Check static allowlist rules
243        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        // Check scripted rules
258        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    /// Record an allow decision from the user or allowlist.
276    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    /// Record a deny decision (the user or a default policy denied the call).
288    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    /// Apply the user's resolution of a blocked outbound call: record the
300    /// audit event and, for `AlwaysAllow`, raise the tool's clearance for the
301    /// rest of the run. Returns `Some((tool_id, message))` when the call is
302    /// denied (and must be skipped), or `None` when it should execute.
303    ///
304    /// Synchronous so it can be unit-tested directly, keeping the async run-loop
305    /// path that awaits the prompt as thin as possible.
306    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    /// Get the audit log.
360    pub fn audit_log(&self) -> &[GateEvent] {
361        &self.audit_log
362    }
363
364    fn log_event(
365        &mut self,
366        agent_id: &str,
367        tool_name: &str,
368        taint_level: TaintLevel,
369        clearance: TaintLevel,
370        allowed: bool,
371        decision_source: GateDecisionSource,
372    ) {
373        self.audit_log.push(GateEvent {
374            timestamp: chrono::Utc::now().timestamp(),
375            agent_id: agent_id.to_string(),
376            tool_name: tool_name.to_string(),
377            taint_level,
378            clearance,
379            allowed,
380            decision_source,
381        });
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use leviath_core::taint::ToolDirection;
389    use leviath_core::{Region, RegionKind};
390
391    fn make_window_with_taint(taint: TaintLevel) -> ContextWindow {
392        let mut window = ContextWindow::new(10000);
393        let region =
394            Region::new("conv".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
395        window.add_region(region);
396        if taint != TaintLevel::Public {
397            window
398                .add_tainted_to_region("conv", "data".to_string(), 10, taint)
399                .unwrap();
400        }
401        window
402    }
403
404    #[test]
405    fn gate_disabled_always_allows() {
406        let mut gate = TaintGate::disabled();
407        assert!(!gate.config().taint_tracking);
408
409        let window = make_window_with_taint(TaintLevel::Private);
410        let decision = gate.check_traditional("agent-1", "shell", &window);
411        assert!(decision.is_allowed());
412        assert_eq!(gate.audit_log().len(), 1);
413        assert_eq!(
414            gate.audit_log()[0].decision_source,
415            GateDecisionSource::TaintDisabled
416        );
417    }
418
419    #[test]
420    fn gate_allows_non_outbound_tool() {
421        let mut gate = TaintGate::new(SecurityConfig::default());
422        let window = make_window_with_taint(TaintLevel::Private);
423        let decision = gate.check_traditional("agent-1", "read_file", &window);
424        assert!(decision.is_allowed());
425    }
426
427    #[test]
428    fn gate_allows_outbound_when_taint_within_clearance() {
429        let mut gate = TaintGate::new(SecurityConfig::default());
430        let window = make_window_with_taint(TaintLevel::Public);
431        let decision = gate.check_traditional("agent-1", "shell", &window);
432        assert!(decision.is_allowed());
433    }
434
435    #[test]
436    fn gate_blocks_outbound_when_taint_exceeds_clearance() {
437        let mut gate = TaintGate::new(SecurityConfig::default());
438        let window = make_window_with_taint(TaintLevel::Private);
439        let decision = gate.check_traditional("agent-1", "shell", &window);
440        assert!(!decision.is_allowed());
441        assert_eq!(
442            decision,
443            GateDecision::Blocked {
444                taint_level: TaintLevel::Private,
445                clearance: TaintLevel::Public,
446                source_regions: vec!["conv".to_string()],
447                tool_name: "shell".to_string(),
448            }
449        );
450    }
451
452    #[test]
453    fn gate_uses_tool_override() {
454        let mut gate = TaintGate::new(SecurityConfig::default());
455        gate.set_tool_classification(
456            "shell".to_string(),
457            ToolClassification::new(
458                TaintLevel::Public,
459                ToolDirection::Outbound,
460                TaintLevel::Private, // relaxed clearance
461            ),
462        );
463        let window = make_window_with_taint(TaintLevel::Private);
464        let decision = gate.check_traditional("agent-1", "shell", &window);
465        assert!(decision.is_allowed());
466    }
467
468    #[test]
469    fn apply_mcp_overrides_replaces_only_the_set_fields() {
470        let mut gate = TaintGate::new(SecurityConfig::default());
471        let before = gate.tool_classification("srv.notify");
472        let overrides = std::collections::HashMap::from([(
473            "srv.notify".to_string(),
474            leviath_core::policy::McpToolOverride {
475                sensitivity: Some(TaintLevel::Private),
476                direction: None,
477                clearance: None,
478            },
479        )]);
480        gate.apply_mcp_overrides(&overrides);
481        let after = gate.tool_classification("srv.notify");
482        assert_eq!(after.sensitivity, TaintLevel::Private);
483        assert_eq!(after.direction, before.direction);
484        assert_eq!(after.clearance, before.clearance);
485    }
486
487    #[test]
488    fn apply_mcp_overrides_parses_direction_and_clearance() {
489        let mut gate = TaintGate::new(SecurityConfig::default());
490        let overrides = std::collections::HashMap::from([(
491            "srv.post".to_string(),
492            leviath_core::policy::McpToolOverride {
493                sensitivity: None,
494                direction: Some("outbound".to_string()),
495                clearance: Some(TaintLevel::Internal),
496            },
497        )]);
498        gate.apply_mcp_overrides(&overrides);
499        let after = gate.tool_classification("srv.post");
500        assert_eq!(after.direction, ToolDirection::Outbound);
501        assert_eq!(after.clearance, TaintLevel::Internal);
502    }
503
504    #[test]
505    fn apply_mcp_overrides_keeps_direction_on_unrecognized_string() {
506        let mut gate = TaintGate::new(SecurityConfig::default());
507        let before = gate.tool_classification("srv.odd");
508        let overrides = std::collections::HashMap::from([(
509            "srv.odd".to_string(),
510            leviath_core::policy::McpToolOverride {
511                sensitivity: None,
512                direction: Some("sideways".to_string()),
513                clearance: None,
514            },
515        )]);
516        gate.apply_mcp_overrides(&overrides);
517        // A typo must not silently reclassify a security property.
518        assert_eq!(
519            gate.tool_classification("srv.odd").direction,
520            before.direction
521        );
522    }
523
524    #[test]
525    fn session_approval_still_wins_over_an_mcp_override() {
526        let mut gate = TaintGate::new(SecurityConfig::default());
527        let overrides = std::collections::HashMap::from([(
528            "srv.send".to_string(),
529            leviath_core::policy::McpToolOverride {
530                sensitivity: None,
531                direction: Some("outbound".to_string()),
532                clearance: Some(TaintLevel::Public),
533            },
534        )]);
535        gate.apply_mcp_overrides(&overrides);
536        // The user's later "always allow" writes the same map and replaces
537        // the config-file entry.
538        gate.set_tool_classification(
539            "srv.send".to_string(),
540            ToolClassification::new(
541                TaintLevel::Public,
542                ToolDirection::Outbound,
543                TaintLevel::Private,
544            ),
545        );
546        assert_eq!(
547            gate.tool_classification("srv.send").clearance,
548            TaintLevel::Private
549        );
550    }
551
552    #[test]
553    fn gate_blocked_identifies_source_regions() {
554        let mut gate = TaintGate::new(SecurityConfig::default());
555        let mut window = ContextWindow::new(10000);
556        let r1 =
557            Region::new("clean".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
558        let r2 =
559            Region::new("dirty".to_string(), RegionKind::Temporary, 5000).with_taint_tracking();
560        window.add_region(r1);
561        window.add_region(r2);
562
563        window
564            .add_tainted_to_region("clean", "ok".to_string(), 5, TaintLevel::Public)
565            .unwrap();
566        window
567            .add_tainted_to_region("dirty", "secret".to_string(), 5, TaintLevel::Private)
568            .unwrap();
569
570        let decision = gate.check_traditional("agent-1", "shell", &window);
571        // Only the Private "dirty" region exceeds shell's Public clearance;
572        // the Public "clean" region is not reported.
573        assert_eq!(
574            decision,
575            GateDecision::Blocked {
576                taint_level: TaintLevel::Private,
577                clearance: TaintLevel::Public,
578                source_regions: vec!["dirty".to_string()],
579                tool_name: "shell".to_string(),
580            }
581        );
582    }
583
584    #[test]
585    fn gate_audit_log_records_events() {
586        let mut gate = TaintGate::new(SecurityConfig::default());
587        let window = make_window_with_taint(TaintLevel::Public);
588
589        gate.check_traditional("agent-1", "shell", &window);
590        gate.check_traditional("agent-1", "read_file", &window);
591
592        assert_eq!(gate.audit_log().len(), 2);
593        assert!(gate.audit_log()[0].allowed);
594        assert!(gate.audit_log()[1].allowed);
595    }
596
597    #[test]
598    fn gate_record_allow() {
599        let mut gate = TaintGate::new(SecurityConfig::default());
600        gate.record_allow(
601            "agent-1",
602            "shell",
603            TaintLevel::Private,
604            TaintLevel::Public,
605            GateDecisionSource::UserAllowOnce,
606        );
607        assert_eq!(gate.audit_log().len(), 1);
608        assert!(gate.audit_log()[0].allowed);
609        assert_eq!(
610            gate.audit_log()[0].decision_source,
611            GateDecisionSource::UserAllowOnce
612        );
613    }
614
615    #[test]
616    fn gate_tool_classification_returns_override() {
617        let mut gate = TaintGate::new(SecurityConfig::default());
618        let custom = ToolClassification::new(
619            TaintLevel::Private,
620            ToolDirection::Outbound,
621            TaintLevel::Private,
622        );
623        gate.set_tool_classification("my_tool".to_string(), custom.clone());
624        assert_eq!(gate.tool_classification("my_tool"), custom);
625    }
626
627    #[test]
628    fn gate_tool_classification_falls_back_to_builtin() {
629        let gate = TaintGate::new(SecurityConfig::default());
630        let tc = gate.tool_classification("read_file");
631        assert_eq!(tc.direction, ToolDirection::Inbound);
632    }
633
634    #[test]
635    fn gate_new_and_config() {
636        let config = SecurityConfig {
637            taint_tracking: true,
638        };
639        let gate = TaintGate::new(config.clone());
640        assert!(gate.config().taint_tracking);
641        assert!(gate.config().taint_tracking);
642    }
643
644    // ─── Policy-aware gate check ────────────────────────────────────────────
645
646    #[test]
647    fn gate_with_policy_allows_via_allowlist() {
648        let mut gate = TaintGate::new(SecurityConfig::default());
649        let window = make_window_with_taint(TaintLevel::Private);
650        let policy = leviath_core::PolicyConfig {
651            allowlist: vec![leviath_core::AllowlistRule {
652                tool: "shell".into(),
653                to: vec![],
654                channel: vec![],
655                max_sensitivity: TaintLevel::Private,
656            }],
657            mcp_overrides: Default::default(),
658        };
659
660        let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
661        assert!(decision.is_allowed());
662
663        // Should have logged an allowlist allow
664        let last = gate.audit_log().last().unwrap();
665        assert!(last.allowed);
666        // The single allowlist rule (index 0) matched.
667        assert_eq!(
668            last.decision_source,
669            GateDecisionSource::AllowlistRule { rule_index: 0 }
670        );
671    }
672
673    #[test]
674    fn gate_with_policy_allows_via_scripted_rule() {
675        let mut gate = TaintGate::new(SecurityConfig::default());
676        let window = make_window_with_taint(TaintLevel::Private);
677        let policy = leviath_core::PolicyConfig::default(); // empty allowlist
678
679        let checker = |tool: &str, _target: Option<&str>, _taint: TaintLevel| -> Option<String> {
680            (tool == "shell").then(|| "company_rule.rhai".to_string())
681        };
682
683        let decision =
684            gate.check_with_policy("agent-1", "shell", &window, None, &policy, Some(&checker));
685        assert!(decision.is_allowed());
686
687        let last = gate.audit_log().last().unwrap();
688        assert_eq!(
689            last.decision_source,
690            GateDecisionSource::ScriptedRule {
691                script_name: "company_rule.rhai".to_string()
692            }
693        );
694    }
695
696    #[test]
697    fn gate_with_policy_blocks_when_no_rule_matches() {
698        let mut gate = TaintGate::new(SecurityConfig::default());
699        let window = make_window_with_taint(TaintLevel::Private);
700        let policy = leviath_core::PolicyConfig::default();
701
702        let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
703        assert!(!decision.is_allowed());
704    }
705
706    #[test]
707    fn gate_with_policy_passes_through_when_already_allowed() {
708        let mut gate = TaintGate::new(SecurityConfig::default());
709        let window = make_window_with_taint(TaintLevel::Public);
710        let policy = leviath_core::PolicyConfig::default();
711
712        let decision = gate.check_with_policy("agent-1", "shell", &window, None, &policy, None);
713        assert!(decision.is_allowed());
714    }
715
716    #[test]
717    fn gate_with_policy_target_pattern_matching() {
718        let mut gate = TaintGate::new(SecurityConfig::default());
719        gate.set_tool_classification(
720            "send_email".to_string(),
721            ToolClassification::new(
722                TaintLevel::Public,
723                ToolDirection::Outbound,
724                TaintLevel::Public,
725            ),
726        );
727        let window = make_window_with_taint(TaintLevel::Private);
728        let policy = leviath_core::PolicyConfig {
729            allowlist: vec![leviath_core::AllowlistRule {
730                tool: "send_email".into(),
731                to: vec!["megan@*".into()],
732                channel: vec![],
733                max_sensitivity: TaintLevel::Private,
734            }],
735            mcp_overrides: Default::default(),
736        };
737
738        // Should match megan@ pattern
739        let decision = gate.check_with_policy(
740            "agent-1",
741            "send_email",
742            &window,
743            Some("megan@work.com"),
744            &policy,
745            None,
746        );
747        assert!(decision.is_allowed());
748
749        // Should not match bob@
750        let decision2 = gate.check_with_policy(
751            "agent-1",
752            "send_email",
753            &window,
754            Some("bob@work.com"),
755            &policy,
756            None,
757        );
758        assert!(!decision2.is_allowed());
759    }
760
761    // ─── Additional TaintGate tests ────────────────────────────────────────
762
763    #[test]
764    fn gate_check_traditional_with_internal_taint() {
765        let mut gate = TaintGate::new(SecurityConfig::default());
766        let window = make_window_with_taint(TaintLevel::Internal);
767        let decision = gate.check_traditional("agent-1", "shell", &window);
768        // Internal > Public clearance for shell, so should be blocked
769        assert!(!decision.is_allowed());
770        assert_eq!(
771            decision,
772            GateDecision::Blocked {
773                taint_level: TaintLevel::Internal,
774                clearance: TaintLevel::Public,
775                source_regions: vec!["conv".to_string()],
776                tool_name: "shell".to_string(),
777            }
778        );
779    }
780
781    #[test]
782    fn gate_audit_log_records_blocked_events() {
783        let mut gate = TaintGate::new(SecurityConfig::default());
784        let window = make_window_with_taint(TaintLevel::Private);
785
786        gate.check_traditional("agent-1", "shell", &window);
787        assert_eq!(gate.audit_log().len(), 1);
788        assert!(!gate.audit_log()[0].allowed);
789        assert_eq!(gate.audit_log()[0].tool_name, "shell");
790        assert_eq!(gate.audit_log()[0].taint_level, TaintLevel::Private);
791        // A clearance block is an automatic decision, not a user denial: the
792        // user's choice (if any) is logged later by `apply_resolution`.
793        assert_eq!(
794            gate.audit_log()[0].decision_source,
795            GateDecisionSource::AutoBlock,
796        );
797    }
798
799    #[test]
800    fn gate_with_policy_scripted_rule_non_matching_tool() {
801        let mut gate = TaintGate::new(SecurityConfig::default());
802        let window = make_window_with_taint(TaintLevel::Private);
803        let policy = leviath_core::PolicyConfig::default();
804
805        let checker = |_tool: &str, _target: Option<&str>, _taint: TaintLevel| -> Option<String> {
806            None // never matches
807        };
808
809        let decision =
810            gate.check_with_policy("agent-1", "shell", &window, None, &policy, Some(&checker));
811        assert!(!decision.is_allowed());
812    }
813
814    #[test]
815    fn gate_with_policy_non_outbound_skips_policy_check() {
816        let mut gate = TaintGate::new(SecurityConfig::default());
817        let window = make_window_with_taint(TaintLevel::Private);
818        let policy = leviath_core::PolicyConfig::default();
819
820        // read_file is non-outbound, so policy check is never reached
821        let decision = gate.check_with_policy("agent-1", "read_file", &window, None, &policy, None);
822        assert!(decision.is_allowed());
823    }
824
825    #[test]
826    fn gate_multiple_tool_overrides() {
827        let mut gate = TaintGate::new(SecurityConfig::default());
828        gate.set_tool_classification(
829            "tool_a".to_string(),
830            ToolClassification::new(
831                TaintLevel::Public,
832                ToolDirection::Outbound,
833                TaintLevel::Internal,
834            ),
835        );
836        gate.set_tool_classification(
837            "tool_b".to_string(),
838            ToolClassification::new(
839                TaintLevel::Public,
840                ToolDirection::Outbound,
841                TaintLevel::Private,
842            ),
843        );
844
845        let window = make_window_with_taint(TaintLevel::Private);
846
847        // tool_a has Internal clearance - should be blocked by Private taint
848        let decision_a = gate.check_traditional("agent-1", "tool_a", &window);
849        assert!(!decision_a.is_allowed());
850
851        // tool_b has Private clearance - should be allowed
852        let decision_b = gate.check_traditional("agent-1", "tool_b", &window);
853        assert!(decision_b.is_allowed());
854    }
855
856    // ─── apply_resolution (synchronous resolution handling) ─────────────────
857
858    #[test]
859    fn apply_resolution_allow_once_records_and_executes() {
860        let mut gate = TaintGate::new(SecurityConfig::default());
861        let out = gate.apply_resolution(
862            "a",
863            "shell",
864            "call1",
865            TaintLevel::Private,
866            TaintLevel::Public,
867            GateResolution::AllowOnce,
868        );
869        assert!(out.is_none()); // execute
870        let allow = gate
871            .audit_log()
872            .iter()
873            .find(|e| e.allowed)
874            .expect("an allowed event should be logged");
875        assert_eq!(allow.decision_source, GateDecisionSource::UserAllowOnce);
876    }
877
878    #[test]
879    fn apply_resolution_always_allow_raises_clearance() {
880        let mut gate = TaintGate::new(SecurityConfig::default());
881        let out = gate.apply_resolution(
882            "a",
883            "shell",
884            "call1",
885            TaintLevel::Private,
886            TaintLevel::Public,
887            GateResolution::AlwaysAllow,
888        );
889        assert!(out.is_none());
890        // Clearance raised so future calls of this tool auto-pass.
891        assert_eq!(
892            gate.tool_classification("shell").clearance,
893            TaintLevel::Private
894        );
895        let allow = gate
896            .audit_log()
897            .iter()
898            .find(|e| e.allowed)
899            .expect("an allowed event should be logged");
900        assert_eq!(allow.decision_source, GateDecisionSource::UserAlwaysAllow);
901    }
902
903    #[test]
904    fn apply_resolution_deny_returns_blocked_result() {
905        let mut gate = TaintGate::new(SecurityConfig::default());
906        let out = gate.apply_resolution(
907            "a",
908            "shell",
909            "call1",
910            TaintLevel::Private,
911            TaintLevel::Public,
912            GateResolution::Deny,
913        );
914        let (id, msg) = out.expect("deny yields a blocked result");
915        assert_eq!(id, "call1");
916        assert!(msg.contains("[blocked]") && msg.contains("shell"));
917        let deny = gate
918            .audit_log()
919            .iter()
920            .find(|e| !e.allowed)
921            .expect("a denied event should be logged");
922        assert_eq!(deny.decision_source, GateDecisionSource::UserDenied);
923    }
924}