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::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 => self.transform_landlock(spec, policy, sandbox_cwd, sandbox_executable),
73            SandboxType::WindowsRestrictedToken => self.transform_windows(spec, policy, sandbox_cwd),
74            SandboxType::None => {
75                Err(SandboxTransformError::InvalidPolicy("Cannot transform with SandboxType::None".into()))
76            }
77        }
78    }
79
80    /// Determine the appropriate sandbox type for the given policy.
81    fn determine_sandbox_type(&self, policy: &SandboxPolicy) -> Result<SandboxType, SandboxTransformError> {
82        match policy {
83            SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Ok(SandboxType::None),
84            SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
85                Ok(SandboxType::platform_default())
86            }
87        }
88    }
89
90    /// Transform for macOS Seatbelt sandbox.
91    #[cfg(target_os = "macos")]
92    fn transform_seatbelt(
93        &self,
94        spec: CommandSpec,
95        policy: &SandboxPolicy,
96        sandbox_cwd: &Path,
97    ) -> Result<ExecEnv, SandboxTransformError> {
98        const SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
99
100        // Build the seatbelt profile
101        let profile = self.build_seatbelt_profile(policy, sandbox_cwd);
102
103        let mut args = vec!["-p".to_string(), profile, os_string_to_arg(spec.program.clone())];
104        args.extend(spec.args);
105
106        Ok(ExecEnv {
107            program: SEATBELT_EXECUTABLE.into(),
108            args,
109            cwd: spec.cwd,
110            env: spec.env,
111            expiration: spec.expiration,
112            sandbox_active: true,
113            sandbox_type: SandboxType::MacosSeatbelt,
114        })
115    }
116
117    #[cfg(not(target_os = "macos"))]
118    fn transform_seatbelt(
119        &self,
120        _spec: CommandSpec,
121        _policy: &SandboxPolicy,
122        _sandbox_cwd: &Path,
123    ) -> Result<ExecEnv, SandboxTransformError> {
124        Err(SandboxTransformError::UnavailableSandboxType(SandboxType::MacosSeatbelt))
125    }
126
127    /// Build a seatbelt profile string.
128    ///
129    /// Implements the field guide's recommendations:
130    /// - "Default-deny outbound network, then allowlist."
131    /// - Block sensitive paths to prevent credential leakage.
132    #[cfg(target_os = "macos")]
133    fn build_seatbelt_profile(&self, policy: &SandboxPolicy, sandbox_cwd: &Path) -> String {
134        fn append_network_rules(
135            profile: &mut String,
136            network_access: bool,
137            network_allowlist: &[NetworkAllowlistEntry],
138        ) {
139            let has_network_allowlist = !network_allowlist.is_empty();
140            if has_network_allowlist || !network_access {
141                // Keep local unix sockets available even when outbound network is restricted.
142                profile.push_str("(allow network* (local unix))\n");
143            }
144            if has_network_allowlist {
145                for entry in network_allowlist {
146                    profile.push_str(&format!(
147                        "(allow network-outbound (remote {} (require-any (port {}))))\n",
148                        entry.protocol, entry.port
149                    ));
150                }
151                profile.push_str("(allow network-outbound (remote udp (port 53)))\n");
152                profile.push_str("(allow network-outbound (remote tcp (port 53)))\n");
153            } else if network_access {
154                profile.push_str("(allow network*)\n");
155            }
156        }
157
158        let mut profile = String::from("(version 1)\n");
159        profile.push_str("(deny default)\n");
160        profile.push_str("(allow process-exec)\n");
161        profile.push_str("(allow process-fork)\n");
162        profile.push_str("(allow sysctl-read)\n");
163        profile.push_str("(allow mach-lookup)\n");
164        profile.push_str("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))\n");
165        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");
166        profile.push_str("(allow user-preference-read)\n");
167
168        // Block sensitive paths BEFORE allowing general read access
169        // This ensures deny rules take precedence
170        let sensitive_paths = policy.sensitive_paths_for_execution(sandbox_cwd);
171        for sp in &sensitive_paths {
172            let expanded = sp.expand_path();
173            let path_str = expanded.display();
174            if sp.block_read {
175                profile.push_str(&format!("(deny file-read* (subpath \"{path_str}\"))\n"));
176            }
177            if sp.block_write {
178                profile.push_str(&format!("(deny file-write* (subpath \"{path_str}\"))\n"));
179            }
180        }
181
182        // Allow reading from everywhere (except denied sensitive paths above)
183        profile.push_str("(allow file-read*)\n");
184
185        match policy {
186            SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
187                // Read-only: only allow writing to /dev/null
188                profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
189                append_network_rules(&mut profile, *network_access, network_allowlist);
190            }
191            SandboxPolicy::WorkspaceWrite { network_access, network_allowlist, .. } => {
192                for root in policy.get_writable_roots_with_cwd(sandbox_cwd) {
193                    let path = root.root.display();
194                    profile.push_str(&format!("(allow file-write* (subpath \"{path}\"))\n"));
195                }
196                append_network_rules(&mut profile, *network_access, network_allowlist);
197            }
198            _ => {}
199        }
200
201        profile
202    }
203
204    /// Transform for Linux Landlock sandbox.
205    ///
206    /// Following the field guide: "Landlock + seccomp is the recommended Linux pattern."
207    /// The sandbox helper binary receives both the policy (for Landlock filesystem rules)
208    /// and the seccomp profile (for syscall filtering).
209    fn transform_landlock(
210        &self,
211        spec: CommandSpec,
212        policy: &SandboxPolicy,
213        sandbox_cwd: &Path,
214        sandbox_executable: Option<&Path>,
215    ) -> Result<ExecEnv, SandboxTransformError> {
216        let sandbox_exe = sandbox_executable.ok_or(SandboxTransformError::MissingSandboxExecutable)?;
217
218        // Serialize the policy for the sandbox helper (includes Landlock rules)
219        let policy_json = serde_json::to_string(policy)
220            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
221
222        // Serialize seccomp profile separately for explicit syscall filtering
223        let seccomp_profile = policy.seccomp_profile();
224        let seccomp_json = seccomp_profile
225            .to_json()
226            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize seccomp profile: {e}")))?;
227
228        // Serialize resource limits for cgroup/rlimit enforcement
229        let resource_limits = policy.resource_limits();
230        let limits_json = serde_json::to_string(&resource_limits)
231            .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize resource limits: {e}")))?;
232
233        let sandbox_cwd_str = sandbox_cwd.to_string_lossy().to_string();
234
235        let mut args = vec![
236            "--sandbox-policy-cwd".to_string(),
237            sandbox_cwd_str,
238            "--sandbox-policy".to_string(),
239            policy_json,
240            "--seccomp-profile".to_string(),
241            seccomp_json,
242            "--resource-limits".to_string(),
243            limits_json,
244            "--".to_string(),
245            os_string_to_arg(spec.program.clone()),
246        ];
247        args.extend(spec.args);
248
249        Ok(ExecEnv {
250            program: sandbox_exe.to_path_buf(),
251            args,
252            cwd: spec.cwd,
253            env: spec.env,
254            expiration: spec.expiration,
255            sandbox_active: true,
256            sandbox_type: SandboxType::LinuxLandlock,
257        })
258    }
259
260    /// Transform for Windows restricted token sandbox.
261    fn transform_windows(
262        &self,
263        spec: CommandSpec,
264        _policy: &SandboxPolicy,
265        _sandbox_cwd: &Path,
266    ) -> Result<ExecEnv, SandboxTransformError> {
267        // Windows sandbox uses restricted tokens - for now, pass through
268        // A full implementation would use Windows job objects and restricted tokens
269        Ok(ExecEnv {
270            program: spec.program.into(),
271            args: spec.args,
272            cwd: spec.cwd,
273            env: spec.env,
274            expiration: spec.expiration,
275            sandbox_active: false, // Windows sandboxing via Job Objects/Restricted Tokens is planned for a future release
276            sandbox_type: SandboxType::WindowsRestrictedToken,
277        })
278    }
279}
280
281fn os_string_to_arg(value: OsString) -> String {
282    value.into_string().unwrap_or_else(|value| value.to_string_lossy().into_owned())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_no_sandbox_for_full_access() {
291        let manager = SandboxManager::new();
292        let spec = CommandSpec::new("echo").with_args(vec!["hello"]);
293        let policy = SandboxPolicy::full_access();
294
295        let env = manager.transform(spec, &policy, Path::new("/tmp"), None).unwrap();
296
297        assert!(!env.sandbox_active);
298        assert_eq!(env.sandbox_type, SandboxType::None);
299    }
300
301    #[test]
302    fn test_sandbox_type_determination() {
303        let manager = SandboxManager::new();
304
305        // Full access = no sandbox
306        let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
307        assert_eq!(result.unwrap(), SandboxType::None);
308
309        // Read-only = platform default
310        let result = manager.determine_sandbox_type(&SandboxPolicy::read_only());
311        assert_eq!(result.unwrap(), SandboxType::platform_default());
312    }
313
314    #[cfg(target_os = "macos")]
315    #[test]
316    fn seatbelt_profile_includes_default_preferences_policy() {
317        let manager = SandboxManager::new();
318        let profile = manager.build_seatbelt_profile(&SandboxPolicy::read_only(), Path::new("/tmp"));
319
320        assert!(profile.contains("(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"));
321        assert!(profile.contains("(global-name \"com.apple.cfprefsd.daemon\")"));
322        assert!(profile.contains("(global-name \"com.apple.cfprefsd.agent\")"));
323        assert!(profile.contains("(local-name \"com.apple.cfprefsd.agent\")"));
324        assert!(profile.contains("(allow user-preference-read)"));
325    }
326}