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 })
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}