1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, 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 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 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 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}