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:
parent
f489971889
commit
ab561baa97
7 changed files with 502 additions and 0 deletions
436
src/approval/mod.rs
Normal file
436
src/approval/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue