1#[derive(
3 Debug,
4 Clone,
5 Copy,
6 PartialEq,
7 Eq,
8 Default,
9 serde::Serialize,
10 serde::Deserialize,
11 documented::DocumentedVariants,
12)]
13#[serde(rename_all = "lowercase")]
14pub enum TrustMode {
15 Calm,
17 #[default]
18 Steady,
20 Eager,
22 Reckless,
24}
25
26impl TrustMode {
27 pub fn sandbox_enabled(self) -> bool {
28 !matches!(self, Self::Reckless)
29 }
30
31 pub fn level(self) -> u8 {
32 match self {
33 Self::Calm => 1,
34 Self::Steady => 2,
35 Self::Eager => 3,
36 Self::Reckless => 4,
37 }
38 }
39
40 pub fn needs_warning(self) -> bool {
41 matches!(self, Self::Eager | Self::Reckless)
42 }
43
44 pub fn warning(self, display: &ModeDisplay) -> Option<String> {
45 match self {
46 Self::Eager => Some(format!(
47 "⚠ {} mode: sandbox guards bash/fs. Workspace-internal ops auto-approved. \
48 Network is unrestricted. Escalated risks follow the configured \
49 escalation policy (deny / ask / allow).",
50 display.name
51 )),
52 Self::Reckless => Some(format!(
53 "⚠ {} mode: sandbox is off. The agent can read/write outside the workspace, \
54 run arbitrary commands, and access the network — all without confirmation. \
55 Make sure you understand the risk.\n\n\
56 Recommended only for one-off / sandbox projects, not for production repos \
57 or directories with sensitive data.",
58 display.name
59 )),
60 _ => None,
61 }
62 }
63
64 pub fn all() -> [TrustMode; 4] {
65 [Self::Calm, Self::Steady, Self::Eager, Self::Reckless]
66 }
67}
68
69impl std::str::FromStr for TrustMode {
70 type Err = String;
71 fn from_str(s: &str) -> Result<Self, Self::Err> {
72 match s.to_ascii_lowercase().as_str() {
73 "calm" | "1" => Ok(Self::Calm),
74 "steady" | "2" | "default" => Ok(Self::Steady),
75 "eager" | "3" => Ok(Self::Eager),
76 "reckless" | "yolo" | "4" => Ok(Self::Reckless),
77 other => Err(format!("unknown trust mode `{other}`")),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum PolicyAction {
86 Auto,
87 Ask,
88 Deny,
89}
90
91impl PolicyAction {
92 pub fn most_restrictive(self, other: Self) -> Self {
93 match (self, other) {
94 (Self::Deny, _) | (_, Self::Deny) => Self::Deny,
95 (Self::Ask, _) | (_, Self::Ask) => Self::Ask,
96 (Self::Auto, Self::Auto) => Self::Auto,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum EscalationPolicy {
105 Deny,
106 #[default]
107 Ask,
108 Allow,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum PolicyEscalation {
114 None,
115 Denied,
116 Pending,
117 Allowed,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct PolicyResolution {
123 pub action: PolicyAction,
124 pub escalation: PolicyEscalation,
125}
126
127impl EscalationPolicy {
128 pub fn next(self) -> Self {
129 match self {
130 Self::Deny => Self::Ask,
131 Self::Ask => Self::Allow,
132 Self::Allow => Self::Deny,
133 }
134 }
135
136 pub fn label(self) -> &'static str {
137 match self {
138 Self::Deny => "deny",
139 Self::Ask => "ask",
140 Self::Allow => "allow",
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ExecutionPolicy {
148 Controlled,
149 Unrestricted,
150}
151
152#[derive(
154 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
155)]
156#[serde(rename_all = "snake_case")]
157pub enum RiskKind {
158 WorkspaceExternal,
159 Network,
160 Irreversible,
161 FilesystemWrite,
162 ProcessSpawn,
163 RepositoryMutation,
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168pub struct TierPolicyOverrides {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub tier0: Option<PolicyAction>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub tier1: Option<PolicyAction>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub tier2: Option<PolicyAction>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub tier3: Option<PolicyAction>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub tier4: Option<PolicyAction>,
179}
180
181impl TierPolicyOverrides {
182 fn resolve(&self, tier: crate::tool::Tier) -> PolicyAction {
183 let configured = match tier {
184 crate::tool::Tier::Zero => self.tier0,
185 crate::tool::Tier::One => self.tier1,
186 crate::tool::Tier::Two => self.tier2,
187 crate::tool::Tier::Three => self.tier3,
188 crate::tool::Tier::Four => self.tier4,
189 };
190 configured.unwrap_or_else(|| default_eager_tier_action(tier))
191 }
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196pub struct TierPolicyConfig {
197 #[serde(default)]
198 pub eager: TierPolicyOverrides,
199}
200
201#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct RiskPolicyOverrides {
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub outside_workspace: Option<PolicyAction>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub network: Option<PolicyAction>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub irreversible: Option<PolicyAction>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub filesystem_write: Option<PolicyAction>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub process_spawn: Option<PolicyAction>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub repository_mutation: Option<PolicyAction>,
217}
218
219impl RiskPolicyOverrides {
220 fn resolve(&self, risk: RiskKind) -> PolicyAction {
221 match risk {
222 RiskKind::WorkspaceExternal => self.outside_workspace,
223 RiskKind::Network => self.network,
224 RiskKind::Irreversible => self.irreversible,
225 RiskKind::FilesystemWrite => self.filesystem_write,
226 RiskKind::ProcessSpawn => self.process_spawn,
227 RiskKind::RepositoryMutation => self.repository_mutation,
228 }
229 .unwrap_or(PolicyAction::Ask)
230 }
231}
232
233#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct RiskPolicyConfig {
237 #[serde(default)]
238 pub eager: RiskPolicyOverrides,
239}
240
241fn default_eager_tier_action(tier: crate::tool::Tier) -> PolicyAction {
242 match tier {
243 crate::tool::Tier::Zero | crate::tool::Tier::One => PolicyAction::Auto,
244 crate::tool::Tier::Two | crate::tool::Tier::Three | crate::tool::Tier::Four => {
245 PolicyAction::Ask
246 }
247 }
248}
249
250#[derive(
252 Debug,
253 Clone,
254 Copy,
255 PartialEq,
256 Eq,
257 serde::Serialize,
258 serde::Deserialize,
259 Default,
260 documented::DocumentedVariants,
261)]
262#[serde(rename_all = "lowercase")]
263pub enum Theme {
264 #[default]
265 Default,
267 Wuxia,
269 Animal,
271 Weather,
273 Drink,
275}
276
277impl std::str::FromStr for Theme {
278 type Err = String;
279 fn from_str(s: &str) -> Result<Self, Self::Err> {
280 match s.to_ascii_lowercase().as_str() {
281 "default" => Ok(Self::Default),
282 "wuxia" => Ok(Self::Wuxia),
283 "animal" => Ok(Self::Animal),
284 "weather" => Ok(Self::Weather),
285 "drink" => Ok(Self::Drink),
286 other => Err(format!("unknown theme `{other}`")),
287 }
288 }
289}
290
291impl std::fmt::Display for Theme {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 match self {
294 Self::Default => write!(f, "default"),
295 Self::Wuxia => write!(f, "wuxia"),
296 Self::Animal => write!(f, "animal"),
297 Self::Weather => write!(f, "weather"),
298 Self::Drink => write!(f, "drink"),
299 }
300 }
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ModeColor {
305 Cyan,
306 Green,
307 Yellow,
308 Red,
309 Orange,
310}
311
312#[derive(Debug, Clone)]
313pub struct ModeDisplay {
314 pub name: &'static str,
315 pub emoji: &'static str,
316 pub color: ModeColor,
317 pub description: &'static str,
318}
319
320#[derive(Debug, Clone)]
321pub struct EscalationDisplay {
322 pub name: &'static str,
323 pub emoji: &'static str,
324 pub color: ModeColor,
325}
326
327impl Theme {
328 pub fn display(&self, mode: TrustMode) -> ModeDisplay {
329 match (self, mode) {
330 (Theme::Default, TrustMode::Calm) => ModeDisplay {
331 name: "calm",
332 emoji: "🌙",
333 color: ModeColor::Cyan,
334 description: "confirm every step",
335 },
336 (Theme::Default, TrustMode::Steady) => ModeDisplay {
337 name: "steady",
338 emoji: "✓",
339 color: ModeColor::Green,
340 description: "auto low-risk work, confirm escalation",
341 },
342 (Theme::Default, TrustMode::Eager) => ModeDisplay {
343 name: "eager",
344 emoji: "⚡",
345 color: ModeColor::Yellow,
346 description: "auto routine work, escalation policy controls risk",
347 },
348 (Theme::Default, TrustMode::Reckless) => ModeDisplay {
349 name: "reckless",
350 emoji: "🔥",
351 color: ModeColor::Red,
352 description: "all off, you decide",
353 },
354
355 (Theme::Wuxia, TrustMode::Calm) => ModeDisplay {
356 name: "守拙",
357 emoji: "🧘",
358 color: ModeColor::Cyan,
359 description: "大巧若拙,步步为营",
360 },
361 (Theme::Wuxia, TrustMode::Steady) => ModeDisplay {
362 name: "行云",
363 emoji: "☁️",
364 color: ModeColor::Green,
365 description: "行云流水,任意所至",
366 },
367 (Theme::Wuxia, TrustMode::Eager) => ModeDisplay {
368 name: "破竹",
369 emoji: "🎋",
370 color: ModeColor::Yellow,
371 description: "势如破竹,迎刃而解",
372 },
373 (Theme::Wuxia, TrustMode::Reckless) => ModeDisplay {
374 name: "逍遥",
375 emoji: "🕊️",
376 color: ModeColor::Red,
377 description: "逍遥御风,无招胜有招",
378 },
379
380 (Theme::Animal, TrustMode::Calm) => ModeDisplay {
381 name: "hedgehog",
382 emoji: "🦔",
383 color: ModeColor::Cyan,
384 description: "curls up, asks about everything",
385 },
386 (Theme::Animal, TrustMode::Steady) => ModeDisplay {
387 name: "cat",
388 emoji: "🐱",
389 color: ModeColor::Green,
390 description: "roams its territory, wary of strangers",
391 },
392 (Theme::Animal, TrustMode::Eager) => ModeDisplay {
393 name: "dog",
394 emoji: "🐶",
395 color: ModeColor::Yellow,
396 description: "fence guards, charges ahead",
397 },
398 (Theme::Animal, TrustMode::Reckless) => ModeDisplay {
399 name: "honey-badger",
400 emoji: "🦡",
401 color: ModeColor::Red,
402 description: "doesn't give a damn",
403 },
404
405 (Theme::Weather, TrustMode::Calm) => ModeDisplay {
406 name: "drizzle",
407 emoji: "🌧",
408 color: ModeColor::Cyan,
409 description: "light rain, step carefully",
410 },
411 (Theme::Weather, TrustMode::Steady) => ModeDisplay {
412 name: "clear",
413 emoji: "☀️",
414 color: ModeColor::Green,
415 description: "clear sky, normal pace",
416 },
417 (Theme::Weather, TrustMode::Eager) => ModeDisplay {
418 name: "storm",
419 emoji: "⛈",
420 color: ModeColor::Yellow,
421 description: "storm, coat on, push forward",
422 },
423 (Theme::Weather, TrustMode::Reckless) => ModeDisplay {
424 name: "tornado",
425 emoji: "🌪",
426 color: ModeColor::Red,
427 description: "tornado, hold nothing back",
428 },
429
430 (Theme::Drink, TrustMode::Calm) => ModeDisplay {
431 name: "water",
432 emoji: "💧",
433 color: ModeColor::Cyan,
434 description: "plain and safe",
435 },
436 (Theme::Drink, TrustMode::Steady) => ModeDisplay {
437 name: "coffee",
438 emoji: "☕",
439 color: ModeColor::Green,
440 description: "normal kick",
441 },
442 (Theme::Drink, TrustMode::Eager) => ModeDisplay {
443 name: "espresso",
444 emoji: "☕",
445 color: ModeColor::Yellow,
446 description: "double shot, go fast",
447 },
448 (Theme::Drink, TrustMode::Reckless) => ModeDisplay {
449 name: "bleach",
450 emoji: "🧪",
451 color: ModeColor::Red,
452 description: "drink it and it's gone",
453 },
454 }
455 }
456
457 pub fn escalation_display(&self, escalation: EscalationPolicy) -> EscalationDisplay {
458 match (self, escalation) {
459 (Theme::Default, EscalationPolicy::Deny) => EscalationDisplay {
460 name: "deny",
461 emoji: "🔒",
462 color: ModeColor::Orange,
463 },
464 (Theme::Default, EscalationPolicy::Ask) => EscalationDisplay {
465 name: "ask",
466 emoji: "⚠️",
467 color: ModeColor::Yellow,
468 },
469 (Theme::Default, EscalationPolicy::Allow) => EscalationDisplay {
470 name: "allow",
471 emoji: "✅",
472 color: ModeColor::Green,
473 },
474 (Theme::Wuxia, EscalationPolicy::Deny) => EscalationDisplay {
475 name: "画地为牢",
476 emoji: "⛩️",
477 color: ModeColor::Orange,
478 },
479 (Theme::Wuxia, EscalationPolicy::Ask) => EscalationDisplay {
480 name: "请示",
481 emoji: "📜",
482 color: ModeColor::Yellow,
483 },
484 (Theme::Wuxia, EscalationPolicy::Allow) => EscalationDisplay {
485 name: "放行",
486 emoji: "🎋",
487 color: ModeColor::Green,
488 },
489 (Theme::Animal, EscalationPolicy::Deny) => EscalationDisplay {
490 name: "turtle",
491 emoji: "🐢",
492 color: ModeColor::Orange,
493 },
494 (Theme::Animal, EscalationPolicy::Ask) => EscalationDisplay {
495 name: "owl",
496 emoji: "🦉",
497 color: ModeColor::Yellow,
498 },
499 (Theme::Animal, EscalationPolicy::Allow) => EscalationDisplay {
500 name: "bird",
501 emoji: "🐦",
502 color: ModeColor::Green,
503 },
504 (Theme::Weather, EscalationPolicy::Deny) => EscalationDisplay {
505 name: "fog",
506 emoji: "🌫",
507 color: ModeColor::Orange,
508 },
509 (Theme::Weather, EscalationPolicy::Ask) => EscalationDisplay {
510 name: "cloud",
511 emoji: "☁️",
512 color: ModeColor::Yellow,
513 },
514 (Theme::Weather, EscalationPolicy::Allow) => EscalationDisplay {
515 name: "clear",
516 emoji: "☀️",
517 color: ModeColor::Green,
518 },
519 (Theme::Drink, EscalationPolicy::Deny) => EscalationDisplay {
520 name: "lock-in",
521 emoji: "🍺",
522 color: ModeColor::Orange,
523 },
524 (Theme::Drink, EscalationPolicy::Ask) => EscalationDisplay {
525 name: "card",
526 emoji: "💳",
527 color: ModeColor::Yellow,
528 },
529 (Theme::Drink, EscalationPolicy::Allow) => EscalationDisplay {
530 name: "open-tab",
531 emoji: "🧾",
532 color: ModeColor::Green,
533 },
534 }
535 }
536}
537
538#[derive(
540 Debug,
541 Clone,
542 Default,
543 PartialEq,
544 Eq,
545 serde::Serialize,
546 serde::Deserialize,
547 documented::Documented,
548 documented::DocumentedFields,
549)]
550#[serde(deny_unknown_fields)]
551pub struct TrustConfig {
552 #[serde(default)]
554 pub mode: TrustMode,
555 #[serde(default)]
557 pub theme: Theme,
558 #[serde(default)]
560 pub escalation: EscalationPolicy,
561 #[serde(default)]
563 pub tiers: TierPolicyConfig,
564 #[serde(default)]
566 pub risks: RiskPolicyConfig,
567}
568
569impl TrustConfig {
570 pub fn display(&self) -> ModeDisplay {
571 self.theme.display(self.mode)
572 }
573
574 pub fn execution_policy(&self) -> ExecutionPolicy {
575 match self.mode {
576 TrustMode::Reckless => ExecutionPolicy::Unrestricted,
577 TrustMode::Calm | TrustMode::Steady | TrustMode::Eager => ExecutionPolicy::Controlled,
578 }
579 }
580
581 pub fn resolve_tier(&self, tier: crate::tool::Tier) -> PolicyAction {
582 match self.mode {
583 TrustMode::Calm => match tier {
584 crate::tool::Tier::Zero => PolicyAction::Auto,
585 crate::tool::Tier::One
586 | crate::tool::Tier::Two
587 | crate::tool::Tier::Three
588 | crate::tool::Tier::Four => PolicyAction::Ask,
589 },
590 TrustMode::Steady => match tier {
591 crate::tool::Tier::Zero | crate::tool::Tier::One => PolicyAction::Auto,
592 crate::tool::Tier::Two | crate::tool::Tier::Three | crate::tool::Tier::Four => {
593 PolicyAction::Ask
594 }
595 },
596 TrustMode::Eager => self.tiers.eager.resolve(tier),
597 TrustMode::Reckless => PolicyAction::Auto,
598 }
599 }
600
601 pub fn resolve_risk(&self, risk: RiskKind) -> PolicyAction {
602 match self.mode {
603 TrustMode::Calm | TrustMode::Steady => PolicyAction::Ask,
604 TrustMode::Eager => self.risks.eager.resolve(risk),
605 TrustMode::Reckless => PolicyAction::Auto,
606 }
607 }
608
609 pub fn resolve_policy(
611 &self,
612 tier: crate::tool::Tier,
613 risks: impl IntoIterator<Item = RiskKind>,
614 ) -> PolicyAction {
615 self.resolve_policy_resolution(tier, risks).action
616 }
617
618 pub fn resolve_policy_resolution(
619 &self,
620 tier: crate::tool::Tier,
621 risks: impl IntoIterator<Item = RiskKind>,
622 ) -> PolicyResolution {
623 if self.execution_policy() == ExecutionPolicy::Unrestricted {
624 return PolicyResolution {
625 action: PolicyAction::Auto,
626 escalation: PolicyEscalation::None,
627 };
628 }
629
630 let action = risks
631 .into_iter()
632 .fold(self.resolve_tier(tier), |action, risk| {
633 action.most_restrictive(self.resolve_risk(risk))
634 });
635
636 if self.mode != TrustMode::Eager || action != PolicyAction::Ask {
637 return PolicyResolution {
638 action,
639 escalation: PolicyEscalation::None,
640 };
641 }
642 match self.escalation {
643 EscalationPolicy::Deny => PolicyResolution {
644 action: PolicyAction::Deny,
645 escalation: PolicyEscalation::Denied,
646 },
647 EscalationPolicy::Ask => PolicyResolution {
648 action: PolicyAction::Ask,
649 escalation: PolicyEscalation::Pending,
650 },
651 EscalationPolicy::Allow => PolicyResolution {
652 action: PolicyAction::Auto,
653 escalation: PolicyEscalation::Allowed,
654 },
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn sandbox_disabled_only_for_reckless() {
665 assert!(TrustMode::Calm.sandbox_enabled());
666 assert!(TrustMode::Steady.sandbox_enabled());
667 assert!(TrustMode::Eager.sandbox_enabled());
668 assert!(!TrustMode::Reckless.sandbox_enabled());
669 }
670
671 #[test]
672 fn needs_warning_for_eager_and_reckless() {
673 assert!(!TrustMode::Calm.needs_warning());
674 assert!(!TrustMode::Steady.needs_warning());
675 assert!(TrustMode::Eager.needs_warning());
676 assert!(TrustMode::Reckless.needs_warning());
677 }
678
679 #[test]
680 fn warning_text_includes_mode_name() {
681 let cfg = TrustConfig {
682 mode: TrustMode::Eager,
683 theme: Theme::Default,
684 ..TrustConfig::default()
685 };
686 let display = cfg.display();
687 let warning = TrustMode::Eager.warning(&display).unwrap();
688 assert!(warning.contains("eager"));
689
690 let cfg2 = TrustConfig {
691 mode: TrustMode::Reckless,
692 theme: Theme::Animal,
693 ..TrustConfig::default()
694 };
695 let display2 = cfg2.display();
696 let warning2 = TrustMode::Reckless.warning(&display2).unwrap();
697 assert!(warning2.contains("honey-badger"));
698 }
699
700 #[test]
701 fn wuxia_escalation_display_preserves_localized_labels() {
702 assert_eq!(
703 Theme::Wuxia.escalation_display(EscalationPolicy::Deny).name,
704 "画地为牢"
705 );
706 assert_eq!(
707 Theme::Wuxia.escalation_display(EscalationPolicy::Ask).name,
708 "请示"
709 );
710 assert_eq!(
711 Theme::Wuxia
712 .escalation_display(EscalationPolicy::Allow)
713 .name,
714 "放行"
715 );
716 }
717
718 #[test]
719 fn all_themes_produce_displays() {
720 let themes = [
721 Theme::Default,
722 Theme::Wuxia,
723 Theme::Animal,
724 Theme::Weather,
725 Theme::Drink,
726 ];
727 for theme in &themes {
728 for mode in &TrustMode::all() {
729 let d = theme.display(*mode);
730 assert!(!d.name.is_empty());
731 assert!(!d.emoji.is_empty());
732 assert!(!d.description.is_empty());
733 }
734 }
735 }
736
737 #[test]
738 fn mode_from_str_parses_all_variants() {
739 assert_eq!("calm".parse::<TrustMode>().unwrap(), TrustMode::Calm);
740 assert_eq!("steady".parse::<TrustMode>().unwrap(), TrustMode::Steady);
741 assert_eq!("eager".parse::<TrustMode>().unwrap(), TrustMode::Eager);
742 assert_eq!(
743 "reckless".parse::<TrustMode>().unwrap(),
744 TrustMode::Reckless
745 );
746 assert_eq!("yolo".parse::<TrustMode>().unwrap(), TrustMode::Reckless);
747 assert_eq!("1".parse::<TrustMode>().unwrap(), TrustMode::Calm);
748 assert_eq!("4".parse::<TrustMode>().unwrap(), TrustMode::Reckless);
749 assert!("unknown".parse::<TrustMode>().is_err());
750 }
751
752 #[test]
753 fn theme_from_str_parses_all_variants() {
754 assert_eq!("default".parse::<Theme>().unwrap(), Theme::Default);
755 assert_eq!("wuxia".parse::<Theme>().unwrap(), Theme::Wuxia);
756 assert_eq!("animal".parse::<Theme>().unwrap(), Theme::Animal);
757 assert_eq!("weather".parse::<Theme>().unwrap(), Theme::Weather);
758 assert_eq!("drink".parse::<Theme>().unwrap(), Theme::Drink);
759 assert!("unknown".parse::<Theme>().is_err());
760 }
761
762 #[test]
763 fn theme_display_roundtrip() {
764 for theme in &[
765 Theme::Default,
766 Theme::Wuxia,
767 Theme::Animal,
768 Theme::Weather,
769 Theme::Drink,
770 ] {
771 let s = theme.to_string();
772 let back: Theme = s.parse().unwrap();
773 assert_eq!(*theme, back);
774 }
775 }
776
777 #[test]
778 fn default_config_is_steady_approve() {
779 let cfg = TrustConfig::default();
780 assert_eq!(cfg.mode, TrustMode::Steady);
781 assert_eq!(cfg.theme, Theme::Default);
782 assert_eq!(cfg.escalation, EscalationPolicy::Ask);
783 }
784
785 #[test]
786 fn wuxia_descriptions_are_chinese() {
787 let d = Theme::Wuxia.display(TrustMode::Calm);
788 assert!(d.description.contains("拙"));
789 let d = Theme::Wuxia.display(TrustMode::Steady);
790 assert!(d.description.contains("行云"));
791 }
792
793 #[test]
794 fn non_wuxia_descriptions_are_english() {
795 for mode in &TrustMode::all() {
796 let d = Theme::Default.display(*mode);
797 assert!(
798 d.description.is_ascii(),
799 "default theme should be ASCII: {}",
800 d.description
801 );
802 let d = Theme::Animal.display(*mode);
803 assert!(
804 d.description.is_ascii(),
805 "animal theme should be ASCII: {}",
806 d.description
807 );
808 }
809 }
810
811 #[test]
812 fn four_levels_ordered() {
813 assert_eq!(TrustMode::Calm.level(), 1);
814 assert_eq!(TrustMode::Steady.level(), 2);
815 assert_eq!(TrustMode::Eager.level(), 3);
816 assert_eq!(TrustMode::Reckless.level(), 4);
817 }
818
819 #[test]
820 fn default_tier_matrix_matches_modes() {
821 use crate::tool::Tier;
822
823 let tiers = [Tier::Zero, Tier::One, Tier::Two, Tier::Three, Tier::Four];
824 let cases = [
825 (
826 TrustMode::Calm,
827 [
828 PolicyAction::Auto,
829 PolicyAction::Ask,
830 PolicyAction::Ask,
831 PolicyAction::Ask,
832 PolicyAction::Ask,
833 ],
834 ),
835 (
836 TrustMode::Steady,
837 [
838 PolicyAction::Auto,
839 PolicyAction::Auto,
840 PolicyAction::Ask,
841 PolicyAction::Ask,
842 PolicyAction::Ask,
843 ],
844 ),
845 (
846 TrustMode::Eager,
847 [
848 PolicyAction::Auto,
849 PolicyAction::Auto,
850 PolicyAction::Ask,
851 PolicyAction::Ask,
852 PolicyAction::Ask,
853 ],
854 ),
855 (TrustMode::Reckless, [PolicyAction::Auto; 5]),
856 ];
857
858 for (mode, expected) in cases {
859 let config = TrustConfig {
860 mode,
861 ..TrustConfig::default()
862 };
863 for (tier, action) in tiers.into_iter().zip(expected) {
864 assert_eq!(
865 config.resolve_tier(tier),
866 action,
867 "mode={mode:?}, tier={tier:?}"
868 );
869 }
870 }
871 }
872
873 #[test]
874 fn calm_and_steady_keep_fixed_safety_floors() {
875 use crate::tool::Tier;
876
877 let configured = TierPolicyConfig {
878 eager: TierPolicyOverrides {
879 tier4: Some(PolicyAction::Auto),
880 ..TierPolicyOverrides::default()
881 },
882 };
883 for mode in [TrustMode::Calm, TrustMode::Steady] {
884 let config = TrustConfig {
885 mode,
886 tiers: configured.clone(),
887 ..TrustConfig::default()
888 };
889 assert_eq!(config.resolve_tier(Tier::Four), PolicyAction::Ask);
890 }
891 }
892
893 #[test]
894 fn eager_allow_never_overrides_explicit_deny() {
895 use crate::tool::Tier;
896
897 let config = TrustConfig {
898 mode: TrustMode::Eager,
899 escalation: EscalationPolicy::Allow,
900 tiers: TierPolicyConfig {
901 eager: TierPolicyOverrides {
902 tier4: Some(PolicyAction::Deny),
903 ..TierPolicyOverrides::default()
904 },
905 },
906 risks: RiskPolicyConfig {
907 eager: RiskPolicyOverrides {
908 network: Some(PolicyAction::Deny),
909 ..RiskPolicyOverrides::default()
910 },
911 },
912 ..TrustConfig::default()
913 };
914
915 assert_eq!(config.resolve_policy(Tier::Four, []), PolicyAction::Deny);
916 assert_eq!(
917 config.resolve_policy(Tier::Zero, [RiskKind::Network]),
918 PolicyAction::Deny
919 );
920 assert_eq!(config.resolve_policy(Tier::Three, []), PolicyAction::Auto);
921 }
922
923 #[test]
924 fn eager_escalation_applies_only_to_ask() {
925 use crate::tool::Tier;
926
927 for (escalation, expected) in [
928 (EscalationPolicy::Deny, PolicyAction::Deny),
929 (EscalationPolicy::Ask, PolicyAction::Ask),
930 (EscalationPolicy::Allow, PolicyAction::Auto),
931 ] {
932 let config = TrustConfig {
933 mode: TrustMode::Eager,
934 escalation,
935 ..TrustConfig::default()
936 };
937 assert_eq!(config.resolve_policy(Tier::Three, []), expected);
938 assert_eq!(config.resolve_policy(Tier::Zero, []), PolicyAction::Auto);
939 }
940 }
941
942 #[test]
943 fn every_risk_defaults_to_ask_in_controlled_modes() {
944 let risks = [
945 RiskKind::WorkspaceExternal,
946 RiskKind::Network,
947 RiskKind::Irreversible,
948 RiskKind::FilesystemWrite,
949 RiskKind::ProcessSpawn,
950 RiskKind::RepositoryMutation,
951 ];
952 for mode in [TrustMode::Calm, TrustMode::Steady, TrustMode::Eager] {
953 let config = TrustConfig {
954 mode,
955 ..TrustConfig::default()
956 };
957 for risk in risks {
958 assert_eq!(config.resolve_risk(risk), PolicyAction::Ask);
959 }
960 }
961 }
962
963 #[test]
964 fn reckless_is_explicitly_unrestricted() {
965 use crate::tool::Tier;
966
967 let config = TrustConfig {
968 mode: TrustMode::Reckless,
969 escalation: EscalationPolicy::Deny,
970 ..TrustConfig::default()
971 };
972 assert_eq!(config.execution_policy(), ExecutionPolicy::Unrestricted);
973 assert_eq!(
974 config.resolve_policy(Tier::Four, [RiskKind::Network]),
975 PolicyAction::Auto
976 );
977 }
978
979 #[test]
980 fn eager_resolution_preserves_how_ask_was_resolved() {
981 use crate::tool::Tier;
982
983 let allowed = TrustConfig {
984 mode: TrustMode::Eager,
985 escalation: EscalationPolicy::Allow,
986 ..TrustConfig::default()
987 }
988 .resolve_policy_resolution(Tier::Two, [RiskKind::ProcessSpawn]);
989 assert_eq!(allowed.action, PolicyAction::Auto);
990 assert_eq!(allowed.escalation, PolicyEscalation::Allowed);
991
992 let automatic = TrustConfig {
993 mode: TrustMode::Eager,
994 tiers: TierPolicyConfig {
995 eager: TierPolicyOverrides {
996 tier2: Some(PolicyAction::Auto),
997 ..TierPolicyOverrides::default()
998 },
999 },
1000 risks: RiskPolicyConfig {
1001 eager: RiskPolicyOverrides {
1002 process_spawn: Some(PolicyAction::Auto),
1003 ..RiskPolicyOverrides::default()
1004 },
1005 },
1006 ..TrustConfig::default()
1007 }
1008 .resolve_policy_resolution(Tier::Two, [RiskKind::ProcessSpawn]);
1009 assert_eq!(automatic.action, PolicyAction::Auto);
1010 assert_eq!(automatic.escalation, PolicyEscalation::None);
1011 }
1012
1013 #[test]
1014 fn trust_config_json_and_toml_round_trip() {
1015 let config = TrustConfig {
1016 mode: TrustMode::Eager,
1017 escalation: EscalationPolicy::Allow,
1018 tiers: TierPolicyConfig {
1019 eager: TierPolicyOverrides {
1020 tier4: Some(PolicyAction::Deny),
1021 ..TierPolicyOverrides::default()
1022 },
1023 },
1024 risks: RiskPolicyConfig {
1025 eager: RiskPolicyOverrides {
1026 network: Some(PolicyAction::Ask),
1027 ..RiskPolicyOverrides::default()
1028 },
1029 },
1030 ..TrustConfig::default()
1031 };
1032
1033 let json = serde_json::to_string(&config).unwrap();
1034 let from_json: TrustConfig = serde_json::from_str(&json).unwrap();
1035 let toml = toml::to_string(&config).unwrap();
1036 let from_toml: TrustConfig = toml::from_str(&toml).unwrap();
1037 for back in [from_json, from_toml] {
1038 assert_eq!(back.mode, config.mode);
1039 assert_eq!(back.escalation, config.escalation);
1040 assert_eq!(back.tiers, config.tiers);
1041 assert_eq!(back.risks, config.risks);
1042 }
1043 }
1044}