Skip to main content

oxios_kernel/access_manager/
mod.rs

1//! Access Manager — least-privilege security for agents.
2//!
3//! Inspired by OWASP Agentic AI security guidelines:
4//! - Least privilege by default
5//! - Agent identity and audit logging
6//! - Sandbox boundaries (path restrictions)
7//! - Tool access control (which agent can use which tools)
8//!
9//! Every agent starts with minimal permissions and must be explicitly granted
10//! access to tools, paths, and network resources.
11
12mod audit_sink;
13mod context;
14mod gate;
15mod permissions;
16mod rbac;
17
18#[cfg(test)]
19pub use audit_sink::NoOpAuditSink;
20pub use audit_sink::{AuditEvent, AuditSink, TracingAuditSink, TrailAuditSink};
21pub use context::AgentContext;
22pub use gate::{AccessDenied, AccessGate, CheckRequest, DenyLayer, PathMode};
23pub use permissions::{AgentPermissions, AuditEntry, PermissionUpdate};
24pub use rbac::{
25    Action, ApprovalStatus, PendingApproval, RbacAuditEntry, RbacManager, RbacPolicy, Role, Subject,
26};
27
28use std::collections::{HashMap, HashSet};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use crate::types::AgentId;
33
34/// Access Manager — least-privilege security for agents.
35///
36/// # Example
37///
38/// ```no_run
39/// use oxios_kernel::access_manager::{AccessManager, AgentPermissions};
40///
41/// let mut access = AccessManager::new();
42///
43/// // Create and configure permissions for a new agent
44/// let mut perms = AgentPermissions::for_new_agent("code-agent");
45/// perms.allow_tool("read");
46/// perms.allow_tool("write");
47/// perms.allow_path("/workspace/");
48/// access.set_permissions(perms);
49///
50/// // Check tool access
51/// let can_exec = access.can_use_tool("code-agent", "exec");
52/// let can_read = access.can_use_tool("code-agent", "read");
53///
54/// // Check path access
55/// let can_read_workspace = access.can_access_path("code-agent", "/workspace/file.rs");
56/// let can_read_root = access.can_access_path("code-agent", "/etc/passwd");
57/// ```
58/// Access Manager — least-privilege security for agents.
59// NOTE: Clone is derived for ExecTool compatibility (Phase 1).
60// Clone is cheap — only HashMaps of primitives, no external resources.
61#[derive(Debug, Clone)]
62pub struct AccessManager {
63    /// Permissions for each agent.
64    permissions: HashMap<String, AgentPermissions>,
65    /// Audit log of all access decisions.
66    audit_log: Vec<AuditEntry>,
67    /// Optional path for audit log file persistence.
68    #[allow(dead_code)]
69    audit_log_path: Option<std::path::PathBuf>,
70    /// Maximum audit log entries to retain.
71    max_audit_entries: usize,
72    /// RBAC manager for HitL approvals.
73    pub(crate) rbac: RbacManager,
74    /// Workspace paths: workspace_name -> workspace_path.
75    workspace_paths: HashMap<String, PathBuf>,
76    /// Agent-to-workspace assignments: agent_name -> workspace_name.
77    agent_workspaces: HashMap<String, String>,
78    /// Workspace-to-agents mapping: workspace_name -> set of agent_names.
79    workspace_agents: HashMap<String, HashSet<String>>,
80    /// Bounded channel sender for async audit log writes.
81    audit_sender: Option<tokio::sync::mpsc::Sender<String>>,
82    /// Handle to the background audit writer task.
83    #[allow(dead_code)]
84    audit_writer_handle: Option<Arc<tokio::task::JoinHandle<()>>>,
85}
86
87impl AccessManager {
88    /// Creates a new access manager.
89    pub fn new() -> Self {
90        Self {
91            permissions: HashMap::new(),
92            audit_log: Vec::new(),
93            audit_log_path: None,
94            max_audit_entries: 10_000,
95            rbac: RbacManager::new(),
96            workspace_paths: HashMap::new(),
97            agent_workspaces: HashMap::new(),
98            workspace_agents: HashMap::new(),
99            audit_sender: None,
100            audit_writer_handle: None,
101        }
102    }
103
104    /// Creates a new access manager with custom settings.
105    ///
106    /// # Arguments
107    /// * `max_audit_entries` - Maximum audit log size (oldest entries are pruned)
108    pub fn with_max_audit_entries(max_audit_entries: usize) -> Self {
109        Self {
110            permissions: HashMap::new(),
111            audit_log: Vec::new(),
112            audit_log_path: None,
113            max_audit_entries,
114            rbac: RbacManager::new(),
115            workspace_paths: HashMap::new(),
116            agent_workspaces: HashMap::new(),
117            workspace_agents: HashMap::new(),
118            audit_sender: None,
119            audit_writer_handle: None,
120        }
121    }
122
123    /// Configure an audit log file path for persistence.
124    ///
125    /// Spawns a background tokio task that reads from a bounded channel
126    /// and appends audit entries to the file. Uses a bounded channel of
127    /// capacity 1000 to provide backpressure.
128    pub fn with_audit_log_path(mut self, path: std::path::PathBuf) -> Self {
129        self.audit_log_path = Some(path.clone());
130
131        // Create the bounded channel (capacity 1000)
132        let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(1000);
133        self.audit_sender = Some(tx);
134
135        // Spawn the background writer task using the current tokio runtime
136        if let Ok(handle) = tokio::runtime::Handle::try_current() {
137            let audit_path = path;
138            let audit_handle = handle.spawn(async move {
139                while let Some(line) = rx.recv().await {
140                    if let Ok(mut f) = std::fs::OpenOptions::new()
141                        .create(true)
142                        .append(true)
143                        .open(&audit_path)
144                    {
145                        use std::io::Write;
146                        let _ = writeln!(f, "{line}");
147                    }
148                }
149            });
150            self.audit_writer_handle = Some(Arc::new(audit_handle));
151        }
152
153        self
154    }
155
156    /// Checks if an agent is allowed to use a specific tool.
157    ///
158    /// Logs the access decision to the audit log.
159    pub fn can_use_tool(&mut self, agent_name: &str, tool: &str) -> bool {
160        let allowed = match self.permissions.get(agent_name) {
161            Some(perms) => perms.allowed_tools.contains(tool),
162            None => {
163                tracing::warn!(agent = %agent_name, tool = %tool, "Agent not found in access manager, denying");
164                false
165            }
166        };
167
168        let reason = if allowed {
169            None
170        } else {
171            Some("tool not in allowed set".to_string())
172        };
173
174        self.log_access(agent_name, "use_tool", tool, allowed, reason);
175
176        allowed
177    }
178
179    /// Checks if an agent is allowed to access a specific path.
180    ///
181    /// Enforces both allowed_paths and denied_paths rules.
182    /// A path is only allowed if it matches an allowed pattern AND
183    /// does not match any denied pattern.
184    ///
185    /// Logs the access decision to the audit log.
186    pub fn can_access_path(&mut self, agent_name: &str, path: &str) -> bool {
187        let allowed = match self.permissions.get(agent_name) {
188            Some(perms) => {
189                // First check denials (they take precedence).
190                if perms.is_path_denied(path) {
191                    false
192                } else {
193                    perms.is_path_allowed(path)
194                }
195            }
196            None => {
197                tracing::warn!(agent = %agent_name, path = %path, "Agent not found, denying path access");
198                false
199            }
200        };
201
202        let reason = if allowed {
203            None
204        } else {
205            Some("path not in allowed set or is denied".to_string())
206        };
207
208        self.log_access(agent_name, "access_path", path, allowed, reason);
209
210        allowed
211    }
212
213    /// Checks if an agent is allowed to make network requests.
214    ///
215    /// Logs the access decision to the audit log.
216    pub fn can_access_network(&mut self, agent_name: &str) -> bool {
217        let allowed = match self.permissions.get(agent_name) {
218            Some(perms) => perms.network_access,
219            None => false,
220        };
221
222        let reason = if allowed {
223            None
224        } else {
225            Some("network access not enabled".to_string())
226        };
227
228        self.log_access(agent_name, "network_request", "<network>", allowed, reason);
229
230        allowed
231    }
232
233    /// Checks if an agent can execute for the given duration (in seconds).
234    ///
235    /// Returns true if unlimited (max_execution_time_secs = 0) or
236    /// if the requested duration is within the limit.
237    pub fn can_execute_for(&self, agent_name: &str, duration_secs: u64) -> bool {
238        match self.permissions.get(agent_name) {
239            Some(perms) => {
240                perms.max_execution_time_secs == 0 || duration_secs <= perms.max_execution_time_secs
241            }
242            None => false,
243        }
244    }
245
246    /// Checks if an agent can use the specified amount of memory (in MB).
247    ///
248    /// Returns true if unlimited (max_memory_mb = 0) or
249    /// if the requested memory is within the limit.
250    pub fn can_use_memory(&self, agent_name: &str, memory_mb: u64) -> bool {
251        match self.permissions.get(agent_name) {
252            Some(perms) => perms.max_memory_mb == 0 || memory_mb <= perms.max_memory_mb,
253            None => false,
254        }
255    }
256
257    /// Checks if an agent can fork (spawn sub-agents).
258    pub fn can_fork(&self, agent_name: &str) -> bool {
259        match self.permissions.get(agent_name) {
260            Some(perms) => perms.can_fork,
261            None => false,
262        }
263    }
264
265    /// Gets the permission set for an agent.
266    ///
267    /// Returns None if no permissions are defined for the agent.
268    pub fn get_permissions(&self, agent_name: &str) -> Option<&AgentPermissions> {
269        self.permissions.get(agent_name)
270    }
271
272    /// Gets the permission set for an agent, creating a default one if needed.
273    ///
274    /// Useful for dynamically creating permissions on first access.
275    pub fn get_or_create_permissions(&mut self, agent_name: &str) -> &mut AgentPermissions {
276        self.permissions
277            .entry(agent_name.to_string())
278            .or_insert_with(|| AgentPermissions::for_new_agent(agent_name))
279    }
280
281    /// Sets the permissions for an agent.
282    ///
283    /// Overwrites any existing permissions for this agent.
284    pub fn set_permissions(&mut self, permissions: AgentPermissions) {
285        let agent_name = permissions.agent_name.clone();
286        self.permissions.insert(agent_name, permissions);
287    }
288
289    /// Updates permissions for an agent using a partial update.
290    ///
291    /// Creates default permissions if the agent doesn't exist.
292    /// Only updates fields that are Some() in the update.
293    pub fn update_permissions(
294        &mut self,
295        agent_name: &str,
296        update: PermissionUpdate,
297    ) -> anyhow::Result<()> {
298        let perms = self
299            .permissions
300            .entry(agent_name.to_string())
301            .or_insert_with(|| AgentPermissions::for_new_agent(agent_name));
302        update.apply(perms);
303        Ok(())
304    }
305
306    /// Removes permissions for an agent.
307    ///
308    /// After removal, all access by this agent will be denied.
309    pub fn remove_permissions(&mut self, agent_name: &str) {
310        self.permissions.remove(agent_name);
311        tracing::info!(agent = %agent_name, "Agent permissions removed");
312    }
313
314    /// Lists all agents with defined permissions.
315    pub fn list_agents(&self) -> Vec<String> {
316        self.permissions.keys().cloned().collect()
317    }
318
319    /// Gets the full audit log.
320    ///
321    /// Returns entries in chronological order (oldest first).
322    pub fn audit_log(&self) -> &[AuditEntry] {
323        &self.audit_log
324    }
325
326    /// Gets recent audit log entries.
327    ///
328    /// # Arguments
329    /// * `limit` - Maximum number of entries to return (from the end)
330    pub fn audit_log_recent(&self, limit: usize) -> Vec<AuditEntry> {
331        let start = self.audit_log.len().saturating_sub(limit);
332        self.audit_log[start..].to_vec()
333    }
334
335    /// Gets audit log entries for a specific agent.
336    pub fn audit_log_for_agent(&self, agent_name: &str) -> Vec<AuditEntry> {
337        self.audit_log
338            .iter()
339            .filter(|e| e.agent_name == agent_name)
340            .cloned()
341            .collect()
342    }
343
344    /// Searches audit log for denied actions.
345    pub fn denied_actions(&self) -> Vec<&AuditEntry> {
346        self.audit_log.iter().filter(|e| !e.allowed).collect()
347    }
348
349    /// Returns a reference to the RBAC manager (for HitL approvals).
350    pub fn rbac_manager(&self) -> &RbacManager {
351        &self.rbac
352    }
353
354    /// Returns a mutable reference to the RBAC manager (for HitL approvals).
355    pub fn rbac_manager_mut(&mut self) -> &mut RbacManager {
356        &mut self.rbac
357    }
358
359    // ─── Workspace Sandbox Integration ────────────────────────────────────
360
361    /// Registers a workspace path.
362    ///
363    /// This is used to report which paths belong to each workspace.
364    ///
365    /// # Arguments
366    /// * `workspace_name` - Name of the workspace
367    /// * `workspace_path` - Absolute path to the workspace directory
368    pub fn register_workspace_path(&mut self, workspace_name: &str, workspace_path: PathBuf) {
369        self.workspace_paths
370            .insert(workspace_name.to_string(), workspace_path);
371        tracing::debug!(workspace = %workspace_name, "Workspace path registered");
372    }
373
374    /// Assigns an agent to a specific workspace.
375    ///
376    /// After assignment, the agent is sandboxed to that workspace.
377    ///
378    /// # Arguments
379    /// * `agent_name` - Name of the agent to assign
380    /// * `workspace_name` - Name of the workspace to assign the agent to
381    ///
382    /// # Returns
383    /// `true` if assignment succeeded, `false` if the workspace doesn't exist
384    pub fn assign_workspace(&mut self, agent_name: &str, workspace_name: &str) -> bool {
385        if !self.workspace_paths.contains_key(workspace_name) {
386            tracing::warn!(agent = %agent_name, workspace = %workspace_name, "Cannot assign agent to non-existent workspace");
387            return false;
388        }
389
390        // Remove from previous workspace if any
391        if let Some(prev_workspace) = self.agent_workspaces.get(agent_name) {
392            if let Some(agents) = self.workspace_agents.get_mut(prev_workspace) {
393                agents.remove(agent_name);
394            }
395        }
396
397        // Assign to new workspace
398        self.agent_workspaces
399            .insert(agent_name.to_string(), workspace_name.to_string());
400        self.workspace_agents
401            .entry(workspace_name.to_string())
402            .or_default()
403            .insert(agent_name.to_string());
404
405        tracing::info!(agent = %agent_name, workspace = %workspace_name, "Agent assigned to workspace");
406        true
407    }
408
409    /// Gets the workspace name that an agent is assigned to.
410    ///
411    /// # Returns
412    /// `Some(workspace_name)` if assigned, `None` if the agent is not assigned to any workspace
413    pub fn get_workspace_for_agent(&self, agent_name: &str) -> Option<String> {
414        self.agent_workspaces.get(agent_name).cloned()
415    }
416
417    /// Gets the path for a specific workspace.
418    ///
419    /// # Returns
420    /// `Some(path)` if the workspace exists, `None` otherwise
421    pub fn get_workspace_path(&self, workspace_name: &str) -> Option<&PathBuf> {
422        self.workspace_paths.get(workspace_name)
423    }
424
425    /// Lists all registered workspaces.
426    pub fn list_workspaces(&self) -> Vec<String> {
427        self.workspace_paths.keys().cloned().collect()
428    }
429
430    /// Lists all agents assigned to a specific workspace.
431    pub fn list_agents_in_workspace(&self, workspace_name: &str) -> Vec<String> {
432        self.workspace_agents
433            .get(workspace_name)
434            .map(|agents| agents.iter().cloned().collect())
435            .unwrap_or_default()
436    }
437
438    /// Checks if an agent can access a specific workspace.
439    ///
440    /// An agent can access a workspace if it is assigned to it.
441    ///
442    /// # Arguments
443    /// * `agent_name` - Name of the agent
444    /// * `workspace_name` - Name of the workspace to check
445    ///
446    /// # Returns
447    /// `true` if the agent is assigned to the workspace, `false` otherwise
448    pub fn can_access_workspace(&self, agent_name: &str, workspace_name: &str) -> bool {
449        self.agent_workspaces
450            .get(agent_name)
451            .map(|w| w == workspace_name)
452            .unwrap_or(false)
453    }
454
455    /// Checks if a path is within a workspace's directory.
456    ///
457    /// This performs a canonical path comparison to ensure the path is
458    /// a descendant of the workspace directory.
459    ///
460    /// # Arguments
461    /// * `workspace_name` - Name of the workspace
462    /// * `path` - Path to check (absolute or relative)
463    ///
464    /// # Returns
465    /// `true` if the path is within the workspace, `false` otherwise
466    pub fn is_path_in_workspace(&self, workspace_name: &str, path: &str) -> bool {
467        let workspace = match self.workspace_paths.get(workspace_name) {
468            Some(w) => w,
469            None => return false,
470        };
471
472        // Resolve the path to an absolute canonical path
473        let requested_path = match Path::new(path).canonicalize() {
474            Ok(p) => p,
475            Err(_) => {
476                // If we can't canonicalize, try as relative to workspace
477                let candidate = workspace.join(path);
478                match candidate.canonicalize() {
479                    Ok(p) => p,
480                    Err(_) => return false,
481                }
482            }
483        };
484
485        // Check if the canonical path starts with the workspace
486        let workspace_canonical = match workspace.canonicalize() {
487            Ok(w) => w,
488            Err(_) => return false,
489        };
490
491        requested_path.starts_with(&workspace_canonical)
492    }
493
494    /// Full sandbox check: RBAC → path allowed? → within workspace?
495    ///
496    /// This is the main method for enforcing sandbox boundaries. It checks:
497    /// 1. RBAC - does the agent's role allow the action?
498    /// 2. Path permissions - is the path in the agent's allowed_paths?
499    /// 3. Workspace boundary - is the path within the assigned workspace?
500    ///
501    /// If any check fails, the access is denied and logged as "sandbox violation"
502    /// if the path would be valid but outside the workspace boundary.
503    ///
504    /// # Arguments
505    /// * `agent_name` - Name of the agent
506    /// * `path` - Path to access
507    /// * `workspace` - Workspace context (if agent is assigned to one)
508    ///
509    /// # Returns
510    /// `true` if all checks pass, `false` otherwise
511    /// Check whether an agent can access a path within its workspace.
512    ///
513    /// Verifies RBAC permissions, path allow/deny lists, and workspace boundary.
514    ///
515    /// # Arguments
516    /// * `agent_id` - The agent's unique identifier
517    /// * `agent_name` - Human-readable agent name for permission lookup
518    /// * `path` - The path to check access for
519    /// * `workspace` - Optional explicit workspace name
520    pub fn can_access_path_in_workspace(
521        &mut self,
522        agent_id: &AgentId,
523        agent_name: &str,
524        path: &str,
525        workspace: Option<&str>,
526    ) -> bool {
527        // RBAC check using the actual agent_id
528        let subject = Subject::Agent(*agent_id);
529        let action = Action::AccessPath(path.to_string());
530        let rbac_allowed = self.rbac.check_permission(&subject, &action, path);
531
532        // Check path permissions (allowed_paths vs denied_paths)
533        let path_allowed = self.can_access_path(agent_name, path);
534
535        // Check workspace boundary
536        let workspace_allowed = if let Some(workspace_name) = workspace {
537            let is_in_workspace = self.is_path_in_workspace(workspace_name, path);
538
539            if !is_in_workspace {
540                // Log as sandbox violation
541                self.log_access(
542                    agent_name,
543                    "sandbox_violation",
544                    path,
545                    false,
546                    Some(format!(
547                        "Path '{path}' is outside workspace '{workspace_name}' boundary"
548                    )),
549                );
550            }
551
552            is_in_workspace
553        } else {
554            // No workspace context - check if agent has any workspace assignment
555            if let Some(assigned_workspace) = self.agent_workspaces.get(agent_name) {
556                let is_in_workspace = self.is_path_in_workspace(assigned_workspace, path);
557
558                if !is_in_workspace {
559                    self.log_access(
560                        agent_name,
561                        "sandbox_violation",
562                        path,
563                        false,
564                        Some(format!(
565                            "Path '{path}' is outside assigned workspace '{assigned_workspace}' boundary"
566                        )),
567                    );
568                }
569
570                is_in_workspace
571            } else {
572                // Agent has no workspace assignment - default to allowing path check only
573                true
574            }
575        };
576
577        // All three checks must pass
578        rbac_allowed && path_allowed && workspace_allowed
579    }
580
581    /// Unassigns an agent from its workspace (if any).
582    ///
583    /// The agent will no longer be sandboxed to any workspace.
584    pub fn unassign_workspace(&mut self, agent_name: &str) -> Option<String> {
585        if let Some(workspace_name) = self.agent_workspaces.remove(agent_name) {
586            if let Some(agents) = self.workspace_agents.get_mut(&workspace_name) {
587                agents.remove(agent_name);
588            }
589            tracing::info!(agent = %agent_name, workspace = %workspace_name, "Agent unassigned from workspace");
590            Some(workspace_name)
591        } else {
592            None
593        }
594    }
595
596    /// Removes a workspace and unassigns all agents from it.
597    ///
598    /// All agents assigned to this workspace will have their assignments cleared.
599    pub fn remove_workspace(&mut self, workspace_name: &str) {
600        // Unassign all agents from this workspace
601        if let Some(agents) = self.workspace_agents.remove(workspace_name) {
602            for agent_name in agents {
603                self.agent_workspaces.remove(&agent_name);
604            }
605        }
606
607        // Remove the workspace path
608        self.workspace_paths.remove(workspace_name);
609
610        tracing::info!(workspace = %workspace_name, "Workspace removed from access manager");
611    }
612
613    /// Clears the audit log.
614    pub fn clear_audit_log(&mut self) {
615        let count = self.audit_log.len();
616        self.audit_log.clear();
617        tracing::info!(cleared = count, "Audit log cleared");
618    }
619
620    /// Logs an access decision to the audit log.
621    ///
622    /// Automatically prunes old entries if max_audit_entries is exceeded.
623    /// Persists to file if audit_log_path is configured.
624    pub(crate) fn log_access(
625        &mut self,
626        agent_name: &str,
627        action: &str,
628        resource: &str,
629        allowed: bool,
630        reason: Option<String>,
631    ) {
632        let entry = AuditEntry::new(agent_name, action, resource, allowed, reason.clone());
633
634        self.audit_log.push(entry.clone());
635
636        // Prune if needed.
637        if self.audit_log.len() > self.max_audit_entries {
638            let prune_count = self.audit_log.len() - self.max_audit_entries;
639            self.audit_log.drain(0..prune_count);
640        }
641
642        // Persist to file (fire-and-forget).
643        self.persist_audit_entry(&entry);
644
645        // Trace denied actions at warn level.
646        if !allowed {
647            tracing::warn!(
648                agent = %agent_name,
649                action = %action,
650                resource = %resource,
651                reason = ?reason,
652                "Access denied"
653            );
654        }
655    }
656
657    /// Persists an audit entry to the configured log file.
658    ///
659    /// Sends the serialized entry through the bounded channel to the
660    /// background writer task. If the channel is full, a warning is logged
661    /// and the entry is dropped (better than blocking the caller).
662    fn persist_audit_entry(&self, entry: &AuditEntry) {
663        if self.audit_log_path.is_none() {
664            return;
665        }
666        let line = match serde_json::to_string(entry) {
667            Ok(s) => s,
668            Err(_) => return,
669        };
670        if let Some(sender) = &self.audit_sender {
671            match sender.try_send(line) {
672                Ok(()) => {}
673                Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
674                    tracing::warn!("Audit log channel full — dropping entry");
675                }
676                Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
677                    tracing::warn!("Audit log channel closed — dropping entry");
678                }
679            }
680        }
681    }
682
683    /// Validates a permission set for correctness.
684    ///
685    /// Returns a list of warnings about the permissions.
686    pub fn validate_permissions(&self, perms: &AgentPermissions) -> Vec<String> {
687        let mut warnings = Vec::new();
688
689        if perms.allowed_tools.is_empty() {
690            warnings.push("Agent has no allowed tools".to_string());
691        }
692
693        if perms.allowed_paths.is_empty() {
694            warnings.push("Agent has no path restrictions (wide open)".to_string());
695        }
696
697        if perms.network_access {
698            warnings.push("Agent has network access enabled".to_string());
699        }
700
701        if perms.can_fork {
702            warnings.push("Agent can fork sub-agents".to_string());
703        }
704
705        if perms.max_execution_time_secs == 0 {
706            warnings.push("Agent has no execution time limit".to_string());
707        }
708
709        if perms.max_memory_mb == 0 {
710            warnings.push("Agent has no memory limit".to_string());
711        }
712
713        warnings
714    }
715}
716
717impl Default for AccessManager {
718    fn default() -> Self {
719        Self::new()
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    // --- AgentPermissions tests ---
728
729    #[test]
730    fn test_default_permissions() {
731        let perms = AgentPermissions::default();
732        assert!(perms.allowed_tools.contains("bash"));
733        assert!(!perms.network_access);
734        assert!(!perms.can_fork);
735        assert_eq!(perms.max_execution_time_secs, 300);
736        assert_eq!(perms.max_memory_mb, 512);
737    }
738
739    #[test]
740    fn test_for_new_agent() {
741        let perms = AgentPermissions::for_new_agent("my-agent");
742        assert_eq!(perms.agent_name, "my-agent");
743        assert!(perms.allowed_tools.contains("bash"));
744    }
745
746    #[test]
747    fn test_allow_deny_tool() {
748        let mut perms = AgentPermissions::for_new_agent("test");
749        assert!(perms.allowed_tools.contains("bash"));
750
751        perms.deny_tool("bash");
752        assert!(!perms.allowed_tools.contains("bash"));
753
754        perms.allow_tool("custom");
755        assert!(perms.allowed_tools.contains("custom"));
756    }
757
758    #[test]
759    fn test_allow_deny_path() {
760        let mut perms = AgentPermissions::for_new_agent("test");
761
762        perms.allow_path("/workspace/**");
763        assert!(perms.allowed_paths.contains(&"/workspace/**".to_string()));
764
765        perms.deny_path("/workspace/.secret/**");
766        assert!(perms
767            .denied_paths
768            .contains(&"/workspace/.secret/**".to_string()));
769    }
770
771    #[test]
772    fn test_enable_network() {
773        let mut perms = AgentPermissions::for_new_agent("test");
774        assert!(!perms.network_access);
775
776        perms.enable_network();
777        assert!(perms.network_access);
778    }
779
780    #[test]
781    fn test_enable_forking() {
782        let mut perms = AgentPermissions::for_new_agent("test");
783        assert!(!perms.can_fork);
784
785        perms.enable_forking();
786        assert!(perms.can_fork);
787    }
788
789    #[test]
790    fn test_path_matching_allowed() {
791        let mut perms = AgentPermissions::for_new_agent("test");
792        perms.allowed_paths = vec!["/workspace/**".to_string(), "/home/*/docs/**".to_string()];
793        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
794
795        // Matches allowed.
796        assert!(perms.is_path_allowed("/workspace/project/file.rs"));
797        assert!(perms.is_path_allowed("/home/user/docs/readme.md"));
798
799        // Does not match any allowed pattern.
800        assert!(!perms.is_path_allowed("/etc/passwd"));
801        assert!(!perms.is_path_allowed("/home/user/secret.txt"));
802    }
803
804    #[test]
805    fn test_path_matching_denied() {
806        let mut perms = AgentPermissions::for_new_agent("test");
807        perms.allowed_paths = vec!["/workspace/**".to_string()];
808        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
809
810        // Denied takes precedence over allowed.
811        assert!(perms.is_path_denied("/workspace/.oxios/config.toml"));
812        assert!(!perms.is_path_denied("/workspace/project/file.rs"));
813
814        // Even though it matches allowed, denied blocks it.
815        let _access = AccessManager::new();
816        let mut perms2 = perms.clone();
817        perms2.agent_name = "test".to_string();
818        // The is_path_denied check happens first in can_access_path.
819        // If allowed_paths matches but denied_paths also matches, it's blocked.
820        // We test this via the full can_access_path method.
821    }
822
823    #[test]
824    fn test_path_denied_pattern_matching() {
825        let mut perms = AgentPermissions::for_new_agent("test");
826        perms.denied_paths = vec!["/etc/**".to_string(), "**/secrets/*".to_string()];
827
828        assert!(perms.is_path_denied("/etc/passwd"));
829        assert!(perms.is_path_denied("/etc/shadow"));
830        assert!(!perms.is_path_denied("/workspace/file"));
831    }
832
833    // --- AccessManager tool access tests ---
834
835    #[test]
836    fn test_can_use_tool_allowed() {
837        let mut access = AccessManager::new();
838
839        let mut perms = AgentPermissions::for_new_agent("code-agent");
840        perms.allow_tool("bash");
841        perms.allow_tool("read");
842        access.set_permissions(perms);
843
844        assert!(access.can_use_tool("code-agent", "bash"));
845        assert!(access.can_use_tool("code-agent", "read"));
846    }
847
848    #[test]
849    fn test_can_use_tool_denied() {
850        let mut access = AccessManager::new();
851
852        let mut perms = AgentPermissions::for_new_agent("code-agent");
853        perms.allow_tool("read");
854        perms.deny_tool("bash"); // Explicitly denied.
855        access.set_permissions(perms);
856
857        assert!(!access.can_use_tool("code-agent", "bash")); // denied
858        assert!(!access.can_use_tool("code-agent", "spawn")); // not in list
859        assert!(!access.can_use_tool("unknown-agent", "bash")); // unknown agent
860    }
861
862    #[test]
863    fn test_unknown_agent_denied_all_tools() {
864        let mut access = AccessManager::new();
865
866        // No permissions set for unknown-agent.
867        assert!(!access.can_use_tool("unknown-agent", "read"));
868        assert!(!access.can_access_path("unknown-agent", "/workspace/test.txt"));
869        assert!(!access.can_access_network("unknown-agent"));
870        assert!(!access.can_fork("unknown-agent"));
871    }
872
873    // --- AccessManager path access tests ---
874
875    #[test]
876    fn test_can_access_path_allowed() {
877        let mut access = AccessManager::new();
878
879        let perms = AgentPermissions::for_new_agent("file-agent");
880        access.set_permissions(perms);
881
882        assert!(access.can_access_path("file-agent", "/workspace/project/file.rs"));
883        assert!(!access.can_access_path("file-agent", "/etc/passwd"));
884    }
885
886    #[test]
887    fn test_can_access_path_denied_takes_precedence() {
888        let mut access = AccessManager::new();
889
890        let mut perms = AgentPermissions::for_new_agent("test");
891        perms.allowed_paths = vec!["/workspace/**".to_string()];
892        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
893        access.set_permissions(perms);
894
895        // Allowed but also denied → blocked.
896        assert!(!access.can_access_path("test", "/workspace/.oxios/config.toml"));
897
898        // Just allowed → allowed.
899        assert!(access.can_access_path("test", "/workspace/project/file.rs"));
900    }
901
902    // --- AccessManager network access tests ---
903
904    #[test]
905    fn test_can_access_network() {
906        let mut access = AccessManager::new();
907
908        let mut perms = AgentPermissions::for_new_agent("net-agent");
909        perms.enable_network();
910        access.set_permissions(perms);
911
912        assert!(access.can_access_network("net-agent"));
913        assert!(!access.can_access_network("no-net-agent"));
914    }
915
916    // --- Execution limits tests ---
917
918    #[test]
919    fn test_can_execute_for() {
920        let mut access = AccessManager::new();
921
922        let mut perms = AgentPermissions::for_new_agent("test");
923        perms.max_execution_time_secs = 300;
924        access.set_permissions(perms);
925
926        assert!(access.can_execute_for("test", 100));
927        assert!(access.can_execute_for("test", 300));
928        assert!(!access.can_execute_for("test", 301));
929    }
930
931    #[test]
932    fn test_unlimited_execution_time() {
933        let mut access = AccessManager::new();
934
935        let mut perms = AgentPermissions::for_new_agent("test");
936        perms.max_execution_time_secs = 0; // unlimited
937        access.set_permissions(perms);
938
939        assert!(access.can_execute_for("test", 100_000));
940    }
941
942    #[test]
943    fn test_can_use_memory() {
944        let mut access = AccessManager::new();
945
946        let mut perms = AgentPermissions::for_new_agent("test");
947        perms.max_memory_mb = 512;
948        access.set_permissions(perms);
949
950        assert!(access.can_use_memory("test", 256));
951        assert!(access.can_use_memory("test", 512));
952        assert!(!access.can_use_memory("test", 513));
953    }
954
955    #[test]
956    fn test_unlimited_memory() {
957        let mut access = AccessManager::new();
958
959        let mut perms = AgentPermissions::for_new_agent("test");
960        perms.max_memory_mb = 0;
961        access.set_permissions(perms);
962
963        assert!(access.can_use_memory("test", 1_000_000));
964    }
965
966    // --- Fork tests ---
967
968    #[test]
969    fn test_can_fork() {
970        let mut access = AccessManager::new();
971
972        let mut perms = AgentPermissions::for_new_agent("test");
973        perms.enable_forking();
974        access.set_permissions(perms);
975
976        assert!(access.can_fork("test"));
977        assert!(!access.can_fork("no-fork-agent"));
978    }
979
980    // --- Permission management tests ---
981
982    #[test]
983    fn test_set_and_get_permissions() {
984        let mut access = AccessManager::new();
985
986        let perms = AgentPermissions::for_new_agent("test-agent");
987        access.set_permissions(perms);
988
989        let retrieved = access.get_permissions("test-agent");
990        assert!(retrieved.is_some());
991        assert_eq!(retrieved.unwrap().agent_name, "test-agent");
992    }
993
994    #[test]
995    fn test_get_nonexistent_permissions() {
996        let access = AccessManager::new();
997        assert!(access.get_permissions("ghost").is_none());
998    }
999
1000    #[test]
1001    fn test_get_or_create_permissions() {
1002        let mut access = AccessManager::new();
1003
1004        // First access creates default.
1005        let perms = access.get_or_create_permissions("new-agent");
1006        assert_eq!(perms.agent_name, "new-agent");
1007
1008        // Second access returns same instance.
1009        let perms2 = access.get_or_create_permissions("new-agent");
1010        assert_eq!(perms2.agent_name, "new-agent");
1011    }
1012
1013    #[test]
1014    fn test_remove_permissions() {
1015        let mut access = AccessManager::new();
1016
1017        let perms = AgentPermissions::for_new_agent("to-remove");
1018        access.set_permissions(perms);
1019
1020        assert!(access.get_permissions("to-remove").is_some());
1021
1022        access.remove_permissions("to-remove");
1023
1024        assert!(access.get_permissions("to-remove").is_none());
1025        // All access should now be denied.
1026        assert!(!access.can_use_tool("to-remove", "bash"));
1027    }
1028
1029    #[test]
1030    fn test_list_agents() {
1031        let mut access = AccessManager::new();
1032
1033        access.set_permissions(AgentPermissions::for_new_agent("agent-1"));
1034        access.set_permissions(AgentPermissions::for_new_agent("agent-2"));
1035
1036        let agents = access.list_agents();
1037        assert_eq!(agents.len(), 2);
1038        assert!(agents.contains(&"agent-1".to_string()));
1039        assert!(agents.contains(&"agent-2".to_string()));
1040    }
1041
1042    // --- Audit log tests ---
1043
1044    #[test]
1045    fn test_audit_log_records_access() {
1046        let mut access = AccessManager::new();
1047
1048        let perms = AgentPermissions::for_new_agent("test-agent");
1049        access.set_permissions(perms);
1050
1051        access.can_use_tool("test-agent", "bash"); // allowed
1052        access.can_use_tool("test-agent", "network"); // denied
1053
1054        let log = access.audit_log();
1055        assert_eq!(log.len(), 2);
1056        assert!(log[0].allowed);
1057        assert!(!log[1].allowed);
1058        assert_eq!(log[0].agent_name, "test-agent");
1059        assert_eq!(log[0].action, "use_tool");
1060        assert_eq!(log[0].resource, "bash");
1061    }
1062
1063    #[test]
1064    fn test_audit_log_recent() {
1065        let mut access = AccessManager::new();
1066
1067        let perms = AgentPermissions::for_new_agent("test");
1068        access.set_permissions(perms);
1069
1070        for i in 0..10 {
1071            access.can_use_tool("test", &format!("tool-{}", i));
1072        }
1073
1074        let recent = access.audit_log_recent(3);
1075        assert_eq!(recent.len(), 3);
1076    }
1077
1078    #[test]
1079    fn test_audit_log_for_agent() {
1080        let mut access = AccessManager::new();
1081
1082        access.set_permissions(AgentPermissions::for_new_agent("agent-a"));
1083        access.set_permissions(AgentPermissions::for_new_agent("agent-b"));
1084
1085        access.can_use_tool("agent-a", "tool1");
1086        access.can_use_tool("agent-b", "tool2");
1087        access.can_use_tool("agent-a", "tool3");
1088
1089        let log_a = access.audit_log_for_agent("agent-a");
1090        assert_eq!(log_a.len(), 2);
1091    }
1092
1093    #[test]
1094    fn test_denied_actions() {
1095        let mut access = AccessManager::new();
1096
1097        let perms = AgentPermissions::for_new_agent("test");
1098        access.set_permissions(perms);
1099
1100        access.can_use_tool("test", "bash"); // allowed
1101        access.can_use_tool("test", "dangerous"); // denied
1102        access.can_access_path("test", "/etc/shadow"); // denied
1103
1104        let denied = access.denied_actions();
1105        assert_eq!(denied.len(), 2);
1106    }
1107
1108    #[test]
1109    fn test_clear_audit_log() {
1110        let mut access = AccessManager::new();
1111
1112        let perms = AgentPermissions::for_new_agent("test");
1113        access.set_permissions(perms);
1114
1115        for _ in 0..5 {
1116            access.can_use_tool("test", "tool");
1117        }
1118
1119        assert_eq!(access.audit_log().len(), 5);
1120
1121        access.clear_audit_log();
1122
1123        assert!(access.audit_log().is_empty());
1124    }
1125
1126    // --- Max audit entries pruning ---
1127
1128    #[test]
1129    fn test_audit_log_prunes_old_entries() {
1130        let mut access = AccessManager::with_max_audit_entries(5);
1131
1132        let perms = AgentPermissions::for_new_agent("test");
1133        access.set_permissions(perms);
1134
1135        // Add 10 entries.
1136        for i in 0..10 {
1137            access.can_use_tool("test", &format!("tool-{}", i));
1138        }
1139
1140        // Should be pruned to max_audit_entries.
1141        assert_eq!(access.audit_log().len(), 5);
1142    }
1143
1144    // --- Validate permissions tests ---
1145
1146    #[test]
1147    fn test_validate_permissions_no_tools() {
1148        let mut access = AccessManager::new();
1149        let mut perms = AgentPermissions::for_new_agent("test");
1150        perms.allowed_tools.clear();
1151        access.set_permissions(perms.clone());
1152
1153        let warnings = access.validate_permissions(&perms);
1154        assert!(warnings.iter().any(|w| w.contains("no allowed tools")));
1155    }
1156
1157    #[test]
1158    fn test_validate_permissions_no_path_restrictions() {
1159        let mut perms = AgentPermissions::for_new_agent("test");
1160        perms.allowed_paths.clear();
1161
1162        let access = AccessManager::new();
1163        let warnings = access.validate_permissions(&perms);
1164        assert!(warnings.iter().any(|w| w.contains("no path restrictions")));
1165    }
1166
1167    #[test]
1168    fn test_validate_permissions_warnings() {
1169        let mut access = AccessManager::new();
1170        let mut perms = AgentPermissions::for_new_agent("test");
1171        perms.network_access = true;
1172        perms.can_fork = true;
1173        perms.max_execution_time_secs = 0;
1174        perms.max_memory_mb = 0;
1175        access.set_permissions(perms.clone());
1176
1177        let warnings = access.validate_permissions(&perms);
1178        assert!(warnings.iter().any(|w| w.contains("network access")));
1179        assert!(warnings.iter().any(|w| w.contains("fork sub-agents")));
1180        assert!(warnings
1181            .iter()
1182            .any(|w| w.contains("no execution time limit")));
1183        assert!(warnings.iter().any(|w| w.contains("no memory limit")));
1184    }
1185
1186    // --- AuditEntry timestamp ---
1187
1188    #[test]
1189    fn test_audit_entry_has_timestamp() {
1190        let entry = AuditEntry::new("agent", "action", "resource", true, None);
1191        // timestamp should be set (not default DateTime).
1192        assert!(entry.timestamp.timestamp() > 0);
1193    }
1194
1195    // --- Workspace Sandbox tests ---
1196
1197    #[test]
1198    fn test_register_workspace_path() {
1199        let mut access = AccessManager::new();
1200        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my-workspace"));
1201
1202        assert_eq!(access.list_workspaces(), vec!["my-workspace"]);
1203        assert_eq!(
1204            access.get_workspace_path("my-workspace"),
1205            Some(&PathBuf::from("/workspace/my-workspace"))
1206        );
1207    }
1208
1209    #[test]
1210    fn test_assign_agent_to_workspace() {
1211        let mut access = AccessManager::new();
1212        access.register_workspace_path("project-alpha", PathBuf::from("/workspace/alpha"));
1213
1214        // Assign agent to workspace
1215        assert!(access.assign_workspace("agent-1", "project-alpha"));
1216
1217        // Check agent is assigned
1218        assert_eq!(
1219            access.get_workspace_for_agent("agent-1"),
1220            Some("project-alpha".to_string())
1221        );
1222        assert!(access.can_access_workspace("agent-1", "project-alpha"));
1223        assert!(!access.can_access_workspace("agent-1", "other-workspace"));
1224    }
1225
1226    #[test]
1227    fn test_assign_agent_to_nonexistent_workspace_fails() {
1228        let mut access = AccessManager::new();
1229
1230        // Cannot assign to non-existent workspace
1231        assert!(!access.assign_workspace("agent-1", "nonexistent"));
1232        assert_eq!(access.get_workspace_for_agent("agent-1"), None);
1233    }
1234
1235    #[test]
1236    fn test_reassign_agent_to_different_workspace() {
1237        let mut access = AccessManager::new();
1238        access.register_workspace_path("workspace-a", PathBuf::from("/workspace/a"));
1239        access.register_workspace_path("workspace-b", PathBuf::from("/workspace/b"));
1240
1241        // Assign to first workspace
1242        access.assign_workspace("agent-1", "workspace-a");
1243        assert_eq!(
1244            access.get_workspace_for_agent("agent-1"),
1245            Some("workspace-a".to_string())
1246        );
1247
1248        // Reassign to second workspace
1249        access.assign_workspace("agent-1", "workspace-b");
1250        assert_eq!(
1251            access.get_workspace_for_agent("agent-1"),
1252            Some("workspace-b".to_string())
1253        );
1254
1255        // Agent should not be in first workspace anymore
1256        assert!(!access.can_access_workspace("agent-1", "workspace-a"));
1257    }
1258
1259    #[test]
1260    fn test_unassign_agent_from_workspace() {
1261        let mut access = AccessManager::new();
1262        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1263
1264        access.assign_workspace("agent-1", "my-workspace");
1265        assert!(access.get_workspace_for_agent("agent-1").is_some());
1266
1267        let removed = access.unassign_workspace("agent-1");
1268        assert_eq!(removed, Some("my-workspace".to_string()));
1269        assert!(access.get_workspace_for_agent("agent-1").is_none());
1270    }
1271
1272    #[test]
1273    fn test_list_agents_in_workspace() {
1274        let mut access = AccessManager::new();
1275        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1276
1277        access.assign_workspace("agent-1", "my-workspace");
1278        access.assign_workspace("agent-2", "my-workspace");
1279        access.assign_workspace("agent-3", "other-workspace");
1280
1281        let agents = access.list_agents_in_workspace("my-workspace");
1282        assert_eq!(agents.len(), 2);
1283        assert!(agents.contains(&"agent-1".to_string()));
1284        assert!(agents.contains(&"agent-2".to_string()));
1285        assert!(!agents.contains(&"agent-3".to_string()));
1286    }
1287
1288    #[test]
1289    fn test_remove_workspace_unassigns_all_agents() {
1290        let mut access = AccessManager::new();
1291        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1292
1293        access.assign_workspace("agent-1", "my-workspace");
1294        access.assign_workspace("agent-2", "my-workspace");
1295
1296        access.remove_workspace("my-workspace");
1297
1298        assert!(access.list_workspaces().is_empty());
1299        assert!(access.get_workspace_for_agent("agent-1").is_none());
1300        assert!(access.get_workspace_for_agent("agent-2").is_none());
1301    }
1302
1303    #[test]
1304    fn test_is_path_in_workspace() {
1305        let mut access = AccessManager::new();
1306
1307        // Use /tmp for testing - it should exist on most systems
1308        let workspace = PathBuf::from("/tmp/oxios-test-workspace");
1309
1310        // Create temp directories BEFORE registering (so canonicalize works)
1311        std::fs::create_dir_all(&workspace).ok();
1312        std::fs::create_dir_all(workspace.join("subdir")).ok();
1313
1314        // Now register the workspace
1315        access.register_workspace_path("my-workspace", workspace.clone());
1316
1317        // Path inside workspace
1318        let inside_path = workspace.join("file.txt");
1319        std::fs::write(&inside_path, "test").ok(); // Create the file too
1320
1321        assert!(
1322            access.is_path_in_workspace("my-workspace", inside_path.to_str().unwrap()),
1323            "Path {:?} should be inside workspace",
1324            inside_path
1325        );
1326
1327        let nested_path = workspace.join("subdir/nested.txt");
1328        std::fs::write(&nested_path, "test").ok();
1329        assert!(
1330            access.is_path_in_workspace("my-workspace", nested_path.to_str().unwrap()),
1331            "Path {:?} should be inside workspace",
1332            nested_path
1333        );
1334
1335        // Path outside workspace (use /tmp directly without our subdirectory)
1336        assert!(!access.is_path_in_workspace("my-workspace", "/tmp/other-workspace/file.txt"));
1337
1338        // Non-existent workspace
1339        assert!(!access.is_path_in_workspace("nonexistent", "/tmp/test"));
1340
1341        // Cleanup
1342        std::fs::remove_dir_all(workspace).ok();
1343    }
1344}