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;