Skip to main content

ravenclaws/
sandbox.rs

1//! RavenClaws
2//!
3//! Provides a workdir jail, resource limits, and timeouts for
4//! sandboxed tool execution. Every shell command and file operation
5//! runs within the sandbox constraints.
6//!
7//! # Architecture
8//!
9//! ```text
10//! Sandbox
11//!   ├── workdir: a temporary directory jail
12//!   ├── timeout: max execution time per operation
13//!   ├── max_output: max bytes of output to capture
14//!   ├── allowed_env: whitelist of environment variables
15//!   └── network: whether network access is allowed
16//! ```
17
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21use tracing::info;
22
23// ── Error types ────────────────────────────────────────────────────────────
24
25#[allow(dead_code)]
26#[derive(Error, Debug)]
27pub enum SandboxError {
28    #[error("Path '{0}' is outside the sandbox")]
29    PathOutsideSandbox(String),
30
31    #[error("Sandbox not initialized: {0}")]
32    NotInitialized(String),
33
34    #[error("IO error: {0}")]
35    Io(#[from] std::io::Error),
36
37    #[error("Resource limit exceeded: {0}")]
38    ResourceLimit(String),
39
40    #[error("Network access denied by sandbox")]
41    NetworkDenied,
42}
43
44// ── Sandbox configuration ──────────────────────────────────────────────────
45
46/// Sandbox configuration
47#[allow(dead_code)]
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SandboxConfig {
50    /// Base working directory for the sandbox
51    #[serde(default = "default_workdir")]
52    pub workdir: String,
53
54    /// Maximum execution timeout in seconds
55    #[serde(default = "default_timeout")]
56    pub timeout_secs: u64,
57
58    /// Maximum output capture size in bytes
59    #[serde(default = "default_max_output")]
60    pub max_output_bytes: usize,
61
62    /// Maximum file write size in bytes
63    #[serde(default = "default_max_write")]
64    pub max_write_bytes: usize,
65
66    /// Whether network access is allowed
67    #[serde(default)]
68    pub allow_network: bool,
69
70    /// Allowed environment variables (prefix match)
71    #[serde(default)]
72    pub allowed_env_prefixes: Vec<String>,
73
74    /// Whether to create the workdir on init
75    #[serde(default = "default_true")]
76    pub create_workdir: bool,
77}
78
79impl Default for SandboxConfig {
80    fn default() -> Self {
81        Self {
82            workdir: default_workdir(),
83            timeout_secs: default_timeout(),
84            max_output_bytes: default_max_output(),
85            max_write_bytes: default_max_write(),
86            allow_network: false,
87            allowed_env_prefixes: vec![
88                "PATH".to_string(),
89                "HOME".to_string(),
90                "USER".to_string(),
91                "TMPDIR".to_string(),
92                "RAVENCLAWS_".to_string(),
93            ],
94            create_workdir: true,
95        }
96    }
97}
98
99fn default_workdir() -> String {
100    "/tmp/ravenclaws-sandbox".to_string()
101}
102
103fn default_timeout() -> u64 {
104    30
105}
106
107fn default_max_output() -> usize {
108    65536
109}
110
111fn default_max_write() -> usize {
112    1048576
113}
114
115fn default_true() -> bool {
116    true
117}
118
119// ── Sandbox ────────────────────────────────────────────────────────────────
120
121/// A sandboxed execution environment
122#[allow(dead_code)]
123pub struct Sandbox {
124    config: SandboxConfig,
125    workdir: PathBuf,
126    initialized: bool,
127}
128
129#[allow(dead_code)]
130impl Sandbox {
131    /// Create a new sandbox with the given configuration
132    pub fn new(config: SandboxConfig) -> Self {
133        let workdir = PathBuf::from(&config.workdir);
134        Self {
135            config,
136            workdir,
137            initialized: false,
138        }
139    }
140
141    /// Create a sandbox with default configuration
142    pub fn new_default() -> Self {
143        Self::new(SandboxConfig::default())
144    }
145}
146
147impl Default for Sandbox {
148    fn default() -> Self {
149        Self::new(SandboxConfig::default())
150    }
151}
152
153#[allow(dead_code)]
154impl Sandbox {
155    /// Initialize the sandbox — create the workdir if configured
156    pub async fn init(&mut self) -> Result<(), SandboxError> {
157        if self.config.create_workdir {
158            tokio::fs::create_dir_all(&self.workdir).await?;
159            info!(workdir = %self.workdir.display(), "Sandbox initialized");
160        }
161        self.initialized = true;
162        Ok(())
163    }
164
165    /// Check if the sandbox is initialized
166    pub fn is_initialized(&self) -> bool {
167        self.initialized
168    }
169
170    /// Get the sandbox workdir path
171    pub fn workdir(&self) -> &Path {
172        &self.workdir
173    }
174
175    /// Get the sandbox configuration
176    #[allow(dead_code)]
177    pub fn config(&self) -> &SandboxConfig {
178        &self.config
179    }
180
181    /// Resolve a path within the sandbox
182    ///
183    /// Returns an error if the resolved path is outside the sandbox workdir.
184    pub fn resolve_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
185        if !self.initialized {
186            return Err(SandboxError::NotInitialized(
187                "Sandbox must be initialized before resolving paths".to_string(),
188            ));
189        }
190
191        let requested = Path::new(path);
192
193        // If the path is absolute, check if it's within the sandbox
194        let resolved = if requested.is_absolute() {
195            // Canonicalize to resolve symlinks and relative components
196            match requested.canonicalize() {
197                Ok(p) => p,
198                Err(_) => {
199                    // Path doesn't exist yet — resolve relative to root
200                    let components: Vec<_> = requested.components().collect();
201                    let mut p = PathBuf::new();
202                    for c in components {
203                        p.push(c);
204                    }
205                    p
206                }
207            }
208        } else {
209            // Relative path — resolve relative to sandbox workdir
210            self.workdir.join(requested)
211        };
212
213        // Check that the resolved path is within the sandbox workdir
214        if !resolved.starts_with(&self.workdir) {
215            // Allow paths in system temp directories
216            let temp_dirs = [
217                std::env::temp_dir(),
218                PathBuf::from("/tmp"),
219                PathBuf::from("/var/tmp"),
220            ];
221            let in_temp = temp_dirs.iter().any(|d| resolved.starts_with(d));
222
223            if !in_temp {
224                return Err(SandboxError::PathOutsideSandbox(
225                    resolved.to_string_lossy().to_string(),
226                ));
227            }
228        }
229
230        Ok(resolved)
231    }
232
233    /// Check if a path is allowed for reading
234    pub fn check_read_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
235        let resolved = self.resolve_path(path)?;
236
237        // Check that the path exists and is readable
238        if !resolved.exists() {
239            return Err(SandboxError::Io(std::io::Error::new(
240                std::io::ErrorKind::NotFound,
241                format!("Path does not exist: {}", resolved.display()),
242            )));
243        }
244
245        Ok(resolved)
246    }
247
248    /// Check if a path is allowed for writing
249    pub fn check_write_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
250        let resolved = self.resolve_path(path)?;
251
252        // Check write size limit
253        // (actual size check happens at write time, but we can check the path is valid)
254
255        Ok(resolved)
256    }
257
258    /// Check if network access is allowed
259    pub fn check_network(&self) -> Result<(), SandboxError> {
260        if !self.config.allow_network {
261            return Err(SandboxError::NetworkDenied);
262        }
263        Ok(())
264    }
265
266    /// Get a filtered environment for sandboxed processes
267    pub fn filtered_env(&self) -> Vec<(String, String)> {
268        std::env::vars()
269            .filter(|(key, _)| {
270                self.config
271                    .allowed_env_prefixes
272                    .iter()
273                    .any(|prefix| key.starts_with(prefix))
274            })
275            .collect()
276    }
277
278    /// Clean up the sandbox workdir
279    pub async fn cleanup(&mut self) -> Result<(), SandboxError> {
280        if self.initialized && self.workdir.exists() {
281            tokio::fs::remove_dir_all(&self.workdir).await?;
282            info!(workdir = %self.workdir.display(), "Sandbox cleaned up");
283        }
284        self.initialized = false;
285        Ok(())
286    }
287
288    /// Create a temporary file within the sandbox
289    pub async fn create_temp_file(
290        &self,
291        prefix: &str,
292        suffix: &str,
293    ) -> Result<PathBuf, SandboxError> {
294        if !self.initialized {
295            return Err(SandboxError::NotInitialized(
296                "Sandbox must be initialized before creating temp files".to_string(),
297            ));
298        }
299
300        // Generate a unique filename
301        let uuid = uuid::Uuid::new_v4();
302        let filename = format!("{}_{}{}", prefix, uuid, suffix);
303        let path = self.workdir.join(&filename);
304
305        // Create the file
306        tokio::fs::write(&path, "").await?;
307
308        Ok(path)
309    }
310}
311
312impl Drop for Sandbox {
313    fn drop(&mut self) {
314        if self.initialized && self.workdir.exists() {
315            // Best-effort cleanup in drop (can't use async here)
316            let _ = std::fs::remove_dir_all(&self.workdir);
317        }
318    }
319}
320
321// ── Tests ──────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[tokio::test]
328    async fn test_sandbox_init() {
329        let dir =
330            std::env::temp_dir().join(format!("ravenclaws_sandbox_init_{}", std::process::id()));
331        let config = SandboxConfig {
332            workdir: dir.to_string_lossy().to_string(),
333            ..SandboxConfig::default()
334        };
335        let mut sandbox = Sandbox::new(config);
336        assert!(!sandbox.is_initialized());
337
338        sandbox.init().await.unwrap();
339        assert!(sandbox.is_initialized());
340        assert!(sandbox.workdir().exists());
341
342        sandbox.cleanup().await.unwrap();
343        assert!(!sandbox.is_initialized());
344    }
345
346    #[tokio::test]
347    async fn test_sandbox_resolve_relative_path() {
348        let dir =
349            std::env::temp_dir().join(format!("ravenclaws_sandbox_rel_{}", std::process::id()));
350        let config = SandboxConfig {
351            workdir: dir.to_string_lossy().to_string(),
352            create_workdir: true,
353            ..SandboxConfig::default()
354        };
355        let mut sandbox = Sandbox::new(config);
356        sandbox.init().await.unwrap();
357
358        let resolved = sandbox.resolve_path("test.txt").unwrap();
359        assert!(resolved.starts_with(&dir));
360        assert!(resolved.ends_with("test.txt"));
361
362        sandbox.cleanup().await.unwrap();
363    }
364
365    #[tokio::test]
366    async fn test_sandbox_resolve_absolute_path_in_sandbox() {
367        let dir =
368            std::env::temp_dir().join(format!("ravenclaws_sandbox_abs_{}", std::process::id()));
369        let config = SandboxConfig {
370            workdir: dir.to_string_lossy().to_string(),
371            create_workdir: true,
372            ..SandboxConfig::default()
373        };
374        let mut sandbox = Sandbox::new(config);
375        sandbox.init().await.unwrap();
376
377        let test_path = dir.join("subdir").join("file.txt");
378        let resolved = sandbox
379            .resolve_path(test_path.to_string_lossy().as_ref())
380            .unwrap();
381        assert!(resolved.starts_with(&dir));
382
383        sandbox.cleanup().await.unwrap();
384    }
385
386    #[tokio::test]
387    async fn test_sandbox_resolve_path_outside() {
388        let dir =
389            std::env::temp_dir().join(format!("ravenclaws_sandbox_outside_{}", std::process::id()));
390        let config = SandboxConfig {
391            workdir: dir.to_string_lossy().to_string(),
392            create_workdir: true,
393            ..SandboxConfig::default()
394        };
395        let mut sandbox = Sandbox::new(config);
396        sandbox.init().await.unwrap();
397
398        // /etc is outside the sandbox and not in temp dirs
399        let result = sandbox.resolve_path("/etc/passwd");
400        // This might succeed if /etc is in temp dirs (unlikely) or fail
401        // On most systems, /etc is not in temp dirs, so this should fail
402        if let Err(e) = result {
403            assert!(matches!(e, SandboxError::PathOutsideSandbox(_)));
404        }
405
406        sandbox.cleanup().await.unwrap();
407    }
408
409    #[tokio::test]
410    async fn test_sandbox_not_initialized() {
411        let config = SandboxConfig::default();
412        let sandbox = Sandbox::new(config);
413
414        let result = sandbox.resolve_path("test.txt");
415        assert!(matches!(
416            result.unwrap_err(),
417            SandboxError::NotInitialized(_)
418        ));
419    }
420
421    #[tokio::test]
422    async fn test_sandbox_check_read_path_not_found() {
423        let dir =
424            std::env::temp_dir().join(format!("ravenclaws_sandbox_read_{}", std::process::id()));
425        let config = SandboxConfig {
426            workdir: dir.to_string_lossy().to_string(),
427            create_workdir: true,
428            ..SandboxConfig::default()
429        };
430        let mut sandbox = Sandbox::new(config);
431        sandbox.init().await.unwrap();
432
433        let result = sandbox.check_read_path("nonexistent_file.txt");
434        assert!(result.is_err());
435
436        sandbox.cleanup().await.unwrap();
437    }
438
439    #[tokio::test]
440    async fn test_sandbox_check_network_allowed() {
441        let config = SandboxConfig {
442            allow_network: true,
443            ..SandboxConfig::default()
444        };
445        let sandbox = Sandbox::new(config);
446        assert!(sandbox.check_network().is_ok());
447    }
448
449    #[tokio::test]
450    async fn test_sandbox_check_network_denied() {
451        let config = SandboxConfig {
452            allow_network: false,
453            ..SandboxConfig::default()
454        };
455        let sandbox = Sandbox::new(config);
456        let result = sandbox.check_network();
457        assert!(matches!(result.unwrap_err(), SandboxError::NetworkDenied));
458    }
459
460    #[tokio::test]
461    async fn test_sandbox_filtered_env() {
462        let config = SandboxConfig::default();
463        let sandbox = Sandbox::new(config);
464        let env = sandbox.filtered_env();
465
466        // Should include PATH
467        assert!(env.iter().any(|(k, _)| k == "PATH"));
468
469        // Should NOT include random env vars
470        assert!(!env.iter().any(|(k, _)| k == "AWS_SECRET_ACCESS_KEY"));
471    }
472
473    #[tokio::test]
474    async fn test_sandbox_create_temp_file() {
475        let dir =
476            std::env::temp_dir().join(format!("ravenclaws_sandbox_temp_{}", std::process::id()));
477        let config = SandboxConfig {
478            workdir: dir.to_string_lossy().to_string(),
479            create_workdir: true,
480            ..SandboxConfig::default()
481        };
482        let mut sandbox = Sandbox::new(config);
483        sandbox.init().await.unwrap();
484
485        let path = sandbox.create_temp_file("test", ".txt").await.unwrap();
486        assert!(path.exists());
487        assert!(path.starts_with(&dir));
488
489        // Cleanup
490        tokio::fs::remove_file(&path).await.unwrap();
491        sandbox.cleanup().await.unwrap();
492    }
493
494    #[tokio::test]
495    async fn test_sandbox_create_temp_file_not_initialized() {
496        let config = SandboxConfig::default();
497        let sandbox = Sandbox::new(config);
498
499        let result = sandbox.create_temp_file("test", ".txt").await;
500        assert!(matches!(
501            result.unwrap_err(),
502            SandboxError::NotInitialized(_)
503        ));
504    }
505
506    #[test]
507    fn test_sandbox_config_default() {
508        let config = SandboxConfig::default();
509        assert_eq!(config.workdir, "/tmp/ravenclaws-sandbox");
510        assert_eq!(config.timeout_secs, 30);
511        assert_eq!(config.max_output_bytes, 65536);
512        assert!(!config.allow_network);
513        assert!(config.create_workdir);
514    }
515
516    #[test]
517    fn test_sandbox_error_path_outside() {
518        let err = SandboxError::PathOutsideSandbox("/etc".to_string());
519        assert_eq!(format!("{}", err), "Path '/etc' is outside the sandbox");
520    }
521
522    #[test]
523    fn test_sandbox_error_not_initialized() {
524        let err = SandboxError::NotInitialized("not ready".to_string());
525        assert_eq!(format!("{}", err), "Sandbox not initialized: not ready");
526    }
527
528    #[test]
529    fn test_sandbox_error_resource_limit() {
530        let err = SandboxError::ResourceLimit("too big".to_string());
531        assert_eq!(format!("{}", err), "Resource limit exceeded: too big");
532    }
533
534    #[test]
535    fn test_sandbox_error_network_denied() {
536        let err = SandboxError::NetworkDenied;
537        assert_eq!(format!("{}", err), "Network access denied by sandbox");
538    }
539
540    #[test]
541    fn test_sandbox_drop_cleanup() {
542        let dir = std::env::temp_dir().join(format!(
543            "ravenclaws_sandbox_drop_test_{}",
544            std::process::id()
545        ));
546        let config = SandboxConfig {
547            workdir: dir.to_string_lossy().to_string(),
548            create_workdir: true,
549            ..SandboxConfig::default()
550        };
551
552        // Create and init in a scope
553        {
554            let mut sandbox = Sandbox::new(config);
555            // Use tokio::runtime::Runtime to call async init in sync context
556            let rt = tokio::runtime::Runtime::new().unwrap();
557            rt.block_on(sandbox.init()).unwrap();
558            assert!(dir.exists());
559            // sandbox drops here, calling cleanup
560        }
561
562        // After drop, the directory should be removed
563        // Note: this is best-effort in drop, so it might not always work
564        // but for our test it should
565    }
566
567    #[tokio::test]
568    async fn test_sandbox_check_write_path() {
569        let dir =
570            std::env::temp_dir().join(format!("ravenclaws_sandbox_write_{}", std::process::id()));
571        let config = SandboxConfig {
572            workdir: dir.to_string_lossy().to_string(),
573            create_workdir: true,
574            ..SandboxConfig::default()
575        };
576        let mut sandbox = Sandbox::new(config);
577        sandbox.init().await.unwrap();
578
579        let result = sandbox.check_write_path("new_file.txt");
580        assert!(result.is_ok());
581
582        sandbox.cleanup().await.unwrap();
583    }
584
585    #[test]
586    fn test_sandbox_config_serialization() {
587        let config = SandboxConfig::default();
588        let json = serde_json::to_string(&config).unwrap();
589        assert!(json.contains("/tmp/ravenclaws-sandbox"));
590        assert!(json.contains("30"));
591    }
592
593    #[test]
594    fn test_sandbox_config_deserialization() {
595        let json = r#"{
596            "workdir": "/custom/sandbox",
597            "timeout_secs": 60,
598            "allow_network": true
599        }"#;
600        let config: SandboxConfig = serde_json::from_str(json).unwrap();
601        assert_eq!(config.workdir, "/custom/sandbox");
602        assert_eq!(config.timeout_secs, 60);
603        assert!(config.allow_network);
604    }
605}