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!["/workspace/**".to_string()],
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                if p.matches(path) {
167                    return true;
168                }
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                if p.matches(path) {
179                    return true;
180                }
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        assert!(perms.is_path_allowed("/workspace/src/main.rs"));
256        assert!(perms.is_path_allowed("/workspace/README.md"));
257        assert!(!perms.is_path_allowed("/tmp/evil"));
258    }
259
260    #[test]
261    fn test_for_new_agent_sets_name() {
262        let perms = AgentPermissions::for_new_agent("test-agent");
263        assert_eq!(perms.agent_name, "test-agent");
264        assert!(perms.allowed_tools.contains("read"));
265    }
266
267    #[test]
268    fn test_allow_and_deny_tool() {
269        let mut perms = AgentPermissions::for_new_agent("a");
270        perms.allow_tool("custom_tool");
271        assert!(perms.allowed_tools.contains("custom_tool"));
272
273        perms.deny_tool("bash");
274        assert!(!perms.allowed_tools.contains("bash"));
275
276        // denying non-existent tool is a no-op
277        perms.deny_tool("nonexistent");
278    }
279
280    #[test]
281    fn test_allow_and_deny_path_deduplication() {
282        let mut perms = AgentPermissions::for_new_agent("a");
283        perms.allow_path("/data/**");
284        perms.allow_path("/data/**"); // duplicate
285        assert_eq!(
286            perms
287                .allowed_paths
288                .iter()
289                .filter(|p| **p == "/data/**")
290                .count(),
291            1
292        );
293
294        perms.deny_path("/secret/**");
295        perms.deny_path("/secret/**"); // duplicate
296        assert_eq!(
297            perms
298                .denied_paths
299                .iter()
300                .filter(|p| **p == "/secret/**")
301                .count(),
302            1
303        );
304    }
305
306    #[test]
307    fn test_enable_network_and_forking() {
308        let mut perms = AgentPermissions::for_new_agent("a");
309        assert!(!perms.network_access);
310        assert!(!perms.can_fork);
311
312        perms.enable_network();
313        assert!(perms.network_access);
314
315        perms.enable_forking();
316        assert!(perms.can_fork);
317    }
318
319    #[test]
320    fn test_denied_overrides_allowed() {
321        let mut perms = AgentPermissions::for_new_agent("a");
322        perms.allowed_paths = vec!["/workspace/**".to_string()];
323        perms.denied_paths = vec!["/workspace/secret/**".to_string()];
324
325        assert!(perms.is_path_allowed("/workspace/secret/key.pem"));
326        assert!(perms.is_path_denied("/workspace/secret/key.pem"));
327        // Both match — denied takes precedence at the gate level
328    }
329
330    #[test]
331    fn test_invalid_glob_pattern() {
332        let mut perms = AgentPermissions::for_new_agent("a");
333        perms.allowed_paths = vec!["[invalid".to_string()];
334        // Invalid glob should not panic, just not match
335        assert!(!perms.is_path_allowed("/anything"));
336    }
337
338    #[test]
339    fn test_permission_update_partial() {
340        let mut perms = AgentPermissions::for_new_agent("a");
341        let original_tools = perms.allowed_tools.clone();
342
343        let update = PermissionUpdate {
344            network_access: Some(true),
345            max_execution_time_secs: Some(600),
346            ..Default::default()
347        };
348        update.apply(&mut perms);
349
350        assert!(perms.network_access);
351        assert_eq!(perms.max_execution_time_secs, 600);
352        // Untouched fields remain the same
353        assert_eq!(perms.allowed_tools, original_tools);
354        assert!(!perms.can_fork);
355    }
356
357    #[test]
358    fn test_permission_update_full_replace() {
359        let mut perms = AgentPermissions::for_new_agent("a");
360
361        let update = PermissionUpdate {
362            allowed_tools: Some(HashSet::from(["read".to_string()])),
363            allowed_paths: Some(vec!["/safe/**".to_string()]),
364            denied_paths: Some(vec![]),
365            network_access: Some(true),
366            max_execution_time_secs: Some(0),
367            max_memory_mb: Some(1024),
368            can_fork: Some(true),
369        };
370        update.apply(&mut perms);
371
372        assert_eq!(perms.allowed_tools.len(), 1);
373        assert!(perms.allowed_tools.contains("read"));
374        assert_eq!(perms.allowed_paths, vec!["/safe/**"]);
375        assert!(perms.denied_paths.is_empty());
376        assert!(perms.network_access);
377        assert!(perms.can_fork);
378        assert_eq!(perms.max_memory_mb, 1024);
379    }
380
381    #[test]
382    fn test_audit_entry_new_allowed() {
383        let entry = AuditEntry::new("agent-1", "use_tool", "bash", true, None);
384        assert_eq!(entry.agent_name, "agent-1");
385        assert_eq!(entry.action, "use_tool");
386        assert_eq!(entry.resource, "bash");
387        assert!(entry.allowed);
388        assert!(entry.reason.is_none());
389    }
390
391    #[test]
392    fn test_audit_entry_new_denied_with_reason() {
393        let entry = AuditEntry::new(
394            "rogue-agent",
395            "access_path",
396            "/etc/shadow",
397            false,
398            Some("path not in allowed list".to_string()),
399        );
400        assert!(!entry.allowed);
401        assert_eq!(entry.reason.as_deref(), Some("path not in allowed list"));
402    }
403
404    #[test]
405    fn test_permissions_serialization_roundtrip() {
406        let mut perms = AgentPermissions::for_new_agent("serializer");
407        perms.enable_network();
408        perms.allow_tool("curl");
409
410        let json = serde_json::to_string(&perms).unwrap();
411        let restored: AgentPermissions = serde_json::from_str(&json).unwrap();
412        assert_eq!(restored.agent_name, "serializer");
413        assert!(restored.network_access);
414        assert!(restored.allowed_tools.contains("curl"));
415    }
416
417    #[test]
418    fn test_audit_entry_serialization_roundtrip() {
419        let entry = AuditEntry::new(
420            "test",
421            "network_request",
422            "https://example.com",
423            false,
424            Some("network not allowed".to_string()),
425        );
426        let json = serde_json::to_string(&entry).unwrap();
427        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
428        assert_eq!(restored.agent_name, entry.agent_name);
429        assert_eq!(restored.action, entry.action);
430        assert_eq!(restored.allowed, entry.allowed);
431        assert_eq!(restored.reason, entry.reason);
432    }
433
434    #[test]
435    fn test_permission_update_default_is_noop() {
436        let mut perms = AgentPermissions::for_new_agent("a");
437        let snapshot = perms.clone();
438
439        let update = PermissionUpdate::default();
440        update.apply(&mut perms);
441
442        assert_eq!(perms.agent_name, snapshot.agent_name);
443        assert_eq!(perms.allowed_tools, snapshot.allowed_tools);
444        assert_eq!(perms.allowed_paths, snapshot.allowed_paths);
445        assert_eq!(perms.denied_paths, snapshot.denied_paths);
446        assert_eq!(perms.network_access, snapshot.network_access);
447        assert_eq!(
448            perms.max_execution_time_secs,
449            snapshot.max_execution_time_secs
450        );
451        assert_eq!(perms.max_memory_mb, snapshot.max_memory_mb);
452        assert_eq!(perms.can_fork, snapshot.can_fork);
453    }
454}