Skip to main content

vtcode_core/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::exec_env::{CommandSpec, ExecEnv, SandboxType};
7#[cfg(target_os = "macos")]
8use super::policy::NetworkAllowlistEntry;
9use super::policy::SandboxPolicy;
10
11/// Error type for sandbox transformation failures.
12#[derive(Debug, thiserror::Error)]
13pub enum SandboxTransformError {
14    #[error("missing sandbox executable path")]
15    MissingSandboxExecutable,
16
17    #[error("sandbox type {0:?} is not available on this platform")]
18    UnavailableSandboxType(SandboxType),
19
20    #[error("failed to create sandbox environment: {0}")]
21    CreationFailed(String),
22
23    #[error("invalid sandbox policy: {0}")]
24    InvalidPolicy(String),
25}
26
27/// Manager for sandbox transformation.
28///
29/// Transforms a `CommandSpec` into an `ExecEnv` by applying the appropriate
30/// sandbox wrapper based on the platform and policy.
31#[derive(Debug, Default)]
32pub struct SandboxManager;
33
34impl SandboxManager {
35    /// Create a new sandbox manager.
36    pub fn new() -> Self {
37        Self
38    }
39
40    /// Transform a command specification into a sandboxed execution environment.
41    pub fn transform(
42        &self,
43        spec: CommandSpec,
44        policy: &SandboxPolicy,
45        sandbox_cwd: &Path,
46        sandbox_executable: Option<&Path>,
47    ) -> Result<ExecEnv, SandboxTransformError> {
48        // Determine the sandbox type based on policy and platform
49        let sandbox_type = self.determine_sandbox_type(policy)?;
50
51        // If no sandbox needed or full access, return direct execution
52        if sandbox_type == SandboxType::None {
53            return Ok(ExecEnv {
54                program: spec.program.into(),
55                args: spec.args,
56                cwd: spec.cwd,
57                env: spec.env,
58                expiration: spec.expiration,
59                sandbox_active: false,
60                sandbox_type: SandboxType::None,
61            });
62        }
63
64        // Check sandbox availability
65        if !sandbox_type.is_available() {
66            return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
67        }
68
69        // Transform based on sandbox type
70        match sandbox_type {
71            SandboxType::MacosSeatbelt => self.transform_seatbelt(spec, policy, sandbox_cwd),
72            SandboxType::LinuxLandlock => {
73                self.transform_landlock(spec, policy, sandbox_cwd, sandbox_executable)
74            }
75            SandboxType::WindowsRestrictedToken => {
76                self.transform_windows(spec, policy, sandbox_cwd)
77            }
78            SandboxType::None => Err(SandboxTransformError::InvalidPolicy(
79                "Cannot transform with SandboxType::None".into(),
80            )),
81        }
82    }
83
84    /// Determine the appropriate sandbox type for the given policy.
85    fn determine_sandbox_type(
86        &self,
87        policy: &SandboxPolicy,
88    ) -> Result<SandboxType, SandboxTransformError> {
89        match policy {
90            SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
91                Ok(SandboxType::None)
92            }
93            SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
94                Ok(SandboxType::platform_default())
95            }
96        }
97    }
98
99    /// Transform for macOS Seatbelt sandbox.
100    #[cfg(target_os = "macos")]
101    fn transform_seatbelt(
102        &self,
103        spec: CommandSpec,
104        policy: &SandboxPolicy,
105        sandbox_cwd: &Path,
106    ) -> Result<ExecEnv, SandboxTransformError> {
107        const SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
108
109        // Build the seatbelt profile
110        let profile = self.build_seatbelt_profile(policy, sandbox_cwd);
111
112        let mut args = vec![
113            "-p".to_string(),
114            profile,
115            os_string_to_arg(spec.program.clone()),
116        ];
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(
138            SandboxType::MacosSeatbelt,
139        ))
140    }
141
142    /// Build a seatbelt profile string.
143    ///
144    /// Implements the field guide's recommendations:
145    /// - "Default-deny outbound network, then allowlist."
146    /// - Block sensitive paths to prevent credential leakage.
147    #[cfg(target_os = "macos")]
148    fn build_seatbelt_profile(&self, policy: &SandboxPolicy, sandbox_cwd: &Path) -> String {
149        fn append_network_rules(
150            profile: &mut String,
151            network_access: bool,
152            network_allowlist: &[NetworkAllowlistEntry],
153        ) {
154            let has_network_allowlist = !network_allowlist.is_empty();
155            if has_network_allowlist || !network_access {
156                // Keep local unix sockets available even when outbound network is restricted.
157                profile.push_str("(allow network* (local unix))\n");
158            }
159            if has_network_allowlist {
160                for entry in network_allowlist {
161                    profile.push_str(&format!(
162                        "(allow network-outbound (remote {} (require-any (port {}))))\n",
163                        entry.protocol, entry.port
164                    ));
165                }
166                profile.push_str("(allow network-outbound (remote udp (port 53)))\n");
167                profile.push_str("(allow network-outbound (remote tcp (port 53)))\n");
168            } else if network_access {
169                profile.push_str("(allow network*)\n");
170            }
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
180            .push_str("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))\n");
181        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");
182        profile.push_str("(allow user-preference-read)\n");
183
184        // Block sensitive paths BEFORE allowing general read access
185        // This ensures deny rules take precedence
186        let sensitive_paths = policy.sensitive_paths_for_execution(sandbox_cwd);
187        for sp in &sensitive_paths {
188            let expanded = sp.expand_path();
189            let path_str = expanded.display();
190            if sp.block_read {
191                profile.push_str(&format!("(deny file-read* (subpath \"{}\"))\n", path_str));
192            }
193            if sp.block_write {
194                profile.push_str(&format!("(deny file-write* (subpath \"{}\"))\n", path_str));
195            }
196        }
197
198        // Allow reading from everywhere (except denied sensitive paths above)
199        profile.push_str("(allow file-read*)\n");
200
201        match policy {
202            SandboxPolicy::ReadOnly {
203                network_access,
204                network_allowlist,
205            } => {
206                // Read-only: only allow writing to /dev/null
207                profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
208                append_network_rules(&mut profile, *network_access, network_allowlist);
209            }
210            SandboxPolicy::WorkspaceWrite {
211                network_access,
212                network_allowlist,
213                ..
214            } => {
215                for root in policy.get_writable_roots_with_cwd(sandbox_cwd) {
216                    let path = root.root.display();
217                    profile.push_str(&format!("(allow file-write* (subpath \"{}\"))\n", path));
218                }
219                append_network_rules(&mut profile, *network_access, network_allowlist);
220            }
221            _ => {}
222        }
223
224        profile
225    }
226
227    /// Transform for Linux Landlock sandbox.
228    ///
229    /// Following the field guide: "Landlock + seccomp is the recommended Linux pattern."
230    /// The sandbox helper binary receives both the policy (for Landlock filesystem rules)
231    /// and the seccomp profile (for syscall filtering).
232    fn transform_landlock(
233        &self,
234        spec: CommandSpec,
235        policy: &SandboxPolicy,
236        sandbox_cwd: &Path,
237        sandbox_executable: Option<&Path>,
238    ) -> Result<ExecEnv, SandboxTransformError> {
239        let sandbox_exe =
240            sandbox_executable.ok_or(SandboxTransformError::MissingSandboxExecutable)?;
241
242        // Serialize the policy for the sandbox helper (includes Landlock rules)
243        let policy_json = serde_json::to_string(policy).map_err(|e| {
244            SandboxTransformError::CreationFailed(format!(
245                "failed to serialize sandbox policy: {}",
246                e
247            ))
248        })?;
249
250        // Serialize seccomp profile separately for explicit syscall filtering
251        let seccomp_profile = policy.seccomp_profile();
252        let seccomp_json = seccomp_profile.to_json().map_err(|e| {
253            SandboxTransformError::CreationFailed(format!(
254                "failed to serialize seccomp profile: {}",
255                e
256            ))
257        })?;
258
259        // Serialize resource limits for cgroup/rlimit enforcement
260        let resource_limits = policy.resource_limits();
261        let limits_json = serde_json::to_string(&resource_limits).map_err(|e| {
262            SandboxTransformError::CreationFailed(format!(
263                "failed to serialize resource limits: {}",
264                e
265            ))
266        })?;
267
268        let sandbox_cwd_str = sandbox_cwd.to_string_lossy().to_string();
269
270        let mut args = vec![
271            "--sandbox-policy-cwd".to_string(),
272            sandbox_cwd_str,
273            "--sandbox-policy".to_string(),
274            policy_json,
275            "--seccomp-profile".to_string(),
276            seccomp_json,
277            "--resource-limits".to_string(),
278            limits_json,
279            "--".to_string(),
280            os_string_to_arg(spec.program.clone()),
281        ];
282        args.extend(spec.args);
283
284        Ok(ExecEnv {
285            program: sandbox_exe.to_path_buf(),
286            args,
287            cwd: spec.cwd,
288            env: spec.env,
289            expiration: spec.expiration,
290            sandbox_active: true,
291            sandbox_type: SandboxType::LinuxLandlock,
292        })
293    }
294
295    /// Transform for Windows restricted token sandbox.
296    fn transform_windows(
297        &self,
298        spec: CommandSpec,
299        _policy: &SandboxPolicy,
300        _sandbox_cwd: &Path,
301    ) -> Result<ExecEnv, SandboxTransformError> {
302        // Windows sandbox uses restricted tokens - for now, pass through
303        // A full implementation would use Windows job objects and restricted tokens
304        Ok(ExecEnv {
305            program: spec.program.into(),
306            args: spec.args,
307            cwd: spec.cwd,
308            env: spec.env,
309            expiration: spec.expiration,
310            sandbox_active: false, // Windows sandboxing via Job Objects/Restricted Tokens is planned for a future release
311            sandbox_type: SandboxType::WindowsRestrictedToken,
312        })
313    }
314}
315
316fn os_string_to_arg(value: OsString) -> String {
317    value
318        .into_string()
319        .unwrap_or_else(|value| value.to_string_lossy().into_owned())
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_no_sandbox_for_full_access() {
328        let manager = SandboxManager::new();
329        let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
330        let policy = SandboxPolicy::full_access();
331
332        let env = manager
333            .transform(spec, &policy, Path::new("/tmp"), None)
334            .unwrap();
335
336        assert!(!env.sandbox_active);
337        assert_eq!(env.sandbox_type, SandboxType::None);
338    }
339
340    #[test]
341    fn test_sandbox_type_determination() {
342        let manager = SandboxManager::new();
343
344        // Full access = no sandbox
345        let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
346        assert_eq!(result.unwrap(), SandboxType::None);
347
348        // Read-only = platform default
349        let result = manager.determine_sandbox_type(&SandboxPolicy::read_only());
350        assert_eq!(result.unwrap(), SandboxType::platform_default());
351    }
352
353    #[cfg(target_os = "macos")]
354    #[test]
355    fn seatbelt_profile_includes_default_preferences_policy() {
356        let manager = SandboxManager::new();
357        let profile =
358            manager.build_seatbelt_profile(&SandboxPolicy::read_only(), Path::new("/tmp"));
359
360        assert!(
361            profile
362                .contains("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))")
363        );
364        assert!(profile.contains("(global-name \"com.apple.cfprefsd.daemon\")"));
365        assert!(profile.contains("(global-name \"com.apple.cfprefsd.agent\")"));
366        assert!(profile.contains("(local-name \"com.apple.cfprefsd.agent\")"));
367        assert!(profile.contains("(allow user-preference-read)"));
368    }
369}