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            && let Some(agents) = self.workspace_agents.get_mut(prev_workspace)
393        {
394            agents.remove(agent_name);
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(
695                "Agent has no path restrictions (paths granted by ensure_permissions)".to_string(),
696            );
697        }
698
699        if perms.network_access {
700            warnings.push("Agent has network access enabled".to_string());
701        }
702
703        if perms.can_fork {
704            warnings.push("Agent can fork sub-agents".to_string());
705        }
706
707        if perms.max_execution_time_secs == 0 {
708            warnings.push("Agent has no execution time limit".to_string());
709        }
710
711        if perms.max_memory_mb == 0 {
712            warnings.push("Agent has no memory limit".to_string());
713        }
714
715        warnings
716    }
717}
718
719impl Default for AccessManager {
720    fn default() -> Self {
721        Self::new()
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    // --- AgentPermissions tests ---
730
731    #[test]
732    fn test_default_permissions() {
733        let perms = AgentPermissions::default();
734        assert!(perms.allowed_tools.contains("bash"));
735        assert!(!perms.network_access);
736        assert!(!perms.can_fork);
737        assert_eq!(perms.max_execution_time_secs, 300);
738        assert_eq!(perms.max_memory_mb, 512);
739    }
740
741    #[test]
742    fn test_for_new_agent() {
743        let perms = AgentPermissions::for_new_agent("my-agent");
744        assert_eq!(perms.agent_name, "my-agent");
745        assert!(perms.allowed_tools.contains("bash"));
746    }
747
748    #[test]
749    fn test_allow_deny_tool() {
750        let mut perms = AgentPermissions::for_new_agent("test");
751        assert!(perms.allowed_tools.contains("bash"));
752
753        perms.deny_tool("bash");
754        assert!(!perms.allowed_tools.contains("bash"));
755
756        perms.allow_tool("custom");
757        assert!(perms.allowed_tools.contains("custom"));
758    }
759
760    #[test]
761    fn test_allow_deny_path() {
762        let mut perms = AgentPermissions::for_new_agent("test");
763
764        perms.allow_path("/workspace/**");
765        assert!(perms.allowed_paths.contains(&"/workspace/**".to_string()));
766
767        perms.deny_path("/workspace/.secret/**");
768        assert!(
769            perms
770                .denied_paths
771                .contains(&"/workspace/.secret/**".to_string())
772        );
773    }
774
775    #[test]
776    fn test_enable_network() {
777        let mut perms = AgentPermissions::for_new_agent("test");
778        assert!(!perms.network_access);
779
780        perms.enable_network();
781        assert!(perms.network_access);
782    }
783
784    #[test]
785    fn test_enable_forking() {
786        let mut perms = AgentPermissions::for_new_agent("test");
787        assert!(!perms.can_fork);
788
789        perms.enable_forking();
790        assert!(perms.can_fork);
791    }
792
793    #[test]
794    fn test_path_matching_allowed() {
795        let mut perms = AgentPermissions::for_new_agent("test");
796        perms.allowed_paths = vec!["/workspace/**".to_string(), "/home/*/docs/**".to_string()];
797        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
798
799        // Matches allowed.
800        assert!(perms.is_path_allowed("/workspace/project/file.rs"));
801        assert!(perms.is_path_allowed("/home/user/docs/readme.md"));
802
803        // Does not match any allowed pattern.
804        assert!(!perms.is_path_allowed("/etc/passwd"));
805        assert!(!perms.is_path_allowed("/home/user/secret.txt"));
806    }
807
808    #[test]
809    fn test_path_matching_denied() {
810        let mut perms = AgentPermissions::for_new_agent("test");
811        perms.allowed_paths = vec!["/workspace/**".to_string()];
812        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
813
814        // Denied takes precedence over allowed.
815        assert!(perms.is_path_denied("/workspace/.oxios/config.toml"));
816        assert!(!perms.is_path_denied("/workspace/project/file.rs"));
817
818        // Even though it matches allowed, denied blocks it.
819        let _access = AccessManager::new();
820        let mut perms2 = perms.clone();
821        perms2.agent_name = "test".to_string();
822        // The is_path_denied check happens first in can_access_path.
823        // If allowed_paths matches but denied_paths also matches, it's blocked.
824        // We test this via the full can_access_path method.
825    }
826
827    #[test]
828    fn test_path_denied_pattern_matching() {
829        let mut perms = AgentPermissions::for_new_agent("test");
830        perms.denied_paths = vec!["/etc/**".to_string(), "**/secrets/*".to_string()];
831
832        assert!(perms.is_path_denied("/etc/passwd"));
833        assert!(perms.is_path_denied("/etc/shadow"));
834        assert!(!perms.is_path_denied("/workspace/file"));
835    }
836
837    // --- AccessManager tool access tests ---
838
839    #[test]
840    fn test_can_use_tool_allowed() {
841        let mut access = AccessManager::new();
842
843        let mut perms = AgentPermissions::for_new_agent("code-agent");
844        perms.allow_tool("bash");
845        perms.allow_tool("read");
846        access.set_permissions(perms);
847
848        assert!(access.can_use_tool("code-agent", "bash"));
849        assert!(access.can_use_tool("code-agent", "read"));
850    }
851
852    #[test]
853    fn test_can_use_tool_denied() {
854        let mut access = AccessManager::new();
855
856        let mut perms = AgentPermissions::for_new_agent("code-agent");
857        perms.allow_tool("read");
858        perms.deny_tool("bash"); // Explicitly denied.
859        access.set_permissions(perms);
860
861        assert!(!access.can_use_tool("code-agent", "bash")); // denied
862        assert!(!access.can_use_tool("code-agent", "spawn")); // not in list
863        assert!(!access.can_use_tool("unknown-agent", "bash")); // unknown agent
864    }
865
866    #[test]
867    fn test_unknown_agent_denied_all_tools() {
868        let mut access = AccessManager::new();
869
870        // No permissions set for unknown-agent.
871        assert!(!access.can_use_tool("unknown-agent", "read"));
872        assert!(!access.can_access_path("unknown-agent", "/workspace/test.txt"));
873        assert!(!access.can_access_network("unknown-agent"));
874        assert!(!access.can_fork("unknown-agent"));
875    }
876
877    // --- AccessManager path access tests ---
878
879    #[test]
880    fn test_can_access_path_allowed() {
881        let mut access = AccessManager::new();
882
883        let mut perms = AgentPermissions::for_new_agent("file-agent");
884        perms.allow_path("/workspace/**");
885        access.set_permissions(perms);
886
887        assert!(access.can_access_path("file-agent", "/workspace/project/file.rs"));
888        assert!(!access.can_access_path("file-agent", "/etc/passwd"));
889    }
890
891    #[test]
892    fn test_can_access_path_denied_takes_precedence() {
893        let mut access = AccessManager::new();
894
895        let mut perms = AgentPermissions::for_new_agent("test");
896        perms.allowed_paths = vec!["/workspace/**".to_string()];
897        perms.denied_paths = vec!["/workspace/.oxios/**".to_string()];
898        access.set_permissions(perms);
899
900        // Allowed but also denied → blocked.
901        assert!(!access.can_access_path("test", "/workspace/.oxios/config.toml"));
902
903        // Just allowed → allowed.
904        assert!(access.can_access_path("test", "/workspace/project/file.rs"));
905    }
906
907    // --- AccessManager network access tests ---
908
909    #[test]
910    fn test_can_access_network() {
911        let mut access = AccessManager::new();
912
913        let mut perms = AgentPermissions::for_new_agent("net-agent");
914        perms.enable_network();
915        access.set_permissions(perms);
916
917        assert!(access.can_access_network("net-agent"));
918        assert!(!access.can_access_network("no-net-agent"));
919    }
920
921    // --- Execution limits tests ---
922
923    #[test]
924    fn test_can_execute_for() {
925        let mut access = AccessManager::new();
926
927        let mut perms = AgentPermissions::for_new_agent("test");
928        perms.max_execution_time_secs = 300;
929        access.set_permissions(perms);
930
931        assert!(access.can_execute_for("test", 100));
932        assert!(access.can_execute_for("test", 300));
933        assert!(!access.can_execute_for("test", 301));
934    }
935
936    #[test]
937    fn test_unlimited_execution_time() {
938        let mut access = AccessManager::new();
939
940        let mut perms = AgentPermissions::for_new_agent("test");
941        perms.max_execution_time_secs = 0; // unlimited
942        access.set_permissions(perms);
943
944        assert!(access.can_execute_for("test", 100_000));
945    }
946
947    #[test]
948    fn test_can_use_memory() {
949        let mut access = AccessManager::new();
950
951        let mut perms = AgentPermissions::for_new_agent("test");
952        perms.max_memory_mb = 512;
953        access.set_permissions(perms);
954
955        assert!(access.can_use_memory("test", 256));
956        assert!(access.can_use_memory("test", 512));
957        assert!(!access.can_use_memory("test", 513));
958    }
959
960    #[test]
961    fn test_unlimited_memory() {
962        let mut access = AccessManager::new();
963
964        let mut perms = AgentPermissions::for_new_agent("test");
965        perms.max_memory_mb = 0;
966        access.set_permissions(perms);
967
968        assert!(access.can_use_memory("test", 1_000_000));
969    }
970
971    // --- Fork tests ---
972
973    #[test]
974    fn test_can_fork() {
975        let mut access = AccessManager::new();
976
977        let mut perms = AgentPermissions::for_new_agent("test");
978        perms.enable_forking();
979        access.set_permissions(perms);
980
981        assert!(access.can_fork("test"));
982        assert!(!access.can_fork("no-fork-agent"));
983    }
984
985    // --- Permission management tests ---
986
987    #[test]
988    fn test_set_and_get_permissions() {
989        let mut access = AccessManager::new();
990
991        let perms = AgentPermissions::for_new_agent("test-agent");
992        access.set_permissions(perms);
993
994        let retrieved = access.get_permissions("test-agent");
995        assert!(retrieved.is_some());
996        assert_eq!(retrieved.unwrap().agent_name, "test-agent");
997    }
998
999    #[test]
1000    fn test_get_nonexistent_permissions() {
1001        let access = AccessManager::new();
1002        assert!(access.get_permissions("ghost").is_none());
1003    }
1004
1005    #[test]
1006    fn test_get_or_create_permissions() {
1007        let mut access = AccessManager::new();
1008
1009        // First access creates default.
1010        let perms = access.get_or_create_permissions("new-agent");
1011        assert_eq!(perms.agent_name, "new-agent");
1012
1013        // Second access returns same instance.
1014        let perms2 = access.get_or_create_permissions("new-agent");
1015        assert_eq!(perms2.agent_name, "new-agent");
1016    }
1017
1018    #[test]
1019    fn test_remove_permissions() {
1020        let mut access = AccessManager::new();
1021
1022        let perms = AgentPermissions::for_new_agent("to-remove");
1023        access.set_permissions(perms);
1024
1025        assert!(access.get_permissions("to-remove").is_some());
1026
1027        access.remove_permissions("to-remove");
1028
1029        assert!(access.get_permissions("to-remove").is_none());
1030        // All access should now be denied.
1031        assert!(!access.can_use_tool("to-remove", "bash"));
1032    }
1033
1034    #[test]
1035    fn test_list_agents() {
1036        let mut access = AccessManager::new();
1037
1038        access.set_permissions(AgentPermissions::for_new_agent("agent-1"));
1039        access.set_permissions(AgentPermissions::for_new_agent("agent-2"));
1040
1041        let agents = access.list_agents();
1042        assert_eq!(agents.len(), 2);
1043        assert!(agents.contains(&"agent-1".to_string()));
1044        assert!(agents.contains(&"agent-2".to_string()));
1045    }
1046
1047    // --- Audit log tests ---
1048
1049    #[test]
1050    fn test_audit_log_records_access() {
1051        let mut access = AccessManager::new();
1052
1053        let perms = AgentPermissions::for_new_agent("test-agent");
1054        access.set_permissions(perms);
1055
1056        access.can_use_tool("test-agent", "bash"); // allowed
1057        access.can_use_tool("test-agent", "network"); // denied
1058
1059        let log = access.audit_log();
1060        assert_eq!(log.len(), 2);
1061        assert!(log[0].allowed);
1062        assert!(!log[1].allowed);
1063        assert_eq!(log[0].agent_name, "test-agent");
1064        assert_eq!(log[0].action, "use_tool");
1065        assert_eq!(log[0].resource, "bash");
1066    }
1067
1068    #[test]
1069    fn test_audit_log_recent() {
1070        let mut access = AccessManager::new();
1071
1072        let perms = AgentPermissions::for_new_agent("test");
1073        access.set_permissions(perms);
1074
1075        for i in 0..10 {
1076            access.can_use_tool("test", &format!("tool-{}", i));
1077        }
1078
1079        let recent = access.audit_log_recent(3);
1080        assert_eq!(recent.len(), 3);
1081    }
1082
1083    #[test]
1084    fn test_audit_log_for_agent() {
1085        let mut access = AccessManager::new();
1086
1087        access.set_permissions(AgentPermissions::for_new_agent("agent-a"));
1088        access.set_permissions(AgentPermissions::for_new_agent("agent-b"));
1089
1090        access.can_use_tool("agent-a", "tool1");
1091        access.can_use_tool("agent-b", "tool2");
1092        access.can_use_tool("agent-a", "tool3");
1093
1094        let log_a = access.audit_log_for_agent("agent-a");
1095        assert_eq!(log_a.len(), 2);
1096    }
1097
1098    #[test]
1099    fn test_denied_actions() {
1100        let mut access = AccessManager::new();
1101
1102        let perms = AgentPermissions::for_new_agent("test");
1103        access.set_permissions(perms);
1104
1105        access.can_use_tool("test", "bash"); // allowed
1106        access.can_use_tool("test", "dangerous"); // denied
1107        access.can_access_path("test", "/etc/shadow"); // denied
1108
1109        let denied = access.denied_actions();
1110        assert_eq!(denied.len(), 2);
1111    }
1112
1113    #[test]
1114    fn test_clear_audit_log() {
1115        let mut access = AccessManager::new();
1116
1117        let perms = AgentPermissions::for_new_agent("test");
1118        access.set_permissions(perms);
1119
1120        for _ in 0..5 {
1121            access.can_use_tool("test", "tool");
1122        }
1123
1124        assert_eq!(access.audit_log().len(), 5);
1125
1126        access.clear_audit_log();
1127
1128        assert!(access.audit_log().is_empty());
1129    }
1130
1131    // --- Max audit entries pruning ---
1132
1133    #[test]
1134    fn test_audit_log_prunes_old_entries() {
1135        let mut access = AccessManager::with_max_audit_entries(5);
1136
1137        let perms = AgentPermissions::for_new_agent("test");
1138        access.set_permissions(perms);
1139
1140        // Add 10 entries.
1141        for i in 0..10 {
1142            access.can_use_tool("test", &format!("tool-{}", i));
1143        }
1144
1145        // Should be pruned to max_audit_entries.
1146        assert_eq!(access.audit_log().len(), 5);
1147    }
1148
1149    // --- Validate permissions tests ---
1150
1151    #[test]
1152    fn test_validate_permissions_no_tools() {
1153        let mut access = AccessManager::new();
1154        let mut perms = AgentPermissions::for_new_agent("test");
1155        perms.allowed_tools.clear();
1156        access.set_permissions(perms.clone());
1157
1158        let warnings = access.validate_permissions(&perms);
1159        assert!(warnings.iter().any(|w| w.contains("no allowed tools")));
1160    }
1161
1162    #[test]
1163    fn test_validate_permissions_no_path_restrictions() {
1164        let mut perms = AgentPermissions::for_new_agent("test");
1165        perms.allowed_paths.clear();
1166
1167        let access = AccessManager::new();
1168        let warnings = access.validate_permissions(&perms);
1169        assert!(warnings.iter().any(|w| w.contains("no path restrictions")));
1170    }
1171
1172    #[test]
1173    fn test_validate_permissions_warnings() {
1174        let mut access = AccessManager::new();
1175        let mut perms = AgentPermissions::for_new_agent("test");
1176        perms.network_access = true;
1177        perms.can_fork = true;
1178        perms.max_execution_time_secs = 0;
1179        perms.max_memory_mb = 0;
1180        access.set_permissions(perms.clone());
1181
1182        let warnings = access.validate_permissions(&perms);
1183        assert!(warnings.iter().any(|w| w.contains("network access")));
1184        assert!(warnings.iter().any(|w| w.contains("fork sub-agents")));
1185        assert!(
1186            warnings
1187                .iter()
1188                .any(|w| w.contains("no execution time limit"))
1189        );
1190        assert!(warnings.iter().any(|w| w.contains("no memory limit")));
1191    }
1192
1193    // --- AuditEntry timestamp ---
1194
1195    #[test]
1196    fn test_audit_entry_has_timestamp() {
1197        let entry = AuditEntry::new("agent", "action", "resource", true, None);
1198        // timestamp should be set (not default DateTime).
1199        assert!(entry.timestamp.timestamp() > 0);
1200    }
1201
1202    // --- Workspace Sandbox tests ---
1203
1204    #[test]
1205    fn test_register_workspace_path() {
1206        let mut access = AccessManager::new();
1207        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my-workspace"));
1208
1209        assert_eq!(access.list_workspaces(), vec!["my-workspace"]);
1210        assert_eq!(
1211            access.get_workspace_path("my-workspace"),
1212            Some(&PathBuf::from("/workspace/my-workspace"))
1213        );
1214    }
1215
1216    #[test]
1217    fn test_assign_agent_to_workspace() {
1218        let mut access = AccessManager::new();
1219        access.register_workspace_path("project-alpha", PathBuf::from("/workspace/alpha"));
1220
1221        // Assign agent to workspace
1222        assert!(access.assign_workspace("agent-1", "project-alpha"));
1223
1224        // Check agent is assigned
1225        assert_eq!(
1226            access.get_workspace_for_agent("agent-1"),
1227            Some("project-alpha".to_string())
1228        );
1229        assert!(access.can_access_workspace("agent-1", "project-alpha"));
1230        assert!(!access.can_access_workspace("agent-1", "other-workspace"));
1231    }
1232
1233    #[test]
1234    fn test_assign_agent_to_nonexistent_workspace_fails() {
1235        let mut access = AccessManager::new();
1236
1237        // Cannot assign to non-existent workspace
1238        assert!(!access.assign_workspace("agent-1", "nonexistent"));
1239        assert_eq!(access.get_workspace_for_agent("agent-1"), None);
1240    }
1241
1242    #[test]
1243    fn test_reassign_agent_to_different_workspace() {
1244        let mut access = AccessManager::new();
1245        access.register_workspace_path("workspace-a", PathBuf::from("/workspace/a"));
1246        access.register_workspace_path("workspace-b", PathBuf::from("/workspace/b"));
1247
1248        // Assign to first workspace
1249        access.assign_workspace("agent-1", "workspace-a");
1250        assert_eq!(
1251            access.get_workspace_for_agent("agent-1"),
1252            Some("workspace-a".to_string())
1253        );
1254
1255        // Reassign to second workspace
1256        access.assign_workspace("agent-1", "workspace-b");
1257        assert_eq!(
1258            access.get_workspace_for_agent("agent-1"),
1259            Some("workspace-b".to_string())
1260        );
1261
1262        // Agent should not be in first workspace anymore
1263        assert!(!access.can_access_workspace("agent-1", "workspace-a"));
1264    }
1265
1266    #[test]
1267    fn test_unassign_agent_from_workspace() {
1268        let mut access = AccessManager::new();
1269        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1270
1271        access.assign_workspace("agent-1", "my-workspace");
1272        assert!(access.get_workspace_for_agent("agent-1").is_some());
1273
1274        let removed = access.unassign_workspace("agent-1");
1275        assert_eq!(removed, Some("my-workspace".to_string()));
1276        assert!(access.get_workspace_for_agent("agent-1").is_none());
1277    }
1278
1279    #[test]
1280    fn test_list_agents_in_workspace() {
1281        let mut access = AccessManager::new();
1282        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1283
1284        access.assign_workspace("agent-1", "my-workspace");
1285        access.assign_workspace("agent-2", "my-workspace");
1286        access.assign_workspace("agent-3", "other-workspace");
1287
1288        let agents = access.list_agents_in_workspace("my-workspace");
1289        assert_eq!(agents.len(), 2);
1290        assert!(agents.contains(&"agent-1".to_string()));
1291        assert!(agents.contains(&"agent-2".to_string()));
1292        assert!(!agents.contains(&"agent-3".to_string()));
1293    }
1294
1295    #[test]
1296    fn test_remove_workspace_unassigns_all_agents() {
1297        let mut access = AccessManager::new();
1298        access.register_workspace_path("my-workspace", PathBuf::from("/workspace/my"));
1299
1300        access.assign_workspace("agent-1", "my-workspace");
1301        access.assign_workspace("agent-2", "my-workspace");
1302
1303        access.remove_workspace("my-workspace");
1304
1305        assert!(access.list_workspaces().is_empty());
1306        assert!(access.get_workspace_for_agent("agent-1").is_none());
1307        assert!(access.get_workspace_for_agent("agent-2").is_none());
1308    }
1309
1310    #[test]
1311    fn test_is_path_in_workspace() {
1312        let mut access = AccessManager::new();
1313
1314        // Use /tmp for testing - it should exist on most systems
1315        let workspace = PathBuf::from("/tmp/oxios-test-workspace");
1316
1317        // Create temp directories BEFORE registering (so canonicalize works)
1318        std::fs::create_dir_all(&workspace).ok();
1319        std::fs::create_dir_all(workspace.join("subdir")).ok();
1320
1321        // Now register the workspace
1322        access.register_workspace_path("my-workspace", workspace.clone());
1323
1324        // Path inside workspace
1325        let inside_path = workspace.join("file.txt");
1326        std::fs::write(&inside_path, "test").ok(); // Create the file too
1327
1328        assert!(
1329            access.is_path_in_workspace("my-workspace", inside_path.to_str().unwrap()),
1330            "Path {:?} should be inside workspace",
1331            inside_path
1332        );
1333
1334        let nested_path = workspace.join("subdir/nested.txt");
1335        std::fs::write(&nested_path, "test").ok();
1336        assert!(
1337            access.is_path_in_workspace("my-workspace", nested_path.to_str().unwrap()),
1338            "Path {:?} should be inside workspace",
1339            nested_path
1340        );
1341
1342        // Path outside workspace (use /tmp directly without our subdirectory)
1343        assert!(!access.is_path_in_workspace("my-workspace", "/tmp/other-workspace/file.txt"));
1344
1345        // Non-existent workspace
1346        assert!(!access.is_path_in_workspace("nonexistent", "/tmp/test"));
1347
1348        // Cleanup
1349        std::fs::remove_dir_all(workspace).ok();
1350    }
1351}