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        })
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_tool_name() {
187        let tool = RequestPermissionsTool::new();
188        assert_eq!(tool.name(), "request_permissions");
189    }
190
191    #[tokio::test]
192    async fn test_valid_single_permission_request() {
193        let tool = RequestPermissionsTool::new();
194        let result = tool
195            .execute(json!({
196                "reason": "Need to write deployment config",
197                "permissions": [{
198                    "type": "write_file",
199                    "resource": "/etc/nginx/conf.d/*"
200                }]
201            }))
202            .await
203            .unwrap();
204
205        assert!(result.success);
206        assert_eq!(
207            result.display_preference,
208            Some("request_permissions".to_string())
209        );
210
211        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
212        assert_eq!(payload["status"], "awaiting_permission_approval");
213        assert!(payload["question"]
214            .as_str()
215            .unwrap()
216            .contains("deployment config"));
217        assert_eq!(payload["permissions"].as_array().unwrap().len(), 1);
218        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
219    }
220
221    #[tokio::test]
222    async fn test_valid_multiple_permissions() {
223        let tool = RequestPermissionsTool::new();
224        let result = tool
225            .execute(json!({
226                "reason": "Need to deploy the application",
227                "permissions": [
228                    {
229                        "type": "execute_command",
230                        "resource": "docker compose up -d",
231                        "description": "Start Docker containers"
232                    },
233                    {
234                        "type": "http_request",
235                        "resource": "registry.example.com",
236                        "description": "Pull container images"
237                    }
238                ]
239            }))
240            .await
241            .unwrap();
242
243        assert!(result.success);
244        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
245        assert_eq!(payload["permissions"].as_array().unwrap().len(), 2);
246        assert_eq!(payload["permissions"][0]["risk_level"], "High Risk");
247        assert_eq!(payload["permissions"][1]["risk_level"], "Medium Risk");
248    }
249
250    #[tokio::test]
251    async fn test_missing_reason() {
252        let tool = RequestPermissionsTool::new();
253        let err = tool
254            .execute(json!({
255                "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
256            }))
257            .await
258            .unwrap_err();
259
260        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("reason")));
261    }
262
263    #[tokio::test]
264    async fn test_empty_reason() {
265        let tool = RequestPermissionsTool::new();
266        let err = tool
267            .execute(json!({
268                "reason": "   ",
269                "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
270            }))
271            .await
272            .unwrap_err();
273
274        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
275    }
276
277    #[tokio::test]
278    async fn test_missing_permissions() {
279        let tool = RequestPermissionsTool::new();
280        let err = tool
281            .execute(json!({
282                "reason": "Need access"
283            }))
284            .await
285            .unwrap_err();
286
287        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("permissions")));
288    }
289
290    #[tokio::test]
291    async fn test_empty_permissions_array() {
292        let tool = RequestPermissionsTool::new();
293        let err = tool
294            .execute(json!({
295                "reason": "Need access",
296                "permissions": []
297            }))
298            .await
299            .unwrap_err();
300
301        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("at least one")));
302    }
303
304    #[tokio::test]
305    async fn test_invalid_permission_type() {
306        let tool = RequestPermissionsTool::new();
307        let err = tool
308            .execute(json!({
309                "reason": "Need access",
310                "permissions": [{"type": "invalid_type", "resource": "/tmp"}]
311            }))
312            .await
313            .unwrap_err();
314
315        assert!(
316            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Unknown permission type"))
317        );
318    }
319
320    #[tokio::test]
321    async fn test_missing_resource() {
322        let tool = RequestPermissionsTool::new();
323        let err = tool
324            .execute(json!({
325                "reason": "Need access",
326                "permissions": [{"type": "write_file"}]
327            }))
328            .await
329            .unwrap_err();
330
331        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("resource")));
332    }
333
334    #[tokio::test]
335    async fn test_all_permission_types() {
336        let tool = RequestPermissionsTool::new();
337        let types = [
338            "write_file",
339            "execute_command",
340            "git_write",
341            "http_request",
342            "delete_operation",
343            "terminal_session",
344        ];
345
346        for ptype in types {
347            let result = tool
348                .execute(json!({
349                    "reason": format!("Test {}", ptype),
350                    "permissions": [{"type": ptype, "resource": "/test"}]
351                }))
352                .await;
353            assert!(
354                result.is_ok(),
355                "Permission type '{}' should be valid",
356                ptype
357            );
358        }
359    }
360
361    #[tokio::test]
362    async fn test_pascal_case_permission_types() {
363        let tool = RequestPermissionsTool::new();
364        let types = [
365            "WriteFile",
366            "ExecuteCommand",
367            "GitWrite",
368            "HttpRequest",
369            "DeleteOperation",
370            "TerminalSession",
371        ];
372
373        for ptype in types {
374            let result = tool
375                .execute(json!({
376                    "reason": format!("Test {}", ptype),
377                    "permissions": [{"type": ptype, "resource": "/test"}]
378                }))
379                .await;
380            assert!(
381                result.is_ok(),
382                "PascalCase permission type '{}' should be valid",
383                ptype
384            );
385        }
386    }
387
388    #[test]
389    fn test_parse_permission_type() {
390        assert_eq!(
391            parse_permission_type("write_file").unwrap(),
392            PermissionType::WriteFile
393        );
394        assert_eq!(
395            parse_permission_type("WriteFile").unwrap(),
396            PermissionType::WriteFile
397        );
398        assert!(parse_permission_type("unknown").is_err());
399    }
400}