Skip to main content

oxios_kernel/access_manager/
gate.rs

1//! Unified access gate — single entry point for all authorization decisions.
2//!
3//! Every security check in the system flows through `AccessGate`. It enforces
4//! a four-layer hierarchy with short-circuit evaluation:
5//!
6//! ```text
7//! Layer 0: CSpace (Capability)  — does the agent have the capability token?
8//! Layer 1: RBAC                  — does the agent's role allow the action?
9//! Layer 2: Agent Permissions     — is the tool/path in allowed lists?
10//! Layer 3: ExecConfig            — is the binary allowed? No metacharacters?
11//! ```
12//!
13//! If any layer denies, the request is rejected immediately (no further checks).
14//! All decisions (allow and deny) are recorded via `AuditSink`.
15
16use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use parking_lot::Mutex;
21
22use crate::access_manager::audit_sink::{AuditEvent, AuditSink};
23use crate::access_manager::context::AgentContext;
24use crate::access_manager::{AccessManager, Action, Subject};
25use crate::capability::{ResourceRef, Rights};
26use crate::config::ExecConfig;
27
28// ─── Path Mode ──────────────────────────────────────────────────────────────
29
30/// Path access mode for permission checks.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PathMode {
33    /// Read-only access (read, ls, grep, find).
34    Read,
35    /// Write access (write, edit).
36    Write,
37}
38
39impl std::fmt::Display for PathMode {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            PathMode::Read => write!(f, "read"),
43            PathMode::Write => write!(f, "write"),
44        }
45    }
46}
47
48// ─── Deny Layer ─────────────────────────────────────────────────────────────
49
50/// Which security layer produced the deny decision.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum DenyLayer {
53    /// CSpace missing required capability.
54    Capability,
55    /// RBAC role does not allow action.
56    Rbac,
57    /// AgentPermissions denied (tool/path not in allowed set).
58    Permission,
59    /// ExecConfig denied (binary not in allowlist, metacharacters).
60    ExecPolicy,
61}
62
63impl std::fmt::Display for DenyLayer {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            DenyLayer::Capability => write!(f, "CSpace"),
67            DenyLayer::Rbac => write!(f, "RBAC"),
68            DenyLayer::Permission => write!(f, "Permissions"),
69            DenyLayer::ExecPolicy => write!(f, "ExecPolicy"),
70        }
71    }
72}
73
74// ─── Access Denied ──────────────────────────────────────────────────────────
75
76/// Authorization denial — includes the layer, reason, and user-facing suggestion.
77#[derive(Debug, Clone)]
78pub struct AccessDenied {
79    /// Agent that was denied.
80    pub agent: String,
81    /// Resource that was accessed.
82    pub resource: String,
83    /// Which security layer produced the denial.
84    pub layer: DenyLayer,
85    /// Machine-readable reason.
86    pub reason: String,
87    /// User-facing suggestion for resolution.
88    pub suggestion: Option<String>,
89}
90
91impl std::fmt::Display for AccessDenied {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(
94            f,
95            "[{}] {} — {}",
96            self.layer,
97            self.reason,
98            self.suggestion.as_deref().unwrap_or("")
99        )
100    }
101}
102
103// ─── Check Request ──────────────────────────────────────────────────────────
104
105/// Authorization check request — specifies what is being accessed.
106#[derive(Debug)]
107pub enum CheckRequest<'a> {
108    /// Tool usage permission.
109    Tool {
110        /// Agent security context.
111        context: &'a AgentContext,
112        /// Name of the tool to use.
113        tool_name: &'a str,
114    },
115    /// Path access permission.
116    Path {
117        /// Agent security context.
118        context: &'a AgentContext,
119        /// Path to access.
120        path: &'a Path,
121        /// Read or write mode.
122        mode: PathMode,
123    },
124    /// Command execution permission.
125    Exec {
126        /// Agent security context.
127        context: &'a AgentContext,
128        /// Binary to execute.
129        binary: &'a str,
130        /// Arguments for the binary.
131        args: &'a [String],
132    },
133    /// Network access permission.
134    Network {
135        /// Agent security context.
136        context: &'a AgentContext,
137    },
138    /// Agent fork (sub-agent spawn) permission.
139    Fork {
140        /// Agent security context.
141        context: &'a AgentContext,
142    },
143}
144
145impl<'a> CheckRequest<'a> {
146    /// Returns the agent context for this request.
147    pub fn agent_context(&self) -> &AgentContext {
148        match self {
149            CheckRequest::Tool { context, .. } => context,
150            CheckRequest::Path { context, .. } => context,
151            CheckRequest::Exec { context, .. } => context,
152            CheckRequest::Network { context } => context,
153            CheckRequest::Fork { context } => context,
154        }
155    }
156
157    /// Returns a string describing the resource being accessed.
158    pub fn resource(&self) -> &str {
159        match self {
160            CheckRequest::Tool { tool_name, .. } => tool_name,
161            CheckRequest::Path { path, .. } => path.to_str().unwrap_or("<invalid-path>"),
162            CheckRequest::Exec { binary, .. } => binary,
163            CheckRequest::Network { .. } => "<network>",
164            CheckRequest::Fork { .. } => "fork",
165        }
166    }
167}
168
169// ─── Shell Metacharacters ───────────────────────────────────────────────────
170
171/// Characters blocked in structured-mode arguments.
172const SHELL_METACHARS: &[char] = &[
173    '|', '&', ';', '$', '`', '<', '>', '(', ')', '{', '}', '\n', '\r', '\0',
174];
175
176/// Check whether any argument contains shell metacharacters or path traversal.
177fn has_metacharacters(args: &[String]) -> bool {
178    for arg in args {
179        if arg.contains("..") {
180            return true;
181        }
182        if SHELL_METACHARS.iter().any(|&c| arg.contains(c)) {
183            return true;
184        }
185    }
186    false
187}
188
189/// Resolve a path to its canonical form for consistent layer matching.
190///
191/// Symlinks and `..` segments are resolved so that the RBAC, permission, and
192/// workspace layers all see the same path — otherwise a path like
193/// `/workspace/../etc/passwd` slips through prefix/glob matches.
194///
195/// If the path does not yet exist (e.g. a file about to be written), the
196/// nearest existing ancestor is canonicalized and the remaining components are
197/// re-appended. If even the ancestor cannot be canonicalized the original path
198/// is returned unchanged (the workspace layer will then reject it).
199fn canonicalize_for_check(path: &Path) -> PathBuf {
200    if let Ok(canon) = path.canonicalize() {
201        return canon;
202    }
203    let mut ancestor = path.to_path_buf();
204    let mut tail: Vec<OsString> = Vec::new();
205    while !ancestor.exists() {
206        match ancestor.file_name() {
207            Some(name) => {
208                tail.push(name.to_os_string());
209                if !ancestor.pop() {
210                    break;
211                }
212            }
213            None => break,
214        }
215    }
216    match ancestor.canonicalize() {
217        Ok(mut base) => {
218            for name in tail.into_iter().rev() {
219                base.push(name);
220            }
221            base
222        }
223        Err(_) => path.to_path_buf(),
224    }
225}
226
227// ─── Access Gate ────────────────────────────────────────────────────────────
228
229/// Single entry point for all authorization decisions.
230///
231/// Every tool execution, path access, command execution, network request,
232/// and agent fork must pass through this gate.
233///
234/// # Example
235///
236/// ```no_run
237/// use oxios_kernel::access_manager::{AccessGate, CheckRequest, PathMode};
238///
239/// // AccessGate is constructed during kernel initialization with internal
240/// // parking_lot::Mutex<AccessManager>, ExecConfig, and an AuditSink.
241/// // Security checks use AgentContext (provided by the kernel's agent lifecycle).
242/// //
243/// // gate.check(CheckRequest::Tool { context: &ctx, tool_name: "exec" })?;
244/// // gate.check(CheckRequest::Path {
245/// //     context: &ctx,
246/// //     path: Path::new("/workspace/file.rs"),
247/// //     mode: PathMode::Read,
248/// // })?;
249/// ```
250pub struct AccessGate {
251    /// Agent permission manager (includes RBAC internally).
252    access: Arc<Mutex<AccessManager>>,
253    /// Execution policy (allowlist, timeouts).
254    exec_config: Arc<ExecConfig>,
255    /// Audit event destination.
256    audit: Arc<dyn AuditSink>,
257}
258
259impl AccessGate {
260    /// Create a new access gate.
261    pub fn new(
262        access: Arc<Mutex<AccessManager>>,
263        exec_config: Arc<ExecConfig>,
264        audit: Arc<dyn AuditSink>,
265    ) -> Self {
266        Self {
267            access,
268            exec_config,
269            audit,
270        }
271    }
272
273    /// Clone the inner access manager Arc (for ExecTool fallback).
274    pub fn access_clone(&self) -> Arc<Mutex<AccessManager>> {
275        self.access.clone()
276    }
277
278    /// Perform a synchronous authorization check.
279    ///
280    /// All decisions (allow and deny) are recorded to the audit sink.
281    /// Checks are evaluated in order with short-circuit: the first layer
282    /// to deny stops further evaluation.
283    pub fn check(&self, req: CheckRequest<'_>) -> Result<(), AccessDenied> {
284        let result = match &req {
285            CheckRequest::Tool { context, tool_name } => self.check_tool(context, tool_name),
286            CheckRequest::Path {
287                context,
288                path,
289                mode,
290            } => self.check_path(context, path, *mode),
291            CheckRequest::Exec {
292                context,
293                binary,
294                args,
295            } => self.check_exec(context, binary, args),
296            CheckRequest::Network { context } => self.check_network(context),
297            CheckRequest::Fork { context } => self.check_fork(context),
298        };
299
300        // Record to audit sink regardless of outcome.
301        self.record_check(&req, &result);
302
303        result
304    }
305
306    // ─── Layer Implementations ───────────────────────────────────────
307
308    fn check_tool(&self, ctx: &AgentContext, tool: &str) -> Result<(), AccessDenied> {
309        // Layer 0: CSpace capability
310        let resource = ResourceRef::KernelDomain {
311            domain: tool.to_string(),
312        };
313        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
314            // CSpace check is advisory only for the always-on local file
315            // tools (read/write/edit/grep/find/ls). Network and script tools
316            // (web_search, browse*, knowledge_*) require an explicit EXECUTE
317            // capability in the agent's Seed — they must not bypass Layer 0.
318            let always_on = ["read", "write", "edit", "grep", "find", "ls"];
319            if !always_on.contains(&tool) {
320                return Err(AccessDenied {
321                    agent: ctx.agent_name.clone(),
322                    resource: tool.to_string(),
323                    layer: DenyLayer::Capability,
324                    reason: format!("CSpace lacks EXECUTE capability for tool '{tool}'"),
325                    suggestion: Some(format!("Add the '{tool}' capability to the agent's Seed.")),
326                });
327            }
328        }
329
330        // Layer 1+2: RBAC + Permissions (AccessManager)
331        let mut access = self.access.lock();
332        if !access.can_use_tool(&ctx.agent_name, tool) {
333            return Err(AccessDenied {
334                agent: ctx.agent_name.clone(),
335                resource: tool.to_string(),
336                layer: DenyLayer::Permission,
337                reason: format!(
338                    "'{}' is not in agent '{}'s allowed_tools",
339                    tool, ctx.agent_name
340                ),
341                suggestion: Some(format!(
342                    "Request permission for the '{}' tool on agent '{}' from your administrator.",
343                    tool, ctx.agent_name
344                )),
345            });
346        }
347
348        Ok(())
349    }
350
351    fn check_path(
352        &self,
353        ctx: &AgentContext,
354        path: &Path,
355        mode: PathMode,
356    ) -> Result<(), AccessDenied> {
357        // Resolve relative paths to absolute using CWD, then canonicalize so
358        // that `..`, symlink prefixes, and case differences are resolved
359        // consistently across the RBAC, permission, and workspace layers.
360        // Without this, `/workspace/../etc/passwd` would pass a `/workspace/`
361        // prefix check. Agents run in the workspace directory.
362        let resolved = if path.is_relative() {
363            std::env::current_dir()
364                .unwrap_or_else(|_| std::path::PathBuf::from("."))
365                .join(path)
366        } else {
367            path.to_path_buf()
368        };
369        let resolved = canonicalize_for_check(&resolved);
370        let path_str = resolved.to_string_lossy();
371
372        // Layer 0: CSpace (file system access)
373        let resource = ResourceRef::KernelDomain {
374            domain: "fs".to_string(),
375        };
376        let required = match mode {
377            PathMode::Read => Rights::READ,
378            PathMode::Write => Rights::WRITE,
379        };
380        if !ctx.cspace.can(&resource, required) {
381            // File system CSpace check is advisory — most agents need file access.
382            // We don't block on CSpace for fs domain, but log it.
383            tracing::debug!(
384                agent = %ctx.agent_name,
385                mode = %mode,
386                "CSpace does not contain fs capability, proceeding (advisory)"
387            );
388        }
389
390        // Layer 1: RBAC check — use the resolved path for matching.
391        let mut access = self.access.lock();
392        let rbac_subject = Subject::Agent(ctx.agent_id);
393        let rbac_action = Action::AccessPath(path_str.to_string());
394        if !access
395            .rbac_manager_mut()
396            .check_permission(&rbac_subject, &rbac_action, &path_str)
397        {
398            return Err(AccessDenied {
399                agent: ctx.agent_name.clone(),
400                resource: path_str.to_string(),
401                layer: DenyLayer::Rbac,
402                reason: "RBAC policy denies access to this path".into(),
403                suggestion: Some("Review the RBAC policy.".into()),
404            });
405        }
406
407        // Layer 2: Path permissions (allowed_paths / denied_paths)
408        if !access.can_access_path(&ctx.agent_name, &path_str) {
409            return Err(AccessDenied {
410                agent: ctx.agent_name.clone(),
411                resource: path_str.to_string(),
412                layer: DenyLayer::Permission,
413                reason: format!("Path '{path_str}' is not in allowed_paths or is in denied_paths"),
414                suggestion: Some("Review the allowed_paths / denied_paths settings.".into()),
415            });
416        }
417
418        // Layer 2 (continued): Workspace sandbox
419        if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
420            && !access.is_path_in_workspace(&ws, &path_str)
421        {
422            // Record sandbox violation separately
423            self.audit.record(AuditEvent::SandboxViolation {
424                timestamp: chrono::Utc::now(),
425                agent: ctx.agent_name.clone(),
426                path: path_str.to_string(),
427                workspace: ws.clone(),
428            });
429            return Err(AccessDenied {
430                agent: ctx.agent_name.clone(),
431                resource: path_str.to_string(),
432                layer: DenyLayer::Permission,
433                reason: format!("Path '{path_str}' is outside the '{ws}' workspace boundary"),
434                suggestion: None,
435            });
436        }
437
438        Ok(())
439    }
440
441    fn check_exec(
442        &self,
443        ctx: &AgentContext,
444        binary: &str,
445        args: &[String],
446    ) -> Result<(), AccessDenied> {
447        // Layer 0: CSpace (exec capability)
448        let resource = ResourceRef::Exec {
449            mode: "structured".to_string(),
450        };
451        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
452            // Also try shell mode CSpace
453            let shell_resource = ResourceRef::Exec {
454                mode: "shell".to_string(),
455            };
456            if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
457                && !ctx.cspace.can(&resource, Rights::EXECUTE)
458            {
459                return Err(AccessDenied {
460                    agent: ctx.agent_name.clone(),
461                    resource: binary.to_string(),
462                    layer: DenyLayer::Capability,
463                    reason: "CSpace lacks Exec capability".into(),
464                    suggestion: Some("Add the Exec capability to the Seed.".into()),
465                });
466            }
467        }
468
469        // Layer 1+2: Permissions — agent must be allowed the 'exec' tool.
470        // Per-binary control is handled by Layer 3 (ExecConfig allowlist), so a
471        // single permission check avoids double audit-log entries.
472        let mut access = self.access.lock();
473        if !access.can_use_tool(&ctx.agent_name, "exec") {
474            return Err(AccessDenied {
475                agent: ctx.agent_name.clone(),
476                resource: binary.to_string(),
477                layer: DenyLayer::Permission,
478                reason: format!("Agent lacks permission to execute '{binary}'"),
479                suggestion: None,
480            });
481        }
482
483        // Layer 3: ExecConfig — binary allowlist
484        if !self.exec_config.is_binary_allowed(binary) {
485            return Err(AccessDenied {
486                agent: ctx.agent_name.clone(),
487                resource: binary.to_string(),
488                layer: DenyLayer::ExecPolicy,
489                reason: format!("Binary '{binary}' is not in the allowlist"),
490                suggestion: Some("Add it to exec.allowed_commands.".into()),
491            });
492        }
493
494        // Layer 3: ExecConfig — metacharacter blocking
495        if has_metacharacters(args) {
496            return Err(AccessDenied {
497                agent: ctx.agent_name.clone(),
498                resource: binary.to_string(),
499                layer: DenyLayer::ExecPolicy,
500                reason: "Arguments contain shell metacharacters or path traversal patterns".into(),
501                suggestion: None,
502            });
503        }
504
505        Ok(())
506    }
507
508    fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
509        let mut access = self.access.lock();
510        if !access.can_access_network(&ctx.agent_name) {
511            return Err(AccessDenied {
512                agent: ctx.agent_name.clone(),
513                resource: "<network>".into(),
514                layer: DenyLayer::Permission,
515                reason: "Network access is disabled".into(),
516                suggestion: Some("Set permissions.network_access to true.".into()),
517            });
518        }
519        Ok(())
520    }
521
522    fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
523        // Layer 0: CSpace
524        let resource = ResourceRef::KernelDomain {
525            domain: "agent".to_string(),
526        };
527        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
528            return Err(AccessDenied {
529                agent: ctx.agent_name.clone(),
530                resource: "fork".into(),
531                layer: DenyLayer::Capability,
532                reason: "CSpace lacks agent-management capability".into(),
533                suggestion: None,
534            });
535        }
536
537        // Layer 2: Permissions
538        let access = self.access.lock();
539        if !access.can_fork(&ctx.agent_name) {
540            return Err(AccessDenied {
541                agent: ctx.agent_name.clone(),
542                resource: "fork".into(),
543                layer: DenyLayer::Permission,
544                reason: "Agent lacks fork permission".into(),
545                suggestion: Some("Set permissions.can_fork to true.".into()),
546            });
547        }
548        Ok(())
549    }
550
551    // ─── Audit Recording ─────────────────────────────────────────────
552
553    fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
554        let event = match result {
555            Ok(()) => self.allowed_event(req),
556            Err(denied) => self.denied_event(req, denied),
557        };
558        self.audit.record(event);
559    }
560
561    fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
562        let ctx = req.agent_context();
563        let ts = chrono::Utc::now();
564        match req {
565            CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
566                timestamp: ts,
567                agent: ctx.agent_name.clone(),
568                tool: tool_name.to_string(),
569                allowed: true,
570                layer: None,
571                reason: None,
572            },
573            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
574                timestamp: ts,
575                agent: ctx.agent_name.clone(),
576                path: path.to_string_lossy().to_string(),
577                mode: mode.to_string(),
578                allowed: true,
579                layer: None,
580                reason: None,
581            },
582            CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
583                timestamp: ts,
584                agent: ctx.agent_name.clone(),
585                binary: binary.to_string(),
586                allowed: true,
587                layer: None,
588                reason: None,
589            },
590            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
591                timestamp: ts,
592                agent: ctx.agent_name.clone(),
593                tool: "network".into(),
594                allowed: true,
595                layer: None,
596                reason: None,
597            },
598            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
599                timestamp: ts,
600                agent: ctx.agent_name.clone(),
601                tool: "fork".into(),
602                allowed: true,
603                layer: None,
604                reason: None,
605            },
606        }
607    }
608
609    fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
610        let ctx = req.agent_context();
611        let ts = chrono::Utc::now();
612        let layer = Some(denied.layer.to_string());
613        let reason = Some(denied.reason.clone());
614
615        match req {
616            CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
617                timestamp: ts,
618                agent: ctx.agent_name.clone(),
619                tool: denied.resource.clone(),
620                allowed: false,
621                layer,
622                reason,
623            },
624            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
625                timestamp: ts,
626                agent: ctx.agent_name.clone(),
627                path: path.to_string_lossy().to_string(),
628                mode: mode.to_string(),
629                allowed: false,
630                layer,
631                reason,
632            },
633            CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
634                timestamp: ts,
635                agent: ctx.agent_name.clone(),
636                binary: denied.resource.clone(),
637                allowed: false,
638                layer,
639                reason,
640            },
641            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
642                timestamp: ts,
643                agent: ctx.agent_name.clone(),
644                tool: "network".into(),
645                allowed: false,
646                layer,
647                reason,
648            },
649            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
650                timestamp: ts,
651                agent: ctx.agent_name.clone(),
652                tool: "fork".into(),
653                allowed: false,
654                layer,
655                reason,
656            },
657        }
658    }
659}
660
661impl std::fmt::Debug for AccessGate {
662    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663        f.debug_struct("AccessGate").finish()
664    }
665}
666
667// ─── Tests ──────────────────────────────────────────────────────────────────
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::access_manager::AgentPermissions;
673    use crate::access_manager::audit_sink::NoOpAuditSink;
674    use crate::config::AllowlistMode;
675
676    /// Helper: build an AccessGate with a configured agent.
677    fn make_gate() -> (AccessGate, AgentContext) {
678        let mut access = AccessManager::new();
679
680        // Create the context first to get a stable agent_id
681        let ctx = AgentContext::test_fixture("test-agent");
682
683        // Set up permissions for test agent
684        let mut perms = AgentPermissions::for_new_agent("test-agent");
685        perms.allow_path("/workspace/**");
686        perms.allow_path("/tmp/**");
687        access.set_permissions(perms);
688
689        // Assign RBAC role using the same agent_id as the context
690        let subject = Subject::Agent(ctx.agent_id);
691        access
692            .rbac_manager_mut()
693            .assign_role(subject, crate::access_manager::Role::Superuser);
694
695        let gate = AccessGate::new(
696            Arc::new(Mutex::new(access)),
697            Arc::new(ExecConfig {
698                allowlist_mode: AllowlistMode::Permissive, // Allow all for general tests
699                ..Default::default()
700            }),
701            Arc::new(NoOpAuditSink),
702        );
703
704        (gate, ctx)
705    }
706
707    /// Helper: build an AccessGate with Enforced mode and specific allowed commands.
708    fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
709        let mut access = AccessManager::new();
710        let ctx = AgentContext::test_fixture("test-agent");
711
712        let perms = AgentPermissions::for_new_agent("test-agent");
713        access.set_permissions(perms);
714
715        let subject = Subject::Agent(ctx.agent_id);
716        access
717            .rbac_manager_mut()
718            .assign_role(subject, crate::access_manager::Role::Superuser);
719
720        let config = ExecConfig {
721            allowlist_mode: AllowlistMode::Enforced,
722            allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
723            ..Default::default()
724        };
725
726        let gate = AccessGate::new(
727            Arc::new(Mutex::new(access)),
728            Arc::new(config),
729            Arc::new(NoOpAuditSink),
730        );
731
732        (gate, ctx)
733    }
734
735    // ─── Tool checks ────────────────────────────────────────────────
736
737    #[test]
738    fn test_tool_access_allowed() {
739        let (gate, ctx) = make_gate();
740        let result = gate.check(CheckRequest::Tool {
741            context: &ctx,
742            tool_name: "bash",
743        });
744        assert!(result.is_ok(), "bash should be allowed: {:?}", result);
745    }
746
747    #[test]
748    fn test_tool_access_unknown_agent_denied() {
749        let gate = AccessGate::new(
750            Arc::new(Mutex::new(AccessManager::new())), // empty — no permissions
751            Arc::new(ExecConfig::default()),
752            Arc::new(NoOpAuditSink),
753        );
754        let ctx = AgentContext::test_fixture("unknown");
755
756        let result = gate.check(CheckRequest::Tool {
757            context: &ctx,
758            tool_name: "exec",
759        });
760        assert!(result.is_err());
761        let err = result.unwrap_err();
762        assert_eq!(err.layer, DenyLayer::Permission);
763    }
764
765    // ─── Exec checks ────────────────────────────────────────────────
766
767    #[test]
768    fn test_exec_allowed_permissive() {
769        let (gate, ctx) = make_gate();
770        let result = gate.check(CheckRequest::Exec {
771            context: &ctx,
772            binary: "echo",
773            args: &["hello".to_string()],
774        });
775        assert!(result.is_ok(), "echo should be allowed in permissive mode");
776    }
777
778    #[test]
779    fn test_exec_denied_enforced() {
780        let (gate, ctx) = make_enforced_gate(vec!["git"]);
781        let result = gate.check(CheckRequest::Exec {
782            context: &ctx,
783            binary: "rm",
784            args: &[],
785        });
786        assert!(result.is_err());
787        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
788    }
789
790    #[test]
791    fn test_exec_metacharacters_denied() {
792        let (gate, ctx) = make_enforced_gate(vec!["echo"]);
793        let result = gate.check(CheckRequest::Exec {
794            context: &ctx,
795            binary: "echo",
796            args: &["foo; rm -rf /".to_string()],
797        });
798        assert!(result.is_err());
799        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
800    }
801
802    #[test]
803    fn test_exec_path_traversal_denied() {
804        let (gate, ctx) = make_enforced_gate(vec!["cat"]);
805        let result = gate.check(CheckRequest::Exec {
806            context: &ctx,
807            binary: "cat",
808            args: &["../etc/passwd".to_string()],
809        });
810        assert!(result.is_err());
811        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
812    }
813
814    #[test]
815    fn test_exec_enforced_allowed() {
816        let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
817        let result = gate.check(CheckRequest::Exec {
818            context: &ctx,
819            binary: "echo",
820            args: &["hello".to_string(), "world".to_string()],
821        });
822        assert!(result.is_ok(), "listed binary should be allowed");
823    }
824
825    // ─── Path checks ────────────────────────────────────────────────
826
827    #[test]
828    fn test_path_read_allowed() {
829        let (gate, ctx) = make_gate();
830        let result = gate.check(CheckRequest::Path {
831            context: &ctx,
832            path: Path::new("/workspace/project/file.rs"),
833            mode: PathMode::Read,
834        });
835        assert!(result.is_ok(), "workspace path should be readable");
836    }
837
838    // ─── Network checks ─────────────────────────────────────────────
839
840    #[test]
841    fn test_network_denied_by_default() {
842        let (gate, ctx) = make_gate();
843        let result = gate.check(CheckRequest::Network { context: &ctx });
844        assert!(result.is_err());
845        assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
846    }
847
848    // ─── Fork checks ────────────────────────────────────────────────
849
850    #[test]
851    fn test_fork_denied_by_default() {
852        let (gate, ctx) = make_gate();
853        let result = gate.check(CheckRequest::Fork { context: &ctx });
854        // Default AgentPermissions has can_fork = false
855        // But we need CSpace to have agent domain first
856        // With an empty CSpace (test_fixture), CSpace check will fail
857        assert!(result.is_err());
858    }
859
860    // ─── Deny layer display ─────────────────────────────────────────
861
862    #[test]
863    fn test_deny_layer_display() {
864        assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
865        assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
866        assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
867        assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
868    }
869
870    // ─── Metacharacter detection ─────────────────────────────────────
871
872    #[test]
873    fn test_no_metacharacters_in_clean_args() {
874        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
875    }
876
877    #[test]
878    fn test_metacharacters_semicolon() {
879        assert!(has_metacharacters(&["foo;bar".into()]));
880    }
881
882    #[test]
883    fn test_metacharacters_pipe() {
884        assert!(has_metacharacters(&["a | b".into()]));
885    }
886
887    #[test]
888    fn test_metacharacters_dollar() {
889        assert!(has_metacharacters(&["$(whoami)".into()]));
890    }
891
892    #[test]
893    fn test_metacharacters_path_traversal() {
894        assert!(has_metacharacters(&["../etc/passwd".into()]));
895    }
896
897    // ─── AccessDenied Display ────────────────────────────────────────
898
899    #[test]
900    fn test_access_denied_display() {
901        let denied = AccessDenied {
902            agent: "test".into(),
903            resource: "exec".into(),
904            layer: DenyLayer::ExecPolicy,
905            reason: "not in allowlist".into(),
906            suggestion: Some("add to config".into()),
907        };
908        let s = format!("{}", denied);
909        assert!(s.contains("[ExecPolicy]"));
910        assert!(s.contains("not in allowlist"));
911    }
912}