Skip to main content

agent_air_runtime/controller/tools/
ask_for_permissions.rs

1//! AskForPermissions tool implementation
2//!
3//! This tool allows the LLM to request permission from the user before
4//! performing sensitive actions like file writes, deletions, or network operations.
5
6use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use super::types::{
12    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
13};
14use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
15
16/// AskForPermissions tool name constant.
17pub const ASK_FOR_PERMISSIONS_TOOL_NAME: &str = "ask_for_permissions";
18
19/// AskForPermissions tool description constant.
20pub const ASK_FOR_PERMISSIONS_TOOL_DESCRIPTION: &str = "Request permission from the user before performing sensitive actions like file writes, \
21     deletions, network operations, or system commands. The user can grant permission for \
22     this request only (once) or for the remainder of the session.";
23
24/// AskForPermissions tool JSON schema constant.
25///
26/// Uses the standardized permission format with explicit target type and level.
27pub const ASK_FOR_PERMISSIONS_TOOL_SCHEMA: &str = r#"{
28    "type": "object",
29    "properties": {
30        "target_type": {
31            "type": "string",
32            "enum": ["path", "domain", "command"],
33            "description": "Type of resource: 'path' for files/directories, 'domain' for network endpoints, 'command' for shell commands"
34        },
35        "target": {
36            "type": "string",
37            "description": "The resource being accessed: file path, domain pattern, or command pattern"
38        },
39        "level": {
40            "type": "string",
41            "enum": ["read", "write", "execute", "admin"],
42            "description": "Permission level required: 'read' for viewing, 'write' for modification, 'execute' for running, 'admin' for deletion/full control"
43        },
44        "recursive": {
45            "type": "boolean",
46            "description": "For path targets only: whether to include subdirectories",
47            "default": false
48        },
49        "description": {
50            "type": "string",
51            "description": "Human-readable description of the action requiring permission"
52        },
53        "reason": {
54            "type": "string",
55            "description": "Why this action is needed"
56        }
57    },
58    "required": ["target_type", "target", "level", "description"]
59}"#;
60
61/// Tool that requests permission from the user for sensitive actions.
62pub struct AskForPermissionsTool {
63    /// Registry for managing pending permission requests.
64    registry: Arc<PermissionRegistry>,
65}
66
67impl AskForPermissionsTool {
68    /// Create a new AskForPermissionsTool instance.
69    ///
70    /// # Arguments
71    /// * `registry` - The permission registry to use for tracking requests.
72    pub fn new(registry: Arc<PermissionRegistry>) -> Self {
73        Self { registry }
74    }
75}
76
77impl Executable for AskForPermissionsTool {
78    fn name(&self) -> &str {
79        ASK_FOR_PERMISSIONS_TOOL_NAME
80    }
81
82    fn description(&self) -> &str {
83        ASK_FOR_PERMISSIONS_TOOL_DESCRIPTION
84    }
85
86    fn input_schema(&self) -> &str {
87        ASK_FOR_PERMISSIONS_TOOL_SCHEMA
88    }
89
90    fn tool_type(&self) -> ToolType {
91        ToolType::UserInteraction
92    }
93
94    fn execute(
95        &self,
96        context: ToolContext,
97        input: HashMap<String, serde_json::Value>,
98    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
99        let registry = self.registry.clone();
100
101        Box::pin(async move {
102            // Parse target_type
103            let target_type = input
104                .get("target_type")
105                .and_then(|v| v.as_str())
106                .ok_or_else(|| "Missing 'target_type' field".to_string())?;
107
108            // Parse target
109            let target_value = input
110                .get("target")
111                .and_then(|v| v.as_str())
112                .ok_or_else(|| "Missing 'target' field".to_string())?
113                .to_string();
114
115            // Parse level
116            let level_str = input
117                .get("level")
118                .and_then(|v| v.as_str())
119                .ok_or_else(|| "Missing 'level' field".to_string())?;
120
121            let level = match level_str {
122                "read" => PermissionLevel::Read,
123                "write" => PermissionLevel::Write,
124                "execute" => PermissionLevel::Execute,
125                "admin" => PermissionLevel::Admin,
126                _ => {
127                    return Err(format!(
128                        "Invalid level '{}': must be read, write, execute, or admin",
129                        level_str
130                    ));
131                }
132            };
133
134            // Parse recursive (optional, defaults to false)
135            let recursive = input
136                .get("recursive")
137                .and_then(|v| v.as_bool())
138                .unwrap_or(false);
139
140            // Parse description
141            let description = input
142                .get("description")
143                .and_then(|v| v.as_str())
144                .ok_or_else(|| "Missing 'description' field".to_string())?
145                .to_string();
146
147            // Validate description is not empty
148            if description.trim().is_empty() {
149                return Err("Description cannot be empty".to_string());
150            }
151
152            // Parse optional reason
153            let reason = input
154                .get("reason")
155                .and_then(|v| v.as_str())
156                .map(|s| s.to_string());
157
158            // Build the grant target based on target_type
159            let target = match target_type {
160                "path" => GrantTarget::path(&target_value, recursive),
161                "domain" => GrantTarget::Domain {
162                    pattern: target_value,
163                },
164                "command" => GrantTarget::Command {
165                    pattern: target_value,
166                },
167                _ => {
168                    return Err(format!(
169                        "Invalid target_type '{}': must be path, domain, or command",
170                        target_type
171                    ));
172                }
173            };
174
175            // Build the permission request using the new types directly
176            let mut request =
177                PermissionRequest::new(&context.tool_use_id, target, level, &description);
178            if let Some(r) = reason {
179                request = request.with_reason(&r);
180            }
181            request = request.with_tool(ASK_FOR_PERMISSIONS_TOOL_NAME);
182
183            // Request permission (auto-approves if already granted)
184            let response_rx = registry
185                .request_permission(context.session_id, request, context.turn_id)
186                .await
187                .map_err(|e| format!("Failed to request permission: {}", e))?;
188
189            // Block waiting for user response
190            let response = response_rx
191                .await
192                .map_err(|_| "User declined to grant permission".to_string())?;
193
194            // Return the response as JSON
195            serde_json::to_string(&response)
196                .map_err(|e| format!("Failed to serialize response: {}", e))
197        })
198    }
199
200    fn display_config(&self) -> DisplayConfig {
201        DisplayConfig {
202            display_name: "Permission Request".to_string(),
203            display_title: Box::new(|input| {
204                let target_type = input
205                    .get("target_type")
206                    .and_then(|v| v.as_str())
207                    .unwrap_or("unknown");
208                let level = input
209                    .get("level")
210                    .and_then(|v| v.as_str())
211                    .unwrap_or("unknown");
212
213                format!(
214                    "{} {}",
215                    capitalize(level),
216                    match target_type {
217                        "path" => "Path",
218                        "domain" => "Domain",
219                        "command" => "Command",
220                        _ => "Permission",
221                    }
222                )
223            }),
224            display_content: Box::new(|input, _result| {
225                let description = input
226                    .get("description")
227                    .and_then(|v| v.as_str())
228                    .unwrap_or("Unknown action");
229
230                let target = input
231                    .get("target")
232                    .and_then(|v| v.as_str())
233                    .map(|t| format!("\nTarget: {}", t))
234                    .unwrap_or_default();
235
236                let reason = input
237                    .get("reason")
238                    .and_then(|v| v.as_str())
239                    .map(|r| format!("\nReason: {}", r))
240                    .unwrap_or_default();
241
242                let content = format!("{}{}{}", description, target, reason);
243
244                DisplayResult {
245                    content,
246                    content_type: ResultContentType::PlainText,
247                    is_truncated: false,
248                    full_length: 0,
249                }
250            }),
251        }
252    }
253
254    fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, result: &str) -> String {
255        let target_type = input
256            .get("target_type")
257            .and_then(|v| v.as_str())
258            .unwrap_or("unknown");
259
260        let level = input
261            .get("level")
262            .and_then(|v| v.as_str())
263            .unwrap_or("unknown");
264
265        let granted = result.contains("\"granted\":true");
266        let status = if granted { "granted" } else { "denied" };
267
268        format!("[Permission {} {} {}]", level, target_type, status)
269    }
270
271    fn handles_own_permissions(&self) -> bool {
272        true // This tool explicitly handles permission requests
273    }
274}
275
276/// Capitalize the first letter of a string.
277fn capitalize(s: &str) -> String {
278    let mut chars = s.chars();
279    match chars.next() {
280        None => String::new(),
281        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_schema_has_required_fields() {
291        let schema: serde_json::Value =
292            serde_json::from_str(ASK_FOR_PERMISSIONS_TOOL_SCHEMA).unwrap();
293
294        let required = schema.get("required").unwrap().as_array().unwrap();
295        assert!(required.contains(&serde_json::Value::String("target_type".to_string())));
296        assert!(required.contains(&serde_json::Value::String("target".to_string())));
297        assert!(required.contains(&serde_json::Value::String("level".to_string())));
298        assert!(required.contains(&serde_json::Value::String("description".to_string())));
299    }
300
301    #[test]
302    fn test_schema_target_type_enum() {
303        let schema: serde_json::Value =
304            serde_json::from_str(ASK_FOR_PERMISSIONS_TOOL_SCHEMA).unwrap();
305
306        let target_type_enum = schema
307            .get("properties")
308            .unwrap()
309            .get("target_type")
310            .unwrap()
311            .get("enum")
312            .unwrap()
313            .as_array()
314            .unwrap();
315
316        assert!(target_type_enum.contains(&serde_json::Value::String("path".to_string())));
317        assert!(target_type_enum.contains(&serde_json::Value::String("domain".to_string())));
318        assert!(target_type_enum.contains(&serde_json::Value::String("command".to_string())));
319    }
320
321    #[test]
322    fn test_schema_level_enum() {
323        let schema: serde_json::Value =
324            serde_json::from_str(ASK_FOR_PERMISSIONS_TOOL_SCHEMA).unwrap();
325
326        let level_enum = schema
327            .get("properties")
328            .unwrap()
329            .get("level")
330            .unwrap()
331            .get("enum")
332            .unwrap()
333            .as_array()
334            .unwrap();
335
336        assert!(level_enum.contains(&serde_json::Value::String("read".to_string())));
337        assert!(level_enum.contains(&serde_json::Value::String("write".to_string())));
338        assert!(level_enum.contains(&serde_json::Value::String("execute".to_string())));
339        assert!(level_enum.contains(&serde_json::Value::String("admin".to_string())));
340    }
341
342    #[test]
343    fn test_capitalize() {
344        assert_eq!(capitalize("read"), "Read");
345        assert_eq!(capitalize("write"), "Write");
346        assert_eq!(capitalize(""), "");
347        assert_eq!(capitalize("ADMIN"), "ADMIN");
348    }
349
350    #[test]
351    fn test_compact_summary_format() {
352        let tool = AskForPermissionsTool::new(Arc::new(PermissionRegistry::new(
353            tokio::sync::mpsc::channel(1).0,
354        )));
355
356        let mut input = HashMap::new();
357        input.insert("target_type".to_string(), serde_json::json!("path"));
358        input.insert("level".to_string(), serde_json::json!("write"));
359
360        let summary = tool.compact_summary(&input, r#"{"granted":true}"#);
361        assert_eq!(summary, "[Permission write path granted]");
362
363        let summary = tool.compact_summary(&input, r#"{"granted":false}"#);
364        assert_eq!(summary, "[Permission write path denied]");
365    }
366}