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