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