1use std::path::Path;
30
31use super::capabilities::AddonCapabilities;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Dims {
38 pub network_allowed: bool,
40 pub fs_writable: bool,
42}
43
44impl Dims {
45 #[must_use]
47 fn is_noop(self) -> bool {
48 self.network_allowed && self.fs_writable
49 }
50}
51
52#[must_use]
56fn dims_for_mode(mode: SandboxMode) -> Dims {
57 match mode {
58 SandboxMode::Off => Dims {
59 network_allowed: true,
60 fs_writable: true,
61 },
62 SandboxMode::Auto => Dims {
63 network_allowed: false,
64 fs_writable: true,
65 },
66 SandboxMode::Strict => Dims {
67 network_allowed: false,
68 fs_writable: false,
69 },
70 }
71}
72
73#[must_use]
75fn dims_for_caps(caps: &AddonCapabilities) -> Dims {
76 Dims {
77 network_allowed: caps.network_allowed(),
78 fs_writable: caps.filesystem_writable(),
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum SandboxMode {
85 #[default]
87 Off,
88 Auto,
91 Strict,
94}
95
96impl SandboxMode {
97 #[must_use]
98 pub fn as_str(self) -> &'static str {
99 match self {
100 Self::Off => "off",
101 Self::Auto => "auto",
102 Self::Strict => "strict",
103 }
104 }
105
106 #[must_use]
108 pub fn parse(s: &str) -> Self {
109 match s.trim().to_ascii_lowercase().as_str() {
110 "auto" => Self::Auto,
111 "strict" => Self::Strict,
112 _ => Self::Off,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Launcher {
120 SandboxExec,
122 Bwrap,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Plan {
129 Direct,
131 Wrap(Launcher),
133 Refuse(String),
135}
136
137#[must_use]
139pub fn plan(mode: SandboxMode, launcher: Option<Launcher>) -> Plan {
140 match (mode, launcher) {
141 (SandboxMode::Off, _) | (SandboxMode::Auto, None) => Plan::Direct,
142 (_, Some(l)) => Plan::Wrap(l),
143 (SandboxMode::Strict, None) => Plan::Refuse(
144 "addons.sandbox = strict but no OS sandbox launcher (sandbox-exec / bwrap) is available"
145 .to_string(),
146 ),
147 }
148}
149
150#[must_use]
152pub fn detect_launcher() -> Option<Launcher> {
153 if cfg!(target_os = "macos") && which("sandbox-exec") {
154 Some(Launcher::SandboxExec)
155 } else if cfg!(target_os = "linux") && which("bwrap") {
156 Some(Launcher::Bwrap)
157 } else {
158 None
159 }
160}
161
162#[must_use]
165pub fn wrap_argv(
166 launcher: Launcher,
167 mode: SandboxMode,
168 command: &str,
169 args: &[String],
170) -> (String, Vec<String>) {
171 wrap_argv_dims(launcher, dims_for_mode(mode), command, args)
172}
173
174#[must_use]
179fn wrap_argv_dims(
180 launcher: Launcher,
181 dims: Dims,
182 command: &str,
183 args: &[String],
184) -> (String, Vec<String>) {
185 match launcher {
186 Launcher::SandboxExec => {
187 let profile = sbpl_profile_dims(dims);
188 let mut v = vec!["-p".to_string(), profile, command.to_string()];
189 v.extend(args.iter().cloned());
190 ("sandbox-exec".to_string(), v)
191 }
192 Launcher::Bwrap => {
193 let mut v = bwrap_flags_dims(dims);
194 v.push(command.to_string());
195 v.extend(args.iter().cloned());
196 ("bwrap".to_string(), v)
197 }
198 }
199}
200
201#[cfg(test)]
204fn sbpl_profile(mode: SandboxMode) -> String {
205 sbpl_profile_dims(dims_for_mode(mode))
206}
207
208fn sbpl_profile_dims(dims: Dims) -> String {
212 let mut p = String::from("(version 1)\n(allow default)\n");
213 if !dims.network_allowed {
214 p.push_str("(deny network*)\n");
215 }
216 if !dims.fs_writable {
217 p.push_str("(deny file-write*)\n");
218 p.push_str("(allow file-write* (subpath \"/tmp\") (subpath \"/private/tmp\") (subpath \"/var/folders\"))\n");
219 }
220 p
221}
222
223#[cfg(test)]
226fn bwrap_flags(mode: SandboxMode) -> Vec<String> {
227 bwrap_flags_dims(dims_for_mode(mode))
228}
229
230fn bwrap_flags_dims(dims: Dims) -> Vec<String> {
233 let mut f: Vec<String> = vec!["--die-with-parent".into()];
234 if !dims.network_allowed {
235 f.push("--unshare-net".into());
236 }
237 if dims.fs_writable {
238 f.extend(
239 ["--bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
240 .iter()
241 .map(|s| (*s).to_string()),
242 );
243 } else {
244 f.extend(
245 [
246 "--ro-bind",
247 "/",
248 "/",
249 "--dev",
250 "/dev",
251 "--proc",
252 "/proc",
253 "--tmpfs",
254 "/tmp",
255 ]
256 .iter()
257 .map(|s| (*s).to_string()),
258 );
259 }
260 f
261}
262
263pub fn apply(command: &str, args: &[String]) -> Result<(String, Vec<String>), String> {
268 let mode = crate::core::config::Config::load().addons.sandbox_mode();
269 if mode == SandboxMode::Off {
270 return Ok((command.to_string(), args.to_vec()));
271 }
272 match plan(mode, detect_launcher()) {
273 Plan::Direct => {
274 if mode != SandboxMode::Off {
275 tracing::warn!(
276 "addons.sandbox = {} but no OS sandbox launcher is available — \
277 spawning `{command}` UNSANDBOXED",
278 mode.as_str()
279 );
280 }
281 Ok((command.to_string(), args.to_vec()))
282 }
283 Plan::Wrap(launcher) => {
284 tracing::debug!(
285 "sandboxing `{command}` via {:?} ({} mode)",
286 launcher,
287 mode.as_str()
288 );
289 Ok(wrap_argv(launcher, mode, command, args))
290 }
291 Plan::Refuse(reason) => Err(reason),
292 }
293}
294
295pub fn apply_for(
306 command: &str,
307 args: &[String],
308 capabilities: Option<&AddonCapabilities>,
309) -> Result<(String, Vec<String>), String> {
310 match capabilities {
311 Some(caps) => apply_caps(command, args, caps),
312 None => apply(command, args),
313 }
314}
315
316fn apply_caps(
323 command: &str,
324 args: &[String],
325 caps: &AddonCapabilities,
326) -> Result<(String, Vec<String>), String> {
327 let dims = dims_for_caps(caps);
328
329 if dims.is_noop() {
332 return Ok((command.to_string(), args.to_vec()));
333 }
334
335 let enforce = crate::core::config::Config::load()
336 .addons
337 .enforce_capabilities;
338
339 let Some(launcher) = detect_launcher() else {
340 if enforce {
342 return Err(format!(
343 "addons.enforce_capabilities = true but no OS sandbox launcher \
344 (sandbox-exec / bwrap) is available to honour `{command}`'s declared \
345 restricted capabilities"
346 ));
347 }
348 tracing::warn!(
349 "addon `{command}` declares restricted capabilities but no OS sandbox \
350 launcher is available — running UNSANDBOXED (set \
351 addons.enforce_capabilities = true to fail closed)"
352 );
353 return Ok((command.to_string(), args.to_vec()));
354 };
355
356 tracing::debug!(
357 "sandboxing `{command}` via {:?} (net={}, fs_write={})",
358 launcher,
359 dims.network_allowed,
360 dims.fs_writable
361 );
362 Ok(wrap_argv_dims(launcher, dims, command, args))
363}
364
365fn which(bin: &str) -> bool {
366 let Ok(path) = std::env::var("PATH") else {
367 return false;
368 };
369 std::env::split_paths(&path).any(|dir| {
370 let p = dir.join(bin);
371 p.is_file() && is_executable(&p)
372 })
373}
374
375#[cfg(unix)]
376fn is_executable(p: &Path) -> bool {
377 use std::os::unix::fs::PermissionsExt;
378 std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
379}
380
381#[cfg(not(unix))]
382fn is_executable(_p: &Path) -> bool {
383 true
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn mode_parse_roundtrip() {
392 assert_eq!(SandboxMode::parse("auto"), SandboxMode::Auto);
393 assert_eq!(SandboxMode::parse("STRICT"), SandboxMode::Strict);
394 assert_eq!(SandboxMode::parse(""), SandboxMode::Off);
395 assert_eq!(SandboxMode::parse("nonsense"), SandboxMode::Off);
396 assert_eq!(SandboxMode::Strict.as_str(), "strict");
397 }
398
399 #[test]
400 fn plan_off_is_always_direct() {
401 assert_eq!(plan(SandboxMode::Off, Some(Launcher::Bwrap)), Plan::Direct);
402 assert_eq!(plan(SandboxMode::Off, None), Plan::Direct);
403 }
404
405 #[test]
406 fn plan_auto_without_launcher_runs_direct() {
407 assert_eq!(plan(SandboxMode::Auto, None), Plan::Direct);
408 }
409
410 #[test]
411 fn plan_strict_without_launcher_refuses() {
412 assert!(matches!(plan(SandboxMode::Strict, None), Plan::Refuse(_)));
413 }
414
415 #[test]
416 fn plan_wraps_when_launcher_present() {
417 assert_eq!(
418 plan(SandboxMode::Auto, Some(Launcher::SandboxExec)),
419 Plan::Wrap(Launcher::SandboxExec)
420 );
421 }
422
423 #[test]
424 fn sandbox_exec_argv_prepends_profile_and_command() {
425 let (cmd, args) = wrap_argv(
426 Launcher::SandboxExec,
427 SandboxMode::Auto,
428 "my-mcp",
429 &["serve".into()],
430 );
431 assert_eq!(cmd, "sandbox-exec");
432 assert_eq!(args[0], "-p");
433 assert!(args[1].contains("(deny network*)"));
434 assert_eq!(args[2], "my-mcp");
435 assert_eq!(args[3], "serve");
436 }
437
438 #[test]
439 fn strict_sbpl_restricts_writes() {
440 let p = sbpl_profile(SandboxMode::Strict);
441 assert!(p.contains("(deny file-write*)"));
442 assert!(p.contains("/tmp"));
443 let auto = sbpl_profile(SandboxMode::Auto);
444 assert!(!auto.contains("(deny file-write*)"));
445 }
446
447 #[test]
448 fn bwrap_argv_unshares_network() {
449 let (cmd, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Auto, "my-mcp", &["x".into()]);
450 assert_eq!(cmd, "bwrap");
451 assert!(args.iter().any(|a| a == "--unshare-net"));
452 assert!(args.iter().any(|a| a == "my-mcp"));
453 assert!(args.iter().any(|a| a == "x"));
454 }
455
456 #[test]
457 fn bwrap_strict_is_readonly_root() {
458 let (_c, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Strict, "m", &[]);
459 assert!(args.iter().any(|a| a == "--ro-bind"));
460 assert!(args.iter().any(|a| a == "--tmpfs"));
461 }
462
463 use super::super::capabilities::{
466 AddonCapabilities, ExecAccess, FilesystemAccess, NetworkAccess,
467 };
468
469 #[test]
470 fn minimal_caps_block_network_and_writes() {
471 let dims = dims_for_caps(&AddonCapabilities::default());
472 assert!(!dims.network_allowed);
473 assert!(!dims.fs_writable);
474 let sbpl = sbpl_profile_dims(dims);
475 assert!(sbpl.contains("(deny network*)"));
476 assert!(sbpl.contains("(deny file-write*)"));
477 }
478
479 #[test]
480 fn full_network_caps_omit_network_deny() {
481 let caps = AddonCapabilities {
482 network: NetworkAccess::Full,
483 filesystem: FilesystemAccess::ReadOnly,
484 env: vec![],
485 exec: ExecAccess::default(),
486 };
487 let dims = dims_for_caps(&caps);
488 assert!(dims.network_allowed);
489 let sbpl = sbpl_profile_dims(dims);
490 assert!(!sbpl.contains("(deny network*)"));
491 assert!(sbpl.contains("(deny file-write*)"));
492 let flags = bwrap_flags_dims(dims);
494 assert!(!flags.iter().any(|f| f == "--unshare-net"));
495 assert!(flags.iter().any(|f| f == "--ro-bind"));
496 }
497
498 #[test]
499 fn permissive_net_fs_is_a_noop_regardless_of_exec() {
500 let caps = AddonCapabilities {
504 network: NetworkAccess::Full,
505 filesystem: FilesystemAccess::ReadWrite,
506 env: vec![],
507 exec: ExecAccess::default(), };
509 assert!(caps.exec_restricted());
510 assert!(dims_for_caps(&caps).is_noop());
511 let (cmd, args) = apply_for("my-mcp", &["serve".into()], Some(&caps)).expect("noop");
513 assert_eq!(cmd, "my-mcp");
514 assert_eq!(args, vec!["serve".to_string()]);
515 }
516
517 #[test]
518 fn caps_wrap_argv_prepends_launcher() {
519 let dims = dims_for_caps(&AddonCapabilities::default());
520 let (cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &["x".into()]);
521 assert_eq!(cmd, "sandbox-exec");
522 assert_eq!(args[0], "-p");
523 assert!(args[1].contains("(deny network*)"));
524 assert_eq!(args[2], "my-mcp");
525 assert_eq!(args[3], "x");
526 }
527
528 #[test]
531 fn sandbox_profile_never_emits_process_exec() {
532 let dims = dims_for_caps(&AddonCapabilities::default());
537 let (_cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &[]);
538 assert!(args[1].contains("(deny network*)"));
539 assert!(args[1].contains("(deny file-write*)"));
540 assert!(!args[1].contains("process-exec"));
541 }
542
543 #[test]
544 fn mode_path_unchanged_via_dims() {
545 assert!(sbpl_profile(SandboxMode::Auto).contains("(deny network*)"));
547 assert!(!sbpl_profile(SandboxMode::Auto).contains("(deny file-write*)"));
548 assert!(sbpl_profile(SandboxMode::Strict).contains("(deny file-write*)"));
549 assert!(
550 bwrap_flags(SandboxMode::Auto)
551 .iter()
552 .any(|f| f == "--unshare-net")
553 );
554 }
555}