Skip to main content

oxicode_sdk/security/
permissions.rs

1//! Agent permissions — 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 (least-privilege defaults).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AgentPermissions {
11    /// Agent name.
12    pub agent_name: String,
13    /// Allowed tool names.
14    #[serde(default)]
15    pub allowed_tools: HashSet<String>,
16    /// Allowed path glob patterns.
17    #[serde(default)]
18    pub allowed_paths: Vec<String>,
19    /// Denied path glob patterns (takes precedence).
20    #[serde(default)]
21    pub denied_paths: Vec<String>,
22    /// Network access.
23    #[serde(default)]
24    pub network_access: bool,
25    /// Max execution time in seconds (0 = unlimited).
26    #[serde(default)]
27    pub max_execution_time_secs: u64,
28    /// Max memory in MB (0 = unlimited).
29    #[serde(default)]
30    pub max_memory_mb: u64,
31    /// Can spawn sub-agents.
32    #[serde(default)]
33    pub can_fork: bool,
34}
35
36impl Default for AgentPermissions {
37    fn default() -> Self {
38        Self {
39            agent_name: String::new(),
40            allowed_tools: ["read", "write", "edit", "bash", "grep", "find", "exec"]
41                .iter()
42                .map(|s| s.to_string())
43                .collect(),
44            allowed_paths: vec!["/workspace/**".to_string()],
45            denied_paths: vec![
46                "/etc/**".to_string(),
47                "/root/**".to_string(),
48                "/sys/**".to_string(),
49                "/proc/**".to_string(),
50                ".oxios/**".to_string(),
51            ],
52            network_access: false,
53            max_execution_time_secs: 300,
54            max_memory_mb: 512,
55            can_fork: false,
56        }
57    }
58}
59
60impl AgentPermissions {
61    /// Create default permissions for a new agent.
62    pub fn for_new_agent(agent_name: &str) -> Self {
63        Self {
64            agent_name: agent_name.to_string(),
65            ..Default::default()
66        }
67    }
68
69    /// Allow a tool.
70    pub fn allow_tool(&mut self, tool: &str) {
71        self.allowed_tools.insert(tool.to_string());
72    }
73
74    /// Deny a tool.
75    pub fn deny_tool(&mut self, tool: &str) {
76        self.allowed_tools.remove(tool);
77    }
78
79    /// Allow a path pattern (dedup).
80    pub fn allow_path(&mut self, path: &str) {
81        if !self.allowed_paths.contains(&path.to_string()) {
82            self.allowed_paths.push(path.to_string());
83        }
84    }
85
86    /// Deny a path pattern (dedup).
87    pub fn deny_path(&mut self, path: &str) {
88        if !self.denied_paths.contains(&path.to_string()) {
89            self.denied_paths.push(path.to_string());
90        }
91    }
92
93    /// Enable network access.
94    pub fn enable_network(&mut self) {
95        self.network_access = true;
96    }
97
98    /// Enable agent forking.
99    pub fn enable_forking(&mut self) {
100        self.can_fork = true;
101    }
102
103    /// Check if a path matches any denied pattern.
104    pub fn is_path_denied(&self, path: &str) -> bool {
105        for pattern in &self.denied_paths {
106            if let Ok(p) = Pattern::new(pattern)
107                && p.matches(path)
108            {
109                return true;
110            }
111        }
112        false
113    }
114
115    /// Check if a path matches any allowed pattern.
116    pub fn is_path_allowed(&self, path: &str) -> bool {
117        for pattern in &self.allowed_paths {
118            if let Ok(p) = Pattern::new(pattern)
119                && p.matches(path)
120            {
121                return true;
122            }
123        }
124        false
125    }
126}
127
128/// Partial update for permissions.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct PermissionUpdate {
131    /// Override allowed tools.
132    pub allowed_tools: Option<HashSet<String>>,
133    /// Override allowed paths.
134    pub allowed_paths: Option<Vec<String>>,
135    /// Override denied paths.
136    pub denied_paths: Option<Vec<String>>,
137    /// Override network access.
138    pub network_access: Option<bool>,
139    /// Override max execution time.
140    pub max_execution_time_secs: Option<u64>,
141    /// Override max memory.
142    pub max_memory_mb: Option<u64>,
143    /// Override can_fork.
144    pub can_fork: Option<bool>,
145}
146
147impl PermissionUpdate {
148    /// Apply non-None fields to target.
149    pub fn apply(&self, perms: &mut AgentPermissions) {
150        if let Some(tools) = &self.allowed_tools {
151            perms.allowed_tools = tools.clone();
152        }
153        if let Some(paths) = &self.allowed_paths {
154            perms.allowed_paths = paths.clone();
155        }
156        if let Some(paths) = &self.denied_paths {
157            perms.denied_paths = paths.clone();
158        }
159        if let Some(v) = self.network_access {
160            perms.network_access = v;
161        }
162        if let Some(v) = self.max_execution_time_secs {
163            perms.max_execution_time_secs = v;
164        }
165        if let Some(v) = self.max_memory_mb {
166            perms.max_memory_mb = v;
167        }
168        if let Some(v) = self.can_fork {
169            perms.can_fork = v;
170        }
171    }
172}
173
174/// Security audit entry.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct PermAuditEntry {
177    /// When.
178    pub timestamp: DateTime<Utc>,
179    /// Agent.
180    pub agent_name: String,
181    /// Action type.
182    pub action: String,
183    /// Resource.
184    pub resource: String,
185    /// Allowed?
186    pub allowed: bool,
187    /// Reason.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub reason: Option<String>,
190}
191
192impl PermAuditEntry {
193    /// Create a new audit entry.
194    pub fn new(
195        agent_name: &str,
196        action: &str,
197        resource: &str,
198        allowed: bool,
199        reason: Option<String>,
200    ) -> Self {
201        Self {
202            timestamp: Utc::now(),
203            agent_name: agent_name.to_string(),
204            action: action.to_string(),
205            resource: resource.to_string(),
206            allowed,
207            reason,
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn default_has_basic_tools() {
218        let p = AgentPermissions::default();
219        assert!(p.allowed_tools.contains("read"));
220        assert!(p.allowed_tools.contains("bash"));
221        assert!(!p.network_access);
222    }
223
224    #[test]
225    fn denies_sensitive_paths() {
226        let p = AgentPermissions::default();
227        assert!(p.is_path_denied("/etc/passwd"));
228        assert!(p.is_path_denied("/root/.ssh/id_rsa"));
229    }
230
231    #[test]
232    fn allows_workspace() {
233        let p = AgentPermissions::default();
234        assert!(p.is_path_allowed("/workspace/src/main.rs"));
235        assert!(!p.is_path_allowed("/tmp/evil"));
236    }
237
238    #[test]
239    fn partial_update() {
240        let mut p = AgentPermissions::for_new_agent("a");
241        let update = PermissionUpdate {
242            network_access: Some(true),
243            ..Default::default()
244        };
245        update.apply(&mut p);
246        assert!(p.network_access);
247        assert!(!p.can_fork);
248    }
249}