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}