Skip to main content

bamboo_tools/tools/
request_permissions.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, 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 invoke(
96        &self,
97        args: serde_json::Value,
98        _ctx: ToolCtx,
99    ) -> Result<ToolOutcome, ToolError> {
100        let reason = args["reason"]
101            .as_str()
102            .ok_or_else(|| ToolError::InvalidArguments("Missing 'reason' parameter".to_string()))?
103            .trim();
104
105        if reason.is_empty() {
106            return Err(ToolError::InvalidArguments(
107                "'reason' cannot be empty".to_string(),
108            ));
109        }
110
111        let permissions = args["permissions"].as_array().ok_or_else(|| {
112            ToolError::InvalidArguments("Missing 'permissions' array parameter".to_string())
113        })?;
114
115        if permissions.is_empty() {
116            return Err(ToolError::InvalidArguments(
117                "'permissions' array must contain at least one item".to_string(),
118            ));
119        }
120
121        // Validate each permission entry
122        let mut validated_permissions = Vec::new();
123        for (i, perm) in permissions.iter().enumerate() {
124            let perm_type_str = perm["type"].as_str().ok_or_else(|| {
125                ToolError::InvalidArguments(format!("permissions[{}]: missing 'type' field", i))
126            })?;
127
128            let perm_type = parse_permission_type(perm_type_str)
129                .map_err(|e| ToolError::InvalidArguments(format!("permissions[{}]: {}", i, e)))?;
130
131            let resource = perm["resource"].as_str().ok_or_else(|| {
132                ToolError::InvalidArguments(format!("permissions[{}]: missing 'resource' field", i))
133            })?;
134
135            if resource.trim().is_empty() {
136                return Err(ToolError::InvalidArguments(format!(
137                    "permissions[{}]: 'resource' cannot be empty",
138                    i
139                )));
140            }
141
142            let description = perm["description"]
143                .as_str()
144                .unwrap_or_else(|| perm_type.description());
145
146            validated_permissions.push(json!({
147                "type": perm_type_str,
148                "resource": resource.trim(),
149                "description": description,
150                "risk_level": perm_type.risk_level().label(),
151            }));
152        }
153
154        // Build a human-readable question for the UI
155        let mut question = format!("**Permission Request**\n\n{}\n\n", reason);
156        question.push_str("**Requested permissions:**\n");
157        for perm in &validated_permissions {
158            let risk = perm["risk_level"].as_str().unwrap_or("Unknown");
159            let desc = perm["description"].as_str().unwrap_or("");
160            let resource = perm["resource"].as_str().unwrap_or("");
161            let ptype = perm["type"].as_str().unwrap_or("");
162            question.push_str(&format!(
163                "- **[{}]** {} `{}` — {}\n",
164                risk, ptype, resource, desc
165            ));
166        }
167
168        let result_payload = json!({
169            "status": "awaiting_permission_approval",
170            "question": question,
171            "reason": reason,
172            "permissions": validated_permissions,
173            "options": ["Approve", "Deny"],
174            "allow_custom": false
175        });
176
177        Ok(ToolOutcome::Completed(ToolResult {
178            success: true,
179            result: result_payload.to_string(),
180            display_preference: Some("request_permissions".to_string()),
181            images: Vec::new(),
182        }))
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_tool_name() {
192        let tool = RequestPermissionsTool::new();
193        assert_eq!(tool.name(), "request_permissions");
194    }
195
196    #[tokio::test]
197    async fn test_valid_single_permission_request() {
198        let tool = RequestPermissionsTool::new();
199        let out = tool
200            .invoke(
201                json!({
202                    "reason": "Need to write deployment config",
203                    "permissions": [{
204                        "type": "write_file",
205                        "resource": "/etc/nginx/conf.d/*"
206                    }]
207                }),
208                ToolCtx::none("t"),
209            )
210            .await
211            .unwrap();
212        let ToolOutcome::Completed(result) = out else {
213            panic!("expected Completed")
214        };
215
216        assert!(result.success);
217        assert_eq!(
218            result.display_preference,
219            Some("request_permissions".to_string())
220        );
221
222        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
223        assert_eq!(payload["status"], "awaiting_permission_approval");
224        assert!(payload["question"]
225            .as_str()
226            .unwrap()
227            .contains("deployment config"));
228        assert_eq!(payload["permissions"].as_array().unwrap().len(), 1);
229        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
230    }
231
232    #[tokio::test]
233    async fn test_valid_multiple_permissions() {
234        let tool = RequestPermissionsTool::new();
235        let out = tool
236            .invoke(
237                json!({
238                    "reason": "Need to deploy the application",
239                    "permissions": [
240                        {
241                            "type": "execute_command",
242                            "resource": "docker compose up -d",
243                            "description": "Start Docker containers"
244                        },
245                        {
246                            "type": "http_request",
247                            "resource": "registry.example.com",
248                            "description": "Pull container images"
249                        }
250                    ]
251                }),
252                ToolCtx::none("t"),
253            )
254            .await
255            .unwrap();
256        let ToolOutcome::Completed(result) = out else {
257            panic!("expected Completed")
258        };
259
260        assert!(result.success);
261        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
262        assert_eq!(payload["permissions"].as_array().unwrap().len(), 2);
263        assert_eq!(payload["permissions"][0]["risk_level"], "High Risk");
264        assert_eq!(payload["permissions"][1]["risk_level"], "Medium Risk");
265    }
266
267    #[tokio::test]
268    async fn test_missing_reason() {
269        let tool = RequestPermissionsTool::new();
270        let err = tool
271            .invoke(
272                json!({
273                    "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
274                }),
275                ToolCtx::none("t"),
276            )
277            .await
278            .unwrap_err();
279
280        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("reason")));
281    }
282
283    #[tokio::test]
284    async fn test_empty_reason() {
285        let tool = RequestPermissionsTool::new();
286        let err = tool
287            .invoke(
288                json!({
289                    "reason": "   ",
290                    "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
291                }),
292                ToolCtx::none("t"),
293            )
294            .await
295            .unwrap_err();
296
297        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
298    }
299
300    #[tokio::test]
301    async fn test_missing_permissions() {
302        let tool = RequestPermissionsTool::new();
303        let err = tool
304            .invoke(
305                json!({
306                    "reason": "Need access"
307                }),
308                ToolCtx::none("t"),
309            )
310            .await
311            .unwrap_err();
312
313        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("permissions")));
314    }
315
316    #[tokio::test]
317    async fn test_empty_permissions_array() {
318        let tool = RequestPermissionsTool::new();
319        let err = tool
320            .invoke(
321                json!({
322                    "reason": "Need access",
323                    "permissions": []
324                }),
325                ToolCtx::none("t"),
326            )
327            .await
328            .unwrap_err();
329
330        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("at least one")));
331    }
332
333    #[tokio::test]
334    async fn test_invalid_permission_type() {
335        let tool = RequestPermissionsTool::new();
336        let err = tool
337            .invoke(
338                json!({
339                    "reason": "Need access",
340                    "permissions": [{"type": "invalid_type", "resource": "/tmp"}]
341                }),
342                ToolCtx::none("t"),
343            )
344            .await
345            .unwrap_err();
346
347        assert!(
348            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Unknown permission type"))
349        );
350    }
351
352    #[tokio::test]
353    async fn test_missing_resource() {
354        let tool = RequestPermissionsTool::new();
355        let err = tool
356            .invoke(
357                json!({
358                    "reason": "Need access",
359                    "permissions": [{"type": "write_file"}]
360                }),
361                ToolCtx::none("t"),
362            )
363            .await
364            .unwrap_err();
365
366        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("resource")));
367    }
368
369    #[tokio::test]
370    async fn test_all_permission_types() {
371        let tool = RequestPermissionsTool::new();
372        let types = [
373            "write_file",
374            "execute_command",
375            "git_write",
376            "http_request",
377            "delete_operation",
378            "terminal_session",
379        ];
380
381        for ptype in types {
382            let result = tool
383                .invoke(
384                    json!({
385                        "reason": format!("Test {}", ptype),
386                        "permissions": [{"type": ptype, "resource": "/test"}]
387                    }),
388                    ToolCtx::none("t"),
389                )
390                .await;
391            assert!(
392                result.is_ok(),
393                "Permission type '{}' should be valid",
394                ptype
395            );
396        }
397    }
398
399    #[tokio::test]
400    async fn test_pascal_case_permission_types() {
401        let tool = RequestPermissionsTool::new();
402        let types = [
403            "WriteFile",
404            "ExecuteCommand",
405            "GitWrite",
406            "HttpRequest",
407            "DeleteOperation",
408            "TerminalSession",
409        ];
410
411        for ptype in types {
412            let result = tool
413                .invoke(
414                    json!({
415                        "reason": format!("Test {}", ptype),
416                        "permissions": [{"type": ptype, "resource": "/test"}]
417                    }),
418                    ToolCtx::none("t"),
419                )
420                .await;
421            assert!(
422                result.is_ok(),
423                "PascalCase permission type '{}' should be valid",
424                ptype
425            );
426        }
427    }
428
429    #[test]
430    fn test_parse_permission_type() {
431        assert_eq!(
432            parse_permission_type("write_file").unwrap(),
433            PermissionType::WriteFile
434        );
435        assert_eq!(
436            parse_permission_type("WriteFile").unwrap(),
437            PermissionType::WriteFile
438        );
439        assert!(parse_permission_type("unknown").is_err());
440    }
441}