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;
6use crate::permission::MAX_PROACTIVE_PERMISSION_BATCH;
7
8/// Proactively request one or more remembered permissions from the user.
9///
10/// The executor classifies every requested entry as a typed permission context
11/// before this implementation runs. It can therefore pause/replay the same
12/// tool-call through each batch item, and reaches `invoke` only after every
13/// context was authorized at a semantically durable scope.
14pub struct RequestPermissionsTool;
15
16impl RequestPermissionsTool {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22impl Default for RequestPermissionsTool {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28/// Validate a permission type string and return the matching PermissionType.
29fn parse_permission_type(s: &str) -> Result<PermissionType, String> {
30    match s {
31        "write_file" | "WriteFile" => Ok(PermissionType::WriteFile),
32        "execute_command" | "ExecuteCommand" => Ok(PermissionType::ExecuteCommand),
33        "git_write" | "GitWrite" => Ok(PermissionType::GitWrite),
34        "http_request" | "HttpRequest" => Ok(PermissionType::HttpRequest),
35        "delete_operation" | "DeleteOperation" => Ok(PermissionType::DeleteOperation),
36        "terminal_session" | "TerminalSession" => Ok(PermissionType::TerminalSession),
37        other => Err(format!(
38            "Unknown permission type '{}'. Valid types: write_file, execute_command, git_write, http_request, delete_operation, terminal_session",
39            other
40        )),
41    }
42}
43
44#[async_trait]
45impl Tool for RequestPermissionsTool {
46    fn name(&self) -> &str {
47        "request_permissions"
48    }
49
50    fn description(&self) -> &str {
51        "Request one or more permissions through Bamboo's typed approval flow. Each item is reviewed independently; proactive requests support remembered scopes, while one-shot approval belongs to the actual target operation."
52    }
53
54    fn parameters_schema(&self) -> serde_json::Value {
55        json!({
56            "type": "object",
57            "properties": {
58                "reason": {
59                    "type": "string",
60                    "description": "Clear explanation of why these permissions are needed"
61                },
62                "permissions": {
63                    "type": "array",
64                    "description": "List of permissions being requested",
65                    "items": {
66                        "type": "object",
67                        "properties": {
68                            "type": {
69                                "type": "string",
70                                "description": "Permission type: write_file, execute_command, git_write, http_request, delete_operation, terminal_session",
71                                "enum": ["write_file", "execute_command", "git_write", "http_request", "delete_operation", "terminal_session"]
72                            },
73                            "resource": {
74                                "type": "string",
75                                "description": "The resource pattern (file path, URL pattern, command pattern, etc.)"
76                            },
77                            "description": {
78                                "type": "string",
79                                "description": "Optional human-readable description of this specific permission"
80                            }
81                        },
82                        "required": ["type", "resource"]
83                    },
84                    "minItems": 1,
85                    "maxItems": MAX_PROACTIVE_PERMISSION_BATCH
86                }
87            },
88            "required": ["reason", "permissions"]
89        })
90    }
91
92    async fn invoke(
93        &self,
94        args: serde_json::Value,
95        _ctx: ToolCtx,
96    ) -> Result<ToolOutcome, ToolError> {
97        let reason = args["reason"]
98            .as_str()
99            .ok_or_else(|| ToolError::InvalidArguments("Missing 'reason' parameter".to_string()))?
100            .trim();
101
102        if reason.is_empty() {
103            return Err(ToolError::InvalidArguments(
104                "'reason' cannot be empty".to_string(),
105            ));
106        }
107
108        let permissions = args["permissions"].as_array().ok_or_else(|| {
109            ToolError::InvalidArguments("Missing 'permissions' array parameter".to_string())
110        })?;
111
112        if permissions.is_empty() {
113            return Err(ToolError::InvalidArguments(
114                "'permissions' array must contain at least one item".to_string(),
115            ));
116        }
117        if permissions.len() > MAX_PROACTIVE_PERMISSION_BATCH {
118            return Err(ToolError::InvalidArguments(format!(
119                "'permissions' array cannot contain more than {MAX_PROACTIVE_PERMISSION_BATCH} items"
120            )));
121        }
122
123        // Validate each permission entry
124        let mut validated_permissions = Vec::new();
125        for (i, perm) in permissions.iter().enumerate() {
126            let perm_type_str = perm["type"].as_str().ok_or_else(|| {
127                ToolError::InvalidArguments(format!("permissions[{}]: missing 'type' field", i))
128            })?;
129
130            let perm_type = parse_permission_type(perm_type_str)
131                .map_err(|e| ToolError::InvalidArguments(format!("permissions[{}]: {}", i, e)))?;
132
133            let resource = perm["resource"].as_str().ok_or_else(|| {
134                ToolError::InvalidArguments(format!("permissions[{}]: missing 'resource' field", i))
135            })?;
136
137            if resource.trim().is_empty() {
138                return Err(ToolError::InvalidArguments(format!(
139                    "permissions[{}]: 'resource' cannot be empty",
140                    i
141                )));
142            }
143
144            let description = perm["description"]
145                .as_str()
146                .unwrap_or_else(|| perm_type.description());
147            validated_permissions.push(json!({
148                "type": perm_type_str,
149                "resource": resource.trim(),
150                "description": description,
151                "risk_level": perm_type.risk_level().label(),
152            }));
153        }
154
155        Ok(ToolOutcome::Completed(ToolResult {
156            success: true,
157            result: json!({
158                "status": "permissions_authorized",
159                "reason": reason,
160                "permissions": validated_permissions,
161                "message": "Every requested permission passed the typed policy gate. Target operations remain subject to the same policy."
162            })
163            .to_string(),
164            // This is a terminal acknowledgement, not another human pause.
165            display_preference: None,
166            images: Vec::new(),
167        }))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_tool_name() {
177        let tool = RequestPermissionsTool::new();
178        assert_eq!(tool.name(), "request_permissions");
179    }
180
181    #[test]
182    fn schema_caps_the_batch_at_the_replay_ledger_limit() {
183        let schema = RequestPermissionsTool::new().parameters_schema();
184        assert_eq!(
185            schema["properties"]["permissions"]["maxItems"],
186            MAX_PROACTIVE_PERMISSION_BATCH
187        );
188    }
189
190    #[tokio::test]
191    async fn invoke_rejects_a_batch_larger_than_the_replay_ledger() {
192        let permissions = (0..=MAX_PROACTIVE_PERMISSION_BATCH)
193            .map(|index| {
194                json!({
195                    "type": "write_file",
196                    "resource": format!("/workspace/file-{index}")
197                })
198            })
199            .collect::<Vec<_>>();
200        let error = RequestPermissionsTool::new()
201            .invoke(
202                json!({"reason": "Prepare files", "permissions": permissions}),
203                ToolCtx::none("t"),
204            )
205            .await
206            .unwrap_err();
207        assert!(error.to_string().contains("more than 64 items"));
208    }
209
210    #[tokio::test]
211    async fn test_valid_single_permission_request() {
212        let tool = RequestPermissionsTool::new();
213        let outcome = tool
214            .invoke(
215                json!({
216                    "reason": "Need to write deployment config",
217                    "permissions": [{
218                        "type": "write_file",
219                        "resource": "/etc/nginx/conf.d/*"
220                    }]
221                }),
222                ToolCtx::none("t"),
223            )
224            .await
225            .unwrap();
226        let ToolOutcome::Completed(result) = outcome else {
227            panic!("terminal acknowledgement")
228        };
229        assert!(result.success);
230        assert!(result.display_preference.is_none());
231        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
232        assert_eq!(payload["status"], "permissions_authorized");
233        assert_eq!(payload["permissions"].as_array().unwrap().len(), 1);
234        assert!(!result.result.contains("awaiting_permission_approval"));
235    }
236
237    #[tokio::test]
238    async fn test_valid_multiple_permissions() {
239        let tool = RequestPermissionsTool::new();
240        let outcome = tool
241            .invoke(
242                json!({
243                    "reason": "Need to deploy the application",
244                    "permissions": [
245                        {
246                            "type": "execute_command",
247                            "resource": "docker compose up -d",
248                            "description": "Start Docker containers"
249                        },
250                        {
251                            "type": "http_request",
252                            "resource": "registry.example.com",
253                            "description": "Pull container images"
254                        }
255                    ]
256                }),
257                ToolCtx::none("t"),
258            )
259            .await
260            .unwrap();
261        let ToolOutcome::Completed(result) = outcome else {
262            panic!("terminal acknowledgement")
263        };
264        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
265        assert_eq!(payload["status"], "permissions_authorized");
266        assert_eq!(payload["permissions"].as_array().unwrap().len(), 2);
267    }
268
269    #[tokio::test]
270    async fn test_missing_reason() {
271        let tool = RequestPermissionsTool::new();
272        let err = tool
273            .invoke(
274                json!({
275                    "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
276                }),
277                ToolCtx::none("t"),
278            )
279            .await
280            .unwrap_err();
281
282        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("reason")));
283    }
284
285    #[tokio::test]
286    async fn test_empty_reason() {
287        let tool = RequestPermissionsTool::new();
288        let err = tool
289            .invoke(
290                json!({
291                    "reason": "   ",
292                    "permissions": [{"type": "write_file", "resource": "/tmp/test"}]
293                }),
294                ToolCtx::none("t"),
295            )
296            .await
297            .unwrap_err();
298
299        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
300    }
301
302    #[tokio::test]
303    async fn test_missing_permissions() {
304        let tool = RequestPermissionsTool::new();
305        let err = tool
306            .invoke(
307                json!({
308                    "reason": "Need access"
309                }),
310                ToolCtx::none("t"),
311            )
312            .await
313            .unwrap_err();
314
315        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("permissions")));
316    }
317
318    #[tokio::test]
319    async fn test_empty_permissions_array() {
320        let tool = RequestPermissionsTool::new();
321        let err = tool
322            .invoke(
323                json!({
324                    "reason": "Need access",
325                    "permissions": []
326                }),
327                ToolCtx::none("t"),
328            )
329            .await
330            .unwrap_err();
331
332        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("at least one")));
333    }
334
335    #[tokio::test]
336    async fn test_invalid_permission_type() {
337        let tool = RequestPermissionsTool::new();
338        let err = tool
339            .invoke(
340                json!({
341                    "reason": "Need access",
342                    "permissions": [{"type": "invalid_type", "resource": "/tmp"}]
343                }),
344                ToolCtx::none("t"),
345            )
346            .await
347            .unwrap_err();
348
349        assert!(
350            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Unknown permission type"))
351        );
352    }
353
354    #[tokio::test]
355    async fn test_missing_resource() {
356        let tool = RequestPermissionsTool::new();
357        let err = tool
358            .invoke(
359                json!({
360                    "reason": "Need access",
361                    "permissions": [{"type": "write_file"}]
362                }),
363                ToolCtx::none("t"),
364            )
365            .await
366            .unwrap_err();
367
368        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("resource")));
369    }
370
371    #[tokio::test]
372    async fn test_all_permission_types() {
373        let tool = RequestPermissionsTool::new();
374        let types = [
375            "write_file",
376            "execute_command",
377            "git_write",
378            "http_request",
379            "delete_operation",
380            "terminal_session",
381        ];
382
383        for ptype in types {
384            let result = tool
385                .invoke(
386                    json!({
387                        "reason": format!("Test {}", ptype),
388                        "permissions": [{"type": ptype, "resource": "/test"}]
389                    }),
390                    ToolCtx::none("t"),
391                )
392                .await;
393            assert!(result.is_ok(), "{ptype}");
394        }
395    }
396
397    #[tokio::test]
398    async fn test_pascal_case_permission_types() {
399        let tool = RequestPermissionsTool::new();
400        let types = [
401            "WriteFile",
402            "ExecuteCommand",
403            "GitWrite",
404            "HttpRequest",
405            "DeleteOperation",
406            "TerminalSession",
407        ];
408
409        for ptype in types {
410            let result = tool
411                .invoke(
412                    json!({
413                        "reason": format!("Test {}", ptype),
414                        "permissions": [{"type": ptype, "resource": "/test"}]
415                    }),
416                    ToolCtx::none("t"),
417                )
418                .await;
419            assert!(result.is_ok(), "{ptype}");
420        }
421    }
422
423    #[test]
424    fn test_parse_permission_type() {
425        assert_eq!(
426            parse_permission_type("write_file").unwrap(),
427            PermissionType::WriteFile
428        );
429        assert_eq!(
430            parse_permission_type("WriteFile").unwrap(),
431            PermissionType::WriteFile
432        );
433        assert!(parse_permission_type("unknown").is_err());
434    }
435}