Skip to main content

punch_types/
sandbox.rs

1//! Subprocess sandbox — the containment ring for agent-spawned processes.
2//!
3//! Provides environment sanitization, path traversal prevention, command
4//! validation, and a restricted execution environment for shell commands
5//! run by agents. Every subprocess enters the sandboxed arena, where only
6//! approved paths, environment variables, and commands are permitted.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12/// Configuration for the subprocess containment ring.
13///
14/// Defines what paths, environment variables, and commands are permitted
15/// within the sandboxed arena. Deny rules always take precedence over
16/// allow rules — a fighter cannot punch through a denied path.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SandboxConfig {
19    /// Directories the subprocess is allowed to access.
20    pub allowed_paths: Vec<PathBuf>,
21    /// Directories explicitly barred from the arena (e.g., /etc/shadow, ~/.ssh).
22    pub denied_paths: Vec<PathBuf>,
23    /// Environment variable names to pass through the containment ring.
24    pub env_allowlist: Vec<String>,
25    /// Environment variable patterns to block (supports glob: `*_TOKEN`, `AWS_*`).
26    pub env_denylist: Vec<String>,
27    /// Maximum bytes of stdout+stderr to capture from the subprocess.
28    pub max_output_bytes: usize,
29    /// Maximum execution time in seconds before the subprocess is killed.
30    pub max_execution_secs: u64,
31    /// Whether to allow network access from the subprocess.
32    pub allow_network: bool,
33    /// Explicit working directory (must reside within allowed_paths).
34    pub working_dir: Option<PathBuf>,
35    /// Command prefixes that are unconditionally denied.
36    pub denied_commands: Vec<String>,
37}
38
39impl Default for SandboxConfig {
40    fn default() -> Self {
41        let home = std::env::var("HOME")
42            .map(PathBuf::from)
43            .unwrap_or_else(|_| PathBuf::from("/root"));
44
45        Self {
46            allowed_paths: vec![
47                std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
48                PathBuf::from("/tmp"),
49            ],
50            denied_paths: vec![
51                PathBuf::from("/etc/shadow"),
52                PathBuf::from("/etc/passwd"),
53                home.join(".ssh"),
54                home.join(".gnupg"),
55                home.join(".aws"),
56            ],
57            env_allowlist: vec![
58                "PATH".into(),
59                "HOME".into(),
60                "USER".into(),
61                "LANG".into(),
62                "LC_ALL".into(),
63                "TERM".into(),
64                "SHELL".into(),
65                "TMPDIR".into(),
66            ],
67            env_denylist: vec![
68                "*_SECRET*".into(),
69                "*_TOKEN".into(),
70                "*_PASSWORD".into(),
71                "*_KEY".into(),
72                "AWS_*".into(),
73                "GITHUB_TOKEN".into(),
74            ],
75            max_output_bytes: 1_048_576, // 1 MB
76            max_execution_secs: 120,
77            allow_network: true,
78            working_dir: None,
79            denied_commands: vec![
80                "rm -rf /".into(),
81                "rm -rf /*".into(),
82                "dd if=/dev".into(),
83                "mkfs".into(),
84                ":(){ :|:& };:".into(),
85                "chmod -R 777 /".into(),
86                "> /dev/sda".into(),
87            ],
88        }
89    }
90}
91
92/// Violations detected by the containment ring.
93///
94/// Each variant describes how a fighter attempted to escape the sandboxed
95/// arena or violate an enforced constraint.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub enum SandboxViolation {
98    /// A denied command was detected.
99    DeniedCommand { command: String, reason: String },
100    /// A path traversal attempt was detected.
101    PathTraversal {
102        path: String,
103        attempted_escape: String,
104    },
105    /// Access to a denied path was attempted.
106    DeniedPath { path: String },
107    /// Access to a path outside the allowed set was attempted.
108    PathNotAllowed { path: String },
109    /// A denied environment variable was referenced.
110    DeniedEnvironment { var_name: String },
111}
112
113impl std::fmt::Display for SandboxViolation {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            SandboxViolation::DeniedCommand { command, reason } => {
117                write!(
118                    f,
119                    "sandbox violation: denied command '{}' — {}",
120                    command, reason
121                )
122            }
123            SandboxViolation::PathTraversal {
124                path,
125                attempted_escape,
126            } => {
127                write!(
128                    f,
129                    "sandbox violation: path traversal in '{}' — attempted escape via '{}'",
130                    path, attempted_escape
131                )
132            }
133            SandboxViolation::DeniedPath { path } => {
134                write!(f, "sandbox violation: access to denied path '{}'", path)
135            }
136            SandboxViolation::PathNotAllowed { path } => {
137                write!(
138                    f,
139                    "sandbox violation: path '{}' is outside allowed directories",
140                    path
141                )
142            }
143            SandboxViolation::DeniedEnvironment { var_name } => {
144                write!(
145                    f,
146                    "sandbox violation: environment variable '{}' is denied",
147                    var_name
148                )
149            }
150        }
151    }
152}
153
154impl std::error::Error for SandboxViolation {}
155
156/// The enforcer that guards the sandboxed arena.
157///
158/// Validates commands, paths, and environment variables before any subprocess
159/// is allowed to enter the containment ring.
160#[derive(Debug, Clone)]
161pub struct SandboxEnforcer {
162    /// The containment ring configuration.
163    pub config: SandboxConfig,
164}
165
166impl SandboxEnforcer {
167    /// Create a new enforcer with the given containment ring configuration.
168    pub fn new(config: SandboxConfig) -> Self {
169        Self { config }
170    }
171
172    /// Create a new enforcer with default containment ring settings.
173    pub fn with_defaults() -> Self {
174        Self::new(SandboxConfig::default())
175    }
176
177    /// Pre-execution validation: check a command before it enters the arena.
178    ///
179    /// Detects denied command prefixes, path traversal attempts, and
180    /// shell injection patterns (backticks, `$()`, pipes to sensitive commands).
181    pub fn validate_command(&self, command: &str) -> Result<(), SandboxViolation> {
182        let trimmed = command.trim();
183
184        // Check against denied command prefixes.
185        for denied in &self.config.denied_commands {
186            if trimmed.starts_with(denied.as_str()) || trimmed.contains(denied.as_str()) {
187                return Err(SandboxViolation::DeniedCommand {
188                    command: trimmed.to_string(),
189                    reason: format!("matches denied pattern '{}'", denied),
190                });
191            }
192        }
193
194        // Detect shell injection via backticks.
195        if trimmed.contains('`') {
196            // Allow backticks only if they appear inside single quotes, which is
197            // hard to determine reliably. For safety, flag all backticks.
198            return Err(SandboxViolation::DeniedCommand {
199                command: trimmed.to_string(),
200                reason: "backtick shell injection detected".into(),
201            });
202        }
203
204        // Detect $() command substitution.
205        if trimmed.contains("$(") {
206            return Err(SandboxViolation::DeniedCommand {
207                command: trimmed.to_string(),
208                reason: "$() command substitution detected".into(),
209            });
210        }
211
212        // Detect pipes to sensitive commands.
213        let sensitive_pipe_targets = ["sh", "bash", "eval", "exec", "sudo"];
214        if trimmed.contains('|') {
215            for segment in trimmed.split('|').skip(1) {
216                let target = segment.split_whitespace().next().unwrap_or("");
217                for sensitive in &sensitive_pipe_targets {
218                    if target == *sensitive {
219                        return Err(SandboxViolation::DeniedCommand {
220                            command: trimmed.to_string(),
221                            reason: format!("pipe to sensitive command '{}' detected", sensitive),
222                        });
223                    }
224                }
225            }
226        }
227
228        Ok(())
229    }
230
231    /// Validate whether a path is accessible within the containment ring.
232    ///
233    /// Canonicalizes the path, then checks denied paths first (deny always wins),
234    /// followed by allowed paths. A fighter cannot reach outside its arena.
235    pub fn validate_path(&self, path: &Path) -> Result<(), SandboxViolation> {
236        // Canonicalize the path. If the path doesn't exist yet, we fall back
237        // to a manual resolution approach to catch traversal attempts.
238        let canonical = match path.canonicalize() {
239            Ok(p) => p,
240            Err(_) => {
241                // Path doesn't exist — resolve manually by normalizing components.
242                self.normalize_path(path)
243            }
244        };
245
246        let canonical_str = canonical.display().to_string();
247
248        // Deny list takes precedence — no fighter punches through a denied path.
249        for denied in &self.config.denied_paths {
250            let denied_canonical = match denied.canonicalize() {
251                Ok(p) => p,
252                Err(_) => self.normalize_path(denied),
253            };
254            if canonical.starts_with(&denied_canonical) {
255                return Err(SandboxViolation::DeniedPath {
256                    path: canonical_str,
257                });
258            }
259        }
260
261        // Check if the path falls within any allowed directory.
262        if self.config.allowed_paths.is_empty() {
263            return Err(SandboxViolation::PathNotAllowed {
264                path: canonical_str,
265            });
266        }
267
268        let mut inside_allowed = false;
269        for allowed in &self.config.allowed_paths {
270            let allowed_canonical = match allowed.canonicalize() {
271                Ok(p) => p,
272                Err(_) => self.normalize_path(allowed),
273            };
274            if canonical.starts_with(&allowed_canonical) {
275                inside_allowed = true;
276                break;
277            }
278        }
279
280        if !inside_allowed {
281            // Check if this is a traversal attempt.
282            let path_str = path.display().to_string();
283            if path_str.contains("..") {
284                return Err(SandboxViolation::PathTraversal {
285                    path: path_str,
286                    attempted_escape: canonical_str,
287                });
288            }
289            return Err(SandboxViolation::PathNotAllowed {
290                path: canonical_str,
291            });
292        }
293
294        Ok(())
295    }
296
297    /// Build a clean environment — only variables that survive the containment ring.
298    ///
299    /// Starts with an empty environment, then includes only variables from the
300    /// allowlist that exist in the current process environment. Any variable
301    /// matching a denylist pattern is filtered out, even if it appears on the
302    /// allowlist.
303    pub fn sanitize_environment(&self) -> Vec<(String, String)> {
304        let current_env: Vec<(String, String)> = std::env::vars().collect();
305        let mut sanitized = Vec::new();
306
307        for (key, value) in &current_env {
308            // Check if the variable is on the allowlist.
309            if !self.config.env_allowlist.contains(key) {
310                continue;
311            }
312
313            // Check if the variable matches any denylist pattern.
314            if self.matches_env_denylist(key) {
315                continue;
316            }
317
318            sanitized.push((key.clone(), value.clone()));
319        }
320
321        sanitized
322    }
323
324    /// Build a sandboxed `tokio::process::Command` ready to enter the arena.
325    ///
326    /// Validates the command, sets a sanitized environment via `env_clear()` +
327    /// individual `env()` calls, and configures the working directory.
328    pub fn build_command(
329        &self,
330        command: &str,
331    ) -> Result<tokio::process::Command, SandboxViolation> {
332        // Validate before the fighter enters the ring.
333        self.validate_command(command)?;
334
335        let mut cmd = tokio::process::Command::new("sh");
336        cmd.arg("-c").arg(command);
337
338        // Sanitize the environment — strip everything, then add approved vars.
339        cmd.env_clear();
340        for (key, value) in self.sanitize_environment() {
341            cmd.env(&key, &value);
342        }
343
344        // Set the working directory.
345        if let Some(ref wd) = self.config.working_dir {
346            cmd.current_dir(wd);
347        }
348
349        Ok(cmd)
350    }
351
352    /// Check if an environment variable name matches any denylist pattern.
353    fn matches_env_denylist(&self, var_name: &str) -> bool {
354        for pattern in &self.config.env_denylist {
355            if glob_match(pattern, var_name) {
356                // Special exception: PATH should never be denied by *_KEY pattern.
357                if var_name == "PATH" && pattern.contains("_KEY") {
358                    continue;
359                }
360                return true;
361            }
362        }
363        false
364    }
365
366    /// Normalize a path by resolving `.` and `..` components and following
367    /// symlinks on the closest existing ancestor. Used when the target
368    /// doesn't exist yet but we still need canonical path resolution
369    /// (e.g., `/tmp` -> `/private/tmp` on macOS).
370    fn normalize_path(&self, path: &Path) -> PathBuf {
371        // Start with the current directory if the path is relative.
372        let effective = if path.is_relative() {
373            if let Some(ref wd) = self.config.working_dir {
374                wd.join(path)
375            } else {
376                std::env::current_dir()
377                    .unwrap_or_else(|_| PathBuf::from("/"))
378                    .join(path)
379            }
380        } else {
381            path.to_path_buf()
382        };
383
384        // First, do a logical normalization (resolve . and ..).
385        let mut logical_components = Vec::new();
386        for component in effective.components() {
387            match component {
388                std::path::Component::ParentDir => {
389                    logical_components.pop();
390                }
391                std::path::Component::CurDir => {}
392                other => {
393                    logical_components.push(other.as_os_str().to_os_string());
394                }
395            }
396        }
397
398        let mut logical = PathBuf::new();
399        for c in &logical_components {
400            logical.push(c);
401        }
402        if logical.as_os_str().is_empty() {
403            logical = PathBuf::from("/");
404        }
405
406        // Try to canonicalize the closest existing ancestor, then append
407        // the remaining non-existent suffix. This resolves symlinks like
408        // /tmp -> /private/tmp on macOS.
409        let mut ancestor = logical.clone();
410        let mut suffix_parts = Vec::new();
411        loop {
412            if ancestor.exists() {
413                if let Ok(real) = ancestor.canonicalize() {
414                    let mut result = real;
415                    for part in suffix_parts.into_iter().rev() {
416                        result.push(part);
417                    }
418                    return result;
419                }
420                break;
421            }
422            if let Some(file_name) = ancestor.file_name() {
423                suffix_parts.push(file_name.to_os_string());
424                if !ancestor.pop() {
425                    break;
426                }
427            } else {
428                break;
429            }
430        }
431
432        logical
433    }
434}
435
436/// Simple glob-style pattern matching for environment variable names.
437///
438/// Supports `*` as a wildcard that matches any sequence of characters.
439/// For example, `*_TOKEN` matches `GITHUB_TOKEN`, and `AWS_*` matches `AWS_SECRET_ACCESS_KEY`.
440fn glob_match(pattern: &str, text: &str) -> bool {
441    let parts: Vec<&str> = pattern.split('*').collect();
442
443    if parts.len() == 1 {
444        // No wildcard — exact match.
445        return pattern == text;
446    }
447
448    let mut pos = 0;
449    for (i, part) in parts.iter().enumerate() {
450        if part.is_empty() {
451            continue;
452        }
453        match text[pos..].find(part) {
454            Some(found) => {
455                // First part must match at the start if the pattern doesn't begin with *.
456                if i == 0 && found != 0 {
457                    return false;
458                }
459                pos += found + part.len();
460            }
461            None => return false,
462        }
463    }
464
465    // If the pattern doesn't end with *, the text must end at `pos`.
466    if !pattern.ends_with('*') {
467        return pos == text.len();
468    }
469
470    true
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    // -----------------------------------------------------------------------
478    // Test 1: Default config has sensible values
479    // -----------------------------------------------------------------------
480    #[test]
481    fn test_default_config_sensible_values() {
482        let config = SandboxConfig::default();
483
484        assert!(!config.allowed_paths.is_empty());
485        assert!(config.allowed_paths.contains(&PathBuf::from("/tmp")));
486        assert!(!config.denied_paths.is_empty());
487        assert!(!config.env_allowlist.is_empty());
488        assert!(config.env_allowlist.contains(&"PATH".to_string()));
489        assert!(config.env_allowlist.contains(&"HOME".to_string()));
490        assert!(!config.env_denylist.is_empty());
491        assert_eq!(config.max_output_bytes, 1_048_576);
492        assert_eq!(config.max_execution_secs, 120);
493        assert!(config.allow_network);
494        assert!(config.working_dir.is_none());
495        assert!(!config.denied_commands.is_empty());
496    }
497
498    // -----------------------------------------------------------------------
499    // Test 2: validate_command allows normal commands
500    // -----------------------------------------------------------------------
501    #[test]
502    fn test_validate_command_allows_normal_commands() {
503        let enforcer = SandboxEnforcer::with_defaults();
504
505        assert!(enforcer.validate_command("ls -la").is_ok());
506        assert!(enforcer.validate_command("cat README.md").is_ok());
507        assert!(enforcer.validate_command("grep -r 'pattern' src/").is_ok());
508        assert!(enforcer.validate_command("cargo build").is_ok());
509        assert!(enforcer.validate_command("echo hello").is_ok());
510    }
511
512    // -----------------------------------------------------------------------
513    // Test 3: validate_command blocks denied commands
514    // -----------------------------------------------------------------------
515    #[test]
516    fn test_validate_command_blocks_denied_commands() {
517        let enforcer = SandboxEnforcer::with_defaults();
518
519        let result = enforcer.validate_command("rm -rf /");
520        assert!(result.is_err());
521        match result.unwrap_err() {
522            SandboxViolation::DeniedCommand { command, reason } => {
523                assert!(command.contains("rm -rf /"));
524                assert!(reason.contains("denied pattern"));
525            }
526            other => panic!("expected DeniedCommand, got {:?}", other),
527        }
528
529        assert!(enforcer.validate_command("rm -rf /*").is_err());
530        assert!(
531            enforcer
532                .validate_command("dd if=/dev/zero of=disk.img")
533                .is_err()
534        );
535        assert!(enforcer.validate_command("mkfs.ext4 /dev/sda1").is_err());
536    }
537
538    // -----------------------------------------------------------------------
539    // Test 4: validate_command detects fork bomb
540    // -----------------------------------------------------------------------
541    #[test]
542    fn test_validate_command_detects_fork_bomb() {
543        let enforcer = SandboxEnforcer::with_defaults();
544
545        let result = enforcer.validate_command(":(){ :|:& };:");
546        assert!(result.is_err());
547        match result.unwrap_err() {
548            SandboxViolation::DeniedCommand { reason, .. } => {
549                assert!(reason.contains("denied pattern"));
550            }
551            other => panic!("expected DeniedCommand, got {:?}", other),
552        }
553    }
554
555    // -----------------------------------------------------------------------
556    // Test 5: validate_path allows files in allowed directories
557    // -----------------------------------------------------------------------
558    #[test]
559    fn test_validate_path_allows_files_in_allowed_dirs() {
560        let mut config = SandboxConfig::default();
561        config.allowed_paths = vec![PathBuf::from("/tmp")];
562        config.denied_paths = vec![];
563        let enforcer = SandboxEnforcer::new(config);
564
565        assert!(enforcer.validate_path(Path::new("/tmp/test.txt")).is_ok());
566        assert!(
567            enforcer
568                .validate_path(Path::new("/tmp/subdir/file.rs"))
569                .is_ok()
570        );
571    }
572
573    // -----------------------------------------------------------------------
574    // Test 6: validate_path blocks files in denied directories
575    // -----------------------------------------------------------------------
576    #[test]
577    fn test_validate_path_blocks_denied_dirs() {
578        let home = std::env::var("HOME")
579            .map(PathBuf::from)
580            .unwrap_or_else(|_| PathBuf::from("/root"));
581
582        let mut config = SandboxConfig::default();
583        config.allowed_paths = vec![home.clone()];
584        let enforcer = SandboxEnforcer::new(config);
585
586        let ssh_key = home.join(".ssh/id_rsa");
587        let result = enforcer.validate_path(&ssh_key);
588        assert!(result.is_err());
589        match result.unwrap_err() {
590            SandboxViolation::DeniedPath { path } => {
591                assert!(path.contains(".ssh"));
592            }
593            other => panic!("expected DeniedPath, got {:?}", other),
594        }
595    }
596
597    // -----------------------------------------------------------------------
598    // Test 7: validate_path detects path traversal
599    // -----------------------------------------------------------------------
600    #[test]
601    fn test_validate_path_detects_traversal() {
602        let mut config = SandboxConfig::default();
603        config.allowed_paths = vec![PathBuf::from("/tmp/sandbox")];
604        config.denied_paths = vec![];
605        let enforcer = SandboxEnforcer::new(config);
606
607        // Attempting to escape /tmp/sandbox via ../../etc/passwd
608        let result = enforcer.validate_path(Path::new("/tmp/sandbox/../../etc/passwd"));
609        assert!(result.is_err());
610        match result.unwrap_err() {
611            SandboxViolation::PathTraversal {
612                path,
613                attempted_escape,
614            } => {
615                assert!(path.contains(".."));
616                assert!(attempted_escape.contains("etc"));
617            }
618            SandboxViolation::PathNotAllowed { .. } => {
619                // Also acceptable — the path is outside allowed dirs.
620            }
621            other => panic!("expected PathTraversal or PathNotAllowed, got {:?}", other),
622        }
623    }
624
625    // -----------------------------------------------------------------------
626    // Test 8: validate_path handles symlink-style traversal
627    // -----------------------------------------------------------------------
628    #[test]
629    fn test_validate_path_handles_symlink_traversal() {
630        let mut config = SandboxConfig::default();
631        config.allowed_paths = vec![PathBuf::from("/tmp/arena")];
632        config.denied_paths = vec![];
633        let enforcer = SandboxEnforcer::new(config);
634
635        // A path that looks like it's in /tmp/arena but escapes via ..
636        let result = enforcer.validate_path(Path::new("/tmp/arena/../../../etc/shadow"));
637        assert!(result.is_err());
638    }
639
640    // -----------------------------------------------------------------------
641    // Test 9: sanitize_environment only passes allowed vars
642    // -----------------------------------------------------------------------
643    #[test]
644    fn test_sanitize_environment_only_allowed_vars() {
645        let enforcer = SandboxEnforcer::with_defaults();
646        let env = enforcer.sanitize_environment();
647
648        // All returned vars must be in the allowlist.
649        for (key, _) in &env {
650            assert!(
651                enforcer.config.env_allowlist.contains(key),
652                "unexpected env var '{}' passed through sanitization",
653                key
654            );
655        }
656
657        // PATH should be present if it exists in the system environment.
658        if std::env::var("PATH").is_ok() {
659            assert!(
660                env.iter().any(|(k, _)| k == "PATH"),
661                "PATH should be in sanitized environment"
662            );
663        }
664    }
665
666    // -----------------------------------------------------------------------
667    // Test 10: sanitize_environment filters denied patterns
668    // -----------------------------------------------------------------------
669    #[test]
670    fn test_sanitize_environment_filters_denied_patterns() {
671        let mut config = SandboxConfig::default();
672        // Add a secret-looking var to the allowlist to test that denylist wins.
673        config.env_allowlist.push("MY_SECRET_KEY".to_string());
674        config.env_allowlist.push("AWS_ACCESS_KEY_ID".to_string());
675        let enforcer = SandboxEnforcer::new(config);
676
677        // Set the env vars for this test.
678        // SAFETY: This test is not run in parallel with other tests that read these vars.
679        unsafe {
680            std::env::set_var("MY_SECRET_KEY", "should-be-denied");
681            std::env::set_var("AWS_ACCESS_KEY_ID", "should-be-denied");
682        }
683
684        let env = enforcer.sanitize_environment();
685
686        // These should be filtered out by denylist patterns.
687        assert!(
688            !env.iter().any(|(k, _)| k == "MY_SECRET_KEY"),
689            "MY_SECRET_KEY should be filtered by *_SECRET* pattern"
690        );
691        assert!(
692            !env.iter().any(|(k, _)| k == "AWS_ACCESS_KEY_ID"),
693            "AWS_ACCESS_KEY_ID should be filtered by AWS_* pattern"
694        );
695
696        // Clean up.
697        // SAFETY: This test is not run in parallel with other tests that read these vars.
698        unsafe {
699            std::env::remove_var("MY_SECRET_KEY");
700            std::env::remove_var("AWS_ACCESS_KEY_ID");
701        }
702    }
703
704    // -----------------------------------------------------------------------
705    // Test 11: build_command creates command with sanitized env
706    // -----------------------------------------------------------------------
707    #[test]
708    fn test_build_command_creates_sanitized_command() {
709        let enforcer = SandboxEnforcer::with_defaults();
710        let result = enforcer.build_command("ls -la");
711        assert!(result.is_ok());
712    }
713
714    // -----------------------------------------------------------------------
715    // Test 12: build_command fails for denied commands
716    // -----------------------------------------------------------------------
717    #[test]
718    fn test_build_command_fails_for_denied_commands() {
719        let enforcer = SandboxEnforcer::with_defaults();
720        let result = enforcer.build_command("rm -rf /");
721        assert!(result.is_err());
722        match result.unwrap_err() {
723            SandboxViolation::DeniedCommand { .. } => {}
724            other => panic!("expected DeniedCommand, got {:?}", other),
725        }
726    }
727
728    // -----------------------------------------------------------------------
729    // Test 13: Custom config overrides defaults
730    // -----------------------------------------------------------------------
731    #[test]
732    fn test_custom_config_overrides_defaults() {
733        let config = SandboxConfig {
734            allowed_paths: vec![PathBuf::from("/opt/arena")],
735            denied_paths: vec![PathBuf::from("/opt/arena/secrets")],
736            env_allowlist: vec!["CUSTOM_VAR".into()],
737            env_denylist: vec![],
738            max_output_bytes: 512,
739            max_execution_secs: 30,
740            allow_network: false,
741            working_dir: Some(PathBuf::from("/opt/arena")),
742            denied_commands: vec!["danger".into()],
743        };
744
745        let enforcer = SandboxEnforcer::new(config.clone());
746        assert_eq!(enforcer.config.max_output_bytes, 512);
747        assert_eq!(enforcer.config.max_execution_secs, 30);
748        assert!(!enforcer.config.allow_network);
749        assert_eq!(enforcer.config.allowed_paths.len(), 1);
750        assert_eq!(enforcer.config.denied_commands, vec!["danger".to_string()]);
751
752        // Custom denied command should be blocked.
753        assert!(enforcer.validate_command("danger zone").is_err());
754        // Default denied commands should not be blocked (custom config replaced them).
755        assert!(enforcer.validate_command("rm -rf /").is_ok());
756    }
757
758    // -----------------------------------------------------------------------
759    // Test 14: Empty allowed_paths denies all paths
760    // -----------------------------------------------------------------------
761    #[test]
762    fn test_empty_allowed_paths_denies_all() {
763        let config = SandboxConfig {
764            allowed_paths: vec![],
765            denied_paths: vec![],
766            ..SandboxConfig::default()
767        };
768        let enforcer = SandboxEnforcer::new(config);
769
770        let result = enforcer.validate_path(Path::new("/tmp/anything"));
771        assert!(result.is_err());
772        match result.unwrap_err() {
773            SandboxViolation::PathNotAllowed { .. } => {}
774            other => panic!("expected PathNotAllowed, got {:?}", other),
775        }
776    }
777
778    // -----------------------------------------------------------------------
779    // Test 15: DeniedCommand display formatting
780    // -----------------------------------------------------------------------
781    #[test]
782    fn test_denied_command_display_formatting() {
783        let violation = SandboxViolation::DeniedCommand {
784            command: "rm -rf /".into(),
785            reason: "matches denied pattern".into(),
786        };
787        let display = format!("{}", violation);
788        assert!(display.contains("sandbox violation"));
789        assert!(display.contains("rm -rf /"));
790        assert!(display.contains("matches denied pattern"));
791
792        let traversal = SandboxViolation::PathTraversal {
793            path: "../../etc/passwd".into(),
794            attempted_escape: "/etc/passwd".into(),
795        };
796        let display = format!("{}", traversal);
797        assert!(display.contains("path traversal"));
798        assert!(display.contains("../../etc/passwd"));
799
800        let denied_path = SandboxViolation::DeniedPath {
801            path: "/etc/shadow".into(),
802        };
803        let display = format!("{}", denied_path);
804        assert!(display.contains("denied path"));
805
806        let not_allowed = SandboxViolation::PathNotAllowed {
807            path: "/root/secret".into(),
808        };
809        let display = format!("{}", not_allowed);
810        assert!(display.contains("outside allowed"));
811
812        let denied_env = SandboxViolation::DeniedEnvironment {
813            var_name: "AWS_SECRET_KEY".into(),
814        };
815        let display = format!("{}", denied_env);
816        assert!(display.contains("denied"));
817        assert!(display.contains("AWS_SECRET_KEY"));
818    }
819
820    // -----------------------------------------------------------------------
821    // Test 16: Path canonicalization handles relative paths
822    // -----------------------------------------------------------------------
823    #[test]
824    fn test_path_canonicalization_relative() {
825        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
826        let mut config = SandboxConfig::default();
827        config.allowed_paths = vec![cwd.clone()];
828        config.denied_paths = vec![];
829        config.working_dir = Some(cwd.clone());
830        let enforcer = SandboxEnforcer::new(config);
831
832        // A relative path like "src/main.rs" should be resolved against cwd.
833        let normalized = enforcer.normalize_path(Path::new("src/main.rs"));
834        assert!(normalized.is_absolute());
835        assert!(normalized.starts_with(&cwd));
836    }
837
838    // -----------------------------------------------------------------------
839    // Additional tests: glob matching
840    // -----------------------------------------------------------------------
841    #[test]
842    fn test_glob_match_patterns() {
843        assert!(glob_match("*_TOKEN", "GITHUB_TOKEN"));
844        assert!(glob_match("*_TOKEN", "SLACK_TOKEN"));
845        assert!(!glob_match("*_TOKEN", "GITHUB_TOKEN_EXTRA"));
846        assert!(glob_match("AWS_*", "AWS_SECRET_ACCESS_KEY"));
847        assert!(glob_match("AWS_*", "AWS_REGION"));
848        assert!(!glob_match("AWS_*", "NOT_AWS"));
849        assert!(glob_match("*_SECRET*", "MY_SECRET_KEY"));
850        assert!(glob_match("*_SECRET*", "DB_SECRET"));
851        assert!(glob_match("EXACT", "EXACT"));
852        assert!(!glob_match("EXACT", "NOT_EXACT"));
853    }
854
855    // -----------------------------------------------------------------------
856    // Test: validate_command detects command substitution
857    // -----------------------------------------------------------------------
858    #[test]
859    fn test_validate_command_detects_substitution() {
860        let enforcer = SandboxEnforcer::with_defaults();
861
862        assert!(enforcer.validate_command("echo $(whoami)").is_err());
863        assert!(enforcer.validate_command("echo `whoami`").is_err());
864    }
865
866    // -----------------------------------------------------------------------
867    // Test: validate_command detects pipe to sensitive commands
868    // -----------------------------------------------------------------------
869    #[test]
870    fn test_validate_command_detects_pipe_to_sensitive() {
871        let enforcer = SandboxEnforcer::with_defaults();
872
873        assert!(enforcer.validate_command("cat file | sh").is_err());
874        assert!(enforcer.validate_command("echo cmd | bash").is_err());
875        assert!(enforcer.validate_command("echo cmd | sudo rm").is_err());
876
877        // Pipe to non-sensitive commands should be fine.
878        assert!(enforcer.validate_command("ls | grep pattern").is_ok());
879        assert!(enforcer.validate_command("cat file | wc -l").is_ok());
880    }
881}