1use crate::tool::ApprovalLevel;
2
3#[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 Calm,
19 #[default]
20 Steady,
22 Eager,
24 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#[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,
108 #[default]
109 Approve,
111 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#[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 Default,
158 Wuxia,
160 Animal,
162 Weather,
164 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#[derive(
435 Debug,
436 Clone,
437 Default,
438 serde::Serialize,
439 serde::Deserialize,
440 documented::Documented,
441 documented::DocumentedFields,
442)]
443pub struct TrustConfig {
444 #[serde(default)]
446 pub mode: TrustMode,
447 #[serde(default)]
449 pub theme: Theme,
450 #[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}