vtcode_safety/sandboxing/
manager.rs1use 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#[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#[derive(Debug, Default)]
32pub struct SandboxManager;
33
34impl SandboxManager {
35 pub fn new() -> Self {
37 Self
38 }
39
40 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 let sandbox_type = self.determine_sandbox_type(policy)?;
50
51 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 if !sandbox_type.is_available() {
66 return Err(SandboxTransformError::UnavailableSandboxType(sandbox_type));
67 }
68
69 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 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 #[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 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 #[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 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 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 profile.push_str("(allow file-read*)\n");
184
185 match policy {
186 SandboxPolicy::ReadOnly { network_access, network_allowlist } => {
187 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 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 let policy_json = serde_json::to_string(policy)
220 .map_err(|e| SandboxTransformError::CreationFailed(format!("failed to serialize sandbox policy: {e}")))?;
221
222 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 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 fn transform_windows(
262 &self,
263 spec: CommandSpec,
264 _policy: &SandboxPolicy,
265 _sandbox_cwd: &Path,
266 ) -> Result<ExecEnv, SandboxTransformError> {
267 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, 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 let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
307 assert_eq!(result.unwrap(), SandboxType::None);
308
309 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}