use serde::{Deserialize, Serialize};
use serde_json::Value;
pub trait ApprovalPolicy: Send + Sync {
fn requires_approval(&self, plan: &Value) -> bool;
fn approval_reason(&self, plan: &Value) -> Option<String> {
if self.requires_approval(plan) {
Some("Plan requires approval".to_string())
} else {
None
}
}
fn name(&self) -> &str {
"approval_policy"
}
}
#[derive(Debug, Clone, Default)]
pub struct AlwaysApprovePolicy;
impl ApprovalPolicy for AlwaysApprovePolicy {
fn requires_approval(&self, _plan: &Value) -> bool {
false
}
fn name(&self) -> &str {
"always_approve"
}
}
#[derive(Debug, Clone, Default)]
pub struct AlwaysRequireApprovalPolicy;
impl ApprovalPolicy for AlwaysRequireApprovalPolicy {
fn requires_approval(&self, _plan: &Value) -> bool {
true
}
fn approval_reason(&self, _plan: &Value) -> Option<String> {
Some("All plans require explicit approval".to_string())
}
fn name(&self) -> &str {
"always_require"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThresholdApprovalPolicy {
pub max_steps: usize,
}
impl ThresholdApprovalPolicy {
pub fn new(max_steps: usize) -> Self {
Self { max_steps }
}
fn get_step_count(&self, plan: &Value) -> usize {
plan.get("steps")
.and_then(|s| s.as_array())
.map(|arr| arr.len())
.unwrap_or(0)
}
}
impl ApprovalPolicy for ThresholdApprovalPolicy {
fn requires_approval(&self, plan: &Value) -> bool {
self.get_step_count(plan) > self.max_steps
}
fn approval_reason(&self, plan: &Value) -> Option<String> {
if self.requires_approval(plan) {
let step_count = self.get_step_count(plan);
Some(format!(
"Plan has {} steps (threshold: {})",
step_count, self.max_steps
))
} else {
None
}
}
fn name(&self) -> &str {
"threshold"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternApprovalPolicy {
pub require_approval_patterns: Vec<String>,
}
impl PatternApprovalPolicy {
pub fn new(patterns: Vec<String>) -> Self {
Self {
require_approval_patterns: patterns,
}
}
fn find_matching_pattern(&self, plan: &Value) -> Option<&str> {
let plan_str = plan.to_string().to_lowercase();
self.require_approval_patterns
.iter()
.find(|pattern| plan_str.contains(&pattern.to_lowercase()))
.map(|s| s.as_str())
}
}
impl ApprovalPolicy for PatternApprovalPolicy {
fn requires_approval(&self, plan: &Value) -> bool {
self.find_matching_pattern(plan).is_some()
}
fn approval_reason(&self, plan: &Value) -> Option<String> {
self.find_matching_pattern(plan)
.map(|pattern| format!("Plan contains sensitive action: {}", pattern))
}
fn name(&self) -> &str {
"pattern"
}
}
#[derive(Default)]
pub struct CompositeApprovalPolicy {
policies: Vec<Box<dyn ApprovalPolicy>>,
}
impl CompositeApprovalPolicy {
pub fn new() -> Self {
Self { policies: vec![] }
}
pub fn add_policy<P: ApprovalPolicy + 'static>(mut self, policy: P) -> Self {
self.policies.push(Box::new(policy));
self
}
pub fn with_policies(policies: Vec<Box<dyn ApprovalPolicy>>) -> Self {
Self { policies }
}
}
impl ApprovalPolicy for CompositeApprovalPolicy {
fn requires_approval(&self, plan: &Value) -> bool {
self.policies.iter().any(|p| p.requires_approval(plan))
}
fn approval_reason(&self, plan: &Value) -> Option<String> {
for policy in &self.policies {
if let Some(reason) = policy.approval_reason(plan) {
return Some(reason);
}
}
None
}
fn name(&self) -> &str {
"composite"
}
}
impl std::fmt::Debug for CompositeApprovalPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompositeApprovalPolicy")
.field("policy_count", &self.policies.len())
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ApprovalPolicyConfig {
#[default]
AlwaysApprove,
AlwaysRequire,
Threshold { max_steps: usize },
Pattern { patterns: Vec<String> },
}
impl ApprovalPolicyConfig {
pub fn into_policy(self) -> Box<dyn ApprovalPolicy> {
match self {
ApprovalPolicyConfig::AlwaysApprove => Box::new(AlwaysApprovePolicy),
ApprovalPolicyConfig::AlwaysRequire => Box::new(AlwaysRequireApprovalPolicy),
ApprovalPolicyConfig::Threshold { max_steps } => {
Box::new(ThresholdApprovalPolicy::new(max_steps))
}
ApprovalPolicyConfig::Pattern { patterns } => {
Box::new(PatternApprovalPolicy::new(patterns))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_always_approve_policy() {
let policy = AlwaysApprovePolicy;
let plan = json!({"steps": ["step1", "step2"]});
assert!(!policy.requires_approval(&plan));
assert!(policy.approval_reason(&plan).is_none());
assert_eq!(policy.name(), "always_approve");
}
#[test]
fn test_always_require_policy() {
let policy = AlwaysRequireApprovalPolicy;
let plan = json!({"steps": ["step1"]});
assert!(policy.requires_approval(&plan));
assert!(policy.approval_reason(&plan).is_some());
assert_eq!(policy.name(), "always_require");
}
#[test]
fn test_threshold_policy_below_threshold() {
let policy = ThresholdApprovalPolicy::new(3);
let small_plan = json!({"steps": ["s1", "s2"]});
assert!(!policy.requires_approval(&small_plan));
assert!(policy.approval_reason(&small_plan).is_none());
}
#[test]
fn test_threshold_policy_above_threshold() {
let policy = ThresholdApprovalPolicy::new(3);
let large_plan = json!({"steps": ["s1", "s2", "s3", "s4"]});
assert!(policy.requires_approval(&large_plan));
let reason = policy.approval_reason(&large_plan).unwrap();
assert!(reason.contains("4 steps"));
assert!(reason.contains("threshold: 3"));
}
#[test]
fn test_threshold_policy_at_threshold() {
let policy = ThresholdApprovalPolicy::new(3);
let plan = json!({"steps": ["s1", "s2", "s3"]});
assert!(!policy.requires_approval(&plan));
}
#[test]
fn test_pattern_policy_match() {
let policy = PatternApprovalPolicy::new(vec![
"delete".to_string(),
"deploy".to_string(),
"publish".to_string(),
]);
let delete_plan = json!({"action": "delete_file", "path": "/tmp/file"});
assert!(policy.requires_approval(&delete_plan));
let reason = policy.approval_reason(&delete_plan).unwrap();
assert!(reason.contains("delete"));
}
#[test]
fn test_pattern_policy_no_match() {
let policy = PatternApprovalPolicy::new(vec!["delete".to_string(), "deploy".to_string()]);
let read_plan = json!({"action": "read_file", "path": "/tmp/file"});
assert!(!policy.requires_approval(&read_plan));
assert!(policy.approval_reason(&read_plan).is_none());
}
#[test]
fn test_pattern_policy_case_insensitive() {
let policy = PatternApprovalPolicy::new(vec!["DELETE".to_string()]);
let plan = json!({"action": "delete_file"});
assert!(policy.requires_approval(&plan));
}
#[test]
fn test_composite_policy_any_match() {
let policy = CompositeApprovalPolicy::new()
.add_policy(ThresholdApprovalPolicy::new(5))
.add_policy(PatternApprovalPolicy::new(vec!["delete".to_string()]));
let delete_plan = json!({"action": "delete", "steps": ["s1"]});
assert!(policy.requires_approval(&delete_plan));
let large_plan = json!({"action": "read", "steps": ["s1", "s2", "s3", "s4", "s5", "s6"]});
assert!(policy.requires_approval(&large_plan));
}
#[test]
fn test_composite_policy_no_match() {
let policy = CompositeApprovalPolicy::new()
.add_policy(ThresholdApprovalPolicy::new(5))
.add_policy(PatternApprovalPolicy::new(vec!["delete".to_string()]));
let safe_plan = json!({"action": "read", "steps": ["s1", "s2"]});
assert!(!policy.requires_approval(&safe_plan));
}
#[test]
fn test_composite_policy_empty() {
let policy = CompositeApprovalPolicy::new();
let plan = json!({"steps": ["s1"]});
assert!(!policy.requires_approval(&plan));
}
#[test]
fn test_policy_config_always_approve() {
let config: ApprovalPolicyConfig =
serde_json::from_str(r#"{"type": "always_approve"}"#).unwrap();
let policy = config.into_policy();
assert!(!policy.requires_approval(&json!({})));
}
#[test]
fn test_policy_config_threshold() {
let config: ApprovalPolicyConfig =
serde_json::from_str(r#"{"type": "threshold", "max_steps": 2}"#).unwrap();
let policy = config.into_policy();
assert!(policy.requires_approval(&json!({"steps": ["s1", "s2", "s3"]})));
assert!(!policy.requires_approval(&json!({"steps": ["s1"]})));
}
#[test]
fn test_policy_config_pattern() {
let config: ApprovalPolicyConfig =
serde_json::from_str(r#"{"type": "pattern", "patterns": ["delete", "deploy"]}"#)
.unwrap();
let policy = config.into_policy();
assert!(policy.requires_approval(&json!({"action": "delete"})));
assert!(!policy.requires_approval(&json!({"action": "read"})));
}
}