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}