Skip to main content

telos_agent/tools/
approval.rs

1//! Asynchronous human-in-the-loop approval for tool calls.
2//!
3//! When a tool or the permission engine returns [`PermissionDecision::Ask`](crate::PermissionDecision::Ask),
4//! the runtime can suspend the turn and ask an [`ApprovalHandler`] to decide
5//! whether to allow, deny, or modify the call.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde_json::Value;
12
13use crate::model::message::Message;
14
15/// A request presented to an approval handler.
16#[derive(Debug, Clone)]
17pub struct ApprovalRequest {
18    /// ID of the tool call awaiting approval.
19    pub tool_call_id: String,
20    /// Canonical tool name.
21    pub tool_name: String,
22    /// Arguments the model supplied.
23    pub arguments: Value,
24    /// Working directory the tool will run in.
25    pub cwd: PathBuf,
26    /// Snapshot of the conversation up to (but not including) this tool call.
27    pub messages: Arc<Vec<Message>>,
28    /// Human-readable reason why approval is required.
29    pub reason: String,
30}
31
32/// Decision returned by an approval handler.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ApprovalDecision {
35    /// Allow the tool call to proceed with the original arguments.
36    Allow,
37    /// Deny the tool call; the model receives an error result.
38    Deny { reason: String },
39    /// Allow the call but replace the arguments with modified ones.
40    Modify { arguments: Value },
41}
42
43/// Handler invoked when a tool call requires explicit human approval.
44#[async_trait]
45pub trait ApprovalHandler: Send + Sync + std::fmt::Debug {
46    /// Present the request to the user/host and return their decision.
47    async fn ask(&self, request: ApprovalRequest) -> ApprovalDecision;
48}
49
50/// Built-in handler that always denies approval requests.
51///
52/// Useful as a safe default when no interactive handler is configured.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct AutoDenyHandler;
55
56#[async_trait]
57impl ApprovalHandler for AutoDenyHandler {
58    async fn ask(&self, _request: ApprovalRequest) -> ApprovalDecision {
59        ApprovalDecision::Deny { reason: "no approval handler configured".into() }
60    }
61}
62
63/// Test helper that always returns a fixed decision.
64#[derive(Debug, Clone)]
65pub struct FixedDecisionHandler {
66    pub decision: ApprovalDecision,
67}
68
69#[async_trait]
70impl ApprovalHandler for FixedDecisionHandler {
71    async fn ask(&self, _request: ApprovalRequest) -> ApprovalDecision {
72        self.decision.clone()
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use serde_json::json;
80
81    #[tokio::test]
82    async fn auto_deny_handler_always_denies() {
83        let handler = AutoDenyHandler;
84        let decision = handler
85            .ask(ApprovalRequest {
86                tool_call_id: "call-1".into(),
87                tool_name: "Bash".into(),
88                arguments: json!({"command": "rm -rf /"}),
89                cwd: PathBuf::from("/tmp"),
90                messages: Arc::new(vec![]),
91                reason: "destructive command".into(),
92            })
93            .await;
94        assert!(matches!(decision, ApprovalDecision::Deny { .. }));
95    }
96
97    #[tokio::test]
98    async fn fixed_handler_returns_configured_decision() {
99        let handler = FixedDecisionHandler { decision: ApprovalDecision::Allow };
100        let decision = handler
101            .ask(ApprovalRequest {
102                tool_call_id: "call-1".into(),
103                tool_name: "Read".into(),
104                arguments: json!({"file_path": "/etc/passwd"}),
105                cwd: PathBuf::from("/tmp"),
106                messages: Arc::new(vec![]),
107                reason: "test".into(),
108            })
109            .await;
110        assert_eq!(decision, ApprovalDecision::Allow);
111    }
112}