enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Approval policy for human-in-the-loop plan approval
//!
//! Defines when and how plans require human approval before execution.
//! Policies integrate with the InterruptableRunner to pause execution
//! and wait for human decisions.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Policy that determines when plans require human approval
pub trait ApprovalPolicy: Send + Sync {
    /// Check if the given plan requires human approval
    fn requires_approval(&self, plan: &Value) -> bool;

    /// Get a human-readable reason for requiring approval
    /// Returns None if approval is not required
    fn approval_reason(&self, plan: &Value) -> Option<String> {
        if self.requires_approval(plan) {
            Some("Plan requires approval".to_string())
        } else {
            None
        }
    }

    /// Get the policy name for logging/debugging
    fn name(&self) -> &str {
        "approval_policy"
    }
}

/// Policy that never requires approval (auto-approve all)
#[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"
    }
}

/// Policy that always requires approval
#[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"
    }
}

/// Policy based on plan complexity threshold
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThresholdApprovalPolicy {
    /// Maximum number of steps before requiring approval
    pub max_steps: usize,
}

impl ThresholdApprovalPolicy {
    pub fn new(max_steps: usize) -> Self {
        Self { max_steps }
    }

    /// Get the step count from a plan
    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"
    }
}

/// Policy based on specific tool/action patterns
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternApprovalPolicy {
    /// Patterns that require approval (e.g., "delete", "deploy", "publish")
    pub require_approval_patterns: Vec<String>,
}

impl PatternApprovalPolicy {
    pub fn new(patterns: Vec<String>) -> Self {
        Self {
            require_approval_patterns: patterns,
        }
    }

    /// Find the first matching pattern in the plan
    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"
    }
}

/// Composite policy that combines multiple policies (OR logic)
/// Requires approval if ANY policy requires it
#[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()
    }
}

/// Configuration for approval policies (for deserialization)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ApprovalPolicyConfig {
    /// Never require approval
    #[default]
    AlwaysApprove,
    /// Always require approval
    AlwaysRequire,
    /// Require approval above step threshold
    Threshold { max_steps: usize },
    /// Require approval for certain patterns
    Pattern { patterns: Vec<String> },
}

impl ApprovalPolicyConfig {
    /// Create a boxed policy from this configuration
    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()]));

        // Matches pattern, not threshold
        let delete_plan = json!({"action": "delete", "steps": ["s1"]});
        assert!(policy.requires_approval(&delete_plan));

        // Matches threshold, not pattern
        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"})));
    }
}