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
8pub 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
28fn 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 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 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}