use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use everruns_core::atoms::{PreToolUseDecision, PreToolUseHook};
use everruns_core::capabilities::{Capability, CapabilityStatus};
use everruns_core::tool_types::{ToolCall, ToolDefinition};
use everruns_core::traits::ToolContext;
use everruns_core::typed_id::SessionId;
use crate::config::ApprovalMode;
use crate::config::service::ConfigService;
pub(crate) const TOOL_APPROVAL_CAPABILITY_ID: &str = "yolop_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, 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 {
hook: Arc<ToolApprovalHook>,
}
impl ToolApprovalCapability {
pub fn new(approver: Arc<dyn ToolApprover>, config: Arc<dyn ConfigService>) -> Self {
Self {
hook: Arc::new(ToolApprovalHook {
approver,
config,
remembered: Mutex::new(HashMap::new()),
}),
}
}
}
#[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 level."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Safety")
}
fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn PreToolUseHook>> {
vec![self.hook.clone()]
}
}
struct ToolApprovalHook {
approver: Arc<dyn ToolApprover>,
config: Arc<dyn ConfigService>,
remembered: 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 {
let mode = self.config.approval_mode();
if !requires_approval(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 everruns_core::tool_types::{BuiltinTool, ToolHints};
use serde_json::json;
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,
})
}
#[test]
fn classify_reads_hints() {
assert_eq!(
classify(&tool_with(ToolHints {
readonly: Some(true),
..Default::default()
})),
ToolRisk::ReadOnly
);
assert_eq!(
classify(&tool_with(ToolHints {
destructive: Some(true),
..Default::default()
})),
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
));
}
}