Skip to main content

opendev_runtime/
sandbox.rs

1//! Sandbox mode for restricting tool operations.
2//!
3//! When enabled, only whitelisted commands may be executed by the bash tool,
4//! and file write/edit operations are restricted to paths within the project directory.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Configuration for sandbox mode.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct SandboxConfig {
12    /// Whether sandbox restrictions are active.
13    pub enabled: bool,
14    /// Commands allowed in the bash tool (matched by prefix, e.g. `"cargo"` allows `"cargo build"`).
15    pub allowed_commands: Vec<String>,
16    /// Paths (directories) where file write/edit operations are permitted.
17    /// Paths are normalized and checked via prefix matching.
18    pub writable_paths: Vec<String>,
19}
20
21impl SandboxConfig {
22    /// Create a disabled sandbox config.
23    pub fn disabled() -> Self {
24        Self::default()
25    }
26
27    /// Create an enabled sandbox config with the given project directory as the sole writable path
28    /// and a default set of safe commands.
29    pub fn for_project(project_dir: &Path) -> Self {
30        Self {
31            enabled: true,
32            allowed_commands: vec![
33                "cargo".into(),
34                "rustc".into(),
35                "npm".into(),
36                "node".into(),
37                "python".into(),
38                "git".into(),
39                "ls".into(),
40                "cat".into(),
41                "head".into(),
42                "tail".into(),
43                "grep".into(),
44                "find".into(),
45                "wc".into(),
46                "sort".into(),
47                "uniq".into(),
48                "diff".into(),
49                "echo".into(),
50                "pwd".into(),
51                "which".into(),
52                "env".into(),
53                "test".into(),
54                "true".into(),
55                "false".into(),
56                "mkdir".into(),
57                "cp".into(),
58                "mv".into(),
59                "touch".into(),
60            ],
61            writable_paths: vec![project_dir.to_string_lossy().to_string()],
62        }
63    }
64
65    /// Check whether a bash command is allowed in sandbox mode.
66    ///
67    /// Returns `Ok(())` if sandbox is disabled or the command is whitelisted.
68    /// Returns `Err` with a human-readable message if blocked.
69    pub fn check_command(&self, command: &str) -> Result<(), String> {
70        if !self.enabled {
71            return Ok(());
72        }
73
74        let trimmed = command.trim();
75        if trimmed.is_empty() {
76            return Ok(());
77        }
78
79        // Extract the base command (first word, stripping any env var prefix)
80        let base_cmd = extract_base_command(trimmed);
81
82        if self
83            .allowed_commands
84            .iter()
85            .any(|allowed| base_cmd == allowed.as_str())
86        {
87            return Ok(());
88        }
89
90        Err(format!(
91            "Sandbox: command '{}' is not in the allowed list. Allowed: {:?}",
92            base_cmd, self.allowed_commands
93        ))
94    }
95
96    /// Check whether a file path is writable in sandbox mode.
97    ///
98    /// Returns `Ok(())` if sandbox is disabled or the path is within a writable directory.
99    /// Returns `Err` with a human-readable message if blocked.
100    pub fn check_writable_path(&self, path: &Path) -> Result<(), String> {
101        if !self.enabled {
102            return Ok(());
103        }
104
105        // Normalize the path for comparison
106        let normalized = normalize_path(path);
107
108        for writable in &self.writable_paths {
109            let writable_normalized = normalize_path(Path::new(writable));
110            if normalized.starts_with(&writable_normalized) {
111                return Ok(());
112            }
113        }
114
115        Err(format!(
116            "Sandbox: path '{}' is not within writable directories. Writable: {:?}",
117            path.display(),
118            self.writable_paths
119        ))
120    }
121}
122
123/// Extract the base command name from a shell command string.
124///
125/// Handles:
126/// - Leading env var assignments: `FOO=bar cmd args` -> `cmd`
127/// - Leading path: `/usr/bin/cmd args` -> `cmd`
128/// - Simple commands: `cmd args` -> `cmd`
129fn extract_base_command(command: &str) -> &str {
130    let mut parts = command.split_whitespace();
131
132    // Skip env var assignments (KEY=VALUE)
133    let cmd = loop {
134        match parts.next() {
135            Some(part) if part.contains('=') && !part.starts_with('-') => continue,
136            Some(part) => break part,
137            None => return "",
138        }
139    };
140
141    // Strip path prefix: `/usr/bin/cargo` -> `cargo`
142    cmd.rsplit('/').next().unwrap_or(cmd)
143}
144
145/// Best-effort path normalization without requiring the path to exist.
146fn normalize_path(path: &Path) -> PathBuf {
147    // Try canonical first (resolves symlinks, requires path to exist)
148    if let Ok(canonical) = path.canonicalize() {
149        return canonical;
150    }
151    // Fall back to the path as-is but with components resolved
152    let mut result = PathBuf::new();
153    for component in path.components() {
154        match component {
155            std::path::Component::ParentDir => {
156                result.pop();
157            }
158            std::path::Component::CurDir => {}
159            _ => result.push(component),
160        }
161    }
162    result
163}
164
165#[cfg(test)]
166#[path = "sandbox_tests.rs"]
167mod tests;