From 5935dae7dd3f83d1dec8fd9873e1f0212df8f8d8 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sun, 23 Nov 2025 00:16:55 +0900 Subject: [PATCH] feat(agent): Add approval callback system for tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ApprovalCallback trait with ApprovalDecision (Approved, Rejected, ModifyInput) - Implement AutoApproveAll, RejectHighRisk, and ChannelApprover callbacks - Integrate approval callback into Agent execution loop - Support interactive approval via channel-based communication - Include comprehensive tests for all approval scenarios All 167 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/miyabi-core/src/agent/approval.rs | 231 +++++++++++++++++++++++ crates/miyabi-core/src/agent/core.rs | 64 ++++++- crates/miyabi-core/src/agent/mod.rs | 5 + 3 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 crates/miyabi-core/src/agent/approval.rs diff --git a/crates/miyabi-core/src/agent/approval.rs b/crates/miyabi-core/src/agent/approval.rs new file mode 100644 index 0000000..21aac52 --- /dev/null +++ b/crates/miyabi-core/src/agent/approval.rs @@ -0,0 +1,231 @@ +//! Approval callback system for tool execution + +use async_trait::async_trait; +use serde_json::Value; + +use super::executor::RiskLevel; + +/// Decision from approval callback +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalDecision { + /// Tool execution approved + Approved, + /// Tool execution rejected with optional reason + Rejected(Option), + /// Request to modify the input before execution + ModifyInput(Value), +} + +/// Request sent to approval callback +#[derive(Debug, Clone)] +pub struct ApprovalRequest { + /// Tool use ID from Claude + pub id: String, + /// Tool name + pub name: String, + /// Tool input parameters + pub input: Value, + /// Risk level of the tool + pub risk_level: RiskLevel, + /// Description of what the tool does + pub description: String, +} + +/// Trait for implementing custom approval handlers +/// +/// Implement this trait to create custom approval UIs or automated +/// approval logic based on tool risk levels. +/// +/// # Example +/// +/// ```rust +/// use miyabi_core::agent::{ApprovalCallback, ApprovalRequest, ApprovalDecision}; +/// use async_trait::async_trait; +/// +/// struct AutoApprover; +/// +/// #[async_trait] +/// impl ApprovalCallback for AutoApprover { +/// async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision { +/// // Auto-approve low risk tools +/// match request.risk_level { +/// miyabi_core::agent::RiskLevel::Low => ApprovalDecision::Approved, +/// _ => ApprovalDecision::Rejected(Some("Manual approval required".to_string())), +/// } +/// } +/// } +/// ``` +#[async_trait] +pub trait ApprovalCallback: Send + Sync { + /// Request approval for tool execution + /// + /// This method is called before executing a tool that requires approval. + /// The implementation should decide whether to approve, reject, or modify + /// the tool execution. + async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision; + + /// Called when tool execution is completed (optional) + async fn on_tool_completed(&self, _request: &ApprovalRequest, _output: &str) {} + + /// Called when tool execution fails (optional) + async fn on_tool_failed(&self, _request: &ApprovalRequest, _error: &str) {} +} + +/// Default approval callback that auto-approves everything +/// +/// This is used when no custom approval callback is provided. +/// Use with caution in production environments. +pub struct AutoApproveAll; + +#[async_trait] +impl ApprovalCallback for AutoApproveAll { + async fn request_approval(&self, _request: &ApprovalRequest) -> ApprovalDecision { + ApprovalDecision::Approved + } +} + +/// Approval callback that requires all high-risk tools to be rejected +pub struct RejectHighRisk; + +#[async_trait] +impl ApprovalCallback for RejectHighRisk { + async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision { + match request.risk_level { + RiskLevel::Low | RiskLevel::Medium => ApprovalDecision::Approved, + RiskLevel::High | RiskLevel::Critical => { + ApprovalDecision::Rejected(Some(format!( + "Tool {} requires manual approval (risk level: {})", + request.name, + request.risk_level.as_str() + ))) + } + } + } +} + +/// Approval callback that uses an async channel for interactive approval +/// +/// This is useful for TUI/GUI applications that need to present +/// approval requests to the user. +pub struct ChannelApprover { + /// Channel to send approval requests + request_tx: tokio::sync::mpsc::Sender, + /// Channel to receive approval decisions + decision_rx: std::sync::Arc>>, +} + +impl ChannelApprover { + /// Create a new channel-based approver + pub fn new() -> ( + Self, + tokio::sync::mpsc::Receiver, + tokio::sync::mpsc::Sender, + ) { + let (request_tx, request_rx) = tokio::sync::mpsc::channel(10); + let (decision_tx, decision_rx) = tokio::sync::mpsc::channel(10); + + let approver = Self { + request_tx, + decision_rx: std::sync::Arc::new(tokio::sync::Mutex::new(decision_rx)), + }; + + (approver, request_rx, decision_tx) + } +} + +#[async_trait] +impl ApprovalCallback for ChannelApprover { + async fn request_approval(&self, request: &ApprovalRequest) -> ApprovalDecision { + // Send request through channel + if self.request_tx.send(request.clone()).await.is_err() { + return ApprovalDecision::Rejected(Some("Approval channel closed".to_string())); + } + + // Wait for decision + let mut rx = self.decision_rx.lock().await; + match rx.recv().await { + Some(decision) => decision, + None => ApprovalDecision::Rejected(Some("No approval decision received".to_string())), + } + } +} + +impl Default for ChannelApprover { + fn default() -> Self { + Self::new().0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_auto_approve_all() { + let approver = AutoApproveAll; + let request = ApprovalRequest { + id: "test-1".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "ls"}), + risk_level: RiskLevel::High, + description: "Execute bash command".to_string(), + }; + + let decision = approver.request_approval(&request).await; + assert_eq!(decision, ApprovalDecision::Approved); + } + + #[tokio::test] + async fn test_reject_high_risk() { + let approver = RejectHighRisk; + + // Low risk should be approved + let low_risk = ApprovalRequest { + id: "test-1".to_string(), + name: "read".to_string(), + input: serde_json::json!({"path": "/test"}), + risk_level: RiskLevel::Low, + description: "Read file".to_string(), + }; + assert_eq!(approver.request_approval(&low_risk).await, ApprovalDecision::Approved); + + // High risk should be rejected + let high_risk = ApprovalRequest { + id: "test-2".to_string(), + name: "bash".to_string(), + input: serde_json::json!({"command": "rm -rf /"}), + risk_level: RiskLevel::High, + description: "Execute bash command".to_string(), + }; + match approver.request_approval(&high_risk).await { + ApprovalDecision::Rejected(_) => {} + _ => panic!("Expected rejection"), + } + } + + #[tokio::test] + async fn test_channel_approver() { + let (approver, mut request_rx, decision_tx) = ChannelApprover::new(); + + let request = ApprovalRequest { + id: "test-1".to_string(), + name: "write".to_string(), + input: serde_json::json!({"path": "/test", "content": "data"}), + risk_level: RiskLevel::Medium, + description: "Write file".to_string(), + }; + + // Spawn task to handle approval + let handle = tokio::spawn(async move { + // Receive request + let _req = request_rx.recv().await.unwrap(); + // Send approval + decision_tx.send(ApprovalDecision::Approved).await.unwrap(); + }); + + let decision = approver.request_approval(&request).await; + assert_eq!(decision, ApprovalDecision::Approved); + + handle.await.unwrap(); + } +} diff --git a/crates/miyabi-core/src/agent/core.rs b/crates/miyabi-core/src/agent/core.rs index ccd535a..24b979b 100644 --- a/crates/miyabi-core/src/agent/core.rs +++ b/crates/miyabi-core/src/agent/core.rs @@ -1,13 +1,15 @@ //! Agent struct and execution loop +use std::sync::Arc; use std::time::Duration; use serde_json::Value; use tokio::sync::mpsc; -use crate::{AnthropicClient, ContentBlock, Message, Role, StopReason}; use crate::hooks::{HookContext, HookEvent, HookManager, HooksConfig}; +use crate::{AnthropicClient, ContentBlock, Message, Role, StopReason}; +use super::approval::{ApprovalCallback, ApprovalDecision, ApprovalRequest, AutoApproveAll}; use super::{AgentError, AgentEvent, AgentResult, ExecutorRegistry}; /// Configuration for agent execution @@ -45,6 +47,7 @@ pub struct Agent { system_prompt: Option, event_tx: Option>, hook_manager: HookManager, + approval_callback: Arc, } impl Agent { @@ -62,6 +65,7 @@ impl Agent { system_prompt: None, event_tx: None, hook_manager, + approval_callback: Arc::new(AutoApproveAll), } } @@ -71,6 +75,12 @@ impl Agent { self } + /// Set custom approval callback + pub fn with_approval_callback(mut self, callback: C) -> Self { + self.approval_callback = Arc::new(callback); + self + } + /// Set agent configuration pub fn with_config(mut self, config: AgentConfig) -> Self { self.config = config; @@ -233,6 +243,8 @@ impl Agent { let needs_approval = !self.is_auto_approved(&tool_use.name) && self.executor_registry.requires_approval(&tool_use.name); + let mut final_input = tool_use.input.clone(); + if needs_approval { self.emit_event(AgentEvent::AwaitingApproval { id: tool_use.id.clone(), @@ -240,8 +252,50 @@ impl Agent { input: tool_use.input.clone(), }) .await; - // In non-interactive mode, auto-approve for now - // TODO: Add approval callback mechanism + + // Request approval via callback + let risk_level = self + .executor_registry + .risk_level(&tool_use.name) + .unwrap_or(super::executor::RiskLevel::Medium); + + let approval_request = ApprovalRequest { + id: tool_use.id.clone(), + name: tool_use.name.clone(), + input: tool_use.input.clone(), + risk_level, + description: self + .executor_registry + .get(&tool_use.name) + .map(|e| e.description().to_string()) + .unwrap_or_default(), + }; + + let decision = self + .approval_callback + .request_approval(&approval_request) + .await; + + match decision { + ApprovalDecision::Approved => { + // Continue with execution + } + ApprovalDecision::Rejected(reason) => { + // Add rejection as tool result + let error_msg = reason.unwrap_or_else(|| { + format!("Tool {} was rejected by approval", tool_use.name) + }); + results.push(ContentBlock::ToolResult { + tool_use_id: tool_use.id, + content: format!("Rejected: {}", error_msg), + }); + continue; + } + ApprovalDecision::ModifyInput(new_input) => { + // Use modified input + final_input = new_input; + } + } } // Execute PreTool hooks @@ -253,13 +307,13 @@ impl Agent { // Execute tool self.emit_event(AgentEvent::ToolExecuting { name: tool_use.name.clone(), - input: tool_use.input.clone(), + input: final_input.clone(), }) .await; match self .executor_registry - .execute(&tool_use.name, tool_use.input.clone()) + .execute(&tool_use.name, final_input.clone()) .await { Ok(output) => { diff --git a/crates/miyabi-core/src/agent/mod.rs b/crates/miyabi-core/src/agent/mod.rs index be3f490..f715459 100644 --- a/crates/miyabi-core/src/agent/mod.rs +++ b/crates/miyabi-core/src/agent/mod.rs @@ -3,10 +3,15 @@ //! This module provides the core Agent loop that enables autonomous //! tool execution with Claude API, similar to Claude Agent SDK. +mod approval; mod core; mod events; mod executor; +pub use approval::{ + ApprovalCallback, ApprovalDecision, ApprovalRequest, AutoApproveAll, ChannelApprover, + RejectHighRisk, +}; pub use core::{Agent, AgentConfig}; pub use events::{AgentError, AgentEvent, AgentResult}; pub use executor::{ExecutorRegistry, RiskLevel, ToolExecutor};