1pub mod bwrap;
26pub mod policy;
27pub mod seatbelt;
28
29pub use policy::SandboxPolicy;
30
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::sync::Arc;
34
35use tokio::process::Command;
36use tracing::{debug, warn};
37
38use crate::config::SandboxConfig;
39
40pub trait SandboxStrategy: Send + Sync {
42 fn name(&self) -> &'static str;
44
45 fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command;
53}
54
55pub struct NoopStrategy;
57
58impl SandboxStrategy for NoopStrategy {
59 fn name(&self) -> &'static str {
60 "noop"
61 }
62
63 fn wrap_command(&self, cmd: Command, _policy: &SandboxPolicy) -> Command {
64 cmd
65 }
66}
67
68pub struct SandboxExecutor {
74 strategy: Arc<dyn SandboxStrategy>,
75 policy: SandboxPolicy,
76 enabled: bool,
77 allow_bypass: bool,
81}
82
83impl SandboxExecutor {
84 pub fn from_config(config: &SandboxConfig, project_dir: &Path) -> Self {
92 Self::from_config_with_bypass(config, project_dir, true)
93 }
94
95 pub fn from_config_with_bypass(
101 config: &SandboxConfig,
102 project_dir: &Path,
103 allow_bypass: bool,
104 ) -> Self {
105 let policy = SandboxPolicy::from_config(config, project_dir);
106 let strategy = pick_strategy(&config.strategy);
107
108 if config.enabled && strategy.name() == "noop" {
109 warn!(
110 "sandbox enabled in config but no working strategy on this platform; \
111 running without OS-level isolation"
112 );
113 }
114
115 Self {
116 strategy,
117 policy,
118 enabled: config.enabled,
119 allow_bypass,
120 }
121 }
122
123 pub fn from_session_config(config: &crate::config::Config, project_dir: &Path) -> Self {
126 Self::from_config_with_bypass(
127 &config.sandbox,
128 project_dir,
129 !config.security.disable_bypass_permissions,
130 )
131 }
132
133 pub fn strategy_name(&self) -> &'static str {
135 self.strategy.name()
136 }
137
138 pub fn is_active(&self) -> bool {
140 self.enabled && self.strategy.name() != "noop"
141 }
142
143 pub fn policy(&self) -> &SandboxPolicy {
145 &self.policy
146 }
147
148 pub fn allow_bypass(&self) -> bool {
153 self.allow_bypass
154 }
155
156 pub fn wrap(&self, cmd: Command) -> Command {
161 if !self.is_active() {
162 return cmd;
163 }
164 debug!(
165 strategy = self.strategy.name(),
166 project_dir = %self.policy.project_dir.display(),
167 "wrapping subprocess with sandbox"
168 );
169 let mut wrapped = self.strategy.wrap_command(cmd, &self.policy);
170 wrapped
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped())
176 .stdin(Stdio::null());
177 wrapped
178 }
179
180 pub fn disabled() -> Self {
182 Self {
183 strategy: Arc::new(NoopStrategy),
184 policy: SandboxPolicy {
185 project_dir: PathBuf::from("."),
186 allowed_write_paths: Vec::new(),
187 forbidden_paths: Vec::new(),
188 allow_network: true,
189 },
190 enabled: false,
191 allow_bypass: true,
192 }
193 }
194}
195
196fn pick_strategy(requested: &str) -> Arc<dyn SandboxStrategy> {
197 match requested {
198 "none" => Arc::new(NoopStrategy),
199 "seatbelt" => make_seatbelt_or_noop(),
200 "bwrap" => make_bwrap_or_noop(),
201 "auto" | "" => auto_detect(),
202 other => {
203 warn!("unknown sandbox strategy {other:?}; falling back to noop");
204 Arc::new(NoopStrategy)
205 }
206 }
207}
208
209fn auto_detect() -> Arc<dyn SandboxStrategy> {
210 if cfg!(target_os = "macos") {
211 make_seatbelt_or_noop()
212 } else if cfg!(target_os = "linux") {
213 make_bwrap_or_noop()
214 } else {
215 Arc::new(NoopStrategy)
216 }
217}
218
219fn make_seatbelt_or_noop() -> Arc<dyn SandboxStrategy> {
220 if cfg!(target_os = "macos") && binary_on_path("sandbox-exec") {
221 Arc::new(seatbelt::SeatbeltStrategy)
222 } else {
223 Arc::new(NoopStrategy)
224 }
225}
226
227fn make_bwrap_or_noop() -> Arc<dyn SandboxStrategy> {
228 if cfg!(target_os = "linux") && binary_on_path("bwrap") {
229 Arc::new(bwrap::BwrapStrategy)
230 } else {
231 Arc::new(NoopStrategy)
232 }
233}
234
235fn binary_on_path(name: &str) -> bool {
237 let Some(path) = std::env::var_os("PATH") else {
240 return false;
241 };
242 for dir in std::env::split_paths(&path) {
243 if dir.join(name).is_file() {
244 return true;
245 }
246 }
247 false
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn sample_config(enabled: bool, strategy: &str) -> SandboxConfig {
255 SandboxConfig {
256 enabled,
257 strategy: strategy.to_string(),
258 allowed_write_paths: vec![],
259 forbidden_paths: vec![],
260 allow_network: false,
261 }
262 }
263
264 #[test]
265 fn pick_strategy_none_is_noop() {
266 assert_eq!(pick_strategy("none").name(), "noop");
267 }
268
269 #[test]
270 fn pick_strategy_empty_is_auto() {
271 assert_eq!(pick_strategy("").name(), auto_detect().name());
273 }
274
275 #[test]
276 fn pick_strategy_auto_matches_auto_detect() {
277 assert_eq!(pick_strategy("auto").name(), auto_detect().name());
278 }
279
280 #[test]
281 fn pick_strategy_unknown_is_noop() {
282 assert_eq!(pick_strategy("martian").name(), "noop");
283 }
284
285 #[test]
286 #[cfg(not(target_os = "macos"))]
287 fn auto_detect_off_macos_is_noop() {
288 assert_eq!(auto_detect().name(), "noop");
289 }
290
291 #[test]
292 #[cfg(target_os = "macos")]
293 fn auto_detect_on_macos_picks_seatbelt() {
294 assert_eq!(auto_detect().name(), "seatbelt");
296 }
297
298 #[test]
299 #[cfg(target_os = "linux")]
300 fn auto_detect_on_linux_picks_bwrap_or_noop() {
301 let name = auto_detect().name();
304 assert!(
305 name == "bwrap" || name == "noop",
306 "expected bwrap or noop on Linux, got {name}"
307 );
308 }
309
310 #[test]
311 #[cfg(target_os = "macos")]
312 fn pick_strategy_seatbelt_matches_make_seatbelt() {
313 assert_eq!(pick_strategy("seatbelt").name(), "seatbelt");
314 }
315
316 #[test]
317 #[cfg(not(target_os = "macos"))]
318 fn pick_strategy_seatbelt_off_macos_is_noop() {
319 assert_eq!(pick_strategy("seatbelt").name(), "noop");
322 }
323
324 #[test]
325 #[cfg(target_os = "linux")]
326 fn pick_strategy_bwrap_on_linux_matches_make_bwrap() {
327 let name = pick_strategy("bwrap").name();
328 assert!(
329 name == "bwrap" || name == "noop",
330 "expected bwrap or noop on Linux, got {name}"
331 );
332 }
333
334 #[test]
335 #[cfg(not(target_os = "linux"))]
336 fn pick_strategy_bwrap_off_linux_is_noop() {
337 assert_eq!(pick_strategy("bwrap").name(), "noop");
340 }
341
342 #[test]
343 fn disabled_executor_is_inactive() {
344 let exec = SandboxExecutor::disabled();
345 assert!(!exec.is_active());
346 assert_eq!(exec.strategy_name(), "noop");
347 assert!(exec.allow_bypass());
348 }
349
350 #[test]
351 fn disabled_executor_wrap_is_identity_program() {
352 let exec = SandboxExecutor::disabled();
356 let cmd = Command::new("echo");
357 let wrapped = exec.wrap(cmd);
358 let program = wrapped.as_std().get_program();
359 assert_eq!(program, "echo");
360 }
361
362 #[test]
363 fn from_config_disabled_is_inactive_regardless_of_strategy() {
364 let cfg = sample_config(false, "seatbelt");
365 let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
366 assert!(!exec.is_active());
367 }
368
369 #[test]
370 fn from_config_strategy_none_is_inactive_even_when_enabled() {
371 let cfg = sample_config(true, "none");
372 let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
373 assert!(!exec.is_active());
374 assert_eq!(exec.strategy_name(), "noop");
375 }
376
377 #[test]
378 fn from_config_policy_contains_project_dir() {
379 let cfg = sample_config(false, "auto");
380 let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/work/repo"));
381 assert_eq!(exec.policy().project_dir, PathBuf::from("/work/repo"));
382 }
383
384 #[test]
385 fn from_config_with_bypass_respects_flag() {
386 let cfg = sample_config(true, "auto");
387 let allowed =
388 SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), true);
389 let denied =
390 SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), false);
391 assert!(allowed.allow_bypass());
392 assert!(!denied.allow_bypass());
393 }
394
395 #[test]
396 fn from_session_config_honors_disable_bypass_permissions() {
397 let base = crate::config::Config {
398 sandbox: sample_config(true, "none"),
399 ..Default::default()
400 };
401
402 let mut denied_cfg = base.clone();
403 denied_cfg.security.disable_bypass_permissions = true;
404 let denied =
405 SandboxExecutor::from_session_config(&denied_cfg, std::path::Path::new("/tmp"));
406 assert!(!denied.allow_bypass());
407
408 let mut allowed_cfg = base;
409 allowed_cfg.security.disable_bypass_permissions = false;
410 let allowed =
411 SandboxExecutor::from_session_config(&allowed_cfg, std::path::Path::new("/tmp"));
412 assert!(allowed.allow_bypass());
413 }
414
415 #[test]
416 #[cfg(target_os = "macos")]
417 fn active_seatbelt_wrap_replaces_program() {
418 let cfg = sample_config(true, "seatbelt");
419 let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
420 if !exec.is_active() {
421 eprintln!("skipping: seatbelt unavailable");
422 return;
423 }
424 let wrapped = exec.wrap(Command::new("echo"));
425 let std_cmd = wrapped.as_std();
426 assert_eq!(std_cmd.get_program(), "sandbox-exec");
427 let args: Vec<_> = std_cmd.get_args().collect();
429 assert_eq!(args.first().map(|a| a.to_str().unwrap()), Some("-p"));
430 assert!(args.iter().any(|a| a.to_str() == Some("echo")));
432 }
433
434 #[test]
435 fn noop_strategy_returns_command_untouched() {
436 let cmd = Command::new("cat");
438 let policy = SandboxPolicy {
439 project_dir: PathBuf::from("/tmp"),
440 allowed_write_paths: vec![],
441 forbidden_paths: vec![],
442 allow_network: false,
443 };
444 let wrapped = NoopStrategy.wrap_command(cmd, &policy);
445 assert_eq!(wrapped.as_std().get_program(), "cat");
446 }
447}