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