Skip to main content

oxicode_sdk/security/
gate.rs

1//! Unified access gate — single entry point for all authorization decisions.
2//!
3//! Four-layer check hierarchy with short-circuit evaluation:
4//!
5//! ```text
6//! Layer 0: CSpace (Capability)  — does the agent have the capability token?
7//! Layer 1: RBAC                  — does the agent's role allow the action?
8//! Layer 2: Agent Permissions     — is the tool/path in allowed lists?
9//! Layer 3: ExecPolicy            — is the binary allowed? No metacharacters?
10//! ```
11
12use std::path::Path;
13use std::sync::Arc;
14
15use parking_lot::Mutex;
16
17use crate::security::audit_sink::{AuditEvent, AuditSink};
18use crate::security::capability::types::{ResourceRef, Rights};
19use crate::security::context::AgentContext;
20use crate::security::exec_policy::ExecPolicy;
21use crate::security::permissions::AgentPermissions;
22use crate::security::rbac::{Action, RbacManager, Role, Subject};
23
24// ─── Path Mode ──────────────────────────────────────────────────────────────
25
26/// Path access mode.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PathMode {
29    /// Read-only.
30    Read,
31    /// Write access.
32    Write,
33}
34
35impl std::fmt::Display for PathMode {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            PathMode::Read => write!(f, "read"),
39            PathMode::Write => write!(f, "write"),
40        }
41    }
42}
43
44// ─── Deny Layer ─────────────────────────────────────────────────────────────
45
46/// Which security layer denied access.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DenyLayer {
49    /// CSpace missing capability.
50    Capability,
51    /// RBAC policy denied.
52    Rbac,
53    /// AgentPermissions denied.
54    Permission,
55    /// ExecPolicy denied.
56    ExecPolicy,
57}
58
59impl std::fmt::Display for DenyLayer {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            DenyLayer::Capability => write!(f, "CSpace"),
63            DenyLayer::Rbac => write!(f, "RBAC"),
64            DenyLayer::Permission => write!(f, "Permissions"),
65            DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
66        }
67    }
68}
69
70// ─── Access Denied ──────────────────────────────────────────────────────────
71
72/// Authorization denial with layer, reason, and suggestion.
73#[derive(Debug, Clone)]
74pub struct AccessDenied {
75    /// Agent name.
76    pub agent: String,
77    /// Resource.
78    pub resource: String,
79    /// Denying layer.
80    pub layer: DenyLayer,
81    /// Machine-readable reason.
82    pub reason: String,
83    /// User-facing suggestion.
84    pub suggestion: Option<String>,
85}
86
87impl std::fmt::Display for AccessDenied {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(
90            f,
91            "[{}] {} — {}",
92            self.layer,
93            self.reason,
94            self.suggestion.as_deref().unwrap_or("")
95        )
96    }
97}
98
99// ─── Check Request ──────────────────────────────────────────────────────────
100
101/// Authorization check request.
102#[derive(Debug)]
103pub enum CheckRequest<'a> {
104    /// Tool usage.
105    Tool {
106        /// Agent context whose permissions are being checked.
107        context: &'a AgentContext,
108        /// Name of the tool the agent wants to invoke.
109        tool_name: &'a str,
110    },
111    /// Path access.
112    Path {
113        /// Agent context whose permissions are being checked.
114        context: &'a AgentContext,
115        /// Filesystem path the agent wants to access.
116        path: &'a Path,
117        /// Requested access mode (read or write).
118        mode: PathMode,
119    },
120    /// Command execution.
121    Exec {
122        /// Agent context whose permissions are being checked.
123        context: &'a AgentContext,
124        /// Name of the binary the agent wants to execute.
125        binary: &'a str,
126        /// Arguments passed to the binary.
127        args: &'a [String],
128    },
129    /// Network access.
130    Network {
131        /// Agent context whose permissions are being checked.
132        context: &'a AgentContext,
133    },
134    /// Agent fork.
135    Fork {
136        /// Agent context whose permissions are being checked.
137        context: &'a AgentContext,
138    },
139}
140
141impl<'a> CheckRequest<'a> {
142    /// Agent context.
143    pub fn agent_context(&self) -> &AgentContext {
144        match self {
145            CheckRequest::Tool { context, .. } => context,
146            CheckRequest::Path { context, .. } => context,
147            CheckRequest::Exec { context, .. } => context,
148            CheckRequest::Network { context } => context,
149            CheckRequest::Fork { context } => context,
150        }
151    }
152
153    /// Resource description.
154    pub fn resource(&self) -> &str {
155        match self {
156            CheckRequest::Tool { tool_name, .. } => tool_name,
157            CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid>"),
158            CheckRequest::Exec { binary, .. } => binary,
159            CheckRequest::Network { .. } => "<network>",
160            CheckRequest::Fork { .. } => "fork",
161        }
162    }
163}
164
165// ─── Shell Metacharacters ───────────────────────────────────────────────────
166
167const SHELL_METACHARS: &[char] = &[
168    '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
169];
170
171fn has_metacharacters(args: &[String]) -> bool {
172    args.iter()
173        .any(|arg| arg.contains("..") || SHELL_METACHARS.iter().any(|&c| arg.contains(c)))
174}
175
176// ─── Access Gate ────────────────────────────────────────────────────────────
177
178/// Single entry point for all authorization decisions.
179///
180/// Wraps RBAC, per-agent permissions, capability checks, and exec policy.
181pub struct AccessGate {
182    /// Per-agent permissions (name → set).
183    permissions: Arc<Mutex<std::collections::HashMap<String, AgentPermissions>>>,
184    /// RBAC manager.
185    rbac: Arc<Mutex<RbacManager>>,
186    /// Execution policy.
187    exec_policy: Arc<ExecPolicy>,
188    /// Audit sink.
189    audit: Arc<dyn AuditSink>,
190}
191
192impl AccessGate {
193    /// Create a new gate.
194    pub fn new(
195        permissions: Arc<Mutex<std::collections::HashMap<String, AgentPermissions>>>,
196        rbac: Arc<Mutex<RbacManager>>,
197        exec_policy: Arc<ExecPolicy>,
198        audit: Arc<dyn AuditSink>,
199    ) -> Self {
200        Self {
201            permissions,
202            rbac,
203            exec_policy,
204            audit,
205        }
206    }
207
208    /// Create a permissive gate (all checks pass). Useful for development.
209    pub fn permissive() -> Self {
210        Self {
211            permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
212            rbac: Arc::new(Mutex::new(RbacManager::new())),
213            exec_policy: Arc::new(ExecPolicy::permissive()),
214            audit: Arc::new(crate::security::audit_sink::TracingAuditSink),
215        }
216    }
217
218    /// Grant default permissions for an agent.
219    pub fn register_agent(&self, agent_name: &str, role: Role) {
220        self.permissions.lock().insert(
221            agent_name.to_string(),
222            AgentPermissions::for_new_agent(agent_name),
223        );
224        self.rbac
225            .lock()
226            .assign_role(Subject::User(agent_name.to_string()), role);
227    }
228
229    /// Perform authorization check.
230    pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
231        let result = match &req {
232            CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
233            CheckRequest::Path {
234                context,
235                path,
236                mode,
237            } => self.check_path(context, path, *mode),
238            CheckRequest::Exec {
239                context,
240                binary,
241                args,
242            } => self.check_exec(context, binary, args),
243            CheckRequest::Network { context } => self.check_network(context),
244            CheckRequest::Fork { context } => self.check_fork(context),
245        };
246        self.record_check(&req, &result);
247        result
248    }
249
250    fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
251        // Layer 0: CSpace
252        let resource = ResourceRef::KernelDomain {
253            domain: tool.to_string(),
254        };
255        if !ctx.cspace.can(&resource, Rights::Execute) {
256            let always_on = [
257                "read", "write", "edit", "grep", "find", "ls", "bash", "exec",
258            ];
259            if !always_on.contains(&tool) {
260                return Err(AccessDenied {
261                    agent: ctx.agent_name.clone(),
262                    resource: tool.to_string(),
263                    layer: DenyLayer::Capability,
264                    reason: format!("No Execute capability for '{tool}' in CSpace"),
265                    suggestion: Some(format!("Add '{tool}' capability to the agent's template.")),
266                });
267            }
268        }
269
270        // Layer 1+2: RBAC + Permissions
271        let subject = Subject::User(ctx.agent_name.clone());
272        if !self
273            .rbac
274            .lock()
275            .check_permission(&subject, &Action::UseTool(tool.to_string()), tool)
276        {
277            return Err(AccessDenied {
278                agent: ctx.agent_name.clone(),
279                resource: tool.to_string(),
280                layer: DenyLayer::Rbac,
281                reason: format!("RBAC denied '{tool}' for '{}'", ctx.agent_name),
282                suggestion: None,
283            });
284        }
285
286        let perms = self.permissions.lock();
287        if let Some(p) = perms.get(&ctx.agent_name)
288            && !p.allowed_tools.contains(tool)
289        {
290            return Err(AccessDenied {
291                agent: ctx.agent_name.clone(),
292                resource: tool.to_string(),
293                layer: DenyLayer::Permission,
294                reason: format!("'{tool}' not in allowed_tools for '{}'", ctx.agent_name),
295                suggestion: None,
296            });
297        }
298
299        Ok(())
300    }
301
302    fn check_path(
303        &self,
304        ctx: &AgentContext,
305        path: &Path,
306        _mode: PathMode,
307    ) -> Result<(), AccessDenied> {
308        let path_str = path.to_string_lossy();
309
310        // Layer 2: Path permissions
311        let perms = self.permissions.lock();
312        if let Some(p) = perms.get(&ctx.agent_name)
313            && p.is_path_denied(&path_str)
314        {
315            return Err(AccessDenied {
316                agent: ctx.agent_name.clone(),
317                resource: path_str.to_string(),
318                layer: DenyLayer::Permission,
319                reason: format!("Path '{path_str}' is in denied_paths"),
320                suggestion: None,
321            });
322        }
323
324        Ok(())
325    }
326
327    fn check_exec(
328        &self,
329        ctx: &AgentContext,
330        binary: &str,
331        args: &[String],
332    ) -> Result<(), AccessDenied> {
333        // Layer 3: ExecPolicy
334        if !self.exec_policy.is_binary_allowed(binary) {
335            return Err(AccessDenied {
336                agent: ctx.agent_name.clone(),
337                resource: binary.to_string(),
338                layer: DenyLayer::ExecPolicy,
339                reason: format!("Binary '{binary}' not in allowlist"),
340                suggestion: Some("Add to ExecPolicy.allowed_commands.".into()),
341            });
342        }
343
344        if has_metacharacters(args) {
345            return Err(AccessDenied {
346                agent: ctx.agent_name.clone(),
347                resource: binary.to_string(),
348                layer: DenyLayer::ExecPolicy,
349                reason: "Arguments contain shell metacharacters or path traversal".into(),
350                suggestion: None,
351            });
352        }
353
354        Ok(())
355    }
356
357    fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
358        let perms = self.permissions.lock();
359        if let Some(p) = perms.get(&ctx.agent_name)
360            && !p.network_access
361        {
362            return Err(AccessDenied {
363                agent: ctx.agent_name.clone(),
364                resource: "<network>".into(),
365                layer: DenyLayer::Permission,
366                reason: "Network access disabled".into(),
367                suggestion: Some("Set network_access to true.".into()),
368            });
369        }
370        Ok(())
371    }
372
373    fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
374        let perms = self.permissions.lock();
375        if let Some(p) = perms.get(&ctx.agent_name)
376            && !p.can_fork
377        {
378            return Err(AccessDenied {
379                agent: ctx.agent_name.clone(),
380                resource: "fork".into(),
381                layer: DenyLayer::Permission,
382                reason: "Fork not allowed".into(),
383                suggestion: Some("Set can_fork to true.".into()),
384            });
385        }
386        Ok(())
387    }
388
389    fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
390        let ctx = req.agent_context();
391        let ts = chrono::Utc::now();
392        let event = match result {
393            Ok(()) => AuditEvent::ToolAccess {
394                timestamp: ts,
395                agent: ctx.agent_name.clone(),
396                tool: req.resource().to_string(),
397                allowed: true,
398                layer: None,
399                reason: None,
400            },
401            Err(denied) => AuditEvent::ToolAccess {
402                timestamp: ts,
403                agent: ctx.agent_name.clone(),
404                tool: req.resource().to_string(),
405                allowed: false,
406                layer: Some(denied.layer.to_string()),
407                reason: Some(denied.reason.clone()),
408            },
409        };
410        self.audit.record(event);
411    }
412}
413
414impl std::fmt::Debug for AccessGate {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        f.debug_struct("AccessGate").finish()
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    struct NoOpSink;
425    impl AuditSink for NoOpSink {
426        fn record(&self, _event: AuditEvent) {}
427    }
428
429    fn make_gate() -> (AccessGate, AgentContext) {
430        let perms = Arc::new(Mutex::new(std::collections::HashMap::new()));
431        let rbac = Arc::new(Mutex::new(RbacManager::new()));
432        let ctx = AgentContext::from_template("test-agent", "standard");
433
434        perms.lock().insert(
435            "test-agent".into(),
436            AgentPermissions::for_new_agent("test-agent"),
437        );
438        rbac.lock()
439            .assign_role(Subject::User("test-agent".into()), Role::Superuser);
440
441        let gate = AccessGate::new(
442            perms,
443            rbac,
444            Arc::new(ExecPolicy::permissive()),
445            Arc::new(NoOpSink),
446        );
447        (gate, ctx)
448    }
449
450    #[test]
451    fn test_tool_allowed() {
452        let (gate, ctx) = make_gate();
453        assert!(
454            gate.check(CheckRequest::Tool {
455                context: &ctx,
456                tool_name: "bash"
457            })
458            .is_ok()
459        );
460    }
461
462    #[test]
463    fn test_exec_metacharacters_denied() {
464        let (gate, ctx) = make_gate();
465        let result = gate.check(CheckRequest::Exec {
466            context: &ctx,
467            binary: "echo",
468            args: &["foo; rm -rf /".to_string()],
469        });
470        assert!(result.is_err());
471        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
472    }
473
474    #[test]
475    fn test_network_denied_by_default() {
476        let (gate, ctx) = make_gate();
477        let result = gate.check(CheckRequest::Network { context: &ctx });
478        assert!(result.is_err());
479    }
480
481    #[test]
482    fn test_fork_denied_by_default() {
483        let (gate, ctx) = make_gate();
484        let result = gate.check(CheckRequest::Fork { context: &ctx });
485        assert!(result.is_err());
486    }
487
488    #[test]
489    fn test_path_denied_in_deny_list() {
490        let (gate, ctx) = make_gate();
491        let result = gate.check(CheckRequest::Path {
492            context: &ctx,
493            path: Path::new("/etc/passwd"),
494            mode: PathMode::Read,
495        });
496        assert!(result.is_err());
497    }
498
499    #[test]
500    fn test_permissive_gate() {
501        let gate = AccessGate::permissive();
502        let _ctx = AgentContext::from_template("dev", "worker");
503        // Permissive gate has no permissions registered, so tool check passes RBAC
504        // but may fail permissions — that's fine, it's for development
505        drop(gate);
506    }
507
508    #[test]
509    fn test_deny_layer_display() {
510        assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
511        assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
512    }
513
514    #[test]
515    fn test_access_denied_display() {
516        let d = AccessDenied {
517            agent: "test".into(),
518            resource: "exec".into(),
519            layer: DenyLayer::ExecPolicy,
520            reason: "not allowed".into(),
521            suggestion: Some("add it".into()),
522        };
523        assert!(format!("{}", d).contains("[ExecPolicy]"));
524    }
525}