diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index b86c91eb4..b0f65d410 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -2696,11 +2696,13 @@ pub(crate) async fn run_tool_call_loop( arguments: tool_args.clone(), }; - // Only prompt interactively on CLI; auto-approve on other channels. - let decision = if channel_name == "cli" { - mgr.prompt_cli(&request) + // Interactive CLI: prompt the operator. + // Non-interactive (channels): auto-deny since no operator + // is present to approve. + let decision = if mgr.is_non_interactive() { + ApprovalResponse::No } else { - ApprovalResponse::Yes + mgr.prompt_cli(&request) }; mgr.record_decision(&tool_name, &tool_args, decision, channel_name); diff --git a/src/approval/mod.rs b/src/approval/mod.rs index 79fe0880c..a3060488c 100644 --- a/src/approval/mod.rs +++ b/src/approval/mod.rs @@ -44,11 +44,18 @@ pub struct ApprovalLogEntry { // ── ApprovalManager ────────────────────────────────────────────── -/// Manages the interactive approval workflow. +/// Manages the approval workflow for tool calls. /// /// - Checks config-level `auto_approve` / `always_ask` lists /// - Maintains a session-scoped "always" allowlist /// - Records an audit trail of all decisions +/// +/// Two modes: +/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt. +/// - **Non-interactive** (channels): tools needing approval are auto-denied +/// because there is no interactive operator to approve them. `auto_approve` +/// policy is still enforced, and `always_ask` / supervised-default tools are +/// denied rather than silently allowed. pub struct ApprovalManager { /// Tools that never need approval (from config). auto_approve: HashSet, @@ -56,6 +63,9 @@ pub struct ApprovalManager { always_ask: HashSet, /// Autonomy level from config. autonomy_level: AutonomyLevel, + /// When `true`, tools that would require interactive approval are + /// auto-denied instead. Used for channel-driven (non-CLI) runs. + non_interactive: bool, /// Session-scoped allowlist built from "Always" responses. session_allowlist: Mutex>, /// Audit trail of approval decisions. @@ -63,17 +73,40 @@ pub struct ApprovalManager { } impl ApprovalManager { - /// Create from autonomy config. + /// Create an interactive (CLI) approval manager from autonomy config. pub fn from_config(config: &AutonomyConfig) -> Self { Self { auto_approve: config.auto_approve.iter().cloned().collect(), always_ask: config.always_ask.iter().cloned().collect(), autonomy_level: config.level, + non_interactive: false, session_allowlist: Mutex::new(HashSet::new()), audit_log: Mutex::new(Vec::new()), } } + /// Create a non-interactive approval manager for channel-driven runs. + /// + /// Enforces the same `auto_approve` / `always_ask` / supervised policies + /// as the CLI manager, but tools that would require interactive approval + /// are auto-denied instead of prompting (since there is no operator). + pub fn for_non_interactive(config: &AutonomyConfig) -> Self { + Self { + auto_approve: config.auto_approve.iter().cloned().collect(), + always_ask: config.always_ask.iter().cloned().collect(), + autonomy_level: config.level, + non_interactive: true, + session_allowlist: Mutex::new(HashSet::new()), + audit_log: Mutex::new(Vec::new()), + } + } + + /// Returns `true` when this manager operates in non-interactive mode + /// (i.e. for channel-driven runs where no operator can approve). + pub fn is_non_interactive(&self) -> bool { + self.non_interactive + } + /// Check whether a tool call requires interactive approval. /// /// Returns `true` if the call needs a prompt, `false` if it can proceed. @@ -147,8 +180,8 @@ impl ApprovalManager { /// Prompt the user on the CLI and return their decision. /// - /// For non-CLI channels, returns `Yes` automatically (interactive - /// approval is only supported on CLI for now). + /// Only called for interactive (CLI) managers. Non-interactive managers + /// auto-deny in the tool-call loop before reaching this point. pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse { prompt_cli_interactive(request) } @@ -401,6 +434,97 @@ mod tests { assert!(summary.contains("just a string")); } + // ── non-interactive (channel) mode ──────────────────────── + + #[test] + fn non_interactive_manager_reports_non_interactive() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + assert!(mgr.is_non_interactive()); + } + + #[test] + fn interactive_manager_reports_interactive() { + let mgr = ApprovalManager::from_config(&supervised_config()); + assert!(!mgr.is_non_interactive()); + } + + #[test] + fn non_interactive_auto_approve_tools_skip_approval() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + // auto_approve tools (file_read, memory_recall) should not need approval. + assert!(!mgr.needs_approval("file_read")); + assert!(!mgr.needs_approval("memory_recall")); + } + + #[test] + fn non_interactive_always_ask_tools_need_approval() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + // always_ask tools (shell) still report as needing approval, + // so the tool-call loop will auto-deny them in non-interactive mode. + assert!(mgr.needs_approval("shell")); + } + + #[test] + fn non_interactive_unknown_tools_need_approval_in_supervised() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + // Unknown tools in supervised mode need approval (will be auto-denied + // by the tool-call loop for non-interactive managers). + assert!(mgr.needs_approval("file_write")); + assert!(mgr.needs_approval("http_request")); + } + + #[test] + fn non_interactive_full_autonomy_never_needs_approval() { + let mgr = ApprovalManager::for_non_interactive(&full_config()); + // Full autonomy means no approval needed, even in non-interactive mode. + assert!(!mgr.needs_approval("shell")); + assert!(!mgr.needs_approval("file_write")); + assert!(!mgr.needs_approval("anything")); + } + + #[test] + fn non_interactive_readonly_never_needs_approval() { + let config = AutonomyConfig { + level: AutonomyLevel::ReadOnly, + ..AutonomyConfig::default() + }; + let mgr = ApprovalManager::for_non_interactive(&config); + // ReadOnly blocks execution elsewhere; approval manager does not prompt. + assert!(!mgr.needs_approval("shell")); + } + + #[test] + fn non_interactive_session_allowlist_still_works() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + assert!(mgr.needs_approval("file_write")); + + // Simulate an "Always" decision (would come from a prior channel run + // if the tool was auto-approved somehow, e.g. via config change). + mgr.record_decision( + "file_write", + &serde_json::json!({"path": "test.txt"}), + ApprovalResponse::Always, + "telegram", + ); + + assert!(!mgr.needs_approval("file_write")); + } + + #[test] + fn non_interactive_always_ask_overrides_session_allowlist() { + let mgr = ApprovalManager::for_non_interactive(&supervised_config()); + + mgr.record_decision( + "shell", + &serde_json::json!({"command": "ls"}), + ApprovalResponse::Always, + "telegram", + ); + + // shell is in always_ask, so it still needs approval even after "Always". + assert!(mgr.needs_approval("shell")); + } + // ── ApprovalResponse serde ─────────────────────────────── #[test] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index d257921be..f28f407c0 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -76,6 +76,7 @@ pub use whatsapp::WhatsAppChannel; pub use whatsapp_web::WhatsAppWebChannel; use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop, scrub_credentials}; +use crate::approval::ApprovalManager; use crate::config::Config; use crate::identity; use crate::memory::{self, Memory}; @@ -314,6 +315,11 @@ struct ChannelRuntimeContext { ack_reactions: bool, show_tool_calls: bool, session_store: Option>, + /// Non-interactive approval manager for channel-driven runs. + /// Enforces `auto_approve` / `always_ask` / supervised policy from + /// `[autonomy]` config; auto-denies tools that would need interactive + /// approval since no operator is present on channel runs. + approval_manager: Arc, } #[derive(Clone)] @@ -2025,7 +2031,7 @@ async fn process_channel_message( route.model.as_str(), runtime_defaults.temperature, true, - None, + Some(&*ctx.approval_manager), msg.channel.as_str(), &ctx.multimodal, ctx.max_tool_iterations, @@ -3853,6 +3859,7 @@ pub async fn start_channels(config: Config) -> Result<()> { } else { None }, + approval_manager: Arc::new(ApprovalManager::for_non_interactive(&config.autonomy)), }); // Hydrate in-memory conversation histories from persisted JSONL session files. @@ -4141,6 +4148,9 @@ mod tests { ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }; assert!(compact_sender_history(&ctx, &sender)); @@ -4245,6 +4255,9 @@ mod tests { ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }; append_sender_turn(&ctx, &sender, ChatMessage::user("hello")); @@ -4305,6 +4318,9 @@ mod tests { ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }; assert!(rollback_orphan_user_turn(&ctx, &sender, "pending")); @@ -4823,6 +4839,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -4891,6 +4910,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -4973,6 +4995,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5040,6 +5065,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5117,6 +5145,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5214,6 +5245,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5293,6 +5327,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5387,6 +5424,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5466,6 +5506,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5535,6 +5578,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -5715,6 +5761,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); let (tx, rx) = tokio::sync::mpsc::channel::(4); @@ -5803,6 +5852,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -5906,6 +5958,9 @@ BTC is currently around $65,000 based on latest tool output."# non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -6006,6 +6061,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -6088,6 +6146,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -6155,6 +6216,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -6780,6 +6844,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -6873,6 +6940,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -6966,6 +7036,9 @@ BTC is currently around $65,000 based on latest tool output."# ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message( @@ -7523,6 +7596,9 @@ This is an example JSON object for profile settings."#; ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); // Simulate a photo attachment message with [IMAGE:] marker. @@ -7597,6 +7673,9 @@ This is an example JSON object for profile settings."#; ack_reactions: true, show_tool_calls: true, session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), }); process_channel_message(