Skip to main content

enact_core/runner/
approval_policy.rs

1//! Approval policy for human-in-the-loop plan approval
2//!
3//! Defines when and how plans require human approval before execution.
4//! Policies integrate with the InterruptableRunner to pause execution
5//! and wait for human decisions.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// Policy that determines when plans require human approval
11pub trait ApprovalPolicy: Send + Sync {
12    /// Check if the given plan requires human approval
13    fn requires_approval(&self, plan: &Value) -> bool;
14
15    /// Get a human-readable reason for requiring approval
16    /// Returns None if approval is not required
17    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    /// Get the policy name for logging/debugging
26    fn name(&self) -> &str {
27        "approval_policy"
28    }
29}
30
31/// Policy that never requires approval (auto-approve all)
32#[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/// Policy that always requires approval
46#[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/// Policy based on plan complexity threshold
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ThresholdApprovalPolicy {
66    /// Maximum number of steps before requiring approval
67    pub max_steps: usize,
68}
69
70impl ThresholdApprovalPolicy {
71    pub fn new(max_steps: usize) -> Self {
72        Self { max_steps }
73    }
74
75    /// Get the step count from a plan
76    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/// Policy based on specific tool/action patterns
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PatternApprovalPolicy {
109    /// Patterns that require approval (e.g., "delete", "deploy", "publish")
110    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    /// Find the first matching pattern in the plan
121    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/// Composite policy that combines multiple policies (OR logic)
146/// Requires approval if ANY policy requires it
147#[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/// Configuration for approval policies (for deserialization)
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum ApprovalPolicyConfig {
198    /// Never require approval
199    #[default]
200    AlwaysApprove,
201    /// Always require approval
202    AlwaysRequire,
203    /// Require approval above step threshold
204    Threshold { max_steps: usize },
205    /// Require approval for certain patterns
206    Pattern { patterns: Vec<String> },
207}
208
209impl ApprovalPolicyConfig {
210    /// Create a boxed policy from this configuration
211    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        // Matches pattern, not threshold
310        let delete_plan = json!({"action": "delete", "steps": ["s1"]});
311        assert!(policy.requires_approval(&delete_plan));
312
313        // Matches threshold, not pattern
314        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}