vtcode_safety/sandboxing/
manager.rs1use std::ffi::OsString;
4use std::path::Path;
5
6use super::child_spawn::filter_sensitive_env;
7use super::exec_env::{CommandSpec, ExecEnv, SandboxType};
8#[cfg(target_os = "macos")]
9use super::policy::NetworkAllowlistEntry;
10use super::policy::SandboxPolicy;
11
12#[derive(Debug, thiserror::Error)]
14pub enum SandboxTransformError {
15 #[error("missing sandbox executable path")]
16 MissingSandboxExecutable,
17
18 #[error("sandbox type {0:?} is not available on this platform")]
19 UnavailableSandboxType(SandboxType),
20
21 #[error("failed to create sandbox environment: {0}")]
22 CreationFailed(String),
23
24 #[error("invalid sandbox policy: {0}")]
25 InvalidPolicy(String),
26}
27
28#[derive(Debug, Default)]
33pub struct SandboxManager;
34
35impl SandboxManager {
36 pub fn new() -> Self {
38 Self
39 }
40
41 pub fn transform(
43 &self,
44 spec: CommandSpec,
45 policy: &SandboxPolicy,
46 sandbox_cwd: &Path,
47 sandbox_executable: Option<&Path>,
48 ) -> Result<ExecEnv, SandboxTransformError> {
49 let sandbox_type = self.determine_sandbox_type(policy)?;
51
52 let spec = if sandbox_type == SandboxType::None {
57 spec
58 } else {
59 let mut spec = spec;
60 spec.env = filter_sensitive_env(&spec.env);
61 spec
62 };
63
64 if sandbox_type == SandboxType::None {
66 return Ok(ExecEnv {
67 program: spec.program.into(),
68 args: spec.args,
69 cwd: spec.cwd,
70 env: spec.env,
71 expiration: spec.expiration,
72 sandbox_active: false,
73 sandbox_type: SandboxType::None,
74 });
75 }
76
77 if !sandbox_type.is_available() {
79 return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
80 }
81
82 match sandbox_type {
84 SandboxType::MacosSeatbelt => self.transform_seatbelt(spec, policy, sandbox_cwd),
85 SandboxType::LinuxLandlock => self.transform_landlock(spec, policy, sandbox_cwd, sandbox_executable),
86 SandboxType::WindowsRestrictedToken => self.transform_windows(spec, policy, sandbox_cwd),
87 SandboxType::None => {
88 Err(SandboxTransformError::InvalidPolicy("Cannot transform with SandboxType::None".into()))
89 }
90 }
91 }
92
93 fn determine_sandbox_type(&self, policy: &SandboxPolicy) -> Result<SandboxType, SandboxTransformError> {
95 match policy {
96 SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Ok(SandboxType::None),
97 SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
98 Ok(SandboxType::platform_default())
99 }
100 }
101 }
102
103 #[cfg(target_os = "macos")]
105 fn transform_seatbelt(
106 &self,
107 spec: CommandSpec,
108 policy: &SandboxPolicy,
109 sandbox_cwd: &Path,
110 ) -> Result<ExecEnv, SandboxTransformError> {
111 const SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
112
113 let profile = self.build_seatbelt_profile(policy, sandbox_cwd)?;
115
116 let mut args = vec!["-p".to_string(), profile, os_string_to_arg(spec.program.clone())];
117 args.extend(spec.args);
118
119 Ok(ExecEnv {
120 program: SEATBELT_EXECUTABLE.into(),
121 args,
122 cwd: spec.cwd,
123 env: spec.env,
124 expiration: spec.expiration,
125 sandbox_active: true,
126 sandbox_type: SandboxType::MacosSeatbelt,
127 })
128 }
129
130 #[cfg(not(target_os = "macos"))]
131 fn transform_seatbelt(
132 &self,
133 _spec: CommandSpec,
134 _policy: &SandboxPolicy,
135 _sandbox_cwd: &Path,
136 ) -> Result<ExecEnv, SandboxTransformError> {
137 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::MacosSeatbelt))
138 }
139
140 #[cfg(target_os = "macos")]
146 fn build_seatbelt_profile(
147 &self,
148 policy: &SandboxPolicy,
149 sandbox_cwd: &Path,
150 ) -> Result<String, SandboxTransformError> {
151 fn append_network_rules(
152 profile: &mut String,
153 network_access: bool,
154 network_allowlist: &[NetworkAllowlistEntry],
155 ) -> Result<(), SandboxTransformError> {
156 if !network_allowlist.is_empty() {
157 return Err(SandboxTransformError::InvalidPolicy(
158 "macOS Seatbelt cannot enforce hostname network allowlists exactly; refusing to widen access"
159 .to_string(),
160 ));
161 }
162
163 if !network_access {
164 profile.push_str("(allow network* (local unix))\n");
166 }
167 if network_access {
168 profile.push_str("(allow network*)\n");
169 }
170 Ok(())
171 }
172
173 let mut profile = String::from("(version 1)\n");
174 profile.push_str("(deny default)\n");
175 profile.push_str("(allow process-exec)\n");
176 profile.push_str("(allow process-fork)\n");
177 profile.push_str("(allow sysctl-read)\n");
178 profile.push_str("(allow mach-lookup)\n");
179 profile.push_str("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))\n");
180 profile.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.daemon\") (global-name \"com.apple.cfprefsd.agent\") (local-name \"com.apple.cfprefsd.agent\"))\n");
181 profile.push_str("(allow user-preference-read)\n");
182
183 let sensitive_paths = policy.sensitive_paths_for_execution(sandbox_cwd);
186 for sp in &sensitive_paths {
187 let expanded = sp.expand_path();
188 let path_str = expanded.display();
189 if sp.block_read {
190 profile.push_str(&format!("(deny file-read* (subpath \"{path_str}\"))\n"));
191 }
192 if sp.block_write {
193 profile.push_str(&format!("(deny file-write* (subpath \"{path_str}\"))\n"));
194 }
195 }
196
197 profile.push_str("(allow file-read*)\n");
199
200 match policy {
201 SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
202 profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
204 append_network_rules(&mut profile, *network_access, network_allowlist)?;
205 }
206 SandboxPolicy::WorkspaceWrite { network_access, network_allowlist, .. } => {
207 for root in policy.get_writable_roots_with_cwd(sandbox_cwd) {
208 let path = root.root.display();
209 profile.push_str(&format!("(allow file-write* (subpath \"{path}\"))\n"));
210 }
211 append_network_rules(&mut profile, *network_access, network_allowlist)?;
212 }
213 _ => {}
214 }
215
216 Ok(profile)
217 }
218
219 fn transform_landlock(
225 &self,
226 spec: CommandSpec,
227 policy: &SandboxPolicy,
228 sandbox_cwd: &Path,
229 sandbox_executable: Option<&Path>,
230 ) -> Result<ExecEnv, SandboxTransformError> {
231 let sandbox_exe = sandbox_executable.ok_or(SandboxTransformError::MissingSandboxExecutable)?;
232
233 let policy_json = serde_json::to_string(policy)
235 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
236
237 let seccomp_profile = policy.seccomp_profile();
239 let seccomp_json = seccomp_profile
240 .to_json()
241 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize seccomp profile: {e}")))?;
242
243 let resource_limits = policy.resource_limits();
245 let limits_json = serde_json::to_string(&resource_limits)
246 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize resource limits: {e}")))?;
247
248 let sandbox_cwd_str = sandbox_cwd.to_string_lossy().to_string();
249
250 let mut args = vec![
251 "--sandbox-policy-cwd".to_string(),
252 sandbox_cwd_str,
253 "--sandbox-policy".to_string(),
254 policy_json,
255 "--seccomp-profile".to_string(),
256 seccomp_json,
257 "--resource-limits".to_string(),
258 limits_json,
259 "--".to_string(),
260 os_string_to_arg(spec.program.clone()),
261 ];
262 args.extend(spec.args);
263
264 Ok(ExecEnv {
265 program: sandbox_exe.to_path_buf(),
266 args,
267 cwd: spec.cwd,
268 env: spec.env,
269 expiration: spec.expiration,
270 sandbox_active: true,
271 sandbox_type: SandboxType::LinuxLandlock,
272 })
273 }
274
275 fn transform_windows(
283 &self,
284 _spec: CommandSpec,
285 _policy: &SandboxPolicy,
286 _sandbox_cwd: &Path,
287 ) -> Result<ExecEnv, SandboxTransformError> {
288 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
289 }
290}
291
292fn os_string_to_arg(value: OsString) -> String {
293 value.into_string().unwrap_or_else(|value| value.to_string_lossy().into_owned())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_no_sandbox_for_full_access() {
302 let manager = SandboxManager::new();
303 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
304 let policy = SandboxPolicy::full_access();
305
306 let env = manager.transform(spec, &policy, Path::new("/tmp"), None).unwrap();
307
308 assert!(!env.sandbox_active);
309 assert_eq!(env.sandbox_type, SandboxType::None);
310 }
311
312 #[test]
313 fn test_sandbox_type_determination() {
314 let manager = SandboxManager::new();
315
316 let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
318 assert_eq!(result.unwrap(), SandboxType::None);
319
320 let result = manager.determine_sandbox_type(&SandboxPolicy::read_only());
322 assert_eq!(result.unwrap(), SandboxType::platform_default());
323 }
324
325 #[cfg(target_os = "macos")]
326 #[test]
327 fn seatbelt_profile_includes_default_preferences_policy() {
328 let manager = SandboxManager::new();
329 let profile = manager
330 .build_seatbelt_profile(&SandboxPolicy::read_only(), Path::new("/tmp"))
331 .unwrap();
332
333 assert!(profile.contains("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"));
334 assert!(profile.contains("(global-name \"com.apple.cfprefsd.daemon\")"));
335 assert!(profile.contains("(global-name \"com.apple.cfprefsd.agent\")"));
336 assert!(profile.contains("(local-name \"com.apple.cfprefsd.agent\")"));
337 assert!(profile.contains("(allow user-preference-read)"));
338 }
339
340 #[cfg(target_os = "macos")]
341 #[test]
342 fn seatbelt_rejects_hostname_allowlist_without_exact_enforcement() {
343 let manager = SandboxManager::new();
344 let policy = SandboxPolicy::read_only_with_network(vec![NetworkAllowlistEntry::https("api.example.com")]);
345
346 let result = manager.build_seatbelt_profile(&policy, Path::new("/tmp"));
347
348 assert!(matches!(result, Err(SandboxTransformError::InvalidPolicy(message)) if message.contains("hostname")));
349 }
350
351 #[test]
355 fn windows_restricted_token_is_not_available() {
356 assert!(!SandboxType::WindowsRestrictedToken.is_available());
357 }
358
359 #[test]
362 fn transform_windows_fails_closed() {
363 let manager = SandboxManager::new();
364 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
365 let result = manager.transform_windows(spec, &SandboxPolicy::read_only(), Path::new("/tmp"));
366
367 assert!(matches!(
368 result,
369 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
370 ));
371 }
372
373 #[cfg(target_os = "linux")]
374 #[test]
375 fn restrictive_linux_policy_requires_sandbox_helper() {
376 let manager = SandboxManager::new();
377 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
378
379 let result = manager.transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), None);
380
381 assert!(matches!(result, Err(SandboxTransformError::MissingSandboxExecutable)));
382 }
383
384 #[cfg(any(target_os = "linux", target_os = "macos"))]
385 #[test]
386 fn restrictive_policy_filters_sensitive_environment_overrides() {
387 use hashbrown::HashMap;
388
389 let manager = SandboxManager::new();
390 let mut env = HashMap::new();
391 drop(env.insert("OPENAI_API_KEY".to_string(), "secret-value".to_string()));
392 drop(env.insert("LD_PRELOAD".to_string(), "injected.so".to_string()));
393 drop(env.insert("SAFE_PROJECT_NAME".to_string(), "vtcode".to_string()));
394 let spec = CommandSpec::new("echo").with_env(env);
395 let sandbox_helper = if cfg!(target_os = "linux") {
396 Some(Path::new("/tmp/vtcode-test-sandbox-helper"))
397 } else {
398 None
399 };
400
401 let transformed = manager
402 .transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), sandbox_helper)
403 .unwrap();
404
405 assert!(transformed.sandbox_active);
406 assert!(!transformed.env.contains_key("OPENAI_API_KEY"));
407 assert!(!transformed.env.contains_key("LD_PRELOAD"));
408 assert_eq!(transformed.env.get("SAFE_PROJECT_NAME"), Some(&"vtcode".to_string()));
409 }
410}