enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Tool Executor - Policy-aware tool execution
//!
//! This module ensures that EVERY tool invocation passes through policy
//! evaluation before execution. This is a critical security boundary.
//!
//! ## Usage
//! ```ignore
//! let policy = ToolPolicy::new();
//! let executor = ToolExecutor::new(policy);
//!
//! // This will evaluate policy before executing
//! let result = executor.execute(&tool, args, &ctx).await?;
//! ```

use super::Tool;
use crate::context::TenantContext;
use crate::kernel::{ExecutionId, StepId};
use crate::policy::{PolicyAction, PolicyContext, PolicyDecision, PolicyEvaluator, ToolPolicy};
use crate::streaming::{EventEmitter, StreamEvent};
use serde_json::Value;
use std::sync::Arc;

/// Error returned when tool execution is denied by policy
#[derive(Debug, thiserror::Error)]
pub enum ToolExecutionError {
    #[error("Tool execution denied: {reason}")]
    PolicyDenied { reason: String },

    #[error("Tool execution error: {0}")]
    ExecutionFailed(#[from] anyhow::Error),
}

/// Context for tool execution
#[derive(Debug, Clone)]
pub struct ToolExecutionContext {
    /// Execution ID
    pub execution_id: ExecutionId,
    /// Step ID
    pub step_id: Option<StepId>,
    /// Tenant context (REQUIRED)
    pub tenant: TenantContext,
    /// Additional metadata
    pub metadata: std::collections::HashMap<String, String>,
}

impl ToolExecutionContext {
    /// Create a new tool execution context
    pub fn new(execution_id: ExecutionId, tenant: TenantContext) -> Self {
        Self {
            execution_id,
            step_id: None,
            tenant,
            metadata: std::collections::HashMap::new(),
        }
    }

    /// Set step ID
    pub fn with_step(mut self, step_id: StepId) -> Self {
        self.step_id = Some(step_id);
        self
    }
}

/// Policy-aware tool executor
///
/// This executor ensures that every tool invocation passes through
/// ToolPolicy::evaluate() before execution. Policy decisions are emitted
/// as events for audit trail when an emitter is configured.
pub struct ToolExecutor {
    policy: Arc<ToolPolicy>,
    /// Optional event emitter for policy decision audit trail
    emitter: Option<EventEmitter>,
}

impl ToolExecutor {
    /// Create a new tool executor with the given policy
    pub fn new(policy: ToolPolicy) -> Self {
        Self {
            policy: Arc::new(policy),
            emitter: None,
        }
    }

    /// Create with a shared policy
    pub fn with_shared_policy(policy: Arc<ToolPolicy>) -> Self {
        Self {
            policy,
            emitter: None,
        }
    }

    /// Configure an event emitter for policy decision audit trail
    pub fn with_emitter(mut self, emitter: EventEmitter) -> Self {
        self.emitter = Some(emitter);
        self
    }

    /// Set the event emitter for policy decision audit trail
    pub fn set_emitter(&mut self, emitter: EventEmitter) {
        self.emitter = Some(emitter);
    }

    /// Execute a tool with policy enforcement
    ///
    /// This is the ONLY way to execute tools - it ensures policy is always checked.
    /// Policy decisions are emitted as events for audit trail when an emitter is configured.
    pub async fn execute(
        &self,
        tool: &dyn Tool,
        args: Value,
        ctx: &ToolExecutionContext,
    ) -> Result<Value, ToolExecutionError> {
        // Create policy context
        let policy_ctx = PolicyContext {
            tenant_id: Some(ctx.tenant.tenant_id().as_str().to_string()),
            user_id: ctx.tenant.user_id().map(|u| u.as_str().to_string()),
            action: PolicyAction::InvokeTool {
                tool_name: tool.name().to_string(),
            },
            metadata: ctx.metadata.clone(),
        };

        let tool_name = tool.name().to_string();

        // Evaluate policy and emit decision event for audit trail
        match self.policy.evaluate(&policy_ctx) {
            PolicyDecision::Allow => {
                // Emit allow decision event
                if let Some(emitter) = &self.emitter {
                    emitter.emit(StreamEvent::policy_decision_allow(
                        &ctx.execution_id,
                        ctx.step_id.as_ref(),
                        &tool_name,
                    ));
                }
                // Policy allows execution
                tool.execute(args).await.map_err(ToolExecutionError::from)
            }
            PolicyDecision::Deny { reason } => {
                // Emit deny decision event
                if let Some(emitter) = &self.emitter {
                    emitter.emit(StreamEvent::policy_decision_deny(
                        &ctx.execution_id,
                        ctx.step_id.as_ref(),
                        &tool_name,
                        &reason,
                    ));
                }
                Err(ToolExecutionError::PolicyDenied { reason })
            }
            PolicyDecision::Warn { message } => {
                // Emit warn decision event
                if let Some(emitter) = &self.emitter {
                    emitter.emit(StreamEvent::policy_decision_warn(
                        &ctx.execution_id,
                        ctx.step_id.as_ref(),
                        &tool_name,
                        &message,
                    ));
                }
                // Policy warns but allows - log and continue
                tracing::warn!(tool = tool.name(), message = %message, "Tool policy warning");
                tool.execute(args).await.map_err(ToolExecutionError::from)
            }
        }
    }

    /// Execute multiple tools in sequence, stopping on first policy denial
    pub async fn execute_sequence(
        &self,
        tools: &[(Arc<dyn Tool>, Value)],
        ctx: &ToolExecutionContext,
    ) -> Result<Vec<Value>, ToolExecutionError> {
        let mut results = Vec::new();
        for (tool, args) in tools {
            let result = self.execute(tool.as_ref(), args.clone(), ctx).await?;
            results.push(result);
        }
        Ok(results)
    }

    /// Check if a tool would be allowed by policy (without executing)
    pub fn is_allowed(&self, tool_name: &str, ctx: &ToolExecutionContext) -> bool {
        let policy_ctx = PolicyContext {
            tenant_id: Some(ctx.tenant.tenant_id().as_str().to_string()),
            user_id: ctx.tenant.user_id().map(|u| u.as_str().to_string()),
            action: PolicyAction::InvokeTool {
                tool_name: tool_name.to_string(),
            },
            metadata: std::collections::HashMap::new(),
        };

        matches!(
            self.policy.evaluate(&policy_ctx),
            PolicyDecision::Allow | PolicyDecision::Warn { .. }
        )
    }

    /// Get the permissions for a tool
    pub fn get_permissions(&self, tool_name: &str) -> &crate::policy::ToolPermissions {
        self.policy.get_permissions(tool_name)
    }

    /// Get the policy reference
    pub fn policy(&self) -> &ToolPolicy {
        &self.policy
    }
}

impl Default for ToolExecutor {
    fn default() -> Self {
        Self::new(ToolPolicy::default())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::kernel::TenantId;
    use async_trait::async_trait;

    struct MockTool {
        name: String,
    }

    #[async_trait]
    impl Tool for MockTool {
        fn name(&self) -> &str {
            &self.name
        }

        fn description(&self) -> &str {
            "Mock tool for testing"
        }

        async fn execute(&self, args: Value) -> anyhow::Result<Value> {
            Ok(args)
        }
    }

    #[tokio::test]
    async fn test_tool_execution_allowed() {
        let policy = ToolPolicy::new();
        let executor = ToolExecutor::new(policy);

        let tool = MockTool {
            name: "test_tool".to_string(),
        };
        let ctx = ToolExecutionContext::new(
            ExecutionId::new(),
            TenantContext::new(TenantId::from("tenant_123")),
        );

        let result = executor
            .execute(&tool, Value::String("test".into()), &ctx)
            .await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_tool_execution_blocked() {
        let policy = ToolPolicy::new().block_tool("blocked_tool");
        let executor = ToolExecutor::new(policy);

        let tool = MockTool {
            name: "blocked_tool".to_string(),
        };
        let ctx = ToolExecutionContext::new(
            ExecutionId::new(),
            TenantContext::new(TenantId::from("tenant_123")),
        );

        let result = executor.execute(&tool, Value::Null, &ctx).await;
        assert!(matches!(
            result,
            Err(ToolExecutionError::PolicyDenied { .. })
        ));
    }

    #[tokio::test]
    async fn test_is_allowed() {
        let policy = ToolPolicy::new().block_tool("blocked_tool");
        let executor = ToolExecutor::new(policy);

        let ctx = ToolExecutionContext::new(
            ExecutionId::new(),
            TenantContext::new(TenantId::from("tenant_123")),
        );

        assert!(executor.is_allowed("allowed_tool", &ctx));
        assert!(!executor.is_allowed("blocked_tool", &ctx));
    }

    #[tokio::test]
    async fn test_policy_decision_event_emission_allowed() {
        let policy = ToolPolicy::new();
        let emitter = EventEmitter::new();
        let executor = ToolExecutor::new(policy).with_emitter(emitter.clone());

        let tool = MockTool {
            name: "test_tool".to_string(),
        };
        let ctx = ToolExecutionContext::new(
            ExecutionId::new(),
            TenantContext::new(TenantId::from("tenant_123")),
        );

        let result = executor.execute(&tool, Value::Null, &ctx).await;
        assert!(result.is_ok());

        // Check that allow event was emitted
        let events = emitter.drain();
        assert_eq!(events.len(), 1);
        match &events[0] {
            StreamEvent::PolicyDecision {
                decision,
                tool_name,
                ..
            } => {
                assert_eq!(decision, "allow");
                assert_eq!(tool_name, "test_tool");
            }
            _ => panic!("Expected PolicyDecision event"),
        }
    }

    #[tokio::test]
    async fn test_policy_decision_event_emission_denied() {
        let policy = ToolPolicy::new().block_tool("blocked_tool");
        let emitter = EventEmitter::new();
        let executor = ToolExecutor::new(policy).with_emitter(emitter.clone());

        let tool = MockTool {
            name: "blocked_tool".to_string(),
        };
        let ctx = ToolExecutionContext::new(
            ExecutionId::new(),
            TenantContext::new(TenantId::from("tenant_123")),
        );

        let result = executor.execute(&tool, Value::Null, &ctx).await;
        assert!(matches!(
            result,
            Err(ToolExecutionError::PolicyDenied { .. })
        ));

        // Check that deny event was emitted
        let events = emitter.drain();
        assert_eq!(events.len(), 1);
        match &events[0] {
            StreamEvent::PolicyDecision {
                decision,
                tool_name,
                reason,
                ..
            } => {
                assert_eq!(decision, "deny");
                assert_eq!(tool_name, "blocked_tool");
                assert!(reason.is_some());
            }
            _ => panic!("Expected PolicyDecision event"),
        }
    }
}