Skip to main content

bamboo_tools/tools/
request_permissions.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolResult};
3use serde_json::json;
4
5use crate::permission::PermissionType;
6
7/// Tool for the LLM to proactively request additional permissions from the user.
8///
9/// Instead of failing when a permission is denied, the LLM can call this tool
10/// to explain why it needs certain permissions and ask the user for approval.
11///
12/// The tool returns a payload that signals the agent loop to pause and ask
13/// the user for permission approval (similar to how `conclusion_with_options` pauses for
14/// user input).
15///
16/// Inspired by Codex's `request_permissions` tool which allows the model to
17/// request filesystem and network permissions at runtime.
18pub struct RequestPermissionsTool;
19
20impl RequestPermissionsTool {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Default for RequestPermissionsTool {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32/// Validate a permission type string and return the matching PermissionType.
33fn parse_permission_type(s: &str) -> Result<PermissionType, String> {
34    match s {
35        "write_file" | "WriteFile" => Ok(PermissionType::WriteFile),
36        "execute_command" | "ExecuteCommand" => Ok(PermissionType::ExecuteCommand),
37        "git_write" | "GitWrite" => Ok(PermissionType::GitWrite),
38        "http_request" | "HttpRequest" => Ok(PermissionType::HttpRequest),
39        "delete_operation" | "DeleteOperation" => Ok(PermissionType::DeleteOperation),
40        "terminal_session" | "TerminalSession" => Ok(PermissionType::TerminalSession),
41        other => Err(format!(
42            "Unknown permission type '{}'. Valid types: write_file, execute_command, git_write, http_request, delete_operation, terminal_session",
43            other
44        )),
45    }
46}
47
48#[async_trait]
49impl Tool for RequestPermissionsTool {
50    fn name(&self) -> &str {
51        "request_permissions"
52    }
53
54    fn description(&self) -> &str {
55        "Request additional permissions from the user. Use this when you need to perform an operation that requires elevated permissions (e.g., writing to a specific directory, executing a dangerous command, making HTTP requests). The user will be prompted to approve or deny the request."
56    }
57
58    fn parameters_schema(&self) -> serde_json::Value {
59        json!({
60            "type": "object",
61            "properties": {
62                "reason": {
63                    "type": "string",
64                    "description": "Clear explanation of why these permissions are needed"
65                },
66                "permissions": {
67                    "type": "array",
68                    "description": "List of permissions being requested",
69                    "items": {
70                        "type": "object",
71                        "properties": {
72                            "type": {
73                                "type": "string",
74                                "description": "Permission type: write_file, execute_command, git_write, http_request, delete_operation, terminal_session",
75                                "enum": ["write_file", "execute_command", "git_write", "http_request", "delete_operation", "terminal_session"]
76                            },
77                            "resource": {
78                                "type": "string",
79                                "description": "The resource pattern (file path, URL pattern, command pattern, etc.)"
80                            },
81                            "description": {
82                                "type": "string",
83                                "description": "Optional human-readable description of this specific permission"
84                            }
85                        },
86                        "required": ["type", "resource"]
87                    },
88                    "minItems": 1
89                }
90            },
91            "required": ["reason", "permissions"]
92        })
93    }
94
95    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
96        let reason = args["reason"]
97            .as_str()
98            .ok_or_else(|| ToolError::InvalidArguments("Missing 'reason' parameter".to_string()))?
99            .trim();
100
101        if reason.is_empty() {
102            return Err(ToolError::InvalidArguments(
103                "'reason' cannot be empty".to_string(),
104            ));
105        }
106
107        let permissions = args["permissions"].as_array().ok_or_else(|| {
108            ToolError::InvalidArguments("Missing 'permissions' array parameter".to_string())
109        })?;
110
111        if permissions.is_empty() {
112            return Err(ToolError::InvalidArguments(
113                "'permissions' array must contain at least one item".to_string(),
114            ));
115        }
116
117        // Validate each permission entry
118        let mut validated_permissions = Vec::new();
119        for (i, perm) in permissions.iter().enumerate() {
120            let perm_type_str = perm["type"].as_str().ok_or_else(|| {
121                ToolError::InvalidArguments(format!("permissions[{}]: missing 'type' field", i))
122            })?;
123
124            let perm_type = parse_permission_type(perm_type_str)
125                .map_err(|e| ToolError::InvalidArguments(format!("permissions[{}]: {}", i, e)))?;
126
127            let resource = perm["resource"].as_str().ok_or_else(|| {
128                ToolError::InvalidArguments(format!("permissions[{}]: missing 'resource' field", i))
129            })?;
130
131            if resource.trim().is_empty() {
132                return Err(ToolError::InvalidArguments(format!(
133                    "permissions[{}]: 'resource' cannot be empty",
134                    i
135                )));
136            }
137
138            let description = perm["description"]
139                .as_str()
140                .unwrap_or_else(|| perm_type.description());
141
142            validated_permissions.push(json!({
143                "type": perm_type_str,
144                "resource": resource.trim(),
145                "description": description,
146                "risk_level": perm_type.risk_level().label(),
147            }));
148        }
149
150        // Build a human-readable question for the UI
151        let mut question = format!("**Permission Request**\n\n{}\n\n", reason);
152        question.push_str("**Requested permissions:**\n");
153        for perm in &validated_permissions {
154            let risk = perm["risk_level"].as_str().unwrap_or("Unknown");
155            let desc = perm["description"].as_str().unwrap_or("");
156            let resource = perm["resource"].as_str().unwrap_or("");
157            let ptype = perm["type"].as_str().unwrap_or("");
158            question.push_str(&format!(
159                "- **[{}]** {} `{}` — {}\n",
160                risk, ptype, resource, desc
161            ));
162        }
163
164        let result_payload = json!({
165            "status": "awaiting_permission_approval",
166            "question": question,
167            "reason": reason,
168            "permissions": validated_permissions,
169            "options": ["Approve", "Deny"],
170            "allow_custom": false
171        });
172
173        Ok(ToolResult {
174            success: true,
175            result: result_payload.to_string(),
176            display_preference: Some("request_permissions".to_string()),
177            images: Vec::new(),
178        })
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_tool_name() {
188        let tool = RequestPermissionsTool::new();
189        assert_eq!(tool.name(), "request_permissions");
190    }
191
192    #[tokio::test]
193    async fn test_valid_single_permission_request() {
194        let tool = RequestPermissionsTool::new();
195        let result = tool
196            .execute(json!({
197                "reason": "Need to write deployment config",
198                "permissions": [{
199                    "type": "write_file",
200                    "resource": "/etc/nginx/conf.d/*"
201                }]
202            }))
203            .await
204            .unwrap();
205
206        assert!(result.success);
207        assert_eq!(
208            result.display_preference,
209            Some("request_permissions".to_string())
210        );
211
212        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
213        assert_eq!(payload["status"], "awaiting_permission_approval");
214        assert!(payload["question"]
215            .as_str()
216            .unwrap()
217            .contains("deployment config"));
218        assert_eq!(payload["permissions"].as_array().unwrap().len(), 1);
219        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
220    }
221
222    #[tokio::test]
223    async fn test_valid_multiple_permissions() {
224        let tool = RequestPermissionsTool::new();
225        let result = tool
226            .execute(json!({
227                "reason": "Need to deploy the application",
228                "permissions": [
229                    {
230                        "type": "execute_command",
231                        "resource": "docker compose up -d",
232                        "description": "Start Docker containers"
233                    },
234                    {
235                        "type": "http_request",
236                        "resource": "registry.example.com",
237                        "description": "Pull container images"
238                    }
239                ]
240            }))
241            .await
242            .unwrap();
243
244        assert!(result.success);
245        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
246        assert_eq!(payload["permissions"].as_array().unwrap().len(), 2);
247        assert_eq!(payload["permissions"][0]["risk_level"], "High Risk");
248        assert_eq!(payload["permissions"][1]["risk_level"], "Medium Risk");
249    }
250
251    #[tokio::test]
252    async fn test_missing_reason() {
253        let tool = RequestPermissionsTool::new();
254        let err = tool
255            .execute(json!({
256                "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
257            }))
258            .await
259            .unwrap_err();
260
261        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("reason")));
262    }
263
264    #[tokio::test]
265    async fn test_empty_reason() {
266        let tool = RequestPermissionsTool::new();
267        let err = tool
268            .execute(json!({
269                "reason": "   ",
270                "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
271            }))
272            .await
273            .unwrap_err();
274
275        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
276    }
277
278    #[tokio::test]
279    async fn test_missing_permissions() {
280        let tool = RequestPermissionsTool::new();
281        let err = tool
282            .execute(json!({
283                "reason": "Need access"
284            }))
285            .await
286            .unwrap_err();
287
288        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("permissions")));
289    }
290
291    #[tokio::test]
292    async fn test_empty_permissions_array() {
293        let tool = RequestPermissionsTool::new();
294        let err = tool
295            .execute(json!({
296                "reason": "Need access",
297                "permissions": []
298            }))
299            .await
300            .unwrap_err();
301
302        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("at least one")));
303    }
304
305    #[tokio::test]
306    async fn test_invalid_permission_type() {
307        let tool = RequestPermissionsTool::new();
308        let err = tool
309            .execute(json!({
310                "reason": "Need access",
311                "permissions": [{"type": "invalid_type", "resource": "/tmp"}]
312            }))
313            .await
314            .unwrap_err();
315
316        assert!(
317            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Unknown permission type"))
318        );
319    }
320
321    #[tokio::test]
322    async fn test_missing_resource() {
323        let tool = RequestPermissionsTool::new();
324        let err = tool
325            .execute(json!({
326                "reason": "Need access",
327                "permissions": [{"type": "write_file"}]
328            }))
329            .await
330            .unwrap_err();
331
332        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("resource")));
333    }
334
335    #[tokio::test]
336    async fn test_all_permission_types() {
337        let tool = RequestPermissionsTool::new();
338        let types = [
339            "write_file",
340            "execute_command",
341            "git_write",
342            "http_request",
343            "delete_operation",
344            "terminal_session",
345        ];
346
347        for ptype in types {
348            let result = tool
349                .execute(json!({
350                    "reason": format!("Test {}", ptype),
351                    "permissions": [{"type": ptype, "resource": "/test"}]
352                }))
353                .await;
354            assert!(
355                result.is_ok(),
356                "Permission type '{}' should be valid",
357                ptype
358            );
359        }
360    }
361
362    #[tokio::test]
363    async fn test_pascal_case_permission_types() {
364        let tool = RequestPermissionsTool::new();
365        let types = [
366            "WriteFile",
367            "ExecuteCommand",
368            "GitWrite",
369            "HttpRequest",
370            "DeleteOperation",
371            "TerminalSession",
372        ];
373
374        for ptype in types {
375            let result = tool
376                .execute(json!({
377                    "reason": format!("Test {}", ptype),
378                    "permissions": [{"type": ptype, "resource": "/test"}]
379                }))
380                .await;
381            assert!(
382                result.is_ok(),
383                "PascalCase permission type '{}' should be valid",
384                ptype
385            );
386        }
387    }
388
389    #[test]
390    fn test_parse_permission_type() {
391        assert_eq!(
392            parse_permission_type("write_file").unwrap(),
393            PermissionType::WriteFile
394        );
395        assert_eq!(
396            parse_permission_type("WriteFile").unwrap(),
397            PermissionType::WriteFile
398        );
399        assert!(parse_permission_type("unknown").is_err());
400    }
401}