vtcode_safety/sandboxing/
manager.rs1use std::ffi::OsString;
4use std::path::Path;
5
6use super::child_spawn::build_sanitized_env;
7use super::exec_env::{CommandSpec, ExecEnv, LinuxSandboxLauncher, 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(
46 &self,
47 spec: CommandSpec,
48 policy: &SandboxPolicy,
49 sandbox_cwd: &Path,
50 linux_launcher: Option<&LinuxSandboxLauncher>,
51 ) -> Result<ExecEnv, SandboxTransformError> {
52 let sandbox_type = self.determine_sandbox_type(policy)?;
54
55 let spec = if sandbox_type == SandboxType::None {
60 spec
61 } else {
62 let mut spec = spec;
63 spec.env = build_sanitized_env(&spec.env, false, false, "", &[]);
64 spec
65 };
66
67 if sandbox_type == SandboxType::None {
69 return Ok(ExecEnv {
70 program: spec.program.into(),
71 args: spec.args,
72 cwd: spec.cwd,
73 env: spec.env,
74 expiration: spec.expiration,
75 sandbox_active: false,
76 sandbox_type: SandboxType::None,
77 });
78 }
79
80 if !sandbox_type.is_available() {
82 return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
83 }
84
85 match sandbox_type {
87 SandboxType::MacosSeatbelt => self.transform_seatbelt(spec, policy, sandbox_cwd),
88 SandboxType::LinuxLandlock => self.transform_landlock(spec, policy, sandbox_cwd, linux_launcher),
89 SandboxType::WindowsRestrictedToken => self.transform_windows(spec, policy, sandbox_cwd),
90 SandboxType::None => {
91 Err(SandboxTransformError::InvalidPolicy("Cannot transform with SandboxType::None".into()))
92 }
93 }
94 }
95
96 fn determine_sandbox_type(&self, policy: &SandboxPolicy) -> Result<SandboxType, SandboxTransformError> {
98 match policy {
99 SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Ok(SandboxType::None),
100 SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
101 Ok(SandboxType::platform_default())
102 }
103 }
104 }
105
106 #[cfg(target_os = "macos")]
108 fn transform_seatbelt(
109 &self,
110 spec: CommandSpec,
111 policy: &SandboxPolicy,
112 sandbox_cwd: &Path,
113 ) -> Result<ExecEnv, SandboxTransformError> {
114 const SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
115
116 let profile = self.build_seatbelt_profile(policy, sandbox_cwd)?;
118
119 let mut args = vec!["-p".to_string(), profile, os_string_to_arg(spec.program.clone())];
120 args.extend(spec.args);
121
122 Ok(ExecEnv {
123 program: SEATBELT_EXECUTABLE.into(),
124 args,
125 cwd: spec.cwd,
126 env: spec.env,
127 expiration: spec.expiration,
128 sandbox_active: true,
129 sandbox_type: SandboxType::MacosSeatbelt,
130 })
131 }
132
133 #[cfg(not(target_os = "macos"))]
134 fn transform_seatbelt(
135 &self,
136 _spec: CommandSpec,
137 _policy: &SandboxPolicy,
138 _sandbox_cwd: &Path,
139 ) -> Result<ExecEnv, SandboxTransformError> {
140 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::MacosSeatbelt))
141 }
142
143 #[cfg(target_os = "macos")]
149 fn build_seatbelt_profile(
150 &self,
151 policy: &SandboxPolicy,
152 sandbox_cwd: &Path,
153 ) -> Result<String, SandboxTransformError> {
154 fn append_network_rules(
155 profile: &mut String,
156 network_access: bool,
157 network_allowlist: &[NetworkAllowlistEntry],
158 ) -> Result<(), SandboxTransformError> {
159 if !network_allowlist.is_empty() {
160 return Err(SandboxTransformError::InvalidPolicy(
161 "macOS Seatbelt cannot enforce hostname network allowlists exactly; refusing to widen access"
162 .to_string(),
163 ));
164 }
165
166 if !network_access {
167 profile.push_str("(allow network* (local unix))\n");
169 }
170 if network_access {
171 profile.push_str("(allow network*)\n");
172 }
173 Ok(())
174 }
175
176 let mut profile = String::from("(version 1)\n");
177 profile.push_str("(deny default)\n");
178 profile.push_str("(allow process-exec)\n");
179 profile.push_str("(allow process-fork)\n");
180 profile.push_str("(allow sysctl-read)\n");
181 profile.push_str("(allow mach-lookup)\n");
182 profile.push_str("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))\n");
183 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");
184 profile.push_str("(allow user-preference-read)\n");
185
186 let sensitive_paths = policy.sensitive_paths_for_execution(sandbox_cwd);
189 for sp in &sensitive_paths {
190 let expanded = sp.expand_path();
191 let path_str = expanded.display();
192 if sp.block_read {
193 profile.push_str(&format!("(deny file-read* (subpath \"{path_str}\"))\n"));
194 }
195 if sp.block_write {
196 profile.push_str(&format!("(deny file-write* (subpath \"{path_str}\"))\n"));
197 }
198 }
199
200 profile.push_str("(allow file-read*)\n");
202
203 match policy {
204 SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
205 profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
207 append_network_rules(&mut profile, *network_access, network_allowlist)?;
208 }
209 SandboxPolicy::WorkspaceWrite { network_access, network_allowlist, .. } => {
210 for root in policy.get_writable_roots_with_cwd(sandbox_cwd) {
211 let path = root.root.display();
212 profile.push_str(&format!("(allow file-write* (subpath \"{path}\"))\n"));
213 }
214 append_network_rules(&mut profile, *network_access, network_allowlist)?;
215 }
216 _ => {}
217 }
218
219 Ok(profile)
220 }
221
222 fn transform_landlock(
228 &self,
229 spec: CommandSpec,
230 policy: &SandboxPolicy,
231 sandbox_cwd: &Path,
232 linux_launcher: Option<&LinuxSandboxLauncher>,
233 ) -> Result<ExecEnv, SandboxTransformError> {
234 let launcher = linux_launcher.ok_or(SandboxTransformError::MissingSandboxExecutable)?;
235
236 if policy.has_network_allowlist() {
240 return Err(SandboxTransformError::InvalidPolicy(
241 "Linux sandbox cannot enforce hostname network allowlists exactly; refusing to run with unrestricted network"
242 .to_string(),
243 ));
244 }
245
246 let policy_json = serde_json::to_string(policy)
248 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
249
250 let seccomp_profile = policy.seccomp_profile();
252 let seccomp_json = seccomp_profile
253 .to_json()
254 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize seccomp profile: {e}")))?;
255
256 let resource_limits = match policy {
260 SandboxPolicy::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
261 _ => super::policy::ResourceLimits::unlimited(),
262 };
263 let limits_json = serde_json::to_string(&resource_limits)
264 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize resource limits: {e}")))?;
265
266 let sandbox_cwd_str = sandbox_cwd.to_string_lossy().to_string();
267
268 let mut args = launcher.prefix_args.clone();
269 args.extend([
270 "--sandbox-policy-cwd".to_string(),
271 sandbox_cwd_str,
272 "--sandbox-policy".to_string(),
273 policy_json,
274 "--seccomp-profile".to_string(),
275 seccomp_json,
276 "--resource-limits".to_string(),
277 limits_json,
278 "--".to_string(),
279 os_string_to_arg(spec.program.clone()),
280 ]);
281 args.extend(spec.args);
282
283 Ok(ExecEnv {
284 program: launcher.program.clone(),
285 args,
286 cwd: spec.cwd,
287 env: spec.env,
288 expiration: spec.expiration,
289 sandbox_active: true,
290 sandbox_type: SandboxType::LinuxLandlock,
291 })
292 }
293
294 fn transform_windows(
302 &self,
303 _spec: CommandSpec,
304 _policy: &SandboxPolicy,
305 _sandbox_cwd: &Path,
306 ) -> Result<ExecEnv, SandboxTransformError> {
307 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
308 }
309}
310
311fn os_string_to_arg(value: OsString) -> String {
312 value.into_string().unwrap_or_else(|value| value.to_string_lossy().into_owned())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use std::path::PathBuf;
319
320 #[test]
321 fn test_no_sandbox_for_full_access() {
322 let manager = SandboxManager::new();
323 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
324 let policy = SandboxPolicy::full_access();
325
326 let env = manager.transform(spec, &policy, Path::new("/tmp"), None).unwrap();
327
328 assert!(!env.sandbox_active);
329 assert_eq!(env.sandbox_type, SandboxType::None);
330 }
331
332 #[test]
333 fn test_sandbox_type_determination() {
334 let manager = SandboxManager::new();
335
336 let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
338 assert_eq!(result.unwrap(), SandboxType::None);
339
340 let result = manager.determine_sandbox_type(&SandboxPolicy::read_only());
342 assert_eq!(result.unwrap(), SandboxType::platform_default());
343 }
344
345 #[cfg(target_os = "macos")]
346 #[test]
347 fn seatbelt_profile_includes_default_preferences_policy() {
348 let manager = SandboxManager::new();
349 let profile = manager
350 .build_seatbelt_profile(&SandboxPolicy::read_only(), Path::new("/tmp"))
351 .unwrap();
352
353 assert!(profile.contains("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"));
354 assert!(profile.contains("(global-name \"com.apple.cfprefsd.daemon\")"));
355 assert!(profile.contains("(global-name \"com.apple.cfprefsd.agent\")"));
356 assert!(profile.contains("(local-name \"com.apple.cfprefsd.agent\")"));
357 assert!(profile.contains("(allow user-preference-read)"));
358 }
359
360 #[cfg(target_os = "macos")]
361 #[test]
362 fn seatbelt_rejects_hostname_allowlist_without_exact_enforcement() {
363 let manager = SandboxManager::new();
364 let policy = SandboxPolicy::read_only_with_network(vec![NetworkAllowlistEntry::https("api.example.com")]);
365
366 let result = manager.build_seatbelt_profile(&policy, Path::new("/tmp"));
367
368 assert!(matches!(result, Err(SandboxTransformError::InvalidPolicy(message)) if message.contains("hostname")));
369 }
370
371 #[test]
375 fn windows_restricted_token_is_not_available() {
376 assert!(!SandboxType::WindowsRestrictedToken.is_available());
377 }
378
379 #[test]
382 fn transform_windows_fails_closed() {
383 let manager = SandboxManager::new();
384 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
385 let result = manager.transform_windows(spec, &SandboxPolicy::read_only(), Path::new("/tmp"));
386
387 assert!(matches!(
388 result,
389 Err(SandboxTransformError::UnavailableSandboxType(SandboxType::WindowsRestrictedToken))
390 ));
391 }
392
393 #[cfg(target_os = "linux")]
394 #[test]
395 fn restrictive_linux_policy_requires_sandbox_helper() {
396 let manager = SandboxManager::new();
397 let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
398
399 let result = manager.transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), None);
400
401 if super::exec_env::SandboxType::LinuxLandlock.is_available() {
402 assert!(matches!(result, Err(SandboxTransformError::MissingSandboxExecutable)));
403 } else {
404 assert!(matches!(result, Err(SandboxTransformError::UnavailableSandboxType(_))));
406 }
407 }
408
409 #[cfg(target_os = "linux")]
410 #[test]
411 fn landlock_transform_rejects_hostname_allowlists() {
412 let manager = SandboxManager::new();
413 if !super::exec_env::SandboxType::LinuxLandlock.is_available() {
414 return;
415 }
416 let launcher = LinuxSandboxLauncher::busybox(PathBuf::from("/usr/local/bin/vtcode"));
417 let policy =
418 SandboxPolicy::read_only_with_network(vec![super::policy::NetworkAllowlistEntry::https("api.example.com")]);
419
420 let result = manager.transform(CommandSpec::new("echo"), &policy, Path::new("/tmp"), Some(&launcher));
421
422 assert!(
423 matches!(result, Err(SandboxTransformError::InvalidPolicy(message)) if message.contains("allowlist")),
424 "allowlist must fail closed on Linux, got {result:?}"
425 );
426 }
427
428 #[cfg(target_os = "linux")]
429 #[test]
430 fn landlock_transform_prepends_busybox_subcommand() {
431 let manager = SandboxManager::new();
432 if !super::exec_env::SandboxType::LinuxLandlock.is_available() {
433 return;
434 }
435 let launcher = LinuxSandboxLauncher::busybox(PathBuf::from("/usr/local/bin/vtcode"));
436 let env = manager
437 .transform(
438 CommandSpec::new("echo").with_args(vec!["hi"]),
439 &SandboxPolicy::read_only(),
440 Path::new("/tmp"),
441 Some(&launcher),
442 )
443 .unwrap();
444
445 assert!(env.sandbox_active);
446 assert_eq!(env.program, PathBuf::from("/usr/local/bin/vtcode"));
447 assert_eq!(env.args.first().map(String::as_str), Some("sandbox-exec"));
448 assert!(env.args.iter().any(|arg| arg == "--sandbox-policy"));
449 assert!(env.args.iter().any(|arg| arg == "--seccomp-profile"));
450 assert!(env.args.iter().any(|arg| arg == "--resource-limits"));
451 assert!(env.args.iter().any(|arg| arg == "--"));
452 }
453
454 #[cfg(any(target_os = "linux", target_os = "macos"))]
455 #[test]
456 fn restrictive_policy_filters_sensitive_environment_overrides() {
457 use hashbrown::HashMap;
458
459 let manager = SandboxManager::new();
460 let mut env = HashMap::new();
461 drop(env.insert("OPENAI_API_KEY".to_string(), "secret-value".to_string()));
462 drop(env.insert("LD_PRELOAD".to_string(), "injected.so".to_string()));
463 drop(env.insert("SAFE_PROJECT_NAME".to_string(), "vtcode".to_string()));
464 drop(env.insert("PATH".to_string(), "/usr/bin:/bin".to_string()));
465 drop(env.insert("INTERNAL_AUTH_BLOB".to_string(), "secret".to_string()));
466 let spec = CommandSpec::new("echo").with_env(env);
467 let sandbox_launcher = if cfg!(target_os = "linux") {
468 Some(LinuxSandboxLauncher::external(Path::new("/tmp/vtcode-test-sandbox-helper").to_path_buf()))
469 } else {
470 None
471 };
472
473 let transformed = manager
474 .transform(spec, &SandboxPolicy::read_only(), Path::new("/tmp"), sandbox_launcher.as_ref())
475 .unwrap();
476
477 assert!(transformed.sandbox_active);
478 assert!(!transformed.env.contains_key("OPENAI_API_KEY"));
479 assert!(!transformed.env.contains_key("LD_PRELOAD"));
480 assert!(!transformed.env.contains_key("INTERNAL_AUTH_BLOB"));
481 assert!(!transformed.env.contains_key("SAFE_PROJECT_NAME"));
482 assert_eq!(transformed.env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
483 }
484 #[test]
485 fn explicit_full_access_preserves_caller_environment() {
486 let manager = SandboxManager::new();
487 let mut env = hashbrown::HashMap::new();
488 drop(env.insert("INTERNAL_AUTH_BLOB".to_string(), "explicit".to_string()));
489 let result = manager
490 .transform(
491 CommandSpec::new("echo").with_env(env.clone()),
492 &SandboxPolicy::full_access(),
493 Path::new("."),
494 None,
495 )
496 .expect("full access transform");
497 assert!(!result.sandbox_active);
498 assert_eq!(result.env, env);
499 }
500}