Skip to main content

agent_air_runtime/permissions/
batch.rs

1//! Batch permission request handling.
2//!
3//! When multiple tools run in parallel, their permission requests are
4//! collected into a batch and presented to the user together, avoiding
5//! the deadlock issue with sequential permission prompts.
6
7use super::{Grant, GrantTarget, PermissionLevel, PermissionRequest};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12/// A batch of permission requests from parallel tool executions.
13///
14/// Batching permission requests allows the UI to present multiple
15/// requests together, letting the user make informed decisions about
16/// granting access to related resources.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct BatchPermissionRequest {
19    /// Unique identifier for this batch.
20    pub batch_id: String,
21    /// The individual permission requests in this batch.
22    pub requests: Vec<PermissionRequest>,
23    /// Suggested grants that would cover all requests.
24    pub suggested_grants: Vec<Grant>,
25}
26
27impl BatchPermissionRequest {
28    /// Creates a new batch permission request.
29    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    /// Returns the number of requests in this batch.
40    pub fn len(&self) -> usize {
41        self.requests.len()
42    }
43
44    /// Returns true if the batch has no requests.
45    pub fn is_empty(&self) -> bool {
46        self.requests.is_empty()
47    }
48
49    /// Returns the unique request IDs in this batch.
50    pub fn request_ids(&self) -> Vec<&str> {
51        self.requests.iter().map(|r| r.id.as_str()).collect()
52    }
53}
54
55/// Response to a batch permission request.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct BatchPermissionResponse {
58    /// The batch ID this response is for.
59    pub batch_id: String,
60    /// Grants that were approved by the user.
61    pub approved_grants: Vec<Grant>,
62    /// Request IDs that were explicitly denied.
63    pub denied_requests: HashSet<String>,
64    /// Request IDs that were auto-approved (already had permission).
65    pub auto_approved: HashSet<String>,
66}
67
68impl BatchPermissionResponse {
69    /// Creates a response where all requests were granted.
70    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    /// Creates a response where all requests were denied.
80    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    /// Creates a response with auto-approved requests.
93    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    /// Checks if a specific request was granted (either explicitly or auto-approved).
106    ///
107    /// # Conflict Resolution
108    /// If a request_id appears in both `auto_approved` and `denied_requests`,
109    /// this is treated as a malformed response. A warning is logged and the
110    /// request is denied (safe default).
111    pub fn is_granted(&self, request_id: &str, request: &PermissionRequest) -> bool {
112        // Validate: request cannot be both auto-approved and denied
113        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        // Check if auto-approved
126        if in_auto_approved {
127            return true;
128        }
129
130        // Check if denied
131        if in_denied {
132            return false;
133        }
134
135        // Check if any approved grant satisfies this request
136        self.approved_grants
137            .iter()
138            .any(|grant| grant.satisfies(request))
139    }
140
141    /// Returns whether any requests were denied.
142    pub fn has_denials(&self) -> bool {
143        !self.denied_requests.is_empty()
144    }
145
146    /// Returns the number of approved grants.
147    pub fn approved_count(&self) -> usize {
148        self.approved_grants.len() + self.auto_approved.len()
149    }
150}
151
152/// Computes suggested grants that would cover all requests.
153///
154/// This function analyzes the requests and suggests a minimal set of
155/// grants that would satisfy all of them. It groups requests by common
156/// parent directories and suggests recursive grants where appropriate.
157pub 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    // Group requests by target type
165    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    // Compute grants for each target type
180    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
188/// Computes suggested grants for path-based requests.
189fn compute_path_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
190    if requests.is_empty() {
191        return Vec::new();
192    }
193
194    // Group paths by their parent directories and track max level needed
195    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    // Try to find common ancestors for related directories
209    let merged_groups = merge_related_directories(dir_groups);
210
211    // Create grants for each group
212    merged_groups
213        .into_iter()
214        .map(|(dir, (level, paths))| {
215            // If multiple paths share the same parent, make it recursive
216            let recursive = paths.len() > 1;
217            Grant::new(GrantTarget::path(dir, recursive), level)
218        })
219        .collect()
220}
221
222/// Merges directory groups that share a common ancestor.
223fn 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        // Check if this directory can be merged with an existing one
234        let mut merged = false;
235
236        for (existing_dir, (existing_level, existing_paths)) in result.iter_mut() {
237            // Check if one is ancestor of the other
238            if dir.starts_with(existing_dir) {
239                // Existing dir is ancestor - add paths and update level
240                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                // New dir is ancestor - this case needs special handling
246                // For simplicity, we'll just add as separate entry
247            } else {
248                // Check for common ancestor within reasonable depth
249                if let Some(common) = find_common_ancestor(&dir, existing_dir, 3) {
250                    // If close enough, could merge under common ancestor
251                    // For now, keep separate to avoid over-granting
252                    let _ = common;
253                }
254            }
255        }
256
257        if !merged {
258            result.insert(dir, (level, paths));
259        }
260    }
261
262    result
263}
264
265/// Finds the common ancestor of two paths, up to a maximum depth from either path.
266fn 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
281/// Computes suggested grants for domain-based requests.
282fn compute_domain_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
283    if requests.is_empty() {
284        return Vec::new();
285    }
286
287    // Group by base domain and track max level
288    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    // Create grants - if multiple subdomains, suggest wildcard
301    domain_levels
302        .into_iter()
303        .map(|(domain, level)| {
304            // Could enhance to detect multiple subdomains and suggest *.domain
305            Grant::new(GrantTarget::domain(domain), level)
306        })
307        .collect()
308}
309
310/// Extracts the base domain from a domain pattern.
311fn extract_base_domain(pattern: &str) -> String {
312    // Remove wildcard prefix if present
313    pattern.strip_prefix("*.").unwrap_or(pattern).to_string()
314}
315
316/// Computes suggested grants for command-based requests.
317fn compute_command_grants(requests: &[&PermissionRequest]) -> Vec<Grant> {
318    if requests.is_empty() {
319        return Vec::new();
320    }
321
322    // Group by command prefix (first word) and track max level
323    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    // Create grants - if multiple commands with same prefix, suggest wildcard
337    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
350/// Computes grants for tool requests.
351///
352/// Each unique tool name gets its own grant at the maximum requested level.
353fn 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
371/// Extracts the command prefix (first word) from a command.
372fn extract_command_prefix(command: &str) -> String {
373    command
374        .split_whitespace()
375        .next()
376        .unwrap_or(command)
377        .to_string()
378}
379
380/// User actions for responding to a batch permission request.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382pub enum BatchAction {
383    /// Approve all requests using the suggested grants.
384    AllowAll,
385    /// Approve selected requests only.
386    AllowSelected,
387    /// Deny all requests.
388    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        // Should suggest a single grant for the parent directory
458        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); // Multiple files means recursive
462        } 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        // Should use the highest level needed
477        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        // Should have separate grants for path and command
491        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        // Should suggest "git *" pattern
504        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        // Create a malformed response where the same ID is in both sets
515        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        // Should be denied (safe default) when in both sets
525        assert!(
526            !response.is_granted("conflict-id", &request),
527            "Conflicting request should be denied as safe default"
528        );
529    }
530}