Skip to main content

atman_runtime/
trust.rs

1use crate::tool::ApprovalLevel;
2
3/// Trust mode controls how aggressively tools auto-approve.
4#[derive(
5    Debug,
6    Clone,
7    Copy,
8    PartialEq,
9    Eq,
10    Default,
11    serde::Serialize,
12    serde::Deserialize,
13    documented::DocumentedVariants,
14)]
15#[serde(rename_all = "lowercase")]
16pub enum TrustMode {
17    /// Auto-approve only Tier::Zero (read-only) tools; everything else needs manual approval.
18    Calm,
19    #[default]
20    /// Auto-approve Tier::Zero and Tier::One; Tier::Two+ needs approval.
21    Steady,
22    /// Auto-approve up to Tier::Two; Tier::Three+ needs approval.
23    Eager,
24    /// Auto-approve everything including dangerous operations.
25    Reckless,
26}
27
28impl TrustMode {
29    pub fn auto_ceiling(self) -> ApprovalLevel {
30        match self {
31            Self::Calm => ApprovalLevel::Auto,
32            Self::Steady => ApprovalLevel::Approve,
33            Self::Eager | Self::Reckless => ApprovalLevel::Dangerous,
34        }
35    }
36
37    pub fn sandbox_enabled(self) -> bool {
38        !matches!(self, Self::Reckless)
39    }
40
41    pub fn level(self) -> u8 {
42        match self {
43            Self::Calm => 1,
44            Self::Steady => 2,
45            Self::Eager => 3,
46            Self::Reckless => 4,
47        }
48    }
49
50    pub fn needs_warning(self) -> bool {
51        matches!(self, Self::Eager | Self::Reckless)
52    }
53
54    pub fn warning(self, display: &ModeDisplay) -> Option<String> {
55        match self {
56            Self::Eager => Some(format!(
57                "⚠ {} mode: sandbox guards bash/fs. Workspace-internal ops auto-approved. \
58                 Network is unrestricted. Operations touching outside the workspace follow \
59                 the outside switch (deny / approve / allow).",
60                display.name
61            )),
62            Self::Reckless => Some(format!(
63                "⚠ {} mode: sandbox is off. The agent can read/write outside the workspace, \
64                 run arbitrary commands, and access the network — all without confirmation. \
65                 Make sure you understand the risk.\n\n\
66                 Recommended only for one-off / sandbox projects, not for production repos \
67                 or directories with sensitive data.",
68                display.name
69            )),
70            _ => None,
71        }
72    }
73
74    pub fn all() -> [TrustMode; 4] {
75        [Self::Calm, Self::Steady, Self::Eager, Self::Reckless]
76    }
77}
78
79impl std::str::FromStr for TrustMode {
80    type Err = String;
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        match s.to_ascii_lowercase().as_str() {
83            "calm" | "1" => Ok(Self::Calm),
84            "steady" | "2" | "default" => Ok(Self::Steady),
85            "eager" | "3" => Ok(Self::Eager),
86            "reckless" | "yolo" | "4" => Ok(Self::Reckless),
87            other => Err(format!("unknown trust mode `{other}`")),
88        }
89    }
90}
91
92/// What to do when a tool outside the flow's declared tool list is invoked (Eager mode only).
93#[derive(
94    Debug,
95    Clone,
96    Copy,
97    PartialEq,
98    Eq,
99    Default,
100    serde::Serialize,
101    serde::Deserialize,
102    documented::DocumentedVariants,
103)]
104#[serde(rename_all = "lowercase")]
105pub enum OutsideBehavior {
106    /// Deny any tool not in the declared list.
107    Deny,
108    #[default]
109    /// Prompt the user to approve undeclared tools.
110    Approve,
111    /// Allow all tools without prompting.
112    Allow,
113}
114
115impl OutsideBehavior {
116    pub fn all() -> [Self; 3] {
117        [Self::Deny, Self::Approve, Self::Allow]
118    }
119
120    pub fn next(self) -> Self {
121        match self {
122            Self::Deny => Self::Approve,
123            Self::Approve => Self::Allow,
124            Self::Allow => Self::Deny,
125        }
126    }
127}
128
129impl std::str::FromStr for OutsideBehavior {
130    type Err = String;
131    fn from_str(s: &str) -> Result<Self, Self::Err> {
132        match s.to_ascii_lowercase().as_str() {
133            "deny" => Ok(Self::Deny),
134            "approve" | "confirm" => Ok(Self::Approve),
135            "allow" | "auto" => Ok(Self::Allow),
136            other => Err(format!("unknown outside behavior `{other}`")),
137        }
138    }
139}
140
141/// Display theme for trust mode labels in the TUI.
142#[derive(
143    Debug,
144    Clone,
145    Copy,
146    PartialEq,
147    Eq,
148    serde::Serialize,
149    serde::Deserialize,
150    Default,
151    documented::DocumentedVariants,
152)]
153#[serde(rename_all = "lowercase")]
154pub enum Theme {
155    #[default]
156    /// Standard English labels.
157    Default,
158    /// Wuxia (martial arts) themed labels.
159    Wuxia,
160    /// Animal-themed labels.
161    Animal,
162    /// Weather-themed labels.
163    Weather,
164    /// Drink-themed labels.
165    Drink,
166}
167
168impl std::str::FromStr for Theme {
169    type Err = String;
170    fn from_str(s: &str) -> Result<Self, Self::Err> {
171        match s.to_ascii_lowercase().as_str() {
172            "default" => Ok(Self::Default),
173            "wuxia" => Ok(Self::Wuxia),
174            "animal" => Ok(Self::Animal),
175            "weather" => Ok(Self::Weather),
176            "drink" => Ok(Self::Drink),
177            other => Err(format!("unknown theme `{other}`")),
178        }
179    }
180}
181
182impl std::fmt::Display for Theme {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        match self {
185            Self::Default => write!(f, "default"),
186            Self::Wuxia => write!(f, "wuxia"),
187            Self::Animal => write!(f, "animal"),
188            Self::Weather => write!(f, "weather"),
189            Self::Drink => write!(f, "drink"),
190        }
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum ModeColor {
196    Cyan,
197    Green,
198    Yellow,
199    Red,
200    Orange,
201}
202
203#[derive(Debug, Clone)]
204pub struct ModeDisplay {
205    pub name: &'static str,
206    pub emoji: &'static str,
207    pub color: ModeColor,
208    pub description: &'static str,
209}
210
211#[derive(Debug, Clone)]
212pub struct OutsideDisplay {
213    pub name: &'static str,
214    pub emoji: &'static str,
215    pub color: ModeColor,
216}
217
218impl Theme {
219    pub fn display(&self, mode: TrustMode) -> ModeDisplay {
220        match (self, mode) {
221            (Theme::Default, TrustMode::Calm) => ModeDisplay {
222                name: "calm",
223                emoji: "🌙",
224                color: ModeColor::Cyan,
225                description: "confirm every step",
226            },
227            (Theme::Default, TrustMode::Steady) => ModeDisplay {
228                name: "steady",
229                emoji: "✓",
230                color: ModeColor::Green,
231                description: "free inside workspace, confirm outside",
232            },
233            (Theme::Default, TrustMode::Eager) => ModeDisplay {
234                name: "eager",
235                emoji: "⚡",
236                color: ModeColor::Yellow,
237                description: "auto inside, outside switch controls",
238            },
239            (Theme::Default, TrustMode::Reckless) => ModeDisplay {
240                name: "reckless",
241                emoji: "🔥",
242                color: ModeColor::Red,
243                description: "all off, you decide",
244            },
245
246            (Theme::Wuxia, TrustMode::Calm) => ModeDisplay {
247                name: "守拙",
248                emoji: "🧘",
249                color: ModeColor::Cyan,
250                description: "大巧若拙,步步为营",
251            },
252            (Theme::Wuxia, TrustMode::Steady) => ModeDisplay {
253                name: "行云",
254                emoji: "☁️",
255                color: ModeColor::Green,
256                description: "行云流水,任意所至",
257            },
258            (Theme::Wuxia, TrustMode::Eager) => ModeDisplay {
259                name: "破竹",
260                emoji: "🎋",
261                color: ModeColor::Yellow,
262                description: "势如破竹,迎刃而解",
263            },
264            (Theme::Wuxia, TrustMode::Reckless) => ModeDisplay {
265                name: "逍遥",
266                emoji: "🕊️",
267                color: ModeColor::Red,
268                description: "逍遥御风,无招胜有招",
269            },
270
271            (Theme::Animal, TrustMode::Calm) => ModeDisplay {
272                name: "hedgehog",
273                emoji: "🦔",
274                color: ModeColor::Cyan,
275                description: "curls up, asks about everything",
276            },
277            (Theme::Animal, TrustMode::Steady) => ModeDisplay {
278                name: "cat",
279                emoji: "🐱",
280                color: ModeColor::Green,
281                description: "roams its territory, wary of strangers",
282            },
283            (Theme::Animal, TrustMode::Eager) => ModeDisplay {
284                name: "dog",
285                emoji: "🐶",
286                color: ModeColor::Yellow,
287                description: "fence guards, charges ahead",
288            },
289            (Theme::Animal, TrustMode::Reckless) => ModeDisplay {
290                name: "honey-badger",
291                emoji: "🦡",
292                color: ModeColor::Red,
293                description: "doesn't give a damn",
294            },
295
296            (Theme::Weather, TrustMode::Calm) => ModeDisplay {
297                name: "drizzle",
298                emoji: "🌧",
299                color: ModeColor::Cyan,
300                description: "light rain, step carefully",
301            },
302            (Theme::Weather, TrustMode::Steady) => ModeDisplay {
303                name: "clear",
304                emoji: "☀️",
305                color: ModeColor::Green,
306                description: "clear sky, normal pace",
307            },
308            (Theme::Weather, TrustMode::Eager) => ModeDisplay {
309                name: "storm",
310                emoji: "⛈",
311                color: ModeColor::Yellow,
312                description: "storm, coat on, push forward",
313            },
314            (Theme::Weather, TrustMode::Reckless) => ModeDisplay {
315                name: "tornado",
316                emoji: "🌪",
317                color: ModeColor::Red,
318                description: "tornado, hold nothing back",
319            },
320
321            (Theme::Drink, TrustMode::Calm) => ModeDisplay {
322                name: "water",
323                emoji: "💧",
324                color: ModeColor::Cyan,
325                description: "plain and safe",
326            },
327            (Theme::Drink, TrustMode::Steady) => ModeDisplay {
328                name: "coffee",
329                emoji: "☕",
330                color: ModeColor::Green,
331                description: "normal kick",
332            },
333            (Theme::Drink, TrustMode::Eager) => ModeDisplay {
334                name: "espresso",
335                emoji: "☕",
336                color: ModeColor::Yellow,
337                description: "double shot, go fast",
338            },
339            (Theme::Drink, TrustMode::Reckless) => ModeDisplay {
340                name: "bleach",
341                emoji: "🧪",
342                color: ModeColor::Red,
343                description: "drink it and it's gone",
344            },
345        }
346    }
347
348    pub fn outside_display(&self, outside: OutsideBehavior) -> OutsideDisplay {
349        match (self, outside) {
350            (Theme::Default, OutsideBehavior::Deny) => OutsideDisplay {
351                name: "deny",
352                emoji: "🔒",
353                color: ModeColor::Orange,
354            },
355            (Theme::Default, OutsideBehavior::Approve) => OutsideDisplay {
356                name: "approve",
357                emoji: "⚠️",
358                color: ModeColor::Yellow,
359            },
360            (Theme::Default, OutsideBehavior::Allow) => OutsideDisplay {
361                name: "allow",
362                emoji: "✅",
363                color: ModeColor::Green,
364            },
365
366            (Theme::Wuxia, OutsideBehavior::Deny) => OutsideDisplay {
367                name: "画地为牢",
368                emoji: "⛩️",
369                color: ModeColor::Orange,
370            },
371            (Theme::Wuxia, OutsideBehavior::Approve) => OutsideDisplay {
372                name: "请示",
373                emoji: "📜",
374                color: ModeColor::Yellow,
375            },
376            (Theme::Wuxia, OutsideBehavior::Allow) => OutsideDisplay {
377                name: "放行",
378                emoji: "🎋",
379                color: ModeColor::Green,
380            },
381
382            (Theme::Animal, OutsideBehavior::Deny) => OutsideDisplay {
383                name: "turtle",
384                emoji: "🐢",
385                color: ModeColor::Orange,
386            },
387            (Theme::Animal, OutsideBehavior::Approve) => OutsideDisplay {
388                name: "owl",
389                emoji: "🦉",
390                color: ModeColor::Yellow,
391            },
392            (Theme::Animal, OutsideBehavior::Allow) => OutsideDisplay {
393                name: "bird",
394                emoji: "🐦",
395                color: ModeColor::Green,
396            },
397
398            (Theme::Weather, OutsideBehavior::Deny) => OutsideDisplay {
399                name: "fog",
400                emoji: "🌫",
401                color: ModeColor::Orange,
402            },
403            (Theme::Weather, OutsideBehavior::Approve) => OutsideDisplay {
404                name: "cloud",
405                emoji: "☁️",
406                color: ModeColor::Yellow,
407            },
408            (Theme::Weather, OutsideBehavior::Allow) => OutsideDisplay {
409                name: "clear",
410                emoji: "☀️",
411                color: ModeColor::Green,
412            },
413
414            (Theme::Drink, OutsideBehavior::Deny) => OutsideDisplay {
415                name: "lock-in",
416                emoji: "🍺",
417                color: ModeColor::Orange,
418            },
419            (Theme::Drink, OutsideBehavior::Approve) => OutsideDisplay {
420                name: "card",
421                emoji: "💳",
422                color: ModeColor::Yellow,
423            },
424            (Theme::Drink, OutsideBehavior::Allow) => OutsideDisplay {
425                name: "open-tab",
426                emoji: "🧾",
427                color: ModeColor::Green,
428            },
429        }
430    }
431}
432
433/// User-configurable trust settings: mode, display theme, and outside-tool behavior.
434#[derive(
435    Debug,
436    Clone,
437    Default,
438    serde::Serialize,
439    serde::Deserialize,
440    documented::Documented,
441    documented::DocumentedFields,
442)]
443pub struct TrustConfig {
444    /// How aggressively tools are auto-approved.
445    #[serde(default)]
446    pub mode: TrustMode,
447    /// Display theme for trust mode labels.
448    #[serde(default)]
449    pub theme: Theme,
450    /// Behavior for tools outside the declared list (Eager mode only).
451    #[serde(default)]
452    pub outside: OutsideBehavior,
453}
454
455impl TrustConfig {
456    pub fn display(&self) -> ModeDisplay {
457        self.theme.display(self.mode)
458    }
459
460    pub fn outside_display(&self) -> OutsideDisplay {
461        self.theme.outside_display(self.outside)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn auto_ceiling_maps_correctly() {
471        assert_eq!(TrustMode::Calm.auto_ceiling(), ApprovalLevel::Auto);
472        assert_eq!(TrustMode::Steady.auto_ceiling(), ApprovalLevel::Approve);
473        assert_eq!(TrustMode::Eager.auto_ceiling(), ApprovalLevel::Dangerous);
474        assert_eq!(TrustMode::Reckless.auto_ceiling(), ApprovalLevel::Dangerous);
475    }
476
477    #[test]
478    fn sandbox_disabled_only_for_reckless() {
479        assert!(TrustMode::Calm.sandbox_enabled());
480        assert!(TrustMode::Steady.sandbox_enabled());
481        assert!(TrustMode::Eager.sandbox_enabled());
482        assert!(!TrustMode::Reckless.sandbox_enabled());
483    }
484
485    #[test]
486    fn needs_warning_for_eager_and_reckless() {
487        assert!(!TrustMode::Calm.needs_warning());
488        assert!(!TrustMode::Steady.needs_warning());
489        assert!(TrustMode::Eager.needs_warning());
490        assert!(TrustMode::Reckless.needs_warning());
491    }
492
493    #[test]
494    fn warning_text_includes_mode_name() {
495        let cfg = TrustConfig {
496            mode: TrustMode::Eager,
497            theme: Theme::Default,
498            outside: OutsideBehavior::default(),
499        };
500        let display = cfg.display();
501        let warning = TrustMode::Eager.warning(&display).unwrap();
502        assert!(warning.contains("eager"));
503
504        let cfg2 = TrustConfig {
505            mode: TrustMode::Reckless,
506            theme: Theme::Animal,
507            outside: OutsideBehavior::default(),
508        };
509        let display2 = cfg2.display();
510        let warning2 = TrustMode::Reckless.warning(&display2).unwrap();
511        assert!(warning2.contains("honey-badger"));
512    }
513
514    #[test]
515    fn all_themes_produce_displays() {
516        let themes = [
517            Theme::Default,
518            Theme::Wuxia,
519            Theme::Animal,
520            Theme::Weather,
521            Theme::Drink,
522        ];
523        for theme in &themes {
524            for mode in &TrustMode::all() {
525                let d = theme.display(*mode);
526                assert!(!d.name.is_empty());
527                assert!(!d.emoji.is_empty());
528                assert!(!d.description.is_empty());
529            }
530            for outside in &OutsideBehavior::all() {
531                let d = theme.outside_display(*outside);
532                assert!(!d.name.is_empty());
533                assert!(!d.emoji.is_empty());
534            }
535        }
536    }
537
538    #[test]
539    fn mode_from_str_parses_all_variants() {
540        assert_eq!("calm".parse::<TrustMode>().unwrap(), TrustMode::Calm);
541        assert_eq!("steady".parse::<TrustMode>().unwrap(), TrustMode::Steady);
542        assert_eq!("eager".parse::<TrustMode>().unwrap(), TrustMode::Eager);
543        assert_eq!(
544            "reckless".parse::<TrustMode>().unwrap(),
545            TrustMode::Reckless
546        );
547        assert_eq!("yolo".parse::<TrustMode>().unwrap(), TrustMode::Reckless);
548        assert_eq!("1".parse::<TrustMode>().unwrap(), TrustMode::Calm);
549        assert_eq!("4".parse::<TrustMode>().unwrap(), TrustMode::Reckless);
550        assert!("unknown".parse::<TrustMode>().is_err());
551    }
552
553    #[test]
554    fn outside_from_str_parses_all_variants() {
555        assert_eq!(
556            "deny".parse::<OutsideBehavior>().unwrap(),
557            OutsideBehavior::Deny
558        );
559        assert_eq!(
560            "approve".parse::<OutsideBehavior>().unwrap(),
561            OutsideBehavior::Approve
562        );
563        assert_eq!(
564            "allow".parse::<OutsideBehavior>().unwrap(),
565            OutsideBehavior::Allow
566        );
567        assert_eq!(
568            "auto".parse::<OutsideBehavior>().unwrap(),
569            OutsideBehavior::Allow
570        );
571        assert!("unknown".parse::<OutsideBehavior>().is_err());
572    }
573
574    #[test]
575    fn outside_next_cycles() {
576        assert_eq!(OutsideBehavior::Deny.next(), OutsideBehavior::Approve);
577        assert_eq!(OutsideBehavior::Approve.next(), OutsideBehavior::Allow);
578        assert_eq!(OutsideBehavior::Allow.next(), OutsideBehavior::Deny);
579    }
580
581    #[test]
582    fn theme_from_str_parses_all_variants() {
583        assert_eq!("default".parse::<Theme>().unwrap(), Theme::Default);
584        assert_eq!("wuxia".parse::<Theme>().unwrap(), Theme::Wuxia);
585        assert_eq!("animal".parse::<Theme>().unwrap(), Theme::Animal);
586        assert_eq!("weather".parse::<Theme>().unwrap(), Theme::Weather);
587        assert_eq!("drink".parse::<Theme>().unwrap(), Theme::Drink);
588        assert!("unknown".parse::<Theme>().is_err());
589    }
590
591    #[test]
592    fn theme_display_roundtrip() {
593        for theme in &[
594            Theme::Default,
595            Theme::Wuxia,
596            Theme::Animal,
597            Theme::Weather,
598            Theme::Drink,
599        ] {
600            let s = theme.to_string();
601            let back: Theme = s.parse().unwrap();
602            assert_eq!(*theme, back);
603        }
604    }
605
606    #[test]
607    fn default_config_is_steady_approve() {
608        let cfg = TrustConfig::default();
609        assert_eq!(cfg.mode, TrustMode::Steady);
610        assert_eq!(cfg.theme, Theme::Default);
611        assert_eq!(cfg.outside, OutsideBehavior::Approve);
612    }
613
614    #[test]
615    fn wuxia_descriptions_are_chinese() {
616        let d = Theme::Wuxia.display(TrustMode::Calm);
617        assert!(d.description.contains("拙"));
618        let d = Theme::Wuxia.display(TrustMode::Steady);
619        assert!(d.description.contains("行云"));
620    }
621
622    #[test]
623    fn non_wuxia_descriptions_are_english() {
624        for mode in &TrustMode::all() {
625            let d = Theme::Default.display(*mode);
626            assert!(
627                d.description.is_ascii(),
628                "default theme should be ASCII: {}",
629                d.description
630            );
631            let d = Theme::Animal.display(*mode);
632            assert!(
633                d.description.is_ascii(),
634                "animal theme should be ASCII: {}",
635                d.description
636            );
637        }
638    }
639
640    #[test]
641    fn four_levels_ordered() {
642        assert_eq!(TrustMode::Calm.level(), 1);
643        assert_eq!(TrustMode::Steady.level(), 2);
644        assert_eq!(TrustMode::Eager.level(), 3);
645        assert_eq!(TrustMode::Reckless.level(), 4);
646    }
647}