enact_core/runner/
approval_policy.rs1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10pub trait ApprovalPolicy: Send + Sync {
12 fn requires_approval(&self, plan: &Value) -> bool;
14
15 fn approval_reason(&self, plan: &Value) -> Option<String> {
18 if self.requires_approval(plan) {
19 Some("Plan requires approval".to_string())
20 } else {
21 None
22 }
23 }
24
25 fn name(&self) -> &str {
27 "approval_policy"
28 }
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct AlwaysApprovePolicy;
34
35impl ApprovalPolicy for AlwaysApprovePolicy {
36 fn requires_approval(&self, _plan: &Value) -> bool {
37 false
38 }
39
40 fn name(&self) -> &str {
41 "always_approve"
42 }
43}
44
45#[derive(Debug, Clone, Default)]
47pub struct AlwaysRequireApprovalPolicy;
48
49impl ApprovalPolicy for AlwaysRequireApprovalPolicy {
50 fn requires_approval(&self, _plan: &Value) -> bool {
51 true
52 }
53
54 fn approval_reason(&self, _plan: &Value) -> Option<String> {
55 Some("All plans require explicit approval".to_string())
56 }
57
58 fn name(&self) -> &str {
59 "always_require"
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ThresholdApprovalPolicy {
66 pub max_steps: usize,
68}
69
70impl ThresholdApprovalPolicy {
71 pub fn new(max_steps: usize) -> Self {
72 Self { max_steps }
73 }
74
75 fn get_step_count(&self, plan: &Value) -> usize {
77 plan.get("steps")
78 .and_then(|s| s.as_array())
79 .map(|arr| arr.len())
80 .unwrap_or(0)
81 }
82}
83
84impl ApprovalPolicy for ThresholdApprovalPolicy {
85 fn requires_approval(&self, plan: &Value) -> bool {
86 self.get_step_count(plan) > self.max_steps
87 }
88
89 fn approval_reason(&self, plan: &Value) -> Option<String> {
90 if self.requires_approval(plan) {
91 let step_count = self.get_step_count(plan);
92 Some(format!(
93 "Plan has {} steps (threshold: {})",
94 step_count, self.max_steps
95 ))
96 } else {
97 None
98 }
99 }
100
101 fn name(&self) -> &str {
102 "threshold"
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PatternApprovalPolicy {
109 pub require_approval_patterns: Vec<String>,
111}
112
113impl PatternApprovalPolicy {
114 pub fn new(patterns: Vec<String>) -> Self {
115 Self {
116 require_approval_patterns: patterns,
117 }
118 }
119
120 fn find_matching_pattern(&self, plan: &Value) -> Option<&str> {
122 let plan_str = plan.to_string().to_lowercase();
123 self.require_approval_patterns
124 .iter()
125 .find(|pattern| plan_str.contains(&pattern.to_lowercase()))
126 .map(|s| s.as_str())
127 }
128}
129
130impl ApprovalPolicy for PatternApprovalPolicy {
131 fn requires_approval(&self, plan: &Value) -> bool {
132 self.find_matching_pattern(plan).is_some()
133 }
134
135 fn approval_reason(&self, plan: &Value) -> Option<String> {
136 self.find_matching_pattern(plan)
137 .map(|pattern| format!("Plan contains sensitive action: {}", pattern))
138 }
139
140 fn name(&self) -> &str {
141 "pattern"
142 }
143}
144
145#[derive(Default)]
148pub struct CompositeApprovalPolicy {
149 policies: Vec<Box<dyn ApprovalPolicy>>,
150}
151
152impl CompositeApprovalPolicy {
153 pub fn new() -> Self {
154 Self { policies: vec![] }
155 }
156
157 pub fn add_policy<P: ApprovalPolicy + 'static>(mut self, policy: P) -> Self {
158 self.policies.push(Box::new(policy));
159 self
160 }
161
162 pub fn with_policies(policies: Vec<Box<dyn ApprovalPolicy>>) -> Self {
163 Self { policies }
164 }
165}
166
167impl ApprovalPolicy for CompositeApprovalPolicy {
168 fn requires_approval(&self, plan: &Value) -> bool {
169 self.policies.iter().any(|p| p.requires_approval(plan))
170 }
171
172 fn approval_reason(&self, plan: &Value) -> Option<String> {
173 for policy in &self.policies {
174 if let Some(reason) = policy.approval_reason(plan) {
175 return Some(reason);
176 }
177 }
178 None
179 }
180
181 fn name(&self) -> &str {
182 "composite"
183 }
184}
185
186impl std::fmt::Debug for CompositeApprovalPolicy {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("CompositeApprovalPolicy")
189 .field("policy_count", &self.policies.len())
190 .finish()
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum ApprovalPolicyConfig {
198 #[default]
200 AlwaysApprove,
201 AlwaysRequire,
203 Threshold { max_steps: usize },
205 Pattern { patterns: Vec<String> },
207}
208
209impl ApprovalPolicyConfig {
210 pub fn into_policy(self) -> Box<dyn ApprovalPolicy> {
212 match self {
213 ApprovalPolicyConfig::AlwaysApprove => Box::new(AlwaysApprovePolicy),
214 ApprovalPolicyConfig::AlwaysRequire => Box::new(AlwaysRequireApprovalPolicy),
215 ApprovalPolicyConfig::Threshold { max_steps } => {
216 Box::new(ThresholdApprovalPolicy::new(max_steps))
217 }
218 ApprovalPolicyConfig::Pattern { patterns } => {
219 Box::new(PatternApprovalPolicy::new(patterns))
220 }
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use serde_json::json;
229
230 #[test]
231 fn test_always_approve_policy() {
232 let policy = AlwaysApprovePolicy;
233 let plan = json!({"steps": ["step1", "step2"]});
234 assert!(!policy.requires_approval(&plan));
235 assert!(policy.approval_reason(&plan).is_none());
236 assert_eq!(policy.name(), "always_approve");
237 }
238
239 #[test]
240 fn test_always_require_policy() {
241 let policy = AlwaysRequireApprovalPolicy;
242 let plan = json!({"steps": ["step1"]});
243 assert!(policy.requires_approval(&plan));
244 assert!(policy.approval_reason(&plan).is_some());
245 assert_eq!(policy.name(), "always_require");
246 }
247
248 #[test]
249 fn test_threshold_policy_below_threshold() {
250 let policy = ThresholdApprovalPolicy::new(3);
251 let small_plan = json!({"steps": ["s1", "s2"]});
252 assert!(!policy.requires_approval(&small_plan));
253 assert!(policy.approval_reason(&small_plan).is_none());
254 }
255
256 #[test]
257 fn test_threshold_policy_above_threshold() {
258 let policy = ThresholdApprovalPolicy::new(3);
259 let large_plan = json!({"steps": ["s1", "s2", "s3", "s4"]});
260 assert!(policy.requires_approval(&large_plan));
261 let reason = policy.approval_reason(&large_plan).unwrap();
262 assert!(reason.contains("4 steps"));
263 assert!(reason.contains("threshold: 3"));
264 }
265
266 #[test]
267 fn test_threshold_policy_at_threshold() {
268 let policy = ThresholdApprovalPolicy::new(3);
269 let plan = json!({"steps": ["s1", "s2", "s3"]});
270 assert!(!policy.requires_approval(&plan));
271 }
272
273 #[test]
274 fn test_pattern_policy_match() {
275 let policy = PatternApprovalPolicy::new(vec![
276 "delete".to_string(),
277 "deploy".to_string(),
278 "publish".to_string(),
279 ]);
280
281 let delete_plan = json!({"action": "delete_file", "path": "/tmp/file"});
282 assert!(policy.requires_approval(&delete_plan));
283 let reason = policy.approval_reason(&delete_plan).unwrap();
284 assert!(reason.contains("delete"));
285 }
286
287 #[test]
288 fn test_pattern_policy_no_match() {
289 let policy = PatternApprovalPolicy::new(vec!["delete".to_string(), "deploy".to_string()]);
290
291 let read_plan = json!({"action": "read_file", "path": "/tmp/file"});
292 assert!(!policy.requires_approval(&read_plan));
293 assert!(policy.approval_reason(&read_plan).is_none());
294 }
295
296 #[test]
297 fn test_pattern_policy_case_insensitive() {
298 let policy = PatternApprovalPolicy::new(vec!["DELETE".to_string()]);
299 let plan = json!({"action": "delete_file"});
300 assert!(policy.requires_approval(&plan));
301 }
302
303 #[test]
304 fn test_composite_policy_any_match() {
305 let policy = CompositeApprovalPolicy::new()
306 .add_policy(ThresholdApprovalPolicy::new(5))
307 .add_policy(PatternApprovalPolicy::new(vec!["delete".to_string()]));
308
309 let delete_plan = json!({"action": "delete", "steps": ["s1"]});
311 assert!(policy.requires_approval(&delete_plan));
312
313 let large_plan = json!({"action": "read", "steps": ["s1", "s2", "s3", "s4", "s5", "s6"]});
315 assert!(policy.requires_approval(&large_plan));
316 }
317
318 #[test]
319 fn test_composite_policy_no_match() {
320 let policy = CompositeApprovalPolicy::new()
321 .add_policy(ThresholdApprovalPolicy::new(5))
322 .add_policy(PatternApprovalPolicy::new(vec!["delete".to_string()]));
323
324 let safe_plan = json!({"action": "read", "steps": ["s1", "s2"]});
325 assert!(!policy.requires_approval(&safe_plan));
326 }
327
328 #[test]
329 fn test_composite_policy_empty() {
330 let policy = CompositeApprovalPolicy::new();
331 let plan = json!({"steps": ["s1"]});
332 assert!(!policy.requires_approval(&plan));
333 }
334
335 #[test]
336 fn test_policy_config_always_approve() {
337 let config: ApprovalPolicyConfig =
338 serde_json::from_str(r#"{"type": "always_approve"}"#).unwrap();
339 let policy = config.into_policy();
340 assert!(!policy.requires_approval(&json!({})));
341 }
342
343 #[test]
344 fn test_policy_config_threshold() {
345 let config: ApprovalPolicyConfig =
346 serde_json::from_str(r#"{"type": "threshold", "max_steps": 2}"#).unwrap();
347 let policy = config.into_policy();
348 assert!(policy.requires_approval(&json!({"steps": ["s1", "s2", "s3"]})));
349 assert!(!policy.requires_approval(&json!({"steps": ["s1"]})));
350 }
351
352 #[test]
353 fn test_policy_config_pattern() {
354 let config: ApprovalPolicyConfig =
355 serde_json::from_str(r#"{"type": "pattern", "patterns": ["delete", "deploy"]}"#)
356 .unwrap();
357 let policy = config.into_policy();
358 assert!(policy.requires_approval(&json!({"action": "delete"})));
359 assert!(!policy.requires_approval(&json!({"action": "read"})));
360 }
361}