feat(approval): interactive approval workflow for supervised mode (#215)

- Add auto_approve / always_ask fields to AutonomyConfig
- New src/approval/ module: ApprovalManager with session-scoped allowlist,
  ApprovalRequest/Response types, audit logging, CLI interactive prompt
- Insert approval hook in agent_turn before tool execution
- Non-CLI channels auto-approve; CLI shows Y/N/A prompt
- Skip approval for read-only tools (file_read, memory_recall) by default
- 15 unit tests covering all approval logic
This commit is contained in:
stawky 2026-02-16 20:03:26 +08:00 committed by Chummy
parent f489971889
commit ab561baa97
7 changed files with 502 additions and 0 deletions

View file

@ -1,3 +1,4 @@
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
use crate::config::Config; use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory}; use crate::memory::{self, Memory, MemoryCategory};
use crate::observability::{self, Observer, ObserverEvent}; use crate::observability::{self, Observer, ObserverEvent};
@ -512,6 +513,8 @@ pub(crate) async fn agent_turn(
model, model,
temperature, temperature,
silent, silent,
None,
"channel",
) )
.await .await
} }
@ -528,6 +531,8 @@ pub(crate) async fn run_tool_call_loop(
model: &str, model: &str,
temperature: f64, temperature: f64,
silent: bool, silent: bool,
approval: Option<&ApprovalManager>,
channel_name: &str,
) -> Result<String> { ) -> Result<String> {
// Build native tool definitions once if the provider supports them. // Build native tool definitions once if the provider supports them.
let use_native_tools = provider.supports_native_tools() && !tools_registry.is_empty(); 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 // Execute each tool call and build results
let mut tool_results = String::new(); let mut tool_results = String::new();
for call in &tool_calls { 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,
"<tool_result name=\"{}\">\nDenied by user.\n</tool_result>",
call.name
);
continue;
}
}
}
observer.record_event(&ObserverEvent::ToolCallStart { observer.record_event(&ObserverEvent::ToolCallStart {
tool: call.name.clone(), tool: call.name.clone(),
}); });
@ -961,6 +994,9 @@ pub async fn run(
// Append structured tool-use instructions with schemas // Append structured tool-use instructions with schemas
system_prompt.push_str(&build_tool_instructions(&tools_registry)); system_prompt.push_str(&build_tool_instructions(&tools_registry));
// ── Approval manager (supervised mode) ───────────────────────
let approval_manager = ApprovalManager::from_config(&config.autonomy);
// ── Execute ────────────────────────────────────────────────── // ── Execute ──────────────────────────────────────────────────
let start = Instant::now(); let start = Instant::now();
@ -1003,6 +1039,8 @@ pub async fn run(
model_name, model_name,
temperature, temperature,
false, false,
Some(&approval_manager),
"cli",
) )
.await?; .await?;
final_output = response.clone(); final_output = response.clone();
@ -1066,6 +1104,8 @@ pub async fn run(
model_name, model_name,
temperature, temperature,
false, false,
Some(&approval_manager),
"cli",
) )
.await .await
{ {

436
src/approval/mod.rs Normal file
View file

@ -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<String>,
/// Tools that always need approval, ignoring session allowlist.
always_ask: HashSet<String>,
/// Autonomy level from config.
autonomy_level: AutonomyLevel,
/// Session-scoped allowlist built from "Always" responses.
session_allowlist: Mutex<HashSet<String>>,
/// Audit trail of approval decisions.
audit_log: Mutex<Vec<ApprovalLogEntry>>,
}
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<ApprovalLogEntry> {
self.audit_log
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// Get the current session allowlist.
pub fn session_allowlist(&self) -> HashSet<String> {
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<String> = 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");
}
}

View file

@ -215,6 +215,8 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
ctx.model.as_str(), ctx.model.as_str(),
ctx.temperature, ctx.temperature,
true, // silent — channels don't write to stdout true, // silent — channels don't write to stdout
None,
msg.channel.as_str(),
), ),
) )
.await; .await;

View file

@ -882,6 +882,22 @@ pub struct AutonomyConfig {
/// Block high-risk shell commands even if allowlisted. /// Block high-risk shell commands even if allowlisted.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub block_high_risk_commands: bool, 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<String>,
/// Tools that always require interactive approval, even after "Always".
#[serde(default = "default_always_ask")]
pub always_ask: Vec<String>,
}
fn default_auto_approve() -> Vec<String> {
vec!["file_read".into(), "memory_recall".into()]
}
fn default_always_ask() -> Vec<String> {
vec![]
} }
impl Default for AutonomyConfig { impl Default for AutonomyConfig {
@ -927,6 +943,8 @@ impl Default for AutonomyConfig {
max_cost_per_day_cents: 500, max_cost_per_day_cents: 500,
require_approval_for_medium_risk: true, require_approval_for_medium_risk: true,
block_high_risk_commands: 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, max_cost_per_day_cents: 1000,
require_approval_for_medium_risk: false, require_approval_for_medium_risk: false,
block_high_risk_commands: true, block_high_risk_commands: true,
auto_approve: vec!["file_read".into()],
always_ask: vec![],
}, },
runtime: RuntimeConfig { runtime: RuntimeConfig {
kind: "docker".into(), kind: "docker".into(),

View file

@ -39,6 +39,7 @@ use clap::Subcommand;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod agent; pub mod agent;
pub mod approval;
pub mod channels; pub mod channels;
pub mod config; pub mod config;
pub mod cost; pub mod cost;

View file

@ -38,6 +38,7 @@ use tracing::info;
use tracing_subscriber::{fmt, EnvFilter}; use tracing_subscriber::{fmt, EnvFilter};
mod agent; mod agent;
mod approval;
mod channels; mod channels;
mod rag { mod rag {
pub use zeroclaw::rag::*; pub use zeroclaw::rag::*;

View file

@ -849,6 +849,7 @@ mod tests {
max_cost_per_day_cents: 1000, max_cost_per_day_cents: 1000,
require_approval_for_medium_risk: false, require_approval_for_medium_risk: false,
block_high_risk_commands: false, block_high_risk_commands: false,
..crate::config::AutonomyConfig::default()
}; };
let workspace = PathBuf::from("/tmp/test-workspace"); let workspace = PathBuf::from("/tmp/test-workspace");
let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);
@ -1201,6 +1202,7 @@ mod tests {
max_cost_per_day_cents: 100, max_cost_per_day_cents: 100,
require_approval_for_medium_risk: true, require_approval_for_medium_risk: true,
block_high_risk_commands: true, block_high_risk_commands: true,
..crate::config::AutonomyConfig::default()
}; };
let workspace = PathBuf::from("/tmp/test"); let workspace = PathBuf::from("/tmp/test");
let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);