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