use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::atoms::{PreToolUseDecision, PreToolUseHook};
use crate::capabilities::{Capability, CapabilityStatus};
use crate::tool_types::{ToolCall, ToolDefinition};
use crate::traits::ToolContext;
use crate::typed_id::SessionId;
pub const TOOL_APPROVAL_CAPABILITY_ID: &str = "tool_approval";
#[async_trait]
pub trait ToolApprover: Send + Sync {
async fn approve(
&self,
session_id: SessionId,
tool_call: &ToolCall,
tool_def: &ToolDefinition,
) -> ApprovalDecision;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
Allow,
AllowAlways,
Reject,
RejectAlways,
Cancelled,
Unavailable,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalMode {
Protective,
#[default]
Normal,
Off,
}
impl ApprovalMode {
pub fn as_str(self) -> &'static str {
match self {
ApprovalMode::Protective => "protective",
ApprovalMode::Normal => "normal",
ApprovalMode::Off => "off",
}
}
fn from_config(config: &serde_json::Value) -> Self {
match config.get("mode").and_then(serde_json::Value::as_str) {
Some("protective") => ApprovalMode::Protective,
Some("off") => ApprovalMode::Off,
_ => ApprovalMode::Normal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolRisk {
ReadOnly,
Mutating,
Destructive,
}
fn classify(tool_def: &ToolDefinition) -> ToolRisk {
let hints = tool_def.hints();
if hints.readonly == Some(true) {
ToolRisk::ReadOnly
} else if hints.destructive == Some(true) || hints.open_world == Some(true) {
ToolRisk::Destructive
} else {
ToolRisk::Mutating
}
}
fn requires_approval(mode: ApprovalMode, risk: ToolRisk) -> bool {
match mode {
ApprovalMode::Off => false,
ApprovalMode::Normal => matches!(risk, ToolRisk::Destructive),
ApprovalMode::Protective => !matches!(risk, ToolRisk::ReadOnly),
}
}
pub struct ToolApprovalCapability {
approver: Arc<dyn ToolApprover>,
remembered: Arc<Mutex<HashMap<(SessionId, String), bool>>>,
}
impl ToolApprovalCapability {
pub fn new(approver: Arc<dyn ToolApprover>) -> Self {
Self {
approver,
remembered: Arc::new(Mutex::new(HashMap::new())),
}
}
fn hook(&self, mode: ApprovalMode) -> Arc<dyn PreToolUseHook> {
Arc::new(ToolApprovalHook {
approver: self.approver.clone(),
mode,
remembered: self.remembered.clone(),
})
}
}
#[async_trait]
impl Capability for ToolApprovalCapability {
fn id(&self) -> &str {
TOOL_APPROVAL_CAPABILITY_ID
}
fn name(&self) -> &str {
"Tool Approval Gate"
}
fn description(&self) -> &str {
"Blocks risky tools behind an interactive host approval, tuned by the approval mode."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Safety")
}
fn is_guardrail(&self) -> bool {
true
}
fn config_schema(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["off", "normal", "protective"],
"default": "normal",
"title": "Approval mode",
"description": "off: never ask. normal: ask before tools that declare themselves destructive or outward-facing. protective: ask before anything that is not declared read-only.",
}
},
"additionalProperties": false,
}))
}
fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
if config.is_null() {
return Ok(());
}
let Some(object) = config.as_object() else {
return Err("tool_approval config must be an object".to_string());
};
match object.get("mode") {
None => Ok(()),
Some(serde_json::Value::String(mode))
if matches!(mode.as_str(), "off" | "normal" | "protective") =>
{
Ok(())
}
Some(other) => Err(format!(
"tool_approval mode must be one of off|normal|protective, got {other}"
)),
}
}
fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn PreToolUseHook>> {
vec![self.hook(ApprovalMode::default())]
}
fn pre_tool_use_hooks_with_config(
&self,
config: &serde_json::Value,
) -> Vec<Arc<dyn PreToolUseHook>> {
vec![self.hook(ApprovalMode::from_config(config))]
}
}
struct ToolApprovalHook {
approver: Arc<dyn ToolApprover>,
mode: ApprovalMode,
remembered: Arc<Mutex<HashMap<(SessionId, String), bool>>>,
}
impl ToolApprovalHook {
fn block(tool_call: ToolCall, reason: &str) -> PreToolUseDecision {
PreToolUseDecision::Block {
reason: reason.to_string(),
user_message: Some(format!("Denied `{}` — {reason}.", tool_call.name)),
tool_call,
}
}
}
#[async_trait]
impl PreToolUseHook for ToolApprovalHook {
async fn before_exec(
&self,
tool_call: ToolCall,
tool_def: &ToolDefinition,
context: &ToolContext,
) -> PreToolUseDecision {
if !requires_approval(self.mode, classify(tool_def)) {
return PreToolUseDecision::Continue(tool_call);
}
let key = (context.session_id, tool_call.name.clone());
if let Some(&allowed) = self.remembered.lock().unwrap().get(&key) {
return if allowed {
PreToolUseDecision::Continue(tool_call)
} else {
Self::block(tool_call, "rejected earlier this session")
};
}
match self
.approver
.approve(context.session_id, &tool_call, tool_def)
.await
{
ApprovalDecision::Allow | ApprovalDecision::Unavailable => {
PreToolUseDecision::Continue(tool_call)
}
ApprovalDecision::AllowAlways => {
self.remembered.lock().unwrap().insert(key, true);
PreToolUseDecision::Continue(tool_call)
}
ApprovalDecision::Reject => Self::block(tool_call, "rejected by user"),
ApprovalDecision::RejectAlways => {
self.remembered.lock().unwrap().insert(key, false);
Self::block(tool_call, "rejected by user")
}
ApprovalDecision::Cancelled => Self::block(tool_call, "turn cancelled"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_types::{BuiltinTool, ToolHints};
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
fn tool_with(hints: ToolHints) -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: "t".to_string(),
display_name: None,
description: String::new(),
parameters: json!({}),
policy: Default::default(),
category: None,
deferrable: Default::default(),
hints,
full_parameters: None,
})
}
fn destructive_tool() -> ToolDefinition {
tool_with(ToolHints {
destructive: Some(true),
..Default::default()
})
}
fn call() -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: "t".to_string(),
arguments: json!({}),
}
}
struct ScriptedApprover {
decision: ApprovalDecision,
asked: AtomicUsize,
}
impl ScriptedApprover {
fn new(decision: ApprovalDecision) -> Arc<Self> {
Arc::new(Self {
decision,
asked: AtomicUsize::new(0),
})
}
}
#[async_trait]
impl ToolApprover for ScriptedApprover {
async fn approve(
&self,
_session_id: SessionId,
_tool_call: &ToolCall,
_tool_def: &ToolDefinition,
) -> ApprovalDecision {
self.asked.fetch_add(1, Ordering::SeqCst);
self.decision
}
}
#[test]
fn classify_reads_hints() {
assert_eq!(
classify(&tool_with(ToolHints {
readonly: Some(true),
..Default::default()
})),
ToolRisk::ReadOnly
);
assert_eq!(classify(&destructive_tool()), ToolRisk::Destructive);
assert_eq!(
classify(&tool_with(ToolHints {
open_world: Some(true),
..Default::default()
})),
ToolRisk::Destructive
);
assert_eq!(
classify(&tool_with(ToolHints::default())),
ToolRisk::Mutating
);
assert_eq!(
classify(&tool_with(ToolHints {
readonly: Some(true),
open_world: Some(true),
..Default::default()
})),
ToolRisk::ReadOnly
);
}
#[test]
fn policy_matches_approval_semantics() {
for risk in [
ToolRisk::ReadOnly,
ToolRisk::Mutating,
ToolRisk::Destructive,
] {
assert!(!requires_approval(ApprovalMode::Off, risk));
}
assert!(!requires_approval(ApprovalMode::Normal, ToolRisk::ReadOnly));
assert!(!requires_approval(ApprovalMode::Normal, ToolRisk::Mutating));
assert!(requires_approval(
ApprovalMode::Normal,
ToolRisk::Destructive
));
assert!(!requires_approval(
ApprovalMode::Protective,
ToolRisk::ReadOnly
));
assert!(requires_approval(
ApprovalMode::Protective,
ToolRisk::Mutating
));
assert!(requires_approval(
ApprovalMode::Protective,
ToolRisk::Destructive
));
}
#[test]
fn mode_comes_from_capability_config() {
assert_eq!(
ApprovalMode::from_config(&json!({"mode": "protective"})),
ApprovalMode::Protective
);
assert_eq!(
ApprovalMode::from_config(&json!({"mode": "off"})),
ApprovalMode::Off
);
assert_eq!(ApprovalMode::from_config(&json!({})), ApprovalMode::Normal);
assert_eq!(
ApprovalMode::from_config(&json!({"mode": "nonsense"})),
ApprovalMode::Normal
);
}
#[test]
fn config_validation_rejects_unknown_modes() {
let capability =
ToolApprovalCapability::new(ScriptedApprover::new(ApprovalDecision::Allow));
assert!(
capability
.validate_config(&json!({"mode": "protective"}))
.is_ok()
);
assert!(capability.validate_config(&serde_json::Value::Null).is_ok());
assert!(
capability
.validate_config(&json!({"mode": "yolo"}))
.is_err()
);
assert!(capability.validate_config(&json!("protective")).is_err());
}
async fn decide(
approver: Arc<ScriptedApprover>,
mode: ApprovalMode,
runs: usize,
) -> Vec<PreToolUseDecision> {
let capability = ToolApprovalCapability::new(approver);
let hook = capability.hook(mode);
let context = ToolContext::new(SessionId::new_random());
let mut decisions = Vec::new();
for _ in 0..runs {
decisions.push(
hook.before_exec(call(), &destructive_tool(), &context)
.await,
);
}
decisions
}
#[tokio::test]
async fn rejection_blocks_the_call() {
let decisions = decide(
ScriptedApprover::new(ApprovalDecision::Reject),
ApprovalMode::Normal,
1,
)
.await;
assert!(matches!(decisions[0], PreToolUseDecision::Block { .. }));
}
#[tokio::test]
async fn an_unreachable_approver_allows_rather_than_deadlocking() {
let decisions = decide(
ScriptedApprover::new(ApprovalDecision::Unavailable),
ApprovalMode::Normal,
1,
)
.await;
assert!(matches!(decisions[0], PreToolUseDecision::Continue(_)));
}
#[tokio::test]
async fn always_answers_are_remembered_for_the_session() {
let approver = ScriptedApprover::new(ApprovalDecision::AllowAlways);
let decisions = decide(approver.clone(), ApprovalMode::Normal, 3).await;
assert!(
decisions
.iter()
.all(|decision| matches!(decision, PreToolUseDecision::Continue(_)))
);
assert_eq!(
approver.asked.load(Ordering::SeqCst),
1,
"the host should be asked once, then the answer reused"
);
let approver = ScriptedApprover::new(ApprovalDecision::RejectAlways);
let decisions = decide(approver.clone(), ApprovalMode::Normal, 3).await;
assert!(
decisions
.iter()
.all(|decision| matches!(decision, PreToolUseDecision::Block { .. }))
);
assert_eq!(approver.asked.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn one_off_answers_are_asked_every_time() {
let approver = ScriptedApprover::new(ApprovalDecision::Allow);
decide(approver.clone(), ApprovalMode::Normal, 3).await;
assert_eq!(approver.asked.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn off_never_asks() {
let approver = ScriptedApprover::new(ApprovalDecision::Reject);
let decisions = decide(approver.clone(), ApprovalMode::Off, 1).await;
assert!(matches!(decisions[0], PreToolUseDecision::Continue(_)));
assert_eq!(approver.asked.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn cancellation_blocks_the_call() {
let decisions = decide(
ScriptedApprover::new(ApprovalDecision::Cancelled),
ApprovalMode::Normal,
1,
)
.await;
match &decisions[0] {
PreToolUseDecision::Block { reason, .. } => assert_eq!(reason, "turn cancelled"),
other => panic!("expected a block, got {other:?}"),
}
}
}