Skip to main content

oxios_kernel/access_manager/
permissions.rs

1//! Agent permissions types — per-agent permission sets and audit entries.
2
3use chrono::{DateTime, Utc};
4use glob::Pattern;
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7
8/// Permissions for a single agent.
9///
10/// Agents start with minimal permissions (least privilege).
11/// Additional permissions must be explicitly granted via configuration
12/// or an authorized request.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AgentPermissions {
15    /// Name of the agent this permission set applies to.
16    pub agent_name: String,
17    /// Set of allowed tool names. Empty means no tools allowed.
18    #[serde(default)]
19    pub allowed_tools: HashSet<String>,
20    /// Allowed path patterns (glob). Used for file operations.
21    #[serde(default)]
22    pub allowed_paths: Vec<String>,
23    /// Denied path patterns (glob). Always blocked, even if allowed_paths matches.
24    #[serde(default)]
25    pub denied_paths: Vec<String>,
26    /// Whether this agent can make network requests.
27    #[serde(default)]
28    pub network_access: bool,
29    /// Maximum execution time in seconds (0 = unlimited).
30    #[serde(default)]
31    pub max_execution_time_secs: u64,
32    /// Maximum memory in MB (0 = unlimited).
33    #[serde(default)]
34    pub max_memory_mb: u64,
35    /// Whether this agent can spawn sub-agents.
36    #[serde(default)]
37    pub can_fork: bool,
38}
39
40impl Default for AgentPermissions {
41    fn default() -> Self {
42        Self {
43            agent_name: String::new(),
44            // By default, agents get basic file tools.
45            // Network access is denied by default.
46            allowed_tools: ["read", "write", "edit", "bash", "grep", "find", "exec"]
47                .iter()
48                .map(|s| s.to_string())
49                .collect(),
50            allowed_paths: vec![],
51            denied_paths: vec![
52                "/etc/**".to_string(),
53                "/root/**".to_string(),
54                "/sys/**".to_string(),
55                "/proc/**".to_string(),
56                ".oxios/**".to_string(),
57            ],
58            network_access: false,
59            max_execution_time_secs: 300,
60            max_memory_mb: 512,
61            can_fork: false,
62        }
63    }
64}
65
66/// Update struct for permission changes (partial updates).
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct PermissionUpdate {
69    /// Set of allowed tool names.
70    #[serde(default)]
71    pub allowed_tools: Option<HashSet<String>>,
72    /// Allowed path patterns (glob).
73    #[serde(default)]
74    pub allowed_paths: Option<Vec<String>>,
75    /// Denied path patterns (glob).
76    #[serde(default)]
77    pub denied_paths: Option<Vec<String>>,
78    /// Whether this agent can make network requests.
79    #[serde(default)]
80    pub network_access: Option<bool>,
81    /// Maximum execution time in seconds (0 = unlimited).
82    #[serde(default)]
83    pub max_execution_time_secs: Option<u64>,
84    /// Maximum memory in MB (0 = unlimited).
85    #[serde(default)]
86    pub max_memory_mb: Option<u64>,
87    /// Whether this agent can spawn sub-agents.
88    #[serde(default)]
89    pub can_fork: Option<bool>,
90}
91
92impl PermissionUpdate {
93    /// Apply this update to a permission set.
94    pub fn apply(&self, perms: &mut AgentPermissions) {
95        if let Some(tools) = &self.allowed_tools {
96            perms.allowed_tools = tools.clone();
97        }
98        if let Some(paths) = &self.allowed_paths {
99            perms.allowed_paths = paths.clone();
100        }
101        if let Some(paths) = &self.denied_paths {
102            perms.denied_paths = paths.clone();
103        }
104        if let Some(v) = self.network_access {
105            perms.network_access = v;
106        }
107        if let Some(v) = self.max_execution_time_secs {
108            perms.max_execution_time_secs = v;
109        }
110        if let Some(v) = self.max_memory_mb {
111            perms.max_memory_mb = v;
112        }
113        if let Some(v) = self.can_fork {
114            perms.can_fork = v;
115        }
116    }
117}
118
119impl AgentPermissions {
120    /// Creates permissions for a new agent with the default restrictive set.
121    pub fn for_new_agent(agent_name: &str) -> Self {
122        Self {
123            agent_name: agent_name.to_string(),
124            ..Default::default()
125        }
126    }
127
128    /// Adds a tool to the allowed set.
129    pub fn allow_tool(&mut self, tool: &str) {
130        self.allowed_tools.insert(tool.to_string());
131    }
132
133    /// Removes a tool from the allowed set.
134    pub fn deny_tool(&mut self, tool: &str) {
135        self.allowed_tools.remove(tool);
136    }
137
138    /// Adds a path pattern to the allowed set.
139    pub fn allow_path(&mut self, path: &str) {
140        if !self.allowed_paths.contains(&path.to_string()) {
141            self.allowed_paths.push(path.to_string());
142        }
143    }
144
145    /// Adds a path pattern to the denied set.
146    pub fn deny_path(&mut self, path: &str) {
147        if !self.denied_paths.contains(&path.to_string()) {
148            self.denied_paths.push(path.to_string());
149        }
150    }
151
152    /// Enables network access for this agent.
153    pub fn enable_network(&mut self) {
154        self.network_access = true;
155    }
156
157    /// Enables agent forking (spawning sub-agents).
158    pub fn enable_forking(&mut self) {
159        self.can_fork = true;
160    }
161
162    /// Checks if a path matches any denied pattern.
163    pub(crate) fn is_path_denied(&self, path: &str) -> bool {
164        for pattern in &self.denied_paths {
165            if let Ok(p) = Pattern::new(pattern)
166                && p.matches(path)
167            {
168                return true;
169            }
170        }
171        false
172    }
173
174    /// Checks if a path matches any allowed pattern.
175    pub(crate) fn is_path_allowed(&self, path: &str) -> bool {
176        for pattern in &self.allowed_paths {
177            if let Ok(p) = Pattern::new(pattern)
178                && p.matches(path)
179            {
180                return true;
181            }
182        }
183        false
184    }
185}
186
187/// An entry in the security audit log.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct AuditEntry {
190    /// When the action occurred.
191    pub timestamp: DateTime<Utc>,
192    /// Agent that performed the action.
193    pub agent_name: String,
194    /// The action attempted (e.g., "use_tool", "access_path", "network_request").
195    pub action: String,
196    /// The resource involved (e.g., "bash", "/workspace/file.txt").
197    pub resource: String,
198    /// Whether the action was allowed.
199    pub allowed: bool,
200    /// Reason for the decision (e.g., "path not in allowed list").
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub reason: Option<String>,
203}
204
205impl AuditEntry {
206    /// Creates a new audit entry.
207    pub fn new(
208        agent_name: &str,
209        action: &str,
210        resource: &str,
211        allowed: bool,
212        reason: Option<String>,
213    ) -> Self {
214        Self {
215            timestamp: Utc::now(),
216            agent_name: agent_name.to_string(),
217            action: action.to_string(),
218            resource: resource.to_string(),
219            allowed,
220            reason,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_default_permissions_has_basic_tools() {
231        let perms = AgentPermissions::default();
232        assert!(perms.allowed_tools.contains("read"));
233        assert!(perms.allowed_tools.contains("write"));
234        assert!(perms.allowed_tools.contains("bash"));
235        assert!(perms.allowed_tools.contains("exec"));
236        assert!(!perms.network_access);
237        assert!(!perms.can_fork);
238        assert_eq!(perms.max_execution_time_secs, 300);
239        assert_eq!(perms.max_memory_mb, 512);
240    }
241
242    #[test]
243    fn test_default_permissions_denies_sensitive_paths() {
244        let perms = AgentPermissions::default();
245        assert!(perms.is_path_denied("/etc/passwd"));
246        assert!(perms.is_path_denied("/root/.ssh/id_rsa"));
247        assert!(perms.is_path_denied("/proc/self/environ"));
248        assert!(perms.is_path_denied("/sys/kernel/addr"));
249        assert!(perms.is_path_denied(".oxios/config.toml"));
250    }
251
252    #[test]
253    fn test_default_permissions_allows_workspace() {
254        let perms = AgentPermissions::default();
255        // Default has empty allowed_paths — paths are granted by ensure_permissions
256        assert!(!perms.is_path_allowed("/workspace/src/main.rs"));
257        assert!(!perms.is_path_allowed("/tmp/evil"));
258        // With explicit pattern, it should match
259        let mut perms = AgentPermissions::for_new_agent("test");
260        perms.allow_path("/workspace/**");
261        assert!(perms.is_path_allowed("/workspace/src/main.rs"));
262        assert!(perms.is_path_allowed("/workspace/README.md"));
263    }
264
265    #[test]
266    fn test_for_new_agent_sets_name() {
267        let perms = AgentPermissions::for_new_agent("test-agent");
268        assert_eq!(perms.agent_name, "test-agent");
269        assert!(perms.allowed_tools.contains("read"));
270    }
271
272    #[test]
273    fn test_allow_and_deny_tool() {
274        let mut perms = AgentPermissions::for_new_agent("a");
275        perms.allow_tool("custom_tool");
276        assert!(perms.allowed_tools.contains("custom_tool"));
277
278        perms.deny_tool("bash");
279        assert!(!perms.allowed_tools.contains("bash"));
280
281        // denying non-existent tool is a no-op
282        perms.deny_tool("nonexistent");
283    }
284
285    #[test]
286    fn test_allow_and_deny_path_deduplication() {
287        let mut perms = AgentPermissions::for_new_agent("a");
288        perms.allow_path("/data/**");
289        perms.allow_path("/data/**"); // duplicate
290        assert_eq!(
291            perms
292                .allowed_paths
293                .iter()
294                .filter(|p| **p == "/data/**")
295                .count(),
296            1
297        );
298
299        perms.deny_path("/secret/**");
300        perms.deny_path("/secret/**"); // duplicate
301        assert_eq!(
302            perms
303                .denied_paths
304                .iter()
305                .filter(|p| **p == "/secret/**")
306                .count(),
307            1
308        );
309    }
310
311    #[test]
312    fn test_enable_network_and_forking() {
313        let mut perms = AgentPermissions::for_new_agent("a");
314        assert!(!perms.network_access);
315        assert!(!perms.can_fork);
316
317        perms.enable_network();
318        assert!(perms.network_access);
319
320        perms.enable_forking();
321        assert!(perms.can_fork);
322    }
323
324    #[test]
325    fn test_denied_overrides_allowed() {
326        let mut perms = AgentPermissions::for_new_agent("a");
327        perms.allowed_paths = vec!["/workspace/**".to_string()];
328        perms.denied_paths = vec!["/workspace/secret/**".to_string()];
329
330        assert!(perms.is_path_allowed("/workspace/secret/key.pem"));
331        assert!(perms.is_path_denied("/workspace/secret/key.pem"));
332        // Both match — denied takes precedence at the gate level
333    }
334
335    #[test]
336    fn test_invalid_glob_pattern() {
337        let mut perms = AgentPermissions::for_new_agent("a");
338        perms.allowed_paths = vec!["[invalid".to_string()];
339        // Invalid glob should not panic, just not match
340        assert!(!perms.is_path_allowed("/anything"));
341    }
342
343    #[test]
344    fn test_permission_update_partial() {
345        let mut perms = AgentPermissions::for_new_agent("a");
346        let original_tools = perms.allowed_tools.clone();
347
348        let update = PermissionUpdate {
349            network_access: Some(true),
350            max_execution_time_secs: Some(600),
351            ..Default::default()
352        };
353        update.apply(&mut perms);
354
355        assert!(perms.network_access);
356        assert_eq!(perms.max_execution_time_secs, 600);
357        // Untouched fields remain the same
358        assert_eq!(perms.allowed_tools, original_tools);
359        assert!(!perms.can_fork);
360    }
361
362    #[test]
363    fn test_permission_update_full_replace() {
364        let mut perms = AgentPermissions::for_new_agent("a");
365
366        let update = PermissionUpdate {
367            allowed_tools: Some(HashSet::from(["read".to_string()])),
368            allowed_paths: Some(vec!["/safe/**".to_string()]),
369            denied_paths: Some(vec![]),
370            network_access: Some(true),
371            max_execution_time_secs: Some(0),
372            max_memory_mb: Some(1024),
373            can_fork: Some(true),
374        };
375        update.apply(&mut perms);
376
377        assert_eq!(perms.allowed_tools.len(), 1);
378        assert!(perms.allowed_tools.contains("read"));
379        assert_eq!(perms.allowed_paths, vec!["/safe/**"]);
380        assert!(perms.denied_paths.is_empty());
381        assert!(perms.network_access);
382        assert!(perms.can_fork);
383        assert_eq!(perms.max_memory_mb, 1024);
384    }
385
386    #[test]
387    fn test_audit_entry_new_allowed() {
388        let entry = AuditEntry::new("agent-1", "use_tool", "bash", true, None);
389        assert_eq!(entry.agent_name, "agent-1");
390        assert_eq!(entry.action, "use_tool");
391        assert_eq!(entry.resource, "bash");
392        assert!(entry.allowed);
393        assert!(entry.reason.is_none());
394    }
395
396    #[test]
397    fn test_audit_entry_new_denied_with_reason() {
398        let entry = AuditEntry::new(
399            "rogue-agent",
400            "access_path",
401            "/etc/shadow",
402            false,
403            Some("path not in allowed list".to_string()),
404        );
405        assert!(!entry.allowed);
406        assert_eq!(entry.reason.as_deref(), Some("path not in allowed list"));
407    }
408
409    #[test]
410    fn test_permissions_serialization_roundtrip() {
411        let mut perms = AgentPermissions::for_new_agent("serializer");
412        perms.enable_network();
413        perms.allow_tool("curl");
414
415        let json = serde_json::to_string(&perms).unwrap();
416        let restored: AgentPermissions = serde_json::from_str(&json).unwrap();
417        assert_eq!(restored.agent_name, "serializer");
418        assert!(restored.network_access);
419        assert!(restored.allowed_tools.contains("curl"));
420    }
421
422    #[test]
423    fn test_audit_entry_serialization_roundtrip() {
424        let entry = AuditEntry::new(
425            "test",
426            "network_request",
427            "https://example.com",
428            false,
429            Some("network not allowed".to_string()),
430        );
431        let json = serde_json::to_string(&entry).unwrap();
432        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
433        assert_eq!(restored.agent_name, entry.agent_name);
434        assert_eq!(restored.action, entry.action);
435        assert_eq!(restored.allowed, entry.allowed);
436        assert_eq!(restored.reason, entry.reason);
437    }
438
439    #[test]
440    fn test_permission_update_default_is_noop() {
441        let mut perms = AgentPermissions::for_new_agent("a");
442        let snapshot = perms.clone();
443
444        let update = PermissionUpdate::default();
445        update.apply(&mut perms);
446
447        assert_eq!(perms.agent_name, snapshot.agent_name);
448        assert_eq!(perms.allowed_tools, snapshot.allowed_tools);
449        assert_eq!(perms.allowed_paths, snapshot.allowed_paths);
450        assert_eq!(perms.denied_paths, snapshot.denied_paths);
451        assert_eq!(perms.network_access, snapshot.network_access);
452        assert_eq!(
453            perms.max_execution_time_secs,
454            snapshot.max_execution_time_secs
455        );
456        assert_eq!(perms.max_memory_mb, snapshot.max_memory_mb);
457        assert_eq!(perms.can_fork, snapshot.can_fork);
458    }
459}