返回 CodeWhale
runtime_policy.rs
根目录 / crates / tui / src / runtime_policy.rs
1 //! Normalization for Runtime mode and permission-posture wires.
2 //!
3 //! Older clients can still send mode aliases and the legacy `auto_approve`
4 //! bit. New callers send a named posture. Keep those inputs readable, but
5 //! persist and execute one contract: Plan / Act / Operate plus Ask /
6 //! Auto-Review / Full Access.
7
8 use anyhow::{Result, bail};
9
10 use codewhale_config::AppMode;
11 use codewhale_execpolicy::ApprovalMode;
12
13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 pub(crate) struct RuntimePolicyProjection {
15 pub(crate) mode: AppMode,
16 pub(crate) permission: ApprovalMode,
17 }
18
19 /// The wire spelling of one approval posture: what a thread or task request
20 /// names in its `permission_posture` field.
21 ///
22 /// `Never` is a managed-policy value the product surface does not carry, so it
23 /// reads as Ask — exactly as [`RuntimePolicyProjection::permission_wire`] has
24 /// always spelled it.
25 #[must_use]
26 pub(crate) fn approval_wire(mode: ApprovalMode) -> &'static str {
27 match mode {
28 ApprovalMode::Auto => "auto_review",
29 ApprovalMode::Bypass => "full_access",
30 ApprovalMode::Suggest | ApprovalMode::Never => "ask",
31 }
32 }
33
34 impl RuntimePolicyProjection {
35 /// Read a persisted compatibility shape. Unknown historical values fail
36 /// closed to Act + Ask unless the old bypass bit is explicitly present.
37 #[must_use]
38 pub(crate) fn from_persisted(
39 mode: &str,
40 permission_posture: Option<&str>,
41 auto_approve: bool,
42 ) -> Self {
43 let parsed_mode = parse_runtime_mode(mode).unwrap_or(AppMode::Agent);
44 let permission = permission_posture
45 .and_then(ApprovalMode::from_config_value)
46 .filter(|permission| *permission != ApprovalMode::Never)
47 .unwrap_or_else(|| {
48 if AppMode::is_legacy_bypass_alias(mode) || auto_approve {
49 ApprovalMode::Bypass
50 } else {
51 ApprovalMode::Suggest
52 }
53 });
54 Self {
55 mode: parsed_mode,
56 permission,
57 }
58 }
59
60 /// Validate and normalize a new Runtime request. Legacy aliases are
61 /// accepted as one-way inputs, but only current values are persisted.
62 pub(crate) fn from_request(
63 mode: &str,
64 permission_posture: Option<&str>,
65 auto_approve: Option<bool>,
66 ) -> Result<Self> {
67 let parsed_mode = parse_runtime_mode(mode).ok_or_else(|| {
68 anyhow::anyhow!("unsupported Runtime mode {mode:?}; expected plan, act, or operate")
69 })?;
70 let permission = match permission_posture {
71 Some(value) => ApprovalMode::from_config_value(value).ok_or_else(|| {
72 anyhow::anyhow!(
73 "unsupported permission posture {value:?}; expected ask, auto-review, or full-access"
74 )
75 })?,
76 None if AppMode::is_legacy_bypass_alias(mode) || auto_approve.unwrap_or(false) => ApprovalMode::Bypass,
77 None => ApprovalMode::Suggest,
78 };
79 if permission == ApprovalMode::Never {
80 bail!("permission posture 'never' is not part of the Runtime product contract");
81 }
82 Ok(Self {
83 mode: parsed_mode,
84 permission,
85 })
86 }
87
88 #[must_use]
89 pub(crate) fn mode_setting(self) -> &'static str {
90 self.mode.as_setting()
91 }
92
93 #[must_use]
94 pub(crate) fn permission_wire(self) -> &'static str {
95 approval_wire(self.permission)
96 }
97
98 #[must_use]
99 pub(crate) fn auto_approve(self) -> bool {
100 self.permission == ApprovalMode::Bypass
101 }
102 }
103
104 #[must_use]
105 pub(crate) fn parse_runtime_mode(value: &str) -> Option<AppMode> {
106 match value.trim().to_ascii_lowercase().as_str() {
107 "normal" => Some(AppMode::Agent),
108 other => AppMode::parse(other),
109 }
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn approval_wire_spells_every_product_posture() {
118 assert_eq!(approval_wire(ApprovalMode::Suggest), "ask");
119 assert_eq!(approval_wire(ApprovalMode::Auto), "auto_review");
120 assert_eq!(approval_wire(ApprovalMode::Bypass), "full_access");
121 // `Never` is a managed-policy value the product surface does not carry.
122 assert_eq!(approval_wire(ApprovalMode::Never), "ask");
123 }
124
125 #[test]
126 fn legacy_inputs_project_to_current_mode_and_permission_wires() {
127 for alias in ["normal", "agent", "auto"] {
128 let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
129 assert_eq!(policy.mode_setting(), "agent", "{alias}");
130 assert_eq!(policy.permission_wire(), "ask", "{alias}");
131 }
132 for alias in ["yolo", "bypass"] {
133 let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
134 assert_eq!(policy.mode_setting(), "agent", "{alias}");
135 assert_eq!(policy.permission_wire(), "full_access", "{alias}");
136 }
137 }
138
139 #[test]
140 fn named_posture_is_normalized_and_authoritative() {
141 let auto = RuntimePolicyProjection::from_request("operate", Some("auto-review"), None)
142 .expect("compat permission");
143 assert_eq!(auto.mode_setting(), "operate");
144 assert_eq!(auto.permission_wire(), "auto_review");
145 assert_eq!(auto.permission, ApprovalMode::Auto);
146 assert!(!auto.auto_approve());
147
148 let full = RuntimePolicyProjection::from_request("act", Some("full_access"), None)
149 .expect("current permission wire");
150 assert_eq!(full.permission_wire(), "full_access");
151 assert_eq!(full.permission, ApprovalMode::Bypass);
152 assert!(full.auto_approve());
153
154 let ask = RuntimePolicyProjection::from_request("yolo", Some("ask"), Some(true))
155 .expect("named posture overrides legacy flags");
156 assert_eq!(ask.mode_setting(), "agent");
157 assert_eq!(ask.permission_wire(), "ask");
158 assert_eq!(ask.permission, ApprovalMode::Suggest);
159 assert!(!ask.auto_approve());
160 }
161
162 #[test]
163 fn invalid_or_never_postures_are_rejected() {
164 assert!(RuntimePolicyProjection::from_request("act", Some("owner"), None).is_err());
165 assert!(RuntimePolicyProjection::from_request("act", Some("never"), None).is_err());
166 }
167 }
168
168 lines RUST