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        let resource = ResourceRef::KernelDomain {
310            domain: tool.to_string(),
311        };
312        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
313            // The always-on tier — registered unconditionally for every
314            // agent by `tools::registration::register_always_on` (file ops
315            // + web search). CSpace capability is advisory for these tools:
316            // they are part of the baseline agent contract documented in
317            // ARCHITECTURE.md §"Tier 1" and enumerated in
318            // `OxiosKernelBridge::tool_names`, so Layer 0 must not gate
319            // them. Without web_search/get_search_results in this list,
320            // every default agent hits a catch-22: the tool is registered
321            // (so the LLM calls it) but no capability template grants the
322            // matching EXECUTE right, so Layer 0 hard-denies. See RFC-017
323            // Q3 ("the bug where web_search was denied despite being
324            // always-on is fixed separately — adding it to the gate's
325            // skip list"). This is that fix.
326            //
327            // CSpace-driven tools (exec, memory_*, knowledge, browse*,
328            // a2a, persona, ...) still require an explicit EXECUTE
329            // capability in the agent's Seed and are denied here when
330            // absent. RFC-017 covers the per-session escalation flow
331            // (GatedTool → PendingToolApprovals → user dialog) for that
332            // case.
333            let always_on = [
334                "read",
335                "write",
336                "edit",
337                "grep",
338                "find",
339                "ls",
340                "web_search",
341                "get_search_results",
342            ];
343            if !always_on.contains(&tool) {
344                return Err(AccessDenied {
345                    agent: ctx.agent_name.clone(),
346                    resource: tool.to_string(),
347                    layer: DenyLayer::Capability,
348                    reason: format!("CSpace lacks EXECUTE capability for tool '{tool}'"),
349                    suggestion: Some(format!("Add the '{tool}' capability to the agent's Seed.")),
350                });
351            }
352        }
353
354        // Layer 1+2: RBAC + Permissions (AccessManager)
355        let mut access = self.access.lock();
356        if !access.can_use_tool(&ctx.agent_name, tool) {
357            return Err(AccessDenied {
358                agent: ctx.agent_name.clone(),
359                resource: tool.to_string(),
360                layer: DenyLayer::Permission,
361                reason: format!(
362                    "'{}' is not in agent '{}'s allowed_tools",
363                    tool, ctx.agent_name
364                ),
365                suggestion: Some(format!(
366                    "Request permission for the '{}' tool on agent '{}' from your administrator.",
367                    tool, ctx.agent_name
368                )),
369            });
370        }
371
372        Ok(())
373    }
374
375    fn check_path(
376        &self,
377        ctx: &AgentContext,
378        path: &Path,
379        mode: PathMode,
380    ) -> Result<(), AccessDenied> {
381        // Resolve relative paths to absolute using CWD, then canonicalize so
382        // that `..`, symlink prefixes, and case differences are resolved
383        // consistently across the RBAC, permission, and workspace layers.
384        // Without this, `/workspace/../etc/passwd` would pass a `/workspace/`
385        // prefix check. Agents run in the workspace directory.
386        let resolved = if path.is_relative() {
387            std::env::current_dir()
388                .unwrap_or_else(|_| std::path::PathBuf::from("."))
389                .join(path)
390        } else {
391            path.to_path_buf()
392        };
393        let resolved = canonicalize_for_check(&resolved);
394        let path_str = resolved.to_string_lossy();
395
396        // Layer 0: CSpace (file system access)
397        let resource = ResourceRef::KernelDomain {
398            domain: "fs".to_string(),
399        };
400        let required = match mode {
401            PathMode::Read => Rights::READ,
402            PathMode::Write => Rights::WRITE,
403        };
404        if !ctx.cspace.can(&resource, required) {
405            // File system CSpace check is advisory — most agents need file access.
406            // We don't block on CSpace for fs domain, but log it.
407            tracing::debug!(
408                agent = %ctx.agent_name,
409                mode = %mode,
410                "CSpace does not contain fs capability, proceeding (advisory)"
411            );
412        }
413
414        // Layer 1: RBAC check — use the resolved path for matching.
415        let mut access = self.access.lock();
416        let rbac_subject = Subject::Agent(ctx.agent_id);
417        let rbac_action = Action::AccessPath(path_str.to_string());
418        if !access
419            .rbac_manager_mut()
420            .check_permission(&rbac_subject, &rbac_action, &path_str)
421        {
422            return Err(AccessDenied {
423                agent: ctx.agent_name.clone(),
424                resource: path_str.to_string(),
425                layer: DenyLayer::Rbac,
426                reason: "RBAC policy denies access to this path".into(),
427                suggestion: Some("Review the RBAC policy.".into()),
428            });
429        }
430
431        // Layer 2: Path permissions (allowed_paths / denied_paths)
432        if !access.can_access_path(&ctx.agent_name, &path_str) {
433            return Err(AccessDenied {
434                agent: ctx.agent_name.clone(),
435                resource: path_str.to_string(),
436                layer: DenyLayer::Permission,
437                reason: format!("Path '{path_str}' is not in allowed_paths or is in denied_paths"),
438                suggestion: Some("Review the allowed_paths / denied_paths settings.".into()),
439            });
440        }
441
442        // Layer 2 (continued): Workspace sandbox
443        if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
444            && !access.is_path_in_workspace(&ws, &path_str)
445        {
446            // Record sandbox violation separately
447            self.audit.record(AuditEvent::SandboxViolation {
448                timestamp: chrono::Utc::now(),
449                agent: ctx.agent_name.clone(),
450                path: path_str.to_string(),
451                workspace: ws.clone(),
452            });
453            return Err(AccessDenied {
454                agent: ctx.agent_name.clone(),
455                resource: path_str.to_string(),
456                layer: DenyLayer::Permission,
457                reason: format!("Path '{path_str}' is outside the '{ws}' workspace boundary"),
458                suggestion: None,
459            });
460        }
461
462        Ok(())
463    }
464
465    fn check_exec(
466        &self,
467        ctx: &AgentContext,
468        binary: &str,
469        args: &[String],
470    ) -> Result<(), AccessDenied> {
471        // Layer 0: CSpace (exec capability)
472        let resource = ResourceRef::Exec {
473            mode: "structured".to_string(),
474        };
475        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
476            // Also try shell mode CSpace
477            let shell_resource = ResourceRef::Exec {
478                mode: "shell".to_string(),
479            };
480            if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
481                && !ctx.cspace.can(&resource, Rights::EXECUTE)
482            {
483                return Err(AccessDenied {
484                    agent: ctx.agent_name.clone(),
485                    resource: binary.to_string(),
486                    layer: DenyLayer::Capability,
487                    reason: "CSpace lacks Exec capability".into(),
488                    suggestion: Some("Add the Exec capability to the Seed.".into()),
489                });
490            }
491        }
492
493        // Layer 1+2: Permissions — agent must be allowed the 'exec' tool.
494        // Per-binary control is handled by Layer 3 (ExecConfig allowlist), so a
495        // single permission check avoids double audit-log entries.
496        let mut access = self.access.lock();
497        if !access.can_use_tool(&ctx.agent_name, "exec") {
498            return Err(AccessDenied {
499                agent: ctx.agent_name.clone(),
500                resource: binary.to_string(),
501                layer: DenyLayer::Permission,
502                reason: format!("Agent lacks permission to execute '{binary}'"),
503                suggestion: None,
504            });
505        }
506
507        // Layer 3: ExecConfig — binary allowlist
508        if !self.exec_config.is_binary_allowed(binary) {
509            return Err(AccessDenied {
510                agent: ctx.agent_name.clone(),
511                resource: binary.to_string(),
512                layer: DenyLayer::ExecPolicy,
513                reason: format!("Binary '{binary}' is not in the allowlist"),
514                suggestion: Some("Add it to exec.allowed_commands.".into()),
515            });
516        }
517
518        // Layer 3: ExecConfig — metacharacter blocking
519        if has_metacharacters(args) {
520            return Err(AccessDenied {
521                agent: ctx.agent_name.clone(),
522                resource: binary.to_string(),
523                layer: DenyLayer::ExecPolicy,
524                reason: "Arguments contain shell metacharacters or path traversal patterns".into(),
525                suggestion: None,
526            });
527        }
528
529        Ok(())
530    }
531
532    fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
533        let mut access = self.access.lock();
534        if !access.can_access_network(&ctx.agent_name) {
535            return Err(AccessDenied {
536                agent: ctx.agent_name.clone(),
537                resource: "<network>".into(),
538                layer: DenyLayer::Permission,
539                reason: "Network access is disabled".into(),
540                suggestion: Some("Set permissions.network_access to true.".into()),
541            });
542        }
543        Ok(())
544    }
545
546    fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
547        // Layer 0: CSpace
548        let resource = ResourceRef::KernelDomain {
549            domain: "agent".to_string(),
550        };
551        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
552            return Err(AccessDenied {
553                agent: ctx.agent_name.clone(),
554                resource: "fork".into(),
555                layer: DenyLayer::Capability,
556                reason: "CSpace lacks agent-management capability".into(),
557                suggestion: None,
558            });
559        }
560
561        // Layer 2: Permissions
562        let access = self.access.lock();
563        if !access.can_fork(&ctx.agent_name) {
564            return Err(AccessDenied {
565                agent: ctx.agent_name.clone(),
566                resource: "fork".into(),
567                layer: DenyLayer::Permission,
568                reason: "Agent lacks fork permission".into(),
569                suggestion: Some("Set permissions.can_fork to true.".into()),
570            });
571        }
572        Ok(())
573    }
574
575    // ─── Audit Recording ─────────────────────────────────────────────
576
577    fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
578        let event = match result {
579            Ok(()) => self.allowed_event(req),
580            Err(denied) => self.denied_event(req, denied),
581        };
582        self.audit.record(event);
583    }
584
585    fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
586        let ctx = req.agent_context();
587        let ts = chrono::Utc::now();
588        match req {
589            CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
590                timestamp: ts,
591                agent: ctx.agent_name.clone(),
592                tool: tool_name.to_string(),
593                allowed: true,
594                layer: None,
595                reason: None,
596            },
597            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
598                timestamp: ts,
599                agent: ctx.agent_name.clone(),
600                path: path.to_string_lossy().to_string(),
601                mode: mode.to_string(),
602                allowed: true,
603                layer: None,
604                reason: None,
605            },
606            CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
607                timestamp: ts,
608                agent: ctx.agent_name.clone(),
609                binary: binary.to_string(),
610                allowed: true,
611                layer: None,
612                reason: None,
613            },
614            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
615                timestamp: ts,
616                agent: ctx.agent_name.clone(),
617                tool: "network".into(),
618                allowed: true,
619                layer: None,
620                reason: None,
621            },
622            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
623                timestamp: ts,
624                agent: ctx.agent_name.clone(),
625                tool: "fork".into(),
626                allowed: true,
627                layer: None,
628                reason: None,
629            },
630        }
631    }
632
633    fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
634        let ctx = req.agent_context();
635        let ts = chrono::Utc::now();
636        let layer = Some(denied.layer.to_string());
637        let reason = Some(denied.reason.clone());
638
639        match req {
640            CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
641                timestamp: ts,
642                agent: ctx.agent_name.clone(),
643                tool: denied.resource.clone(),
644                allowed: false,
645                layer,
646                reason,
647            },
648            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
649                timestamp: ts,
650                agent: ctx.agent_name.clone(),
651                path: path.to_string_lossy().to_string(),
652                mode: mode.to_string(),
653                allowed: false,
654                layer,
655                reason,
656            },
657            CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
658                timestamp: ts,
659                agent: ctx.agent_name.clone(),
660                binary: denied.resource.clone(),
661                allowed: false,
662                layer,
663                reason,
664            },
665            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
666                timestamp: ts,
667                agent: ctx.agent_name.clone(),
668                tool: "network".into(),
669                allowed: false,
670                layer,
671                reason,
672            },
673            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
674                timestamp: ts,
675                agent: ctx.agent_name.clone(),
676                tool: "fork".into(),
677                allowed: false,
678                layer,
679                reason,
680            },
681        }
682    }
683}
684
685impl std::fmt::Debug for AccessGate {
686    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687        f.debug_struct("AccessGate").finish()
688    }
689}
690
691// ─── Tests ──────────────────────────────────────────────────────────────────
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::access_manager::AgentPermissions;
697    use crate::access_manager::audit_sink::NoOpAuditSink;
698    use crate::config::AllowlistMode;
699
700    /// Helper: build an AccessGate with a configured agent.
701    fn make_gate() -> (AccessGate, AgentContext) {
702        let mut access = AccessManager::new();
703
704        // Create the context first to get a stable agent_id
705        let ctx = AgentContext::test_fixture("test-agent");
706
707        // Set up permissions for test agent
708        let mut perms = AgentPermissions::for_new_agent("test-agent");
709        perms.allow_path("/workspace/**");
710        perms.allow_path("/tmp/**");
711        access.set_permissions(perms);
712
713        // Assign RBAC role using the same agent_id as the context
714        let subject = Subject::Agent(ctx.agent_id);
715        access
716            .rbac_manager_mut()
717            .assign_role(subject, crate::access_manager::Role::Superuser);
718
719        let gate = AccessGate::new(
720            Arc::new(Mutex::new(access)),
721            Arc::new(ExecConfig {
722                allowlist_mode: AllowlistMode::Permissive, // Allow all for general tests
723                ..Default::default()
724            }),
725            Arc::new(NoOpAuditSink),
726        );
727
728        (gate, ctx)
729    }
730
731    /// Helper: build an AccessGate with Enforced mode and specific allowed commands.
732    fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
733        let mut access = AccessManager::new();
734        let ctx = AgentContext::test_fixture("test-agent");
735
736        let perms = AgentPermissions::for_new_agent("test-agent");
737        access.set_permissions(perms);
738
739        let subject = Subject::Agent(ctx.agent_id);
740        access
741            .rbac_manager_mut()
742            .assign_role(subject, crate::access_manager::Role::Superuser);
743
744        let config = ExecConfig {
745            allowlist_mode: AllowlistMode::Enforced,
746            allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
747            ..Default::default()
748        };
749
750        let gate = AccessGate::new(
751            Arc::new(Mutex::new(access)),
752            Arc::new(config),
753            Arc::new(NoOpAuditSink),
754        );
755
756        (gate, ctx)
757    }
758
759    // ─── Tool checks ────────────────────────────────────────────────
760
761    #[test]
762    fn test_tool_access_allowed() {
763        let (gate, ctx) = make_gate();
764        let result = gate.check(CheckRequest::Tool {
765            context: &ctx,
766            tool_name: "bash",
767        });
768        assert!(result.is_ok(), "bash should be allowed: {:?}", result);
769    }
770
771    #[test]
772    fn test_tool_access_web_search_always_on() {
773        // Regression: web_search + get_search_results are registered
774        // unconditionally for every agent (register_always_on) and must
775        // pass Layer 0 even when the agent's CSpace carries no matching
776        // capability. Before this fix, the test_fixture CSpace (which
777        // grants no web_search cap) caused a hard deny — the
778        // triple-deadlock described in RFC-017 Q3.
779        let (gate, ctx) = make_gate();
780        let result = gate.check(CheckRequest::Tool {
781            context: &ctx,
782            tool_name: "web_search",
783        });
784        assert!(result.is_ok(), "web_search is always-on: {:?}", result);
785
786        let result = gate.check(CheckRequest::Tool {
787            context: &ctx,
788            tool_name: "get_search_results",
789        });
790        assert!(
791            result.is_ok(),
792            "get_search_results is always-on: {:?}",
793            result
794        );
795    }
796
797    #[test]
798    fn test_tool_access_unknown_agent_denied() {
799        let gate = AccessGate::new(
800            Arc::new(Mutex::new(AccessManager::new())), // empty — no permissions
801            Arc::new(ExecConfig::default()),
802            Arc::new(NoOpAuditSink),
803        );
804        let ctx = AgentContext::test_fixture("unknown");
805
806        let result = gate.check(CheckRequest::Tool {
807            context: &ctx,
808            tool_name: "exec",
809        });
810        assert!(result.is_err());
811        let err = result.unwrap_err();
812        assert_eq!(err.layer, DenyLayer::Permission);
813    }
814
815    // ─── Exec checks ────────────────────────────────────────────────
816
817    #[test]
818    fn test_exec_allowed_permissive() {
819        let (gate, ctx) = make_gate();
820        let result = gate.check(CheckRequest::Exec {
821            context: &ctx,
822            binary: "echo",
823            args: &["hello".to_string()],
824        });
825        assert!(result.is_ok(), "echo should be allowed in permissive mode");
826    }
827
828    #[test]
829    fn test_exec_denied_enforced() {
830        let (gate, ctx) = make_enforced_gate(vec!["git"]);
831        let result = gate.check(CheckRequest::Exec {
832            context: &ctx,
833            binary: "rm",
834            args: &[],
835        });
836        assert!(result.is_err());
837        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
838    }
839
840    #[test]
841    fn test_exec_metacharacters_denied() {
842        let (gate, ctx) = make_enforced_gate(vec!["echo"]);
843        let result = gate.check(CheckRequest::Exec {
844            context: &ctx,
845            binary: "echo",
846            args: &["foo; rm -rf /".to_string()],
847        });
848        assert!(result.is_err());
849        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
850    }
851
852    #[test]
853    fn test_exec_path_traversal_denied() {
854        let (gate, ctx) = make_enforced_gate(vec!["cat"]);
855        let result = gate.check(CheckRequest::Exec {
856            context: &ctx,
857            binary: "cat",
858            args: &["../etc/passwd".to_string()],
859        });
860        assert!(result.is_err());
861        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
862    }
863
864    #[test]
865    fn test_exec_enforced_allowed() {
866        let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
867        let result = gate.check(CheckRequest::Exec {
868            context: &ctx,
869            binary: "echo",
870            args: &["hello".to_string(), "world".to_string()],
871        });
872        assert!(result.is_ok(), "listed binary should be allowed");
873    }
874
875    // ─── Path checks ────────────────────────────────────────────────
876
877    #[test]
878    fn test_path_read_allowed() {
879        let (gate, ctx) = make_gate();
880        let result = gate.check(CheckRequest::Path {
881            context: &ctx,
882            path: Path::new("/workspace/project/file.rs"),
883            mode: PathMode::Read,
884        });
885        assert!(result.is_ok(), "workspace path should be readable");
886    }
887
888    // ─── Network checks ─────────────────────────────────────────────
889
890    #[test]
891    fn test_network_denied_by_default() {
892        let (gate, ctx) = make_gate();
893        let result = gate.check(CheckRequest::Network { context: &ctx });
894        assert!(result.is_err());
895        assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
896    }
897
898    // ─── Fork checks ────────────────────────────────────────────────
899
900    #[test]
901    fn test_fork_denied_by_default() {
902        let (gate, ctx) = make_gate();
903        let result = gate.check(CheckRequest::Fork { context: &ctx });
904        // Default AgentPermissions has can_fork = false
905        // But we need CSpace to have agent domain first
906        // With an empty CSpace (test_fixture), CSpace check will fail
907        assert!(result.is_err());
908    }
909
910    // ─── Deny layer display ─────────────────────────────────────────
911
912    #[test]
913    fn test_deny_layer_display() {
914        assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
915        assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
916        assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
917        assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
918    }
919
920    // ─── Metacharacter detection ─────────────────────────────────────
921
922    #[test]
923    fn test_no_metacharacters_in_clean_args() {
924        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
925    }
926
927    #[test]
928    fn test_metacharacters_semicolon() {
929        assert!(has_metacharacters(&["foo;bar".into()]));
930    }
931
932    #[test]
933    fn test_metacharacters_pipe() {
934        assert!(has_metacharacters(&["a | b".into()]));
935    }
936
937    #[test]
938    fn test_metacharacters_dollar() {
939        assert!(has_metacharacters(&["$(whoami)".into()]));
940    }
941
942    #[test]
943    fn test_metacharacters_path_traversal() {
944        assert!(has_metacharacters(&["../etc/passwd".into()]));
945    }
946
947    // ─── AccessDenied Display ────────────────────────────────────────
948
949    #[test]
950    fn test_access_denied_display() {
951        let denied = AccessDenied {
952            agent: "test".into(),
953            resource: "exec".into(),
954            layer: DenyLayer::ExecPolicy,
955            reason: "not in allowlist".into(),
956            suggestion: Some("add to config".into()),
957        };
958        let s = format!("{}", denied);
959        assert!(s.contains("[ExecPolicy]"));
960        assert!(s.contains("not in allowlist"));
961    }
962}