Skip to main content

dscode_extension_host/
sandbox.rs

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/// Wrapper to make Windows `HANDLE` `Send` + `Sync`.
13/// A HANDLE is a kernel object identifier that is inherently safe to share
14/// between threads — the kernel manages all synchronization.
15#[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/// Global storage for the Windows Job Object handle.
26/// The job object lives for the duration of the process and is used to
27/// sandbox all extension host child processes. The handle is intentionally
28/// never closed — with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, closing it would
29/// terminate all child processes in the job.
30#[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    // Create and configure the job object (idempotent — only creates once).
187    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                // Log and continue, matching the macOS/Linux behaviour where
196                // individual setrlimit failures are silently ignored.
197            }
198        }
199    }
200    Ok(())
201}
202
203/// Completes sandbox setup after the process has been spawned.
204/// On Windows, this assigns the child process to the Job Object created by
205/// `apply_windows_sandbox`. On other platforms this is a no-op.
206///
207/// Call this after `Command::spawn()` and before interacting with the child.
208pub 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/// Assigns the spawned child process to the Windows Job Object.
221#[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    // Open a handle to the child process with the minimum access rights
235    // required by AssignProcessToJobObject.
236    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    // Assign the process to the job object.
253    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    // Close the process handle we opened — the assignment persists independently.
265    unsafe {
266        let _ = CloseHandle(process_handle);
267    }
268
269    Ok(())
270}
271
272/// Creates and configures a Windows Job Object with the specified limits.
273#[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    // Create an anonymous job object.
287    let job = unsafe { CreateJobObjectW(None, None) }
288        .map_err(|e| format!("Failed to create job object: {e}"))?;
289
290    // Configure extended limits: memory cap, active-process cap, kill-on-close.
291    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    // Memory limit (e.g., 512 MB).
297    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    // Active process limit — allow up to 4 concurrent processes in the job.
303    limit_flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
304    extended_info.BasicLimitInformation.ActiveProcessLimit = 4;
305
306    // Kill all processes in the job when the last handle is closed.
307    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    // Configure UI restrictions.
322    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        // Verify apply_sandbox returns Ok for a basic command with default config
371        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        // Verify apply_sandbox returns Ok with network-enabled config
378        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        // Verify default config values are restrictive
390        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        // apply_sandbox should still succeed with None limits
422        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        // On non-Windows, complete_sandbox_setup should be a no-op
450        // We can't easily test with a real child, but we can verify the function exists
451        // and the module compiles. The function is called after spawn.
452        // Just verify SandboxConfig defaults are as expected.
453        let config = SandboxConfig::default();
454        assert!(!config.allow_network);
455        assert!(!config.allow_file_write);
456    }
457}