1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde_json::json;
4
5use crate::permission::PermissionType;
6
7pub 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
32fn 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 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 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}