diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 81882d6..f2e7592 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -1,3 +1,4 @@ +use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse}; use crate::config::Config; use crate::memory::{self, Memory, MemoryCategory}; use crate::observability::{self, Observer, ObserverEvent}; @@ -512,6 +513,8 @@ pub(crate) async fn agent_turn( model, temperature, silent, + None, + "channel", ) .await } @@ -528,6 +531,8 @@ pub(crate) async fn run_tool_call_loop( model: &str, temperature: f64, silent: bool, + approval: Option<&ApprovalManager>, + channel_name: &str, ) -> Result { // Build native tool definitions once if the provider supports them. let use_native_tools = provider.supports_native_tools() && !tools_registry.is_empty(); @@ -651,6 +656,34 @@ pub(crate) async fn run_tool_call_loop( // Execute each tool call and build results let mut tool_results = String::new(); for call in &tool_calls { + // ── Approval hook ──────────────────────────────── + if let Some(mgr) = approval { + if mgr.needs_approval(&call.name) { + let request = ApprovalRequest { + tool_name: call.name.clone(), + arguments: call.arguments.clone(), + }; + + // Only prompt interactively on CLI; auto-approve on other channels. + let decision = if channel_name == "cli" { + mgr.prompt_cli(&request) + } else { + ApprovalResponse::Yes + }; + + mgr.record_decision(&call.name, &call.arguments, decision, channel_name); + + if decision == ApprovalResponse::No { + let _ = writeln!( + tool_results, + "\nDenied by user.\n", + call.name + ); + continue; + } + } + } + observer.record_event(&ObserverEvent::ToolCallStart { tool: call.name.clone(), }); @@ -961,6 +994,9 @@ pub async fn run( // Append structured tool-use instructions with schemas system_prompt.push_str(&build_tool_instructions(&tools_registry)); + // ── Approval manager (supervised mode) ─────────────────────── + let approval_manager = ApprovalManager::from_config(&config.autonomy); + // ── Execute ────────────────────────────────────────────────── let start = Instant::now(); @@ -1003,6 +1039,8 @@ pub async fn run( model_name, temperature, false, + Some(&approval_manager), + "cli", ) .await?; final_output = response.clone(); @@ -1066,6 +1104,8 @@ pub async fn run( model_name, temperature, false, + Some(&approval_manager), + "cli", ) .await { diff --git a/src/approval/mod.rs b/src/approval/mod.rs new file mode 100644 index 0000000..c673b46 --- /dev/null +++ b/src/approval/mod.rs @@ -0,0 +1,436 @@ +//! Interactive approval workflow for supervised mode. +//! +//! Provides a pre-execution hook that prompts the user before tool calls, +//! with session-scoped "Always" allowlists and audit logging. + +use crate::config::AutonomyConfig; +use crate::security::AutonomyLevel; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::io::{self, BufRead, Write}; +use std::sync::Mutex; + +// ── Types ──────────────────────────────────────────────────────── + +/// A request to approve a tool call before execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequest { + pub tool_name: String, + pub arguments: serde_json::Value, +} + +/// The user's response to an approval request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApprovalResponse { + /// Execute this one call. + Yes, + /// Deny this call. + No, + /// Execute and add tool to session-scoped allowlist. + Always, +} + +/// A single audit log entry for an approval decision. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalLogEntry { + pub timestamp: String, + pub tool_name: String, + pub arguments_summary: String, + pub decision: ApprovalResponse, + pub channel: String, +} + +// ── ApprovalManager ────────────────────────────────────────────── + +/// Manages the interactive approval workflow. +/// +/// - Checks config-level `auto_approve` / `always_ask` lists +/// - Maintains a session-scoped "always" allowlist +/// - Records an audit trail of all decisions +pub struct ApprovalManager { + /// Tools that never need approval (from config). + auto_approve: HashSet, + /// Tools that always need approval, ignoring session allowlist. + always_ask: HashSet, + /// Autonomy level from config. + autonomy_level: AutonomyLevel, + /// Session-scoped allowlist built from "Always" responses. + session_allowlist: Mutex>, + /// Audit trail of approval decisions. + audit_log: Mutex>, +} + +impl ApprovalManager { + /// Create 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, + session_allowlist: Mutex::new(HashSet::new()), + audit_log: Mutex::new(Vec::new()), + } + } + + /// Check whether a tool call requires interactive approval. + /// + /// Returns `true` if the call needs a prompt, `false` if it can proceed. + pub fn needs_approval(&self, tool_name: &str) -> bool { + // Full autonomy never prompts. + if self.autonomy_level == AutonomyLevel::Full { + return false; + } + + // ReadOnly blocks everything — handled elsewhere; no prompt needed. + if self.autonomy_level == AutonomyLevel::ReadOnly { + return false; + } + + // always_ask overrides everything. + if self.always_ask.contains(tool_name) { + return true; + } + + // auto_approve skips the prompt. + if self.auto_approve.contains(tool_name) { + return false; + } + + // Session allowlist (from prior "Always" responses). + let allowlist = self + .session_allowlist + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if allowlist.contains(tool_name) { + return false; + } + + // Default: supervised mode requires approval. + true + } + + /// Record an approval decision and update session state. + pub fn record_decision( + &self, + tool_name: &str, + args: &serde_json::Value, + decision: ApprovalResponse, + channel: &str, + ) { + // If "Always", add to session allowlist. + if decision == ApprovalResponse::Always { + let mut allowlist = self + .session_allowlist + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + allowlist.insert(tool_name.to_string()); + } + + // Append to audit log. + let summary = summarize_args(args); + let entry = ApprovalLogEntry { + timestamp: Utc::now().to_rfc3339(), + tool_name: tool_name.to_string(), + arguments_summary: summary, + decision, + channel: channel.to_string(), + }; + let mut log = self + .audit_log + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + log.push(entry); + } + + /// Get a snapshot of the audit log. + pub fn audit_log(&self) -> Vec { + self.audit_log + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Get the current session allowlist. + pub fn session_allowlist(&self) -> HashSet { + self.session_allowlist + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// 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). + pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse { + prompt_cli_interactive(request) + } +} + +// ── CLI prompt ─────────────────────────────────────────────────── + +/// Display the approval prompt and read user input from stdin. +fn prompt_cli_interactive(request: &ApprovalRequest) -> ApprovalResponse { + let summary = summarize_args(&request.arguments); + eprintln!(); + eprintln!("🔧 Agent wants to execute: {}", request.tool_name); + eprintln!(" {summary}"); + eprint!(" [Y]es / [N]o / [A]lways for {}: ", request.tool_name); + let _ = io::stderr().flush(); + + let stdin = io::stdin(); + let mut line = String::new(); + if stdin.lock().read_line(&mut line).is_err() { + return ApprovalResponse::No; + } + + match line.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => ApprovalResponse::Yes, + "a" | "always" => ApprovalResponse::Always, + _ => ApprovalResponse::No, + } +} + +/// Produce a short human-readable summary of tool arguments. +fn summarize_args(args: &serde_json::Value) -> String { + match args { + serde_json::Value::Object(map) => { + let parts: Vec = map + .iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => { + if s.len() > 80 { + format!("{}…", &s[..77]) + } else { + s.clone() + } + } + other => { + let s = other.to_string(); + if s.len() > 80 { + format!("{}…", &s[..77]) + } else { + s + } + } + }; + format!("{k}: {val}") + }) + .collect(); + parts.join(", ") + } + other => { + let s = other.to_string(); + if s.len() > 120 { + format!("{}…", &s[..117]) + } else { + s + } + } + } +} + +// ── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AutonomyConfig; + + fn supervised_config() -> AutonomyConfig { + AutonomyConfig { + level: AutonomyLevel::Supervised, + auto_approve: vec!["file_read".into(), "memory_recall".into()], + always_ask: vec!["shell".into()], + ..AutonomyConfig::default() + } + } + + fn full_config() -> AutonomyConfig { + AutonomyConfig { + level: AutonomyLevel::Full, + ..AutonomyConfig::default() + } + } + + // ── needs_approval ─────────────────────────────────────── + + #[test] + fn auto_approve_tools_skip_prompt() { + let mgr = ApprovalManager::from_config(&supervised_config()); + assert!(!mgr.needs_approval("file_read")); + assert!(!mgr.needs_approval("memory_recall")); + } + + #[test] + fn always_ask_tools_always_prompt() { + let mgr = ApprovalManager::from_config(&supervised_config()); + assert!(mgr.needs_approval("shell")); + } + + #[test] + fn unknown_tool_needs_approval_in_supervised() { + let mgr = ApprovalManager::from_config(&supervised_config()); + assert!(mgr.needs_approval("file_write")); + assert!(mgr.needs_approval("http_request")); + } + + #[test] + fn full_autonomy_never_prompts() { + let mgr = ApprovalManager::from_config(&full_config()); + assert!(!mgr.needs_approval("shell")); + assert!(!mgr.needs_approval("file_write")); + assert!(!mgr.needs_approval("anything")); + } + + #[test] + fn readonly_never_prompts() { + let config = AutonomyConfig { + level: AutonomyLevel::ReadOnly, + ..AutonomyConfig::default() + }; + let mgr = ApprovalManager::from_config(&config); + assert!(!mgr.needs_approval("shell")); + } + + // ── session allowlist ──────────────────────────────────── + + #[test] + fn always_response_adds_to_session_allowlist() { + let mgr = ApprovalManager::from_config(&supervised_config()); + assert!(mgr.needs_approval("file_write")); + + mgr.record_decision( + "file_write", + &serde_json::json!({"path": "test.txt"}), + ApprovalResponse::Always, + "cli", + ); + + // Now file_write should be in session allowlist. + assert!(!mgr.needs_approval("file_write")); + } + + #[test] + fn always_ask_overrides_session_allowlist() { + let mgr = ApprovalManager::from_config(&supervised_config()); + + // Even after "Always" for shell, it should still prompt. + mgr.record_decision( + "shell", + &serde_json::json!({"command": "ls"}), + ApprovalResponse::Always, + "cli", + ); + + // shell is in always_ask, so it still needs approval. + assert!(mgr.needs_approval("shell")); + } + + #[test] + fn yes_response_does_not_add_to_allowlist() { + let mgr = ApprovalManager::from_config(&supervised_config()); + mgr.record_decision( + "file_write", + &serde_json::json!({}), + ApprovalResponse::Yes, + "cli", + ); + assert!(mgr.needs_approval("file_write")); + } + + // ── audit log ──────────────────────────────────────────── + + #[test] + fn audit_log_records_decisions() { + let mgr = ApprovalManager::from_config(&supervised_config()); + + mgr.record_decision( + "shell", + &serde_json::json!({"command": "rm -rf ./build/"}), + ApprovalResponse::No, + "cli", + ); + mgr.record_decision( + "file_write", + &serde_json::json!({"path": "out.txt", "content": "hello"}), + ApprovalResponse::Yes, + "cli", + ); + + let log = mgr.audit_log(); + assert_eq!(log.len(), 2); + assert_eq!(log[0].tool_name, "shell"); + assert_eq!(log[0].decision, ApprovalResponse::No); + assert_eq!(log[1].tool_name, "file_write"); + assert_eq!(log[1].decision, ApprovalResponse::Yes); + } + + #[test] + fn audit_log_contains_timestamp_and_channel() { + let mgr = ApprovalManager::from_config(&supervised_config()); + mgr.record_decision( + "shell", + &serde_json::json!({"command": "ls"}), + ApprovalResponse::Yes, + "telegram", + ); + + let log = mgr.audit_log(); + assert_eq!(log.len(), 1); + assert!(!log[0].timestamp.is_empty()); + assert_eq!(log[0].channel, "telegram"); + } + + // ── summarize_args ─────────────────────────────────────── + + #[test] + fn summarize_args_object() { + let args = serde_json::json!({"command": "ls -la", "cwd": "/tmp"}); + let summary = summarize_args(&args); + assert!(summary.contains("command: ls -la")); + assert!(summary.contains("cwd: /tmp")); + } + + #[test] + fn summarize_args_truncates_long_values() { + let long_val = "x".repeat(200); + let args = serde_json::json!({"content": long_val}); + let summary = summarize_args(&args); + assert!(summary.contains('…')); + assert!(summary.len() < 200); + } + + #[test] + fn summarize_args_non_object() { + let args = serde_json::json!("just a string"); + let summary = summarize_args(&args); + assert!(summary.contains("just a string")); + } + + // ── ApprovalResponse serde ─────────────────────────────── + + #[test] + fn approval_response_serde_roundtrip() { + let json = serde_json::to_string(&ApprovalResponse::Always).unwrap(); + assert_eq!(json, "\"always\""); + let parsed: ApprovalResponse = serde_json::from_str("\"no\"").unwrap(); + assert_eq!(parsed, ApprovalResponse::No); + } + + // ── ApprovalRequest ────────────────────────────────────── + + #[test] + fn approval_request_serde() { + let req = ApprovalRequest { + tool_name: "shell".into(), + arguments: serde_json::json!({"command": "echo hi"}), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tool_name, "shell"); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 5908adf..cb293cd 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -215,6 +215,8 @@ async fn process_channel_message(ctx: Arc, msg: traits::C ctx.model.as_str(), ctx.temperature, true, // silent — channels don't write to stdout + None, + msg.channel.as_str(), ), ) .await; diff --git a/src/config/schema.rs b/src/config/schema.rs index 2c2af1b..99ac0fe 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -882,6 +882,22 @@ pub struct AutonomyConfig { /// Block high-risk shell commands even if allowlisted. #[serde(default = "default_true")] pub block_high_risk_commands: bool, + + /// Tools that never require approval (e.g. read-only tools). + #[serde(default = "default_auto_approve")] + pub auto_approve: Vec, + + /// Tools that always require interactive approval, even after "Always". + #[serde(default = "default_always_ask")] + pub always_ask: Vec, +} + +fn default_auto_approve() -> Vec { + vec!["file_read".into(), "memory_recall".into()] +} + +fn default_always_ask() -> Vec { + vec![] } impl Default for AutonomyConfig { @@ -927,6 +943,8 @@ impl Default for AutonomyConfig { max_cost_per_day_cents: 500, require_approval_for_medium_risk: true, block_high_risk_commands: true, + auto_approve: default_auto_approve(), + always_ask: default_always_ask(), } } } @@ -2157,6 +2175,8 @@ default_temperature = 0.7 max_cost_per_day_cents: 1000, require_approval_for_medium_risk: false, block_high_risk_commands: true, + auto_approve: vec!["file_read".into()], + always_ask: vec![], }, runtime: RuntimeConfig { kind: "docker".into(), diff --git a/src/lib.rs b/src/lib.rs index 726d756..9856880 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ use clap::Subcommand; use serde::{Deserialize, Serialize}; pub mod agent; +pub mod approval; pub mod channels; pub mod config; pub mod cost; diff --git a/src/main.rs b/src/main.rs index ecb5fb0..181c046 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ use tracing::info; use tracing_subscriber::{fmt, EnvFilter}; mod agent; +mod approval; mod channels; mod rag { pub use zeroclaw::rag::*; diff --git a/src/security/policy.rs b/src/security/policy.rs index e47947a..7db3ef8 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -849,6 +849,7 @@ mod tests { max_cost_per_day_cents: 1000, require_approval_for_medium_risk: false, block_high_risk_commands: false, + ..crate::config::AutonomyConfig::default() }; let workspace = PathBuf::from("/tmp/test-workspace"); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); @@ -1201,6 +1202,7 @@ mod tests { max_cost_per_day_cents: 100, require_approval_for_medium_risk: true, block_high_risk_commands: true, + ..crate::config::AutonomyConfig::default() }; let workspace = PathBuf::from("/tmp/test"); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);