1use std::process::Command;
2
3#[cfg(target_os = "linux")]
4use tracing::info;
5
6#[cfg(target_os = "windows")]
7use std::sync::OnceLock;
8
9#[cfg(target_os = "windows")]
10use tracing::{info, warn};
11
12#[cfg(target_os = "windows")]
16#[derive(Copy, Clone)]
17struct JobHandle(windows::Win32::Foundation::HANDLE);
18
19#[cfg(target_os = "windows")]
20unsafe impl Sync for JobHandle {}
21
22#[cfg(target_os = "windows")]
23unsafe impl Send for JobHandle {}
24
25#[cfg(target_os = "windows")]
31static JOB_OBJECT: OnceLock<JobHandle> = OnceLock::new();
32
33pub struct SandboxConfig {
34 pub allow_network: bool,
35 pub allow_file_write: bool,
36 pub max_memory_mb: Option<u64>,
37 pub max_cpu_percent: Option<u32>,
38}
39
40impl Default for SandboxConfig {
41 fn default() -> Self {
42 Self {
43 allow_network: false,
44 allow_file_write: false,
45 max_memory_mb: Some(512),
46 max_cpu_percent: Some(50),
47 }
48 }
49}
50
51pub fn apply_sandbox(command: &mut Command, config: &SandboxConfig) -> Result<(), String> {
52 #[cfg(target_os = "linux")]
53 apply_linux_sandbox(command, config)?;
54
55 #[cfg(target_os = "macos")]
56 apply_macos_sandbox(command, config)?;
57
58 #[cfg(target_os = "windows")]
59 apply_windows_sandbox(command, config)?;
60
61 Ok(())
62}
63
64#[cfg(target_os = "linux")]
65fn apply_linux_sandbox(command: &mut Command, config: &SandboxConfig) -> Result<(), String> {
66 if which::which("bwrap").is_ok() {
67 return apply_bubblewrap_sandbox(command, config);
68 }
69 apply_linux_resource_limits(command, config)?;
70 Ok(())
71}
72
73#[cfg(target_os = "linux")]
74fn apply_bubblewrap_sandbox(
75 original_command: &mut Command, config: &SandboxConfig,
76) -> Result<(), String> {
77 let program = original_command.get_program().to_string_lossy().to_string();
78 let args: Vec<String> =
79 original_command.get_args().map(|s| s.to_string_lossy().to_string()).collect();
80
81 let envs: Vec<(String, String)> = original_command
82 .get_envs()
83 .filter_map(|(k, v)| {
84 v.map(|val| (k.to_string_lossy().to_string(), val.to_string_lossy().to_string()))
85 })
86 .collect();
87
88 let mut bwrap = Command::new("bwrap");
89
90 bwrap.args(&["--ro-bind", "/usr", "/usr"]);
91 bwrap.args(&["--ro-bind", "/lib", "/lib"]);
92 bwrap.args(&["--ro-bind", "/lib64", "/lib64"]);
93 bwrap.args(&["--ro-bind", "/bin", "/bin"]);
94 bwrap.args(&["--ro-bind", "/sbin", "/sbin"]);
95 bwrap.args(&["--proc", "/proc"]);
96 bwrap.args(&["--dev", "/dev"]);
97 bwrap.args(&["--bind", "/tmp", "/tmp"]);
98
99 if let Some(home) = std::env::var_os("HOME") {
100 let home_str = home.to_string_lossy();
101 bwrap.args(&["--ro-bind", &*home_str, &*home_str]);
102
103 if config.allow_file_write {
104 let dscode_dir = format!("{}/.dscode", home_str);
105 let local_share = format!("{}/.local/share", home_str);
106 let _ = std::fs::create_dir_all(&dscode_dir);
107 let _ = std::fs::create_dir_all(&local_share);
108 bwrap.args(&["--bind", &dscode_dir, &dscode_dir]);
109 bwrap.args(&["--bind", &local_share, &local_share]);
110 }
111 }
112
113 if !config.allow_network {
114 bwrap.arg("--unshare-net");
115 }
116
117 bwrap.args(&["--unshare-pid", "--unshare-uts"]);
118 bwrap.arg("--die-with-parent");
119 bwrap.arg("--");
120 bwrap.arg(program);
121 bwrap.args(args);
122
123 for (key, value) in envs {
124 bwrap.env(key, value);
125 }
126
127 info!("Applying bubblewrap sandbox");
128
129 *original_command = bwrap;
130 Ok(())
131}
132
133#[cfg(target_os = "linux")]
134fn apply_linux_resource_limits(
135 command: &mut Command, config: &SandboxConfig,
136) -> Result<(), String> {
137 use std::os::unix::process::CommandExt;
138
139 let max_memory_mb = config.max_memory_mb;
140
141 unsafe {
142 command.pre_exec(move || {
143 if let Some(max_mb) = max_memory_mb {
144 let max_bytes = max_mb * 1024 * 1024;
145 let limit = libc::rlimit { rlim_cur: max_bytes, rlim_max: max_bytes };
146 libc::setrlimit(libc::RLIMIT_AS, &limit);
147 }
148 Ok(())
149 });
150 }
151
152 Ok(())
153}
154
155#[cfg(target_os = "macos")]
156fn apply_macos_sandbox(command: &mut Command, config: &SandboxConfig) -> Result<(), String> {
157 command.env("NODE_ENV", "production");
158 apply_macos_resource_limits(command, config)?;
159 Ok(())
160}
161
162#[cfg(target_os = "macos")]
163fn apply_macos_resource_limits(
164 command: &mut Command, config: &SandboxConfig,
165) -> Result<(), String> {
166 use std::os::unix::process::CommandExt;
167
168 let max_memory_mb = config.max_memory_mb;
169
170 unsafe {
171 command.pre_exec(move || {
172 if let Some(max_mb) = max_memory_mb {
173 let max_bytes = max_mb * 1024 * 1024;
174 let limit = libc::rlimit { rlim_cur: max_bytes, rlim_max: libc::RLIM_INFINITY };
175 let _ = libc::setrlimit(libc::RLIMIT_AS, &limit);
176 }
177 Ok(())
178 });
179 }
180
181 Ok(())
182}
183
184#[cfg(target_os = "windows")]
185fn apply_windows_sandbox(_command: &mut Command, config: &SandboxConfig) -> Result<(), String> {
186 if JOB_OBJECT.get().is_none() {
188 match create_and_configure_job_object(config) {
189 Ok(handle) => {
190 let _ = JOB_OBJECT.set(JobHandle(handle));
191 info!("Windows Job Object sandbox created and configured");
192 }
193 Err(e) => {
194 warn!("Failed to create Windows Job Object sandbox: {e}");
195 }
198 }
199 }
200 Ok(())
201}
202
203pub fn complete_sandbox_setup(child: &std::process::Child) -> Result<(), String> {
209 #[cfg(target_os = "windows")]
210 {
211 complete_windows_sandbox(child)?;
212 }
213 #[cfg(not(target_os = "windows"))]
214 {
215 let _ = child;
216 }
217 Ok(())
218}
219
220#[cfg(target_os = "windows")]
222fn complete_windows_sandbox(child: &std::process::Child) -> Result<(), String> {
223 use windows::Win32::Foundation::{CloseHandle, FALSE, HANDLE};
224 use windows::Win32::System::JobObjects::AssignProcessToJobObject;
225 use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
226
227 let Some(handle_wrapper) = JOB_OBJECT.get() else {
228 warn!("No Windows Job Object found, skipping sandbox assignment");
229 return Ok(());
230 };
231
232 let job = handle_wrapper.0;
233
234 let process_handle = unsafe {
237 OpenProcess(
238 PROCESS_SET_QUOTA | PROCESS_TERMINATE,
239 FALSE,
240 child.id(),
241 )
242 };
243
244 let process_handle: HANDLE = match process_handle {
245 Ok(h) => h,
246 Err(e) => {
247 warn!("Failed to open child process for job assignment: {e}");
248 return Ok(());
249 }
250 };
251
252 let assign_result = unsafe { AssignProcessToJobObject(job, process_handle) };
254
255 match assign_result {
256 Ok(()) => {
257 info!("Successfully assigned child process to Windows Job Object");
258 }
259 Err(e) => {
260 warn!("Failed to assign process to Windows Job Object: {e}");
261 }
262 }
263
264 unsafe {
266 let _ = CloseHandle(process_handle);
267 }
268
269 Ok(())
270}
271
272#[cfg(target_os = "windows")]
274fn create_and_configure_job_object(
275 config: &SandboxConfig,
276) -> Result<windows::Win32::Foundation::HANDLE, String> {
277 use windows::Win32::System::JobObjects::{
278 CreateJobObjectW, JobObjectBasicUIRestrictions, JobObjectExtendedLimitInformation,
279 JOBOBJECT_BASIC_UI_RESTRICTIONS, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
280 JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_JOB_MEMORY,
281 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_UILIMIT_DESKTOP,
282 JOB_OBJECT_UILIMIT_DISPLAY_SETTINGS, JOB_OBJECT_UILIMIT_EXIT_WINDOWS,
283 JOB_OBJECT_UILIMIT_FLAGS, SetInformationJobObject,
284 };
285
286 let job = unsafe { CreateJobObjectW(None, None) }
288 .map_err(|e| format!("Failed to create job object: {e}"))?;
289
290 let mut extended_info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION =
292 unsafe { std::mem::zeroed() };
293
294 let mut limit_flags = JOB_OBJECT_LIMIT_FLAGS(0);
295
296 if let Some(max_mb) = config.max_memory_mb {
298 limit_flags |= JOB_OBJECT_LIMIT_JOB_MEMORY;
299 extended_info.JobMemoryLimit = max_mb * 1024 * 1024;
300 }
301
302 limit_flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
304 extended_info.BasicLimitInformation.ActiveProcessLimit = 4;
305
306 limit_flags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
308
309 extended_info.BasicLimitInformation.LimitFlags = limit_flags;
310
311 unsafe {
312 SetInformationJobObject(
313 job,
314 JobObjectExtendedLimitInformation,
315 &extended_info as *const _ as *const std::ffi::c_void,
316 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
317 )
318 .map_err(|e| format!("Failed to set job extended limits: {e}"))?;
319 }
320
321 let mut ui_restrictions: JOBOBJECT_BASIC_UI_RESTRICTIONS = unsafe { std::mem::zeroed() };
323
324 let mut ui_flags = JOB_OBJECT_UILIMIT_FLAGS(0);
325 ui_flags |= JOB_OBJECT_UILIMIT_EXIT_WINDOWS;
326 ui_flags |= JOB_OBJECT_UILIMIT_DESKTOP;
327 ui_flags |= JOB_OBJECT_UILIMIT_DISPLAY_SETTINGS;
328
329 ui_restrictions.UIRestrictionsClass = ui_flags;
330
331 unsafe {
332 SetInformationJobObject(
333 job,
334 JobObjectBasicUIRestrictions,
335 &ui_restrictions as *const _ as *const std::ffi::c_void,
336 std::mem::size_of::<JOBOBJECT_BASIC_UI_RESTRICTIONS>() as u32,
337 )
338 .map_err(|e| format!("Failed to set job UI restrictions: {e}"))?;
339 }
340
341 Ok(job)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_sandbox_config_default() {
350 let config = SandboxConfig::default();
351 assert!(!config.allow_network);
352 assert!(!config.allow_file_write);
353 assert_eq!(config.max_memory_mb, Some(512));
354 }
355
356 #[test]
357 #[cfg(target_os = "linux")]
358 fn test_linux_sandbox_application() {
359 let mut cmd = Command::new("echo");
360 cmd.arg("test");
361
362 let config = SandboxConfig::default();
363 let result = apply_sandbox(&mut cmd, &config);
364
365 assert!(result.is_ok());
366 }
367
368 #[test]
369 fn test_apply_sandbox_config() {
370 let mut cmd = Command::new("echo");
372 cmd.arg("test");
373 let config = SandboxConfig::default();
374 let result = apply_sandbox(&mut cmd, &config);
375 assert!(result.is_ok());
376
377 let mut cmd2 = Command::new("echo");
379 cmd2.arg("hello");
380 let network_config = SandboxConfig {
381 allow_network: true,
382 allow_file_write: true,
383 max_memory_mb: Some(256),
384 max_cpu_percent: Some(25),
385 };
386 let result2 = apply_sandbox(&mut cmd2, &network_config);
387 assert!(result2.is_ok());
388
389 assert!(!config.allow_network, "Default should block network");
391 assert!(!config.allow_file_write, "Default should block file write");
392 assert_eq!(config.max_memory_mb, Some(512), "Default memory limit should be 512MB");
393 assert_eq!(config.max_cpu_percent, Some(50), "Default CPU limit should be 50%");
394 }
395
396 #[test]
397 fn test_sandbox_config_custom_values() {
398 let config = SandboxConfig {
399 allow_network: true,
400 allow_file_write: true,
401 max_memory_mb: Some(1024),
402 max_cpu_percent: Some(80),
403 };
404 assert!(config.allow_network);
405 assert!(config.allow_file_write);
406 assert_eq!(config.max_memory_mb, Some(1024));
407 assert_eq!(config.max_cpu_percent, Some(80));
408 }
409
410 #[test]
411 fn test_sandbox_config_none_limits() {
412 let config = SandboxConfig {
413 allow_network: false,
414 allow_file_write: false,
415 max_memory_mb: None,
416 max_cpu_percent: None,
417 };
418 assert!(config.max_memory_mb.is_none());
419 assert!(config.max_cpu_percent.is_none());
420
421 let mut cmd = Command::new("echo");
423 let result = apply_sandbox(&mut cmd, &config);
424 assert!(result.is_ok());
425 }
426
427 #[test]
428 fn test_sandbox_config_default_cpu_percent() {
429 let config = SandboxConfig::default();
430 assert_eq!(config.max_cpu_percent, Some(50));
431 }
432
433 #[test]
434 fn test_apply_sandbox_permissive_config() {
435 let config = SandboxConfig {
436 allow_network: true,
437 allow_file_write: true,
438 max_memory_mb: Some(2048),
439 max_cpu_percent: Some(100),
440 };
441 let mut cmd = Command::new("echo");
442 cmd.arg("permissive");
443 let result = apply_sandbox(&mut cmd, &config);
444 assert!(result.is_ok());
445 }
446
447 #[test]
448 fn test_complete_sandbox_setup_noop_on_unix() {
449 let config = SandboxConfig::default();
454 assert!(!config.allow_network);
455 assert!(!config.allow_file_write);
456 }
457}