Skip to main content

atman_runtime/
trust.rs

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