sandbox.rs

  1#[cfg(target_os = "linux")]
  2mod cgroup;
  3#[cfg(unix)]
  4mod sandbox_exec;
  5#[cfg(target_os = "linux")]
  6mod sandbox_linux;
  7#[cfg(target_os = "macos")]
  8pub(crate) mod sandbox_macos;
  9#[cfg(all(test, unix))]
 10mod sandbox_tests;
 11
 12#[cfg(target_os = "linux")]
 13pub use cgroup::CgroupSession;
 14#[cfg(target_os = "macos")]
 15pub use sandbox_macos::SessionFingerprint;
 16
 17#[cfg(unix)]
 18pub use sandbox_exec::{SandboxExecConfig, sandbox_exec_main};
 19
 20use std::path::PathBuf;
 21
 22/// Resolved sandbox configuration with all defaults applied.
 23/// This is the concrete type passed to the terminal spawning code.
 24#[derive(Clone, Debug)]
 25pub struct SandboxConfig {
 26    pub project_dir: PathBuf,
 27    pub system_paths: ResolvedSystemPaths,
 28    pub additional_executable_paths: Vec<PathBuf>,
 29    pub additional_read_only_paths: Vec<PathBuf>,
 30    pub additional_read_write_paths: Vec<PathBuf>,
 31    pub allow_network: bool,
 32    pub allowed_env_vars: Vec<String>,
 33}
 34
 35/// Resolved system paths with OS-specific defaults applied.
 36#[derive(Clone, Debug)]
 37pub struct ResolvedSystemPaths {
 38    pub executable: Vec<PathBuf>,
 39    pub read_only: Vec<PathBuf>,
 40    pub read_write: Vec<PathBuf>,
 41}
 42
 43impl ResolvedSystemPaths {
 44    pub fn from_settings(settings: &settings_content::SystemPathsSettingsContent) -> Self {
 45        Self {
 46            executable: settings
 47                .executable
 48                .clone()
 49                .map(|v| v.into_iter().map(PathBuf::from).collect())
 50                .unwrap_or_else(Self::default_executable),
 51            read_only: settings
 52                .read_only
 53                .clone()
 54                .map(|v| v.into_iter().map(PathBuf::from).collect())
 55                .unwrap_or_else(Self::default_read_only),
 56            read_write: settings
 57                .read_write
 58                .clone()
 59                .map(|v| v.into_iter().map(PathBuf::from).collect())
 60                .unwrap_or_else(Self::default_read_write),
 61        }
 62    }
 63
 64    pub fn with_defaults() -> Self {
 65        Self {
 66            executable: Self::default_executable(),
 67            read_only: Self::default_read_only(),
 68            read_write: Self::default_read_write(),
 69        }
 70    }
 71
 72    #[cfg(target_os = "macos")]
 73    fn default_executable() -> Vec<PathBuf> {
 74        vec![
 75            "/bin".into(),
 76            "/usr/bin".into(),
 77            "/usr/sbin".into(),
 78            "/sbin".into(),
 79            "/usr/lib".into(),
 80            "/usr/libexec".into(),
 81            "/System/Library/dyld".into(),
 82            "/System/Cryptexes".into(),
 83            "/Library/Developer/CommandLineTools/usr/bin".into(),
 84            "/Library/Developer/CommandLineTools/usr/lib".into(),
 85            "/Library/Apple/usr/bin".into(),
 86            "/opt/homebrew/bin".into(),
 87            "/opt/homebrew/sbin".into(),
 88            "/opt/homebrew/Cellar".into(),
 89            "/opt/homebrew/lib".into(),
 90            "/usr/local/bin".into(),
 91            "/usr/local/lib".into(),
 92        ]
 93    }
 94
 95    #[cfg(target_os = "linux")]
 96    fn default_executable() -> Vec<PathBuf> {
 97        vec![
 98            "/usr/bin".into(),
 99            "/usr/sbin".into(),
100            "/usr/lib".into(),
101            "/usr/lib64".into(),
102            "/usr/libexec".into(),
103            "/lib".into(),
104            "/lib64".into(),
105            "/bin".into(),
106            "/sbin".into(),
107        ]
108    }
109
110    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
111    fn default_executable() -> Vec<PathBuf> {
112        vec![]
113    }
114
115    #[cfg(target_os = "macos")]
116    fn default_read_only() -> Vec<PathBuf> {
117        vec![
118            "/private/etc".into(),
119            "/usr/share".into(),
120            "/System/Library/Keychains".into(),
121            "/Library/Developer/CommandLineTools/SDKs".into(),
122            "/Library/Preferences/SystemConfiguration".into(),
123            "/opt/homebrew/share".into(),
124            "/opt/homebrew/etc".into(),
125            "/usr/local/share".into(),
126            "/usr/local/etc".into(),
127        ]
128    }
129
130    #[cfg(target_os = "linux")]
131    fn default_read_only() -> Vec<PathBuf> {
132        vec![
133            "/etc".into(),
134            "/usr/share".into(),
135            "/usr/include".into(),
136            "/usr/lib/locale".into(),
137        ]
138    }
139
140    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
141    fn default_read_only() -> Vec<PathBuf> {
142        vec![]
143    }
144
145    #[cfg(target_os = "macos")]
146    fn default_read_write() -> Vec<PathBuf> {
147        let mut paths = vec![
148            PathBuf::from("/dev"),
149            PathBuf::from("/private/tmp"),
150            PathBuf::from("/private/var/run/mDNSResponder"),
151        ];
152        // Resolve $TMPDIR to the per-user, per-session temp directory
153        // (e.g. /private/var/folders/xx/xxxx/T/) rather than granting
154        // broad access to all of /var/folders.
155        if let Ok(tmpdir) = std::env::var("TMPDIR") {
156            let tmpdir = PathBuf::from(tmpdir);
157            if tmpdir.exists() {
158                paths.push(tmpdir);
159            }
160        }
161        paths
162    }
163
164    #[cfg(target_os = "linux")]
165    fn default_read_write() -> Vec<PathBuf> {
166        vec![
167            "/dev".into(),
168            "/tmp".into(),
169            "/var/tmp".into(),
170            "/dev/shm".into(),
171            "/run/user".into(),
172        ]
173    }
174
175    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
176    fn default_read_write() -> Vec<PathBuf> {
177        vec![]
178    }
179}
180
181impl SandboxConfig {
182    /// Shell configuration dotfiles that need read-only access.
183    /// Both macOS and Linux sandbox implementations use this list.
184    pub const READ_ONLY_DOTFILES: &[&str] = &[
185        ".bashrc",
186        ".bash_login",
187        ".bash_profile",
188        ".gitconfig",
189        ".inputrc",
190        ".profile",
191        ".terminfo",
192        ".zlogin",
193        ".zlogout",
194        ".zprofile",
195        ".zshenv",
196        ".zshrc",
197    ];
198
199    /// Shell history dotfiles that need read-write access so shells can
200    /// persist command history without silent failures.
201    pub const READ_WRITE_DOTFILES: &[&str] = &[
202        ".bash_history",
203        ".zsh_history",
204    ];
205
206    /// Default environment variables to pass through to sandboxed terminals.
207    pub fn default_allowed_env_vars() -> Vec<String> {
208        vec![
209            "PATH".into(),
210            "HOME".into(),
211            "USER".into(),
212            "SHELL".into(),
213            "LANG".into(),
214            "TERM".into(),
215            "TERM_PROGRAM".into(),
216            "CARGO_HOME".into(),
217            "RUSTUP_HOME".into(),
218            "GOPATH".into(),
219            "EDITOR".into(),
220            "VISUAL".into(),
221            "XDG_CONFIG_HOME".into(),
222            "XDG_DATA_HOME".into(),
223            "XDG_RUNTIME_DIR".into(),
224            "SSH_AUTH_SOCK".into(),
225            "GPG_TTY".into(),
226            "COLORTERM".into(),
227        ]
228    }
229
230    /// Resolve a `SandboxConfig` from settings, applying all defaults.
231    pub fn from_settings(
232        sandbox_settings: &settings_content::SandboxSettingsContent,
233        project_dir: PathBuf,
234    ) -> Self {
235        let system_paths = sandbox_settings
236            .system_paths
237            .as_ref()
238            .map(|sp| ResolvedSystemPaths::from_settings(sp))
239            .unwrap_or_else(ResolvedSystemPaths::with_defaults);
240
241        let home_dir = std::env::var("HOME").ok().map(PathBuf::from);
242        let expand_paths = |paths: &Option<Vec<String>>| -> Vec<PathBuf> {
243            paths
244                .as_ref()
245                .map(|v| {
246                    v.iter()
247                        .map(|p| {
248                            if let Some(rest) = p.strip_prefix("~/") {
249                                if let Some(ref home) = home_dir {
250                                    return home.join(rest);
251                                }
252                            }
253                            PathBuf::from(p)
254                        })
255                        .collect()
256                })
257                .unwrap_or_default()
258        };
259
260        Self {
261            project_dir,
262            system_paths,
263            additional_executable_paths: expand_paths(
264                &sandbox_settings.additional_executable_paths,
265            ),
266            additional_read_only_paths: expand_paths(&sandbox_settings.additional_read_only_paths),
267            additional_read_write_paths: expand_paths(
268                &sandbox_settings.additional_read_write_paths,
269            ),
270            allow_network: sandbox_settings.allow_network.unwrap_or(true),
271            allowed_env_vars: sandbox_settings
272                .allowed_env_vars
273                .clone()
274                .unwrap_or_else(Self::default_allowed_env_vars),
275        }
276    }
277
278    /// Resolve sandbox config from settings if enabled and applicable for the given target.
279    ///
280    /// The caller is responsible for checking feature flags before calling this.
281    /// `target` should be `SandboxApplyTo::Terminal` for user terminals or
282    /// `SandboxApplyTo::Tool` for agent terminal tools.
283    pub fn resolve_if_enabled(
284        sandbox_settings: &settings_content::SandboxSettingsContent,
285        target: settings_content::SandboxApplyTo,
286        project_dir: PathBuf,
287    ) -> Option<Self> {
288        if !sandbox_settings.enabled.unwrap_or(false) {
289            return None;
290        }
291        let apply_to = sandbox_settings.apply_to.unwrap_or_default();
292        let applies = match target {
293            settings_content::SandboxApplyTo::Terminal => matches!(
294                apply_to,
295                settings_content::SandboxApplyTo::Terminal | settings_content::SandboxApplyTo::Both
296            ),
297            settings_content::SandboxApplyTo::Tool => matches!(
298                apply_to,
299                settings_content::SandboxApplyTo::Tool | settings_content::SandboxApplyTo::Both
300            ),
301            settings_content::SandboxApplyTo::Both => {
302                matches!(apply_to, settings_content::SandboxApplyTo::Both)
303            }
304            settings_content::SandboxApplyTo::Neither => false,
305        };
306        if !applies {
307            return None;
308        }
309        Some(Self::from_settings(sandbox_settings, project_dir))
310    }
311
312    pub fn canonicalize_paths(&mut self) {
313        match std::fs::canonicalize(&self.project_dir) {
314            Ok(canonical) => self.project_dir = canonical,
315            Err(err) => log::warn!(
316                "Failed to canonicalize project dir {:?}: {}",
317                self.project_dir,
318                err
319            ),
320        }
321        canonicalize_path_list(&mut self.system_paths.executable);
322        canonicalize_path_list(&mut self.system_paths.read_only);
323        canonicalize_path_list(&mut self.system_paths.read_write);
324        canonicalize_path_list(&mut self.additional_executable_paths);
325        canonicalize_path_list(&mut self.additional_read_only_paths);
326        canonicalize_path_list(&mut self.additional_read_write_paths);
327    }
328}
329
330fn try_canonicalize(path: &mut PathBuf) {
331    if let Ok(canonical) = std::fs::canonicalize(&*path) {
332        *path = canonical;
333    }
334}
335
336fn canonicalize_path_list(paths: &mut Vec<PathBuf>) {
337    for path in paths.iter_mut() {
338        try_canonicalize(path);
339    }
340}
341
342/// Platform-specific session tracker for process lifetime management.
343///
344/// On macOS, uses Seatbelt fingerprinting with `sandbox_check()`.
345/// On Linux, uses cgroups v2.
346/// On other platforms, tracking is not available.
347#[cfg(target_os = "macos")]
348pub struct SessionTracker {
349    pub(crate) fingerprint: sandbox_macos::SessionFingerprint,
350}
351
352#[cfg(target_os = "linux")]
353pub struct SessionTracker {
354    pub(crate) cgroup: Option<cgroup::CgroupSession>,
355}
356
357#[cfg(not(any(target_os = "macos", target_os = "linux")))]
358pub struct SessionTracker;
359
360#[cfg(target_os = "macos")]
361impl SessionTracker {
362    /// Create a new session tracker. On macOS, creates a SessionFingerprint.
363    pub fn new() -> std::io::Result<Self> {
364        Ok(Self {
365            fingerprint: sandbox_macos::SessionFingerprint::new()?,
366        })
367    }
368
369    /// Get the fingerprint UUID string for passing to the child process.
370    pub fn fingerprint_uuid(&self) -> Option<String> {
371        Some(self.fingerprint.uuid_string())
372    }
373
374    /// Get the cgroup path for passing to the child process (macOS: always None).
375    pub fn cgroup_path(&self) -> Option<String> {
376        None
377    }
378
379    /// Kill all processes belonging to this session.
380    pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
381        self.fingerprint.kill_all_processes(process_group_id);
382    }
383}
384
385#[cfg(target_os = "linux")]
386impl SessionTracker {
387    /// Create a new session tracker. On Linux, creates a CgroupSession.
388    pub fn new() -> std::io::Result<Self> {
389        match cgroup::CgroupSession::new() {
390            Ok(cgroup) => Ok(Self {
391                cgroup: Some(cgroup),
392            }),
393            Err(err) => {
394                log::warn!("Failed to create cgroup session, process tracking degraded: {err}");
395                Ok(Self { cgroup: None })
396            }
397        }
398    }
399
400    /// Get the fingerprint UUID string (Linux: always None).
401    pub fn fingerprint_uuid(&self) -> Option<String> {
402        None
403    }
404
405    /// Get the cgroup path for passing to the child process.
406    pub fn cgroup_path(&self) -> Option<String> {
407        self.cgroup.as_ref().map(|c| c.path_string())
408    }
409
410    /// Kill all processes belonging to this session.
411    pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
412        // Best-effort process group kill first
413        if let Some(pgid) = process_group_id {
414            unsafe {
415                libc::killpg(pgid, libc::SIGKILL);
416            }
417        }
418
419        if let Some(ref cgroup) = self.cgroup {
420            cgroup.kill_all_and_cleanup();
421        }
422    }
423}
424
425#[cfg(not(any(target_os = "macos", target_os = "linux")))]
426impl SessionTracker {
427    pub fn new() -> std::io::Result<Self> {
428        Ok(Self)
429    }
430
431    pub fn fingerprint_uuid(&self) -> Option<String> {
432        None
433    }
434
435    pub fn cgroup_path(&self) -> Option<String> {
436        None
437    }
438
439    pub fn kill_all_processes(&self, _process_group_id: Option<libc::pid_t>) {}
440}