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에 '{tool}' 도구에 대한 EXECUTE capability 없음"),
325                    suggestion: Some(format!(
326                        "에이전트의 Seed에 '{tool}' capability를 추가하세요."
327                    )),
328                });
329            }
330        }
331
332        // Layer 1+2: RBAC + Permissions (AccessManager)
333        let mut access = self.access.lock();
334        if !access.can_use_tool(&ctx.agent_name, tool) {
335            return Err(AccessDenied {
336                agent: ctx.agent_name.clone(),
337                resource: tool.to_string(),
338                layer: DenyLayer::Permission,
339                reason: format!(
340                    "Agent '{}'의 allowed_tools에 '{}' 없음",
341                    ctx.agent_name, tool
342                ),
343                suggestion: Some(format!(
344                    "관리자에게 '{}' 에이전트의 '{}' 도구 권한을 요청하세요.",
345                    ctx.agent_name, tool
346                )),
347            });
348        }
349
350        Ok(())
351    }
352
353    fn check_path(
354        &self,
355        ctx: &AgentContext,
356        path: &Path,
357        mode: PathMode,
358    ) -> Result<(), AccessDenied> {
359        // Resolve relative paths to absolute using CWD, then canonicalize so
360        // that `..`, symlink prefixes, and case differences are resolved
361        // consistently across the RBAC, permission, and workspace layers.
362        // Without this, `/workspace/../etc/passwd` would pass a `/workspace/`
363        // prefix check. Agents run in the workspace directory.
364        let resolved = if path.is_relative() {
365            std::env::current_dir()
366                .unwrap_or_else(|_| std::path::PathBuf::from("."))
367                .join(path)
368        } else {
369            path.to_path_buf()
370        };
371        let resolved = canonicalize_for_check(&resolved);
372        let path_str = resolved.to_string_lossy();
373
374        // Layer 0: CSpace (file system access)
375        let resource = ResourceRef::KernelDomain {
376            domain: "fs".to_string(),
377        };
378        let required = match mode {
379            PathMode::Read => Rights::READ,
380            PathMode::Write => Rights::WRITE,
381        };
382        if !ctx.cspace.can(&resource, required) {
383            // File system CSpace check is advisory — most agents need file access.
384            // We don't block on CSpace for fs domain, but log it.
385            tracing::debug!(
386                agent = %ctx.agent_name,
387                mode = %mode,
388                "CSpace does not contain fs capability, proceeding (advisory)"
389            );
390        }
391
392        // Layer 1: RBAC check — use the resolved path for matching.
393        let mut access = self.access.lock();
394        let rbac_subject = Subject::Agent(ctx.agent_id);
395        let rbac_action = Action::AccessPath(path_str.to_string());
396        if !access
397            .rbac_manager_mut()
398            .check_permission(&rbac_subject, &rbac_action, &path_str)
399        {
400            return Err(AccessDenied {
401                agent: ctx.agent_name.clone(),
402                resource: path_str.to_string(),
403                layer: DenyLayer::Rbac,
404                reason: "RBAC 정책이 경로 접근을 허용하지 않음".into(),
405                suggestion: Some("RBAC 정책을 확인하세요.".into()),
406            });
407        }
408
409        // Layer 2: Path permissions (allowed_paths / denied_paths)
410        if !access.can_access_path(&ctx.agent_name, &path_str) {
411            return Err(AccessDenied {
412                agent: ctx.agent_name.clone(),
413                resource: path_str.to_string(),
414                layer: DenyLayer::Permission,
415                reason: format!("경로 '{path_str}'이(가) 허용 목록에 없거나 거부 목록에 포함됨"),
416                suggestion: Some("allowed_paths / denied_paths 설정을 확인하세요.".into()),
417            });
418        }
419
420        // Layer 2 (continued): Workspace sandbox
421        if let Some(ws) = access.get_workspace_for_agent(&ctx.agent_name)
422            && !access.is_path_in_workspace(&ws, &path_str)
423        {
424            // Record sandbox violation separately
425            self.audit.record(AuditEvent::SandboxViolation {
426                timestamp: chrono::Utc::now(),
427                agent: ctx.agent_name.clone(),
428                path: path_str.to_string(),
429                workspace: ws.clone(),
430            });
431            return Err(AccessDenied {
432                agent: ctx.agent_name.clone(),
433                resource: path_str.to_string(),
434                layer: DenyLayer::Permission,
435                reason: format!("경로 '{path_str}'이(가) 워크스페이스 '{ws}' 경계를 벗어남"),
436                suggestion: None,
437            });
438        }
439
440        Ok(())
441    }
442
443    fn check_exec(
444        &self,
445        ctx: &AgentContext,
446        binary: &str,
447        args: &[String],
448    ) -> Result<(), AccessDenied> {
449        // Layer 0: CSpace (exec capability)
450        let resource = ResourceRef::Exec {
451            mode: "structured".to_string(),
452        };
453        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
454            // Also try shell mode CSpace
455            let shell_resource = ResourceRef::Exec {
456                mode: "shell".to_string(),
457            };
458            if !ctx.cspace.can(&shell_resource, Rights::EXECUTE)
459                && !ctx.cspace.can(&resource, Rights::EXECUTE)
460            {
461                return Err(AccessDenied {
462                    agent: ctx.agent_name.clone(),
463                    resource: binary.to_string(),
464                    layer: DenyLayer::Capability,
465                    reason: "CSpace에 Exec capability 없음".into(),
466                    suggestion: Some("Seed에 Exec capability를 추가하세요.".into()),
467                });
468            }
469        }
470
471        // Layer 1+2: Permissions — agent must be allowed the 'exec' tool.
472        // Per-binary control is handled by Layer 3 (ExecConfig allowlist), so a
473        // single permission check avoids double audit-log entries.
474        let mut access = self.access.lock();
475        if !access.can_use_tool(&ctx.agent_name, "exec") {
476            return Err(AccessDenied {
477                agent: ctx.agent_name.clone(),
478                resource: binary.to_string(),
479                layer: DenyLayer::Permission,
480                reason: format!("에이전트가 '{binary}' 실행 권한 없음"),
481                suggestion: None,
482            });
483        }
484
485        // Layer 3: ExecConfig — binary allowlist
486        if !self.exec_config.is_binary_allowed(binary) {
487            return Err(AccessDenied {
488                agent: ctx.agent_name.clone(),
489                resource: binary.to_string(),
490                layer: DenyLayer::ExecPolicy,
491                reason: format!("바이너리 '{binary}'이(가) 허용 목록에 없음"),
492                suggestion: Some("exec.allowed_commands에 추가하세요.".into()),
493            });
494        }
495
496        // Layer 3: ExecConfig — metacharacter blocking
497        if has_metacharacters(args) {
498            return Err(AccessDenied {
499                agent: ctx.agent_name.clone(),
500                resource: binary.to_string(),
501                layer: DenyLayer::ExecPolicy,
502                reason: "인수에 셸 메타문자 또는 경로 순회 패턴 포함".into(),
503                suggestion: None,
504            });
505        }
506
507        Ok(())
508    }
509
510    fn check_network(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
511        let mut access = self.access.lock();
512        if !access.can_access_network(&ctx.agent_name) {
513            return Err(AccessDenied {
514                agent: ctx.agent_name.clone(),
515                resource: "<network>".into(),
516                layer: DenyLayer::Permission,
517                reason: "네트워크 접근이 비활성화됨".into(),
518                suggestion: Some("permissions.network_access를 true로 설정하세요.".into()),
519            });
520        }
521        Ok(())
522    }
523
524    fn check_fork(&self, ctx: &AgentContext) -> Result<(), AccessDenied> {
525        // Layer 0: CSpace
526        let resource = ResourceRef::KernelDomain {
527            domain: "agent".to_string(),
528        };
529        if !ctx.cspace.can(&resource, Rights::EXECUTE) {
530            return Err(AccessDenied {
531                agent: ctx.agent_name.clone(),
532                resource: "fork".into(),
533                layer: DenyLayer::Capability,
534                reason: "CSpace에 에이전트 관리 capability 없음".into(),
535                suggestion: None,
536            });
537        }
538
539        // Layer 2: Permissions
540        let access = self.access.lock();
541        if !access.can_fork(&ctx.agent_name) {
542            return Err(AccessDenied {
543                agent: ctx.agent_name.clone(),
544                resource: "fork".into(),
545                layer: DenyLayer::Permission,
546                reason: "에이전트 fork 권한 없음".into(),
547                suggestion: Some("permissions.can_fork를 true로 설정하세요.".into()),
548            });
549        }
550        Ok(())
551    }
552
553    // ─── Audit Recording ─────────────────────────────────────────────
554
555    fn record_check(&self, req: &CheckRequest<'_>, result: &Result<(), AccessDenied>) {
556        let event = match result {
557            Ok(()) => self.allowed_event(req),
558            Err(denied) => self.denied_event(req, denied),
559        };
560        self.audit.record(event);
561    }
562
563    fn allowed_event(&self, req: &CheckRequest<'_>) -> AuditEvent {
564        let ctx = req.agent_context();
565        let ts = chrono::Utc::now();
566        match req {
567            CheckRequest::Tool { tool_name, .. } => AuditEvent::ToolAccess {
568                timestamp: ts,
569                agent: ctx.agent_name.clone(),
570                tool: tool_name.to_string(),
571                allowed: true,
572                layer: None,
573                reason: None,
574            },
575            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
576                timestamp: ts,
577                agent: ctx.agent_name.clone(),
578                path: path.to_string_lossy().to_string(),
579                mode: mode.to_string(),
580                allowed: true,
581                layer: None,
582                reason: None,
583            },
584            CheckRequest::Exec { binary, .. } => AuditEvent::ExecAccess {
585                timestamp: ts,
586                agent: ctx.agent_name.clone(),
587                binary: binary.to_string(),
588                allowed: true,
589                layer: None,
590                reason: None,
591            },
592            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
593                timestamp: ts,
594                agent: ctx.agent_name.clone(),
595                tool: "network".into(),
596                allowed: true,
597                layer: None,
598                reason: None,
599            },
600            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
601                timestamp: ts,
602                agent: ctx.agent_name.clone(),
603                tool: "fork".into(),
604                allowed: true,
605                layer: None,
606                reason: None,
607            },
608        }
609    }
610
611    fn denied_event(&self, req: &CheckRequest<'_>, denied: &AccessDenied) -> AuditEvent {
612        let ctx = req.agent_context();
613        let ts = chrono::Utc::now();
614        let layer = Some(denied.layer.to_string());
615        let reason = Some(denied.reason.clone());
616
617        match req {
618            CheckRequest::Tool { .. } => AuditEvent::ToolAccess {
619                timestamp: ts,
620                agent: ctx.agent_name.clone(),
621                tool: denied.resource.clone(),
622                allowed: false,
623                layer,
624                reason,
625            },
626            CheckRequest::Path { path, mode, .. } => AuditEvent::PathAccess {
627                timestamp: ts,
628                agent: ctx.agent_name.clone(),
629                path: path.to_string_lossy().to_string(),
630                mode: mode.to_string(),
631                allowed: false,
632                layer,
633                reason,
634            },
635            CheckRequest::Exec { .. } => AuditEvent::ExecAccess {
636                timestamp: ts,
637                agent: ctx.agent_name.clone(),
638                binary: denied.resource.clone(),
639                allowed: false,
640                layer,
641                reason,
642            },
643            CheckRequest::Network { .. } => AuditEvent::ToolAccess {
644                timestamp: ts,
645                agent: ctx.agent_name.clone(),
646                tool: "network".into(),
647                allowed: false,
648                layer,
649                reason,
650            },
651            CheckRequest::Fork { .. } => AuditEvent::ToolAccess {
652                timestamp: ts,
653                agent: ctx.agent_name.clone(),
654                tool: "fork".into(),
655                allowed: false,
656                layer,
657                reason,
658            },
659        }
660    }
661}
662
663impl std::fmt::Debug for AccessGate {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        f.debug_struct("AccessGate").finish()
666    }
667}
668
669// ─── Tests ──────────────────────────────────────────────────────────────────
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::access_manager::AgentPermissions;
675    use crate::access_manager::audit_sink::NoOpAuditSink;
676    use crate::config::AllowlistMode;
677
678    /// Helper: build an AccessGate with a configured agent.
679    fn make_gate() -> (AccessGate, AgentContext) {
680        let mut access = AccessManager::new();
681
682        // Create the context first to get a stable agent_id
683        let ctx = AgentContext::test_fixture("test-agent");
684
685        // Set up permissions for test agent
686        let mut perms = AgentPermissions::for_new_agent("test-agent");
687        perms.allow_path("/workspace/**");
688        perms.allow_path("/tmp/**");
689        access.set_permissions(perms);
690
691        // Assign RBAC role using the same agent_id as the context
692        let subject = Subject::Agent(ctx.agent_id);
693        access
694            .rbac_manager_mut()
695            .assign_role(subject, crate::access_manager::Role::Superuser);
696
697        let gate = AccessGate::new(
698            Arc::new(Mutex::new(access)),
699            Arc::new(ExecConfig {
700                allowlist_mode: AllowlistMode::Permissive, // Allow all for general tests
701                ..Default::default()
702            }),
703            Arc::new(NoOpAuditSink),
704        );
705
706        (gate, ctx)
707    }
708
709    /// Helper: build an AccessGate with Enforced mode and specific allowed commands.
710    fn make_enforced_gate(allowed_commands: Vec<&str>) -> (AccessGate, AgentContext) {
711        let mut access = AccessManager::new();
712        let ctx = AgentContext::test_fixture("test-agent");
713
714        let perms = AgentPermissions::for_new_agent("test-agent");
715        access.set_permissions(perms);
716
717        let subject = Subject::Agent(ctx.agent_id);
718        access
719            .rbac_manager_mut()
720            .assign_role(subject, crate::access_manager::Role::Superuser);
721
722        let config = ExecConfig {
723            allowlist_mode: AllowlistMode::Enforced,
724            allowed_commands: allowed_commands.into_iter().map(String::from).collect(),
725            ..Default::default()
726        };
727
728        let gate = AccessGate::new(
729            Arc::new(Mutex::new(access)),
730            Arc::new(config),
731            Arc::new(NoOpAuditSink),
732        );
733
734        (gate, ctx)
735    }
736
737    // ─── Tool checks ────────────────────────────────────────────────
738
739    #[test]
740    fn test_tool_access_allowed() {
741        let (gate, ctx) = make_gate();
742        let result = gate.check(CheckRequest::Tool {
743            context: &ctx,
744            tool_name: "bash",
745        });
746        assert!(result.is_ok(), "bash should be allowed: {:?}", result);
747    }
748
749    #[test]
750    fn test_tool_access_unknown_agent_denied() {
751        let gate = AccessGate::new(
752            Arc::new(Mutex::new(AccessManager::new())), // empty — no permissions
753            Arc::new(ExecConfig::default()),
754            Arc::new(NoOpAuditSink),
755        );
756        let ctx = AgentContext::test_fixture("unknown");
757
758        let result = gate.check(CheckRequest::Tool {
759            context: &ctx,
760            tool_name: "exec",
761        });
762        assert!(result.is_err());
763        let err = result.unwrap_err();
764        assert_eq!(err.layer, DenyLayer::Permission);
765    }
766
767    // ─── Exec checks ────────────────────────────────────────────────
768
769    #[test]
770    fn test_exec_allowed_permissive() {
771        let (gate, ctx) = make_gate();
772        let result = gate.check(CheckRequest::Exec {
773            context: &ctx,
774            binary: "echo",
775            args: &["hello".to_string()],
776        });
777        assert!(result.is_ok(), "echo should be allowed in permissive mode");
778    }
779
780    #[test]
781    fn test_exec_denied_enforced() {
782        let (gate, ctx) = make_enforced_gate(vec!["git"]);
783        let result = gate.check(CheckRequest::Exec {
784            context: &ctx,
785            binary: "rm",
786            args: &[],
787        });
788        assert!(result.is_err());
789        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
790    }
791
792    #[test]
793    fn test_exec_metacharacters_denied() {
794        let (gate, ctx) = make_enforced_gate(vec!["echo"]);
795        let result = gate.check(CheckRequest::Exec {
796            context: &ctx,
797            binary: "echo",
798            args: &["foo; rm -rf /".to_string()],
799        });
800        assert!(result.is_err());
801        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
802    }
803
804    #[test]
805    fn test_exec_path_traversal_denied() {
806        let (gate, ctx) = make_enforced_gate(vec!["cat"]);
807        let result = gate.check(CheckRequest::Exec {
808            context: &ctx,
809            binary: "cat",
810            args: &["../etc/passwd".to_string()],
811        });
812        assert!(result.is_err());
813        assert_eq!(result.unwrap_err().layer, DenyLayer::ExecPolicy);
814    }
815
816    #[test]
817    fn test_exec_enforced_allowed() {
818        let (gate, ctx) = make_enforced_gate(vec!["echo", "git"]);
819        let result = gate.check(CheckRequest::Exec {
820            context: &ctx,
821            binary: "echo",
822            args: &["hello".to_string(), "world".to_string()],
823        });
824        assert!(result.is_ok(), "listed binary should be allowed");
825    }
826
827    // ─── Path checks ────────────────────────────────────────────────
828
829    #[test]
830    fn test_path_read_allowed() {
831        let (gate, ctx) = make_gate();
832        let result = gate.check(CheckRequest::Path {
833            context: &ctx,
834            path: Path::new("/workspace/project/file.rs"),
835            mode: PathMode::Read,
836        });
837        assert!(result.is_ok(), "workspace path should be readable");
838    }
839
840    // ─── Network checks ─────────────────────────────────────────────
841
842    #[test]
843    fn test_network_denied_by_default() {
844        let (gate, ctx) = make_gate();
845        let result = gate.check(CheckRequest::Network { context: &ctx });
846        assert!(result.is_err());
847        assert_eq!(result.unwrap_err().layer, DenyLayer::Permission);
848    }
849
850    // ─── Fork checks ────────────────────────────────────────────────
851
852    #[test]
853    fn test_fork_denied_by_default() {
854        let (gate, ctx) = make_gate();
855        let result = gate.check(CheckRequest::Fork { context: &ctx });
856        // Default AgentPermissions has can_fork = false
857        // But we need CSpace to have agent domain first
858        // With an empty CSpace (test_fixture), CSpace check will fail
859        assert!(result.is_err());
860    }
861
862    // ─── Deny layer display ─────────────────────────────────────────
863
864    #[test]
865    fn test_deny_layer_display() {
866        assert_eq!(format!("{}", DenyLayer::Capability), "CSpace");
867        assert_eq!(format!("{}", DenyLayer::Rbac), "RBAC");
868        assert_eq!(format!("{}", DenyLayer::Permission), "Permissions");
869        assert_eq!(format!("{}", DenyLayer::ExecPolicy), "ExecPolicy");
870    }
871
872    // ─── Metacharacter detection ─────────────────────────────────────
873
874    #[test]
875    fn test_no_metacharacters_in_clean_args() {
876        assert!(!has_metacharacters(&["hello".into(), "world".into()]));
877    }
878
879    #[test]
880    fn test_metacharacters_semicolon() {
881        assert!(has_metacharacters(&["foo;bar".into()]));
882    }
883
884    #[test]
885    fn test_metacharacters_pipe() {
886        assert!(has_metacharacters(&["a | b".into()]));
887    }
888
889    #[test]
890    fn test_metacharacters_dollar() {
891        assert!(has_metacharacters(&["$(whoami)".into()]));
892    }
893
894    #[test]
895    fn test_metacharacters_path_traversal() {
896        assert!(has_metacharacters(&["../etc/passwd".into()]));
897    }
898
899    // ─── AccessDenied Display ────────────────────────────────────────
900
901    #[test]
902    fn test_access_denied_display() {
903        let denied = AccessDenied {
904            agent: "test".into(),
905            resource: "exec".into(),
906            layer: DenyLayer::ExecPolicy,
907            reason: "not in allowlist".into(),
908            suggestion: Some("add to config".into()),
909        };
910        let s = format!("{}", denied);
911        assert!(s.contains("[ExecPolicy]"));
912        assert!(s.contains("not in allowlist"));
913    }
914}