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::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/// 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    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        // Determine the sandbox type based on policy and platform
50        let sandbox_type = self.determine_sandbox_type(policy)?;
51
52        // A restrictive sandbox must not inherit secrets or dynamic-loader
53        // controls, including values supplied by a caller through `spec.env`.
54        // Full-access and externally managed policies intentionally preserve
55        // the caller's environment because this manager is not their boundary.
56        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 no sandbox needed or full access, return direct execution
65        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        // Check sandbox availability
78        if !sandbox_type.is_available() {
79            return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
80        }
81
82        // Transform based on sandbox type
83        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    /// Determine the appropriate sandbox type for the given policy.
94    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    /// Transform for macOS Seatbelt sandbox.
104    #[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        // Build the seatbelt profile
114        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    /// Build a seatbelt profile string.
141    ///
142    /// Implements the field guide's recommendations:
143    /// - "Default-deny outbound network, then allowlist."
144    /// - Block sensitive paths to prevent credential leakage.
145    #[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                // Keep local unix sockets available even when outbound network is restricted.
165                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        // Block sensitive paths BEFORE allowing general read access
184        // This ensures deny rules take precedence
185        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        // Allow reading from everywhere (except denied sensitive paths above)
198        profile.push_str("(allow file-read*)\n");
199
200        match policy {
201            SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
202                // Read-only: only allow writing to /dev/null
203                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    /// Transform for Linux Landlock sandbox.
220    ///
221    /// Following the field guide: "Landlock + seccomp is the recommended Linux pattern."
222    /// The sandbox helper binary receives both the policy (for Landlock filesystem rules)
223    /// and the seccomp profile (for syscall filtering).
224    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        // Serialize the policy for the sandbox helper (includes Landlock rules)
234        let policy_json = serde_json::to_string(policy)
235            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
236
237        // Serialize seccomp profile separately for explicit syscall filtering
238        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        // Serialize resource limits for cgroup/rlimit enforcement
244        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    /// Transform for Windows restricted token sandbox.
276    ///
277    /// Not yet implemented. Returns `UnavailableSandboxType` so that callers
278    /// requesting a restrictive policy on Windows get an explicit error
279    /// instead of silently running unsandboxed. The `is_available()` check in
280    /// `transform()` normally catches this first, but this guard ensures
281    /// fail-closed behavior even if the availability check is bypassed.
282    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        // Full access = no sandbox
317        let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
318        assert_eq!(result.unwrap(), SandboxType::None);
319
320        // Read-only = platform default
321        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    /// Windows restricted-token sandbox is not yet implemented, so
352    /// `is_available()` must return `false` on **all** platforms. This
353    /// prevents silent pass-through when a restrictive policy is requested.
354    #[test]
355    fn windows_restricted_token_is_not_available() {
356        assert!(!SandboxType::WindowsRestrictedToken.is_available());
357    }
358
359    /// `transform_windows` must fail-closed with `UnavailableSandboxType`,
360    /// not silently pass the command through unsandboxed.
361    #[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}