agent_air_runtime/permissions/
batch.rs1use super::{Grant, GrantTarget, PermissionLevel, PermissionRequest};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BatchPermissionRequest {
19 pub batch_id: String,
21 pub requests: Vec<PermissionRequest>,
23 pub suggested_grants: Vec<Grant>,
25}
26
27impl BatchPermissionRequest {
28 pub fn new(batch_id: impl Into<String>, requests: Vec<PermissionRequest>) -> Self {
30 let batch_id = batch_id.into();
31 let suggested_grants = compute_suggested_grants(&requests);
32 Self {
33 batch_id,
34 requests,
35 suggested_grants,
36 }
37 }
38
39 pub fn len(&self) -> usize {
41 self.requests.len()
42 }
43
44 pub fn is_empty(&self) -> bool {
46 self.requests.is_empty()
47 }
48
49 pub fn request_ids(&self) -> Vec<&str> {
51 self.requests.iter().map(|r| r.id.as_str()).collect()
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct BatchPermissionResponse {
58 pub batch_id: String,
60 pub approved_grants: Vec<Grant>,
62 pub denied_requests: HashSet<String>,
64 pub auto_approved: HashSet<String>,
66}
67
68impl BatchPermissionResponse {
69 pub fn all_granted(batch_id: impl Into<String>, grants: Vec<Grant>) -> Self {
71 Self {
72 batch_id: batch_id.into(),
73 approved_grants: grants,
74 denied_requests: HashSet::new(),
75 auto_approved: HashSet::new(),
76 }
77 }
78
79 pub fn all_denied(
81 batch_id: impl Into<String>,
82 request_ids: impl IntoIterator<Item = String>,
83 ) -> Self {
84 Self {
85 batch_id: batch_id.into(),
86 approved_grants: Vec::new(),
87 denied_requests: request_ids.into_iter().collect(),
88 auto_approved: HashSet::new(),
89 }
90 }
91
92 pub fn with_auto_approved(
94 batch_id: impl Into<String>,
95 auto_approved: impl IntoIterator<Item = String>,
96 ) -> Self {
97 Self {
98 batch_id: batch_id.into(),
99 approved_grants: Vec::new(),
100 denied_requests: HashSet::new(),
101 auto_approved: auto_approved.into_iter().collect(),
102 }
103 }
104
105 pub fn is_granted(&self, request_id: &str, request: &PermissionRequest) -> bool {
112 let in_auto_approved = self.auto_approved.contains(request_id);
114 let in_denied = self.denied_requests.contains(request_id);
115
116 if in_auto_approved && in_denied {
117 tracing::warn!(
118 request_id,
119 batch_id = %self.batch_id,
120 "Request appears in both auto_approved and denied_requests, treating as denied"
121 );
122 return false;
123 }
124
125 if in_auto_approved {
127 return true;
128 }
129
130 if in_denied {
132 return false;
133 }
134
135 self.approved_grants
137 .iter()
138 .any(|grant| grant.satisfies(request))
139 }
140
141 pub fn has_denials(&self) -> bool {
143 !self.denied_requests.is_empty()
144 }
145
146 pub fn approved_count(&self) -> usize {
148 self.approved_grants.len() + self.auto_approved.len()
149 }
150}
151
152pub fn compute_suggested_grants(requests: &[PermissionRequest]) -> Vec<Grant> {
158 if requests.is_empty() {
159 return Vec::new();
160 }
161
162 let mut grants = Vec::new();
163
164 let mut path_requests: Vec<&PermissionRequest> = Vec::new();
166 let mut domain_requests: Vec<&PermissionRequest> = Vec::new();
167 let mut command_requests: Vec<&PermissionRequest> = Vec::new();
168 let mut tool_requests: Vec<&PermissionRequest> = Vec::new();
169
170 for req in requests {
171 match &req.target {
172 GrantTarget::Path { .. } => path_requests.push(req),
173 GrantTarget::Domain { .. } => domain_requests.push(req),
174 GrantTarget::Command { .. } => command_requests.push(req),
175 GrantTarget::Tool { .. } => tool_requests.push(req),
176 }
177 }
178
179 grants.extend(compute_path_grants(&path_requests));
181 grants.extend(compute_domain_grants(&domain_requests));
182 grants.extend(compute_command_grants(&command_requests));
183 grants.extend(compute_tool_grants(&tool_requests));
184
185 grants
186}
187
188fn compute_path_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
190 if requests.is_empty() {
191 return Vec::new();
192 }
193
194 let mut dir_groups: HashMap<PathBuf, (PermissionLevel, Vec<PathBuf>)> = HashMap::new();
196
197 for req in requests {
198 if let GrantTarget::Path { path, .. } = &req.target {
199 let parent = path.parent().unwrap_or(path).to_path_buf();
200 let entry = dir_groups
201 .entry(parent)
202 .or_insert((PermissionLevel::None, Vec::new()));
203 entry.0 = std::cmp::max(entry.0, req.required_level);
204 entry.1.push(path.clone());
205 }
206 }
207
208 let merged_groups = merge_related_directories(dir_groups);
210
211 merged_groups
213 .into_iter()
214 .map(|(dir, (level, paths))| {
215 let recursive = paths.len() > 1;
217 Grant::new(GrantTarget::path(dir, recursive), level)
218 })
219 .collect()
220}
221
222fn merge_related_directories(
224 groups: HashMap<PathBuf, (PermissionLevel, Vec<PathBuf>)>,
225) -> HashMap<PathBuf, (PermissionLevel, Vec<PathBuf>)> {
226 if groups.len() <= 1 {
227 return groups;
228 }
229
230 let mut result: HashMap<PathBuf, (PermissionLevel, Vec<PathBuf>)> = HashMap::new();
231
232 for (dir, (level, paths)) in groups {
233 let mut merged = false;
235
236 for (existing_dir, (existing_level, existing_paths)) in result.iter_mut() {
237 if dir.starts_with(existing_dir) {
239 existing_paths.extend(paths.clone());
241 *existing_level = std::cmp::max(*existing_level, level);
242 merged = true;
243 break;
244 } else if existing_dir.starts_with(&dir) {
245 } else {
248 if let Some(common) = find_common_ancestor(&dir, existing_dir, 3) {
250 let _ = common;
253 }
254 }
255 }
256
257 if !merged {
258 result.insert(dir, (level, paths));
259 }
260 }
261
262 result
263}
264
265fn find_common_ancestor(path1: &Path, path2: &Path, max_depth: usize) -> Option<PathBuf> {
267 let ancestors1: Vec<_> = path1.ancestors().take(max_depth + 1).collect();
268 let ancestors2: Vec<_> = path2.ancestors().take(max_depth + 1).collect();
269
270 for a1 in &ancestors1 {
271 for a2 in &ancestors2 {
272 if a1 == a2 {
273 return Some(a1.to_path_buf());
274 }
275 }
276 }
277
278 None
279}
280
281fn compute_domain_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
283 if requests.is_empty() {
284 return Vec::new();
285 }
286
287 let mut domain_levels: HashMap<String, PermissionLevel> = HashMap::new();
289
290 for req in requests {
291 if let GrantTarget::Domain { pattern } = &req.target {
292 let base_domain = extract_base_domain(pattern);
293 let entry = domain_levels
294 .entry(base_domain)
295 .or_insert(PermissionLevel::None);
296 *entry = std::cmp::max(*entry, req.required_level);
297 }
298 }
299
300 domain_levels
302 .into_iter()
303 .map(|(domain, level)| {
304 Grant::new(GrantTarget::domain(domain), level)
306 })
307 .collect()
308}
309
310fn extract_base_domain(pattern: &str) -> String {
312 pattern.strip_prefix("*.").unwrap_or(pattern).to_string()
314}
315
316fn compute_command_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
318 if requests.is_empty() {
319 return Vec::new();
320 }
321
322 let mut cmd_groups: HashMap<String, (PermissionLevel, Vec<String>)> = HashMap::new();
324
325 for req in requests {
326 if let GrantTarget::Command { pattern } = &req.target {
327 let prefix = extract_command_prefix(pattern);
328 let entry = cmd_groups
329 .entry(prefix)
330 .or_insert((PermissionLevel::None, Vec::new()));
331 entry.0 = std::cmp::max(entry.0, req.required_level);
332 entry.1.push(pattern.clone());
333 }
334 }
335
336 cmd_groups
338 .into_iter()
339 .map(|(prefix, (level, commands))| {
340 let pattern = if commands.len() > 1 {
341 format!("{} *", prefix)
342 } else {
343 commands.into_iter().next().unwrap_or(prefix)
344 };
345 Grant::new(GrantTarget::command(pattern), level)
346 })
347 .collect()
348}
349
350fn compute_tool_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
354 let mut tool_levels: HashMap<String, PermissionLevel> = HashMap::new();
355
356 for req in requests {
357 if let GrantTarget::Tool { tool_name } = &req.target {
358 let entry = tool_levels
359 .entry(tool_name.clone())
360 .or_insert(PermissionLevel::None);
361 *entry = std::cmp::max(*entry, req.required_level);
362 }
363 }
364
365 tool_levels
366 .into_iter()
367 .map(|(tool_name, level)| Grant::new(GrantTarget::tool(tool_name), level))
368 .collect()
369}
370
371fn extract_command_prefix(command: &str) -> String {
373 command
374 .split_whitespace()
375 .next()
376 .unwrap_or(command)
377 .to_string()
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382pub enum BatchAction {
383 AllowAll,
385 AllowSelected,
387 DenyAll,
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_batch_request_creation() {
397 let requests = vec![
398 PermissionRequest::file_read("1", "/project/src/main.rs"),
399 PermissionRequest::file_read("2", "/project/src/lib.rs"),
400 ];
401
402 let batch = BatchPermissionRequest::new("batch-1", requests);
403
404 assert_eq!(batch.batch_id, "batch-1");
405 assert_eq!(batch.len(), 2);
406 assert!(!batch.suggested_grants.is_empty());
407 }
408
409 #[test]
410 fn test_batch_response_all_granted() {
411 let grants = vec![Grant::read_path("/project/src", true)];
412 let response = BatchPermissionResponse::all_granted("batch-1", grants);
413
414 let request = PermissionRequest::file_read("1", "/project/src/main.rs");
415 assert!(response.is_granted("1", &request));
416 assert!(!response.has_denials());
417 }
418
419 #[test]
420 fn test_batch_response_all_denied() {
421 let response =
422 BatchPermissionResponse::all_denied("batch-1", vec!["1".to_string(), "2".to_string()]);
423
424 let request = PermissionRequest::file_read("1", "/project/src/main.rs");
425 assert!(!response.is_granted("1", &request));
426 assert!(response.has_denials());
427 }
428
429 #[test]
430 fn test_batch_response_auto_approved() {
431 let response =
432 BatchPermissionResponse::with_auto_approved("batch-1", vec!["1".to_string()]);
433
434 let request = PermissionRequest::file_read("1", "/project/src/main.rs");
435 assert!(response.is_granted("1", &request));
436 }
437
438 #[test]
439 fn test_compute_suggested_grants_single_path() {
440 let requests = vec![PermissionRequest::file_read("1", "/project/src/main.rs")];
441
442 let grants = compute_suggested_grants(&requests);
443
444 assert_eq!(grants.len(), 1);
445 assert_eq!(grants[0].level, PermissionLevel::Read);
446 }
447
448 #[test]
449 fn test_compute_suggested_grants_multiple_same_dir() {
450 let requests = vec![
451 PermissionRequest::file_read("1", "/project/src/main.rs"),
452 PermissionRequest::file_read("2", "/project/src/lib.rs"),
453 ];
454
455 let grants = compute_suggested_grants(&requests);
456
457 assert_eq!(grants.len(), 1);
459 if let GrantTarget::Path { path, recursive } = &grants[0].target {
460 assert_eq!(path.to_str().unwrap(), "/project/src");
461 assert!(recursive); } else {
463 panic!("Expected path target");
464 }
465 }
466
467 #[test]
468 fn test_compute_suggested_grants_different_levels() {
469 let requests = vec![
470 PermissionRequest::file_read("1", "/project/src/main.rs"),
471 PermissionRequest::file_write("2", "/project/src/lib.rs"),
472 ];
473
474 let grants = compute_suggested_grants(&requests);
475
476 assert_eq!(grants.len(), 1);
478 assert_eq!(grants[0].level, PermissionLevel::Write);
479 }
480
481 #[test]
482 fn test_compute_suggested_grants_mixed_targets() {
483 let requests = vec![
484 PermissionRequest::file_read("1", "/project/src/main.rs"),
485 PermissionRequest::command_execute("2", "git status"),
486 ];
487
488 let grants = compute_suggested_grants(&requests);
489
490 assert_eq!(grants.len(), 2);
492 }
493
494 #[test]
495 fn test_compute_suggested_grants_commands() {
496 let requests = vec![
497 PermissionRequest::command_execute("1", "git status"),
498 PermissionRequest::command_execute("2", "git commit -m 'msg'"),
499 ];
500
501 let grants = compute_suggested_grants(&requests);
502
503 assert_eq!(grants.len(), 1);
505 if let GrantTarget::Command { pattern } = &grants[0].target {
506 assert!(pattern.contains("git"));
507 } else {
508 panic!("Expected command target");
509 }
510 }
511
512 #[test]
513 fn test_is_granted_conflict_resolution() {
514 let response = BatchPermissionResponse {
516 batch_id: "batch-1".to_string(),
517 approved_grants: Vec::new(),
518 denied_requests: ["conflict-id".to_string()].into_iter().collect(),
519 auto_approved: ["conflict-id".to_string()].into_iter().collect(),
520 };
521
522 let request = PermissionRequest::file_read("conflict-id", "/project/src/main.rs");
523
524 assert!(
526 !response.is_granted("conflict-id", &request),
527 "Conflicting request should be denied as safe default"
528 );
529 }
530}