Skip to main content

vtcode_safety/sandboxing/
manager.rs

1//! Sandbox manager for transforming commands into sandboxed execution environments.
2
3use 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/// Error type for sandbox transformation failures.
13#[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/// Manager for sandbox transformation.
29///
30/// Transforms a `CommandSpec` into an `ExecEnv` by applying the appropriate
31/// sandbox wrapper based on the platform and policy.
32#[derive(Debug, Default)]
33pub struct SandboxManager;
34
35impl SandboxManager {
36    /// Create a new sandbox manager.
37    pub fn new() -> Self {
38        Self
39    }
40
41    /// Transform a command specification into a sandboxed execution environment.
42    ///
43    /// `linux_launcher` describes how the Linux sandbox helper is invoked
44    /// (`None` on other platforms, or when no helper could be resolved).
45    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        // Determine the sandbox type based on policy and platform
53        let sandbox_type = self.determine_sandbox_type(policy)?;
54
55        // A restrictive sandbox must not inherit secrets or dynamic-loader
56        // controls, including values supplied by a caller through `spec.env`.
57        // Full-access and externally managed policies intentionally preserve
58        // the caller's environment because this manager is not their boundary.
59        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 no sandbox needed or full access, return direct execution
68        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        // Check sandbox availability
81        if !sandbox_type.is_available() {
82            return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
83        }
84
85        // Transform based on sandbox type
86        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    /// Determine the appropriate sandbox type for the given policy.
97    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    /// Transform for macOS Seatbelt sandbox.
107    #[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        // Build the seatbelt profile
117        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    /// Build a seatbelt profile string.
144    ///
145    /// Implements the field guide's recommendations:
146    /// - "Default-deny outbound network, then allowlist."
147    /// - Block sensitive paths to prevent credential leakage.
148    #[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                // Keep local unix sockets available even when outbound network is restricted.
168                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        // Block sensitive paths BEFORE allowing general read access
187        // This ensures deny rules take precedence
188        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        // Allow reading from everywhere (except denied sensitive paths above)
201        profile.push_str("(allow file-read*)\n");
202
203        match policy {
204            SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
205                // Read-only: only allow writing to /dev/null
206                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    /// Transform for Linux Landlock sandbox.
223    ///
224    /// Following the field guide: "Landlock + seccomp is the recommended Linux pattern."
225    /// The sandbox helper binary receives both the policy (for Landlock filesystem rules)
226    /// and the seccomp profile (for syscall filtering).
227    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        // Hostname allowlists cannot be enforced exactly by Landlock/seccomp
237        // (BPF cannot inspect connect() destinations) and no managed proxy
238        // exists yet — fail closed exactly like the Seatbelt profile does.
239        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        // Serialize the policy for the sandbox helper (includes Landlock rules)
247        let policy_json = serde_json::to_string(policy)
248            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
249
250        // Serialize seccomp profile separately for explicit syscall filtering
251        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        // Resource limits are enforced only when a policy explicitly carries
257        // them; auto-derived defaults (e.g. conservative limits for read-only)
258        // are serialized as unlimited so ordinary commands keep working.
259        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    /// Transform for Windows restricted token sandbox.
295    ///
296    /// Not yet implemented. Returns `UnavailableSandboxType` so that callers
297    /// requesting a restrictive policy on Windows get an explicit error
298    /// instead of silently running unsandboxed. The `is_available()` check in
299    /// `transform()` normally catches this first, but this guard ensures
300    /// fail-closed behavior even if the availability check is bypassed.
301    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        // Full access = no sandbox
337        let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
338        assert_eq!(result.unwrap(), SandboxType::None);
339
340        // Read-only = platform default
341        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    /// Windows restricted-token sandbox is not yet implemented, so
372    /// `is_available()` must return `false` on **all** platforms. This
373    /// prevents silent pass-through when a restrictive policy is requested.
374    #[test]
375    fn windows_restricted_token_is_not_available() {
376        assert!(!SandboxType::WindowsRestrictedToken.is_available());
377    }
378
379    /// `transform_windows` must fail-closed with `UnavailableSandboxType`,
380    /// not silently pass the command through unsandboxed.
381    #[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            // Kernels without Landlock fail closed even earlier.
405            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}