1use 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 => {
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 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 #[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 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 #[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 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 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 profile.push_str("(allow file-read*)\n");
200
201 match policy {
202 SandboxPolicy::ReadOnly {
203 network_access,
204 network_allowlist,
205 } => {
206 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 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 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 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 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 fn transform_windows(
297 &self,
298 spec: CommandSpec,
299 _policy: &SandboxPolicy,
300 _sandbox_cwd: &Path,
301 ) -> Result<ExecEnv, SandboxTransformError> {
302 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, 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 let result = manager.determine_sandbox_type(&SandboxPolicy::DangerFullAccess);
346 assert_eq!(result.unwrap(), SandboxType::None);
347
348 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}