gestura_core_security/
sandbox.rs

1//! Agent sandboxing and isolation utilities
2//!
3//! Provides security boundaries for agent processes, including:
4//! - Resource limits (memory, CPU time)
5//! - File system access control
6//! - Network access control
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use gestura_core::sandbox::{SandboxConfig, SandboxManager, create_default_sandbox};
12//!
13//! let mut manager = SandboxManager::new();
14//! let config = create_default_sandbox("mcp-agent");
15//! manager.register_agent("my-agent", config);
16//!
17//! // Validate file access
18//! manager.validate_file_access("my-agent", &path, false)?;
19//! ```
20
21use gestura_core_foundation::AppError;
22use std::collections::HashMap;
23use std::path::PathBuf;
24
25/// Sandbox configuration for agent processes
26///
27/// Defines resource limits and access controls for an agent.
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct SandboxConfig {
30    /// Maximum memory usage in MB
31    pub max_memory_mb: u64,
32    /// Maximum CPU time in seconds
33    pub max_cpu_time_secs: u64,
34    /// Allowed file system paths (read-only)
35    pub allowed_read_paths: Vec<PathBuf>,
36    /// Allowed file system paths (read-write)
37    pub allowed_write_paths: Vec<PathBuf>,
38    /// Allowed network hosts
39    pub allowed_hosts: Vec<String>,
40    /// Environment variables to pass through
41    pub env_vars: HashMap<String, String>,
42}
43
44impl Default for SandboxConfig {
45    fn default() -> Self {
46        Self {
47            max_memory_mb: 512,
48            max_cpu_time_secs: 300,
49            allowed_read_paths: vec![],
50            allowed_write_paths: vec![],
51            allowed_hosts: vec![],
52            env_vars: HashMap::new(),
53        }
54    }
55}
56
57impl SandboxConfig {
58    /// Create a new sandbox config with default values
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Set memory limit in MB
64    pub fn with_memory_limit(mut self, mb: u64) -> Self {
65        self.max_memory_mb = mb;
66        self
67    }
68
69    /// Set CPU time limit in seconds
70    pub fn with_cpu_limit(mut self, secs: u64) -> Self {
71        self.max_cpu_time_secs = secs;
72        self
73    }
74
75    /// Add a read-only path
76    pub fn with_read_path(mut self, path: PathBuf) -> Self {
77        self.allowed_read_paths.push(path);
78        self
79    }
80
81    /// Add a read-write path
82    pub fn with_write_path(mut self, path: PathBuf) -> Self {
83        self.allowed_write_paths.push(path);
84        self
85    }
86
87    /// Add an allowed network host
88    pub fn with_allowed_host(mut self, host: String) -> Self {
89        self.allowed_hosts.push(host);
90        self
91    }
92
93    /// Set an environment variable
94    pub fn with_env(mut self, key: String, value: String) -> Self {
95        self.env_vars.insert(key, value);
96        self
97    }
98}
99
100/// Sandbox manager for agent processes
101///
102/// Manages sandbox configurations for multiple agents and provides
103/// validation methods for file and network access.
104pub struct SandboxManager {
105    configs: HashMap<String, SandboxConfig>,
106}
107
108impl Default for SandboxManager {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl SandboxManager {
115    /// Create a new sandbox manager
116    pub fn new() -> Self {
117        Self {
118            configs: HashMap::new(),
119        }
120    }
121
122    /// Register sandbox config for an agent
123    pub fn register_agent(&mut self, agent_id: &str, config: SandboxConfig) {
124        tracing::debug!(
125            agent_id = %agent_id,
126            max_memory_mb = config.max_memory_mb,
127            max_cpu_time_secs = config.max_cpu_time_secs,
128            "Registering sandbox config for agent"
129        );
130        self.configs.insert(agent_id.to_string(), config);
131    }
132
133    /// Unregister an agent's sandbox config
134    pub fn unregister_agent(&mut self, agent_id: &str) {
135        self.configs.remove(agent_id);
136    }
137
138    /// Get sandbox config for an agent
139    pub fn get_config(&self, agent_id: &str) -> SandboxConfig {
140        self.configs.get(agent_id).cloned().unwrap_or_default()
141    }
142
143    /// Check if an agent has a registered config
144    pub fn has_agent(&self, agent_id: &str) -> bool {
145        self.configs.contains_key(agent_id)
146    }
147
148    /// List all registered agent IDs
149    pub fn list_agents(&self) -> Vec<String> {
150        self.configs.keys().cloned().collect()
151    }
152
153    /// Apply sandbox restrictions to a command
154    ///
155    /// Sets resource limits and environment variables on the command.
156    pub fn apply_sandbox(
157        &self,
158        agent_id: &str,
159        mut cmd: tokio::process::Command,
160    ) -> tokio::process::Command {
161        let config = self.get_config(agent_id);
162
163        // Set resource limits (platform-specific)
164        #[cfg(unix)]
165        {
166            cmd.env(
167                "RLIMIT_AS",
168                (config.max_memory_mb * 1024 * 1024).to_string(),
169            );
170            cmd.env("RLIMIT_CPU", config.max_cpu_time_secs.to_string());
171        }
172
173        // Set allowed environment variables
174        cmd.env_clear();
175        for (key, value) in &config.env_vars {
176            cmd.env(key, value);
177        }
178
179        // Add basic security environment
180        cmd.env("GESTURA_AGENT_ID", agent_id);
181        cmd.env("GESTURA_SANDBOX", "1");
182
183        tracing::info!("Applied sandbox config for agent: {}", agent_id);
184        cmd
185    }
186
187    /// Validate file access for an agent
188    ///
189    /// Returns Ok if the agent is allowed to access the path, Err otherwise.
190    pub fn validate_file_access(
191        &self,
192        agent_id: &str,
193        path: &PathBuf,
194        write_access: bool,
195    ) -> Result<(), AppError> {
196        let config = self.get_config(agent_id);
197        let access_type = if write_access { "write" } else { "read" };
198
199        tracing::debug!(
200            agent_id = %agent_id,
201            path = ?path,
202            access_type = %access_type,
203            "Validating file access for agent"
204        );
205
206        if write_access {
207            for allowed_path in &config.allowed_write_paths {
208                if path.starts_with(allowed_path) {
209                    tracing::debug!(
210                        agent_id = %agent_id,
211                        path = ?path,
212                        matched_pattern = ?allowed_path,
213                        "File write access granted"
214                    );
215                    return Ok(());
216                }
217            }
218            tracing::warn!(
219                agent_id = %agent_id,
220                path = ?path,
221                allowed_write_paths = ?config.allowed_write_paths,
222                "File write access denied"
223            );
224            Err(AppError::PermissionDenied(format!(
225                "Write access denied for agent {} to path: {:?}",
226                agent_id, path
227            )))
228        } else {
229            // Check read paths first
230            for allowed_path in &config.allowed_read_paths {
231                if path.starts_with(allowed_path) {
232                    return Ok(());
233                }
234            }
235            // Write paths also grant read access
236            for allowed_path in &config.allowed_write_paths {
237                if path.starts_with(allowed_path) {
238                    return Ok(());
239                }
240            }
241            tracing::warn!(
242                agent_id = %agent_id,
243                path = ?path,
244                allowed_read_paths = ?config.allowed_read_paths,
245                allowed_write_paths = ?config.allowed_write_paths,
246                "File read access denied"
247            );
248            Err(AppError::PermissionDenied(format!(
249                "Read access denied for agent {} to path: {:?}",
250                agent_id, path
251            )))
252        }
253    }
254
255    /// Validate network access for an agent
256    ///
257    /// Returns Ok if the agent is allowed to access the host, Err otherwise.
258    pub fn validate_network_access(&self, agent_id: &str, host: &str) -> Result<(), AppError> {
259        let config = self.get_config(agent_id);
260
261        tracing::debug!(
262            agent_id = %agent_id,
263            host = %host,
264            "Validating network access for agent"
265        );
266
267        if config.allowed_hosts.is_empty() {
268            return Ok(());
269        }
270
271        for allowed_host in &config.allowed_hosts {
272            if host == allowed_host || host.ends_with(&format!(".{}", allowed_host)) {
273                return Ok(());
274            }
275        }
276
277        tracing::warn!(
278            agent_id = %agent_id,
279            host = %host,
280            allowed_hosts = ?config.allowed_hosts,
281            "Network access denied"
282        );
283        Err(AppError::PermissionDenied(format!(
284            "Network access denied for agent {} to host: {}",
285            agent_id, host
286        )))
287    }
288}
289
290/// Create default sandbox config for different agent types
291///
292/// Returns a pre-configured sandbox based on the agent type.
293pub fn create_default_sandbox(agent_type: &str) -> SandboxConfig {
294    tracing::debug!(agent_type = %agent_type, "Creating default sandbox config");
295
296    let mut config = SandboxConfig::default();
297
298    match agent_type {
299        "voice-agent" => {
300            config.max_memory_mb = 256;
301            config.max_cpu_time_secs = 60;
302            if let Some(temp_dir) = std::env::temp_dir().to_str() {
303                config.allowed_read_paths.push(PathBuf::from(temp_dir));
304            }
305        }
306        "mcp-agent" => {
307            config.max_memory_mb = 512;
308            config.max_cpu_time_secs = 300;
309            config.allowed_hosts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
310        }
311        "default-agent" => {
312            config.max_memory_mb = 128;
313            config.max_cpu_time_secs = 120;
314        }
315        _ => {
316            tracing::debug!(
317                agent_type = %agent_type,
318                "Unknown agent type, using restrictive defaults"
319            );
320            config.max_memory_mb = 64;
321            config.max_cpu_time_secs = 30;
322        }
323    }
324
325    tracing::debug!(
326        agent_type = %agent_type,
327        max_memory_mb = config.max_memory_mb,
328        max_cpu_time_secs = config.max_cpu_time_secs,
329        "Created sandbox config"
330    );
331
332    config
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_sandbox_config_defaults() {
341        let config = SandboxConfig::default();
342        assert_eq!(config.max_memory_mb, 512);
343        assert_eq!(config.max_cpu_time_secs, 300);
344        assert!(config.allowed_read_paths.is_empty());
345        assert!(config.allowed_write_paths.is_empty());
346        assert!(config.allowed_hosts.is_empty());
347    }
348
349    #[test]
350    fn test_sandbox_config_builder() {
351        let config = SandboxConfig::new()
352            .with_memory_limit(256)
353            .with_cpu_limit(60)
354            .with_read_path(PathBuf::from("/tmp"))
355            .with_write_path(PathBuf::from("/var/app"))
356            .with_allowed_host("localhost".to_string())
357            .with_env("KEY".to_string(), "VALUE".to_string());
358
359        assert_eq!(config.max_memory_mb, 256);
360        assert_eq!(config.max_cpu_time_secs, 60);
361        assert_eq!(config.allowed_read_paths.len(), 1);
362        assert_eq!(config.allowed_write_paths.len(), 1);
363        assert_eq!(config.allowed_hosts.len(), 1);
364        assert_eq!(config.env_vars.get("KEY"), Some(&"VALUE".to_string()));
365    }
366
367    #[test]
368    fn test_sandbox_config_creation() {
369        let voice_config = create_default_sandbox("voice-agent");
370        assert_eq!(voice_config.max_memory_mb, 256);
371        assert_eq!(voice_config.max_cpu_time_secs, 60);
372
373        let mcp_config = create_default_sandbox("mcp-agent");
374        assert_eq!(mcp_config.max_memory_mb, 512);
375        assert!(mcp_config.allowed_hosts.contains(&"localhost".to_string()));
376
377        let default_config = create_default_sandbox("default-agent");
378        assert_eq!(default_config.max_memory_mb, 128);
379
380        let unknown_config = create_default_sandbox("unknown");
381        assert_eq!(unknown_config.max_memory_mb, 64);
382    }
383
384    #[test]
385    fn test_sandbox_manager_registration() {
386        let mut manager = SandboxManager::new();
387        assert!(!manager.has_agent("test"));
388
389        let config = SandboxConfig::default();
390        manager.register_agent("test", config);
391        assert!(manager.has_agent("test"));
392
393        let agents = manager.list_agents();
394        assert!(agents.contains(&"test".to_string()));
395
396        manager.unregister_agent("test");
397        assert!(!manager.has_agent("test"));
398    }
399
400    #[test]
401    fn test_file_access_validation() {
402        let mut manager = SandboxManager::new();
403        let config = SandboxConfig::new()
404            .with_read_path(PathBuf::from("/tmp"))
405            .with_write_path(PathBuf::from("/var/app"));
406        manager.register_agent("test-agent", config);
407
408        assert!(
409            manager
410                .validate_file_access("test-agent", &PathBuf::from("/tmp/test.txt"), false)
411                .is_ok()
412        );
413        assert!(
414            manager
415                .validate_file_access("test-agent", &PathBuf::from("/var/app/data.json"), true)
416                .is_ok()
417        );
418        assert!(
419            manager
420                .validate_file_access("test-agent", &PathBuf::from("/var/app/data.json"), false)
421                .is_ok()
422        );
423        assert!(
424            manager
425                .validate_file_access("test-agent", &PathBuf::from("/etc/passwd"), false)
426                .is_err()
427        );
428        assert!(
429            manager
430                .validate_file_access("test-agent", &PathBuf::from("/tmp/test.txt"), true)
431                .is_err()
432        );
433    }
434
435    #[test]
436    fn test_network_access_validation() {
437        let mut manager = SandboxManager::new();
438        let config = SandboxConfig::new()
439            .with_allowed_host("localhost".to_string())
440            .with_allowed_host("api.example.com".to_string());
441        manager.register_agent("test-agent", config);
442
443        assert!(
444            manager
445                .validate_network_access("test-agent", "localhost")
446                .is_ok()
447        );
448        assert!(
449            manager
450                .validate_network_access("test-agent", "api.example.com")
451                .is_ok()
452        );
453        assert!(
454            manager
455                .validate_network_access("test-agent", "sub.api.example.com")
456                .is_ok()
457        );
458        assert!(
459            manager
460                .validate_network_access("test-agent", "evil.com")
461                .is_err()
462        );
463    }
464
465    #[test]
466    fn test_network_access_permissive_default() {
467        let mut manager = SandboxManager::new();
468        let config = SandboxConfig::default();
469        manager.register_agent("permissive", config);
470        assert!(
471            manager
472                .validate_network_access("permissive", "any.host.com")
473                .is_ok()
474        );
475    }
476}