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    #[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, // relaxed clearance
462            ),
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        // A typo must not silently reclassify a security property.
519        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        // The user's later "always allow" writes the same map and replaces
538        // the config-file entry.
539        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        // Only the Private "dirty" region exceeds shell's Public clearance;
573        // the Public "clean" region is not reported.
574        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    // ─── Policy-aware gate check ────────────────────────────────────────────
646
647    #[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        // Should have logged an allowlist allow
665        let last = gate.audit_log().last().unwrap();
666        assert!(last.allowed);
667        // The single allowlist rule (index 0) matched.
668        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(); // empty allowlist
679
680        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        // Should match megan@ pattern
740        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        // Should not match bob@
751        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    // ─── Additional TaintGate tests ────────────────────────────────────────
763
764    #[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        // Internal > Public clearance for shell, so should be blocked
770        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        // A clearance block is an automatic decision, not a user denial: the
793        // user's choice (if any) is logged later by `apply_resolution`.
794        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 // never matches
808        };
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        // read_file is non-outbound, so policy check is never reached
822        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        // tool_a has Internal clearance - should be blocked by Private taint
849        let decision_a = gate.check_traditional("agent-1", "tool_a", &window);
850        assert!(!decision_a.is_allowed());
851
852        // tool_b has Private clearance - should be allowed
853        let decision_b = gate.check_traditional("agent-1", "tool_b", &window);
854        assert!(decision_b.is_allowed());
855    }
856
857    // ─── apply_resolution (synchronous resolution handling) ─────────────────
858
859    #[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()); // execute
871        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        // Clearance raised so future calls of this tool auto-pass.
892        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}