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