Skip to main content

agent_air_runtime/permissions/
grant.rs

1//! Grant structure combining target and permission level.
2//!
3//! A Grant is the fundamental unit of the permission system, representing
4//! the tuple `(Target, Level)` - what resource is being accessed and how.
5
6use super::{GrantTarget, PermissionLevel};
7use serde::{Deserialize, Serialize};
8use std::time::Instant;
9
10/// A permission grant combining a target and permission level.
11///
12/// Grants are the core building blocks of the permission system. Each grant
13/// specifies:
14/// - What can be accessed (the target)
15/// - What level of access is permitted (the level)
16/// - When the grant expires (optional)
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Grant {
19    /// What this grant applies to (path, domain, or command).
20    pub target: GrantTarget,
21    /// The permission level granted.
22    pub level: PermissionLevel,
23    /// Optional expiration time. If `None`, grant lasts for the session.
24    #[serde(skip)]
25    pub expires: Option<Instant>,
26}
27
28impl Grant {
29    /// Creates a new grant with the given target and level.
30    ///
31    /// The grant has no expiration (session-scoped).
32    pub fn new(target: GrantTarget, level: PermissionLevel) -> Self {
33        Self {
34            target,
35            level,
36            expires: None,
37        }
38    }
39
40    /// Creates a new grant with an expiration time.
41    pub fn with_expiration(target: GrantTarget, level: PermissionLevel, expires: Instant) -> Self {
42        Self {
43            target,
44            level,
45            expires: Some(expires),
46        }
47    }
48
49    /// Creates a read grant for a path.
50    pub fn read_path(path: impl Into<std::path::PathBuf>, recursive: bool) -> Self {
51        Self::new(GrantTarget::path(path, recursive), PermissionLevel::Read)
52    }
53
54    /// Creates a write grant for a path.
55    pub fn write_path(path: impl Into<std::path::PathBuf>, recursive: bool) -> Self {
56        Self::new(GrantTarget::path(path, recursive), PermissionLevel::Write)
57    }
58
59    /// Creates an execute grant for a path.
60    pub fn execute_path(path: impl Into<std::path::PathBuf>, recursive: bool) -> Self {
61        Self::new(GrantTarget::path(path, recursive), PermissionLevel::Execute)
62    }
63
64    /// Creates an admin grant for a path.
65    pub fn admin_path(path: impl Into<std::path::PathBuf>, recursive: bool) -> Self {
66        Self::new(GrantTarget::path(path, recursive), PermissionLevel::Admin)
67    }
68
69    /// Creates a grant for a network domain.
70    pub fn domain(pattern: impl Into<String>, level: PermissionLevel) -> Self {
71        Self::new(GrantTarget::domain(pattern), level)
72    }
73
74    /// Creates a grant for a shell command pattern.
75    pub fn command(pattern: impl Into<String>, level: PermissionLevel) -> Self {
76        Self::new(GrantTarget::command(pattern), level)
77    }
78
79    /// Creates a grant for a tool invocation.
80    pub fn tool(tool_name: impl Into<String>, level: PermissionLevel) -> Self {
81        Self::new(GrantTarget::tool(tool_name), level)
82    }
83
84    /// Checks if this grant satisfies a permission request.
85    ///
86    /// A grant satisfies a request if:
87    /// 1. The grant's target covers the request's target
88    /// 2. The grant's level satisfies the request's required level
89    /// 3. The grant has not expired
90    ///
91    /// # Arguments
92    /// * `request` - The permission request to check against
93    ///
94    /// # Returns
95    /// `true` if this grant satisfies the request
96    pub fn satisfies(&self, request: &PermissionRequest) -> bool {
97        // Check expiration
98        if let Some(expires) = self.expires
99            && Instant::now() >= expires
100        {
101            return false;
102        }
103
104        // Check target coverage
105        if !self.target.covers(&request.target) {
106            return false;
107        }
108
109        // Check level hierarchy
110        self.level.satisfies(request.required_level)
111    }
112
113    /// Checks if this grant has expired.
114    pub fn is_expired(&self) -> bool {
115        self.expires.is_some_and(|e| Instant::now() >= e)
116    }
117
118    /// Returns a display-friendly description of this grant.
119    pub fn description(&self) -> String {
120        format!("[{}] {}", self.level, self.target)
121    }
122}
123
124impl std::fmt::Display for Grant {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}", self.description())
127    }
128}
129
130/// A request for permission to perform an operation.
131///
132/// Permission requests are generated by tools when they need access to
133/// resources. The request specifies:
134/// - What target is being accessed
135/// - What level of access is needed
136/// - A human-readable description of the operation
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct PermissionRequest {
139    /// Unique identifier for this request.
140    pub id: String,
141    /// The target being accessed.
142    pub target: GrantTarget,
143    /// The required permission level.
144    pub required_level: PermissionLevel,
145    /// Human-readable description of the operation.
146    pub description: String,
147    /// Optional reason explaining why this access is needed.
148    pub reason: Option<String>,
149    /// The tool that generated this request.
150    pub tool_name: Option<String>,
151}
152
153impl PermissionRequest {
154    /// Creates a new permission request.
155    pub fn new(
156        id: impl Into<String>,
157        target: GrantTarget,
158        required_level: PermissionLevel,
159        description: impl Into<String>,
160    ) -> Self {
161        Self {
162            id: id.into(),
163            target,
164            required_level,
165            description: description.into(),
166            reason: None,
167            tool_name: None,
168        }
169    }
170
171    /// Sets the reason for this request.
172    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
173        self.reason = Some(reason.into());
174        self
175    }
176
177    /// Sets the tool name for this request.
178    pub fn with_tool(mut self, tool_name: impl Into<String>) -> Self {
179        self.tool_name = Some(tool_name.into());
180        self
181    }
182
183    /// Creates a file read request.
184    pub fn file_read(id: impl Into<String>, path: impl Into<std::path::PathBuf>) -> Self {
185        let path = path.into();
186        let description = format!("Read file: {}", path.display());
187        Self::new(
188            id,
189            GrantTarget::path(path, false),
190            PermissionLevel::Read,
191            description,
192        )
193    }
194
195    /// Creates a file write request.
196    pub fn file_write(id: impl Into<String>, path: impl Into<std::path::PathBuf>) -> Self {
197        let path = path.into();
198        let description = format!("Write file: {}", path.display());
199        Self::new(
200            id,
201            GrantTarget::path(path, false),
202            PermissionLevel::Write,
203            description,
204        )
205    }
206
207    /// Creates a directory read request.
208    pub fn directory_read(
209        id: impl Into<String>,
210        path: impl Into<std::path::PathBuf>,
211        recursive: bool,
212    ) -> Self {
213        let path = path.into();
214        let description = if recursive {
215            format!("Read directory (recursive): {}", path.display())
216        } else {
217            format!("Read directory: {}", path.display())
218        };
219        Self::new(
220            id,
221            GrantTarget::path(path, recursive),
222            PermissionLevel::Read,
223            description,
224        )
225    }
226
227    /// Creates a command execution request.
228    pub fn command_execute(id: impl Into<String>, command: impl Into<String>) -> Self {
229        let command = command.into();
230        let description = format!("Execute command: {}", command);
231        Self::new(
232            id,
233            GrantTarget::command(command),
234            PermissionLevel::Execute,
235            description,
236        )
237    }
238
239    /// Creates a tool invocation request.
240    pub fn tool_use(
241        id: impl Into<String>,
242        tool_name: impl Into<String>,
243        level: PermissionLevel,
244    ) -> Self {
245        let tool_name = tool_name.into();
246        let description = format!("Use tool: {}", tool_name);
247        Self::new(id, GrantTarget::tool(&tool_name), level, description).with_tool(tool_name)
248    }
249
250    /// Creates a network request.
251    pub fn network_access(
252        id: impl Into<String>,
253        domain: impl Into<String>,
254        level: PermissionLevel,
255    ) -> Self {
256        let domain = domain.into();
257        let description = format!("Access domain: {}", domain);
258        Self::new(id, GrantTarget::domain(domain), level, description)
259    }
260}
261
262impl std::fmt::Display for PermissionRequest {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        write!(f, "[{}] {}", self.required_level, self.description)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    mod grant_tests {
273        use super::*;
274
275        #[test]
276        fn test_grant_satisfies_same_level() {
277            let grant = Grant::read_path("/project/src", true);
278            let request = PermissionRequest::file_read("1", "/project/src/main.rs");
279            assert!(grant.satisfies(&request));
280        }
281
282        #[test]
283        fn test_grant_satisfies_higher_level() {
284            let grant = Grant::write_path("/project/src", true);
285            let request = PermissionRequest::file_read("1", "/project/src/main.rs");
286            assert!(grant.satisfies(&request));
287        }
288
289        #[test]
290        fn test_grant_fails_lower_level() {
291            let grant = Grant::read_path("/project/src", true);
292            let request = PermissionRequest::file_write("1", "/project/src/main.rs");
293            assert!(!grant.satisfies(&request));
294        }
295
296        #[test]
297        fn test_grant_fails_wrong_path() {
298            let grant = Grant::write_path("/project/src", true);
299            let request = PermissionRequest::file_write("1", "/other/file.rs");
300            assert!(!grant.satisfies(&request));
301        }
302
303        #[test]
304        fn test_grant_fails_non_recursive() {
305            let grant = Grant::read_path("/project/src", false);
306            let request = PermissionRequest::file_read("1", "/project/src/utils/mod.rs");
307            assert!(!grant.satisfies(&request));
308        }
309
310        #[test]
311        fn test_admin_grant_satisfies_all_levels() {
312            let grant = Grant::admin_path("/project", true);
313
314            let read_request = PermissionRequest::file_read("1", "/project/src/main.rs");
315            let write_request = PermissionRequest::file_write("2", "/project/src/main.rs");
316
317            assert!(grant.satisfies(&read_request));
318            assert!(grant.satisfies(&write_request));
319        }
320
321        #[test]
322        fn test_domain_grant() {
323            let grant = Grant::domain("*.github.com", PermissionLevel::Read);
324            let request =
325                PermissionRequest::network_access("1", "api.github.com", PermissionLevel::Read);
326            assert!(grant.satisfies(&request));
327        }
328
329        #[test]
330        fn test_command_grant() {
331            let grant = Grant::command("git *", PermissionLevel::Execute);
332            let request = PermissionRequest::command_execute("1", "git status");
333            assert!(grant.satisfies(&request));
334        }
335
336        #[test]
337        fn test_tool_grant() {
338            let grant = Grant::tool("switch_aws_account", PermissionLevel::Execute);
339            let request =
340                PermissionRequest::tool_use("1", "switch_aws_account", PermissionLevel::Execute);
341            assert!(grant.satisfies(&request));
342        }
343
344        #[test]
345        fn test_tool_grant_different_tool_fails() {
346            let grant = Grant::tool("switch_aws_account", PermissionLevel::Execute);
347            let request =
348                PermissionRequest::tool_use("1", "delete_resource", PermissionLevel::Execute);
349            assert!(!grant.satisfies(&request));
350        }
351
352        #[test]
353        fn test_expired_grant() {
354            use std::time::Duration;
355            let expired = Instant::now() - Duration::from_secs(1);
356            let grant = Grant::with_expiration(
357                GrantTarget::path("/project", true),
358                PermissionLevel::Read,
359                expired,
360            );
361
362            let request = PermissionRequest::file_read("1", "/project/file.rs");
363            assert!(!grant.satisfies(&request));
364        }
365
366        #[test]
367        fn test_grant_description() {
368            let grant = Grant::write_path("/project/src", true);
369            let desc = grant.description();
370            assert!(desc.contains("Write"));
371            assert!(desc.contains("/project/src"));
372        }
373    }
374
375    mod request_tests {
376        use super::*;
377
378        #[test]
379        fn test_file_read_request() {
380            let request = PermissionRequest::file_read("test-id", "/path/to/file.rs");
381            assert_eq!(request.id, "test-id");
382            assert_eq!(request.required_level, PermissionLevel::Read);
383            assert!(request.description.contains("Read file"));
384        }
385
386        #[test]
387        fn test_file_write_request() {
388            let request = PermissionRequest::file_write("test-id", "/path/to/file.rs");
389            assert_eq!(request.required_level, PermissionLevel::Write);
390            assert!(request.description.contains("Write file"));
391        }
392
393        #[test]
394        fn test_request_with_reason() {
395            let request = PermissionRequest::file_read("1", "/file.rs")
396                .with_reason("Need to analyze the code");
397            assert_eq!(request.reason, Some("Need to analyze the code".to_string()));
398        }
399
400        #[test]
401        fn test_request_with_tool() {
402            let request = PermissionRequest::file_read("1", "/file.rs").with_tool("read_file");
403            assert_eq!(request.tool_name, Some("read_file".to_string()));
404        }
405
406        #[test]
407        fn test_tool_use_request() {
408            let request = PermissionRequest::tool_use(
409                "test-id",
410                "switch_aws_account",
411                PermissionLevel::Execute,
412            );
413            assert_eq!(request.id, "test-id");
414            assert_eq!(request.required_level, PermissionLevel::Execute);
415            assert!(request.description.contains("Use tool"));
416            assert!(request.description.contains("switch_aws_account"));
417            assert_eq!(request.tool_name, Some("switch_aws_account".to_string()));
418        }
419    }
420
421    mod serialization_tests {
422        use super::*;
423
424        #[test]
425        fn test_grant_serialization() {
426            let grant = Grant::write_path("/project/src", true);
427            let json = serde_json::to_string(&grant).unwrap();
428
429            let deserialized: Grant = serde_json::from_str(&json).unwrap();
430            assert_eq!(deserialized.target, grant.target);
431            assert_eq!(deserialized.level, grant.level);
432        }
433
434        #[test]
435        fn test_request_serialization() {
436            let request =
437                PermissionRequest::file_read("test-id", "/path/to/file.rs").with_reason("testing");
438            let json = serde_json::to_string(&request).unwrap();
439
440            let deserialized: PermissionRequest = serde_json::from_str(&json).unwrap();
441            assert_eq!(deserialized.id, request.id);
442            assert_eq!(deserialized.reason, request.reason);
443        }
444    }
445}