1use anyhow::{Context, Result};
31use serde::{Deserialize, Serialize};
32use std::path::{Path, PathBuf};
33
34macro_rules! args {
37 ($v:expr, $($s:expr),+ $(,)?) => {{ $( $v.push($s.to_string()); )+ }};
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Backend {
44 #[default]
48 None,
49 Bwrap,
52 Docker,
55}
56
57impl Backend {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Backend::None => "none",
61 Backend::Bwrap => "bwrap",
62 Backend::Docker => "docker",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct SandboxConfig {
70 pub kind: Backend,
71 pub network: bool,
77 pub writable: Vec<PathBuf>,
79 pub readable: Vec<PathBuf>,
82 pub env: Vec<String>,
85 pub image: String,
87 pub memory_mb: Option<u64>,
89 pub cpus: Option<f64>,
91}
92
93impl Default for SandboxConfig {
94 fn default() -> Self {
95 SandboxConfig {
96 kind: Backend::None,
97 network: false,
98 writable: Vec::new(),
99 readable: Vec::new(),
100 env: Vec::new(),
101 image: "debian:stable-slim".into(),
104 memory_mb: None,
105 cpus: None,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct Sandbox {
112 cfg: SandboxConfig,
113}
114
115impl Sandbox {
116 pub fn new(cfg: SandboxConfig) -> Self {
117 Sandbox { cfg }
118 }
119
120 pub fn backend(&self) -> Backend {
121 self.cfg.kind
122 }
123
124 pub fn with_network(&self, network: bool) -> Self {
131 Sandbox {
132 cfg: SandboxConfig {
133 network,
134 ..self.cfg.clone()
135 },
136 }
137 }
138
139 pub fn is_enabled(&self) -> bool {
140 self.cfg.kind != Backend::None
141 }
142
143 pub fn can_reach_network(&self) -> bool {
149 !self.is_enabled() || self.cfg.network
150 }
151
152 pub fn reaches_beyond_workspace(&self) -> bool {
158 !self.is_enabled() || !self.cfg.writable.is_empty() || !self.cfg.readable.is_empty()
159 }
160
161 pub fn command(
166 &self,
167 command: &str,
168 workspace: &Path,
169 cwd: &Path,
170 ) -> Result<tokio::process::Command> {
171 match self.cfg.kind {
172 Backend::None => {
173 let mut c = tokio::process::Command::new("bash");
174 c.arg("-lc").arg(command).current_dir(cwd);
175 Ok(c)
176 }
177 _ => self.wrap_argv("bash", &["-lc".into(), command.into()], workspace, cwd),
178 }
179 }
180
181 pub fn wrap_argv(
187 &self,
188 program: &str,
189 args: &[String],
190 workspace: &Path,
191 cwd: &Path,
192 ) -> Result<tokio::process::Command> {
193 match self.cfg.kind {
194 Backend::None => {
195 let mut c = tokio::process::Command::new(program);
196 c.args(args).current_dir(cwd);
197 Ok(c)
198 }
199 Backend::Bwrap => {
200 let mut c = tokio::process::Command::new("bwrap");
201 c.args(self.bwrap_args(workspace, cwd)?);
202 c.arg("--").arg(program).args(args);
203 Ok(c)
204 }
205 Backend::Docker => {
206 let mut c = tokio::process::Command::new("docker");
207 c.args(self.docker_args(workspace, cwd)?);
208 c.arg(program).args(args);
209 Ok(c)
210 }
211 }
212 }
213
214 pub fn child_env(passthrough: &[String]) -> Vec<(String, String)> {
224 const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
225
226 BASE.iter()
227 .map(|s| s.to_string())
228 .chain(passthrough.iter().cloned())
229 .filter_map(|name| std::env::var(&name).ok().map(|v| (name, v)))
230 .collect()
231 }
232
233 pub fn bwrap_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
236 let mut a: Vec<String> = Vec::new();
237
238 args!(
242 a,
243 "--die-with-parent",
244 "--new-session",
245 "--unshare-user",
246 "--unshare-pid",
247 "--unshare-ipc",
248 "--unshare-uts",
249 "--unshare-cgroup-try",
250 );
251 if !self.cfg.network {
252 args!(a, "--unshare-net");
253 }
254
255 for dir in ["/usr", "/etc", "/opt"] {
259 if Path::new(dir).is_dir() {
260 args!(a, "--ro-bind-try", dir, dir);
261 }
262 }
263 for dir in ["/bin", "/sbin", "/lib", "/lib32", "/lib64"] {
264 match std::fs::symlink_metadata(dir) {
265 Ok(meta) if meta.file_type().is_symlink() => {
266 let target = std::fs::read_link(dir)?;
267 args!(a, "--symlink", target.to_string_lossy(), dir);
268 }
269 Ok(_) => args!(a, "--ro-bind-try", dir, dir),
270 Err(_) => {}
271 }
272 }
273
274 args!(a, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp");
277
278 for path in &self.cfg.readable {
279 args!(a, "--ro-bind-try", path.display(), path.display());
280 }
281
282 let workspace = absolute(workspace)?;
283 args!(a, "--bind", workspace.display(), workspace.display());
284 for path in &self.cfg.writable {
285 args!(a, "--bind-try", path.display(), path.display());
286 }
287
288 args!(a, "--chdir", absolute(cwd)?.display());
289
290 args!(
294 a,
295 "--clearenv",
296 "--setenv",
297 "PATH",
298 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
299 "--setenv",
300 "HOME",
301 workspace.display(),
302 );
303 for name in &self.cfg.env {
304 if let Ok(value) = std::env::var(name) {
305 args!(a, "--setenv", name, value);
306 }
307 }
308
309 Ok(a)
310 }
311
312 pub fn docker_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
314 let workspace = absolute(workspace)?;
315 let mut a: Vec<String> = Vec::new();
316 args!(a, "run", "--rm", "-i");
317
318 args!(
319 a,
320 "--network",
321 if self.cfg.network { "bridge" } else { "none" }
322 );
323
324 #[cfg(unix)]
328 {
329 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
330 args!(a, "--user", format!("{uid}:{gid}"));
331 }
332
333 args!(
334 a,
335 "--security-opt",
336 "no-new-privileges",
337 "--cap-drop",
338 "ALL"
339 );
340
341 if let Some(mb) = self.cfg.memory_mb {
342 args!(a, "--memory", format!("{mb}m"));
343 }
344 if let Some(cpus) = self.cfg.cpus {
345 args!(a, "--cpus", cpus);
346 }
347
348 for path in &self.cfg.readable {
349 args!(a, "-v", format!("{}:{}:ro", path.display(), path.display()));
350 }
351 args!(
352 a,
353 "-v",
354 format!("{}:{}", workspace.display(), workspace.display())
355 );
356 for path in &self.cfg.writable {
357 args!(a, "-v", format!("{}:{}", path.display(), path.display()));
358 }
359
360 args!(a, "-w", absolute(cwd)?.display());
361
362 for name in &self.cfg.env {
363 if let Ok(value) = std::env::var(name) {
364 args!(a, "-e", format!("{name}={value}"));
365 }
366 }
367
368 args!(a, self.cfg.image);
369 Ok(a)
370 }
371
372 pub async fn preflight(&self, workspace: &Path) -> Result<()> {
378 if !self.is_enabled() {
379 return Ok(());
380 }
381
382 let marker = "mecha-sandbox-ok";
383 let mut command = self
384 .command(&format!("echo {marker}"), workspace, workspace)
385 .context("building the sandbox command")?;
386
387 let output = tokio::time::timeout(
388 std::time::Duration::from_secs(60),
389 command.stdin(std::process::Stdio::null()).output(),
390 )
391 .await
392 .map_err(|_| anyhow::anyhow!("the {} sandbox timed out starting", self.cfg.kind.as_str()))?
393 .with_context(|| {
394 format!(
395 "cannot run `{}` — is it installed?",
396 match self.cfg.kind {
397 Backend::Docker => "docker",
398 _ => "bwrap",
399 }
400 )
401 })?;
402
403 if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(marker) {
404 return Ok(());
405 }
406
407 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
408 anyhow::bail!(
409 "the {} sandbox does not work here: {}{}",
410 self.cfg.kind.as_str(),
411 if stderr.is_empty() {
412 "no output".into()
413 } else {
414 stderr.clone()
415 },
416 diagnose(self.cfg.kind, &stderr)
417 )
418 }
419}
420
421fn diagnose(kind: Backend, stderr: &str) -> String {
427 match kind {
428 Backend::Bwrap if stderr.contains("uid map") || stderr.contains("user namespace") => {
429 "\n\nUnprivileged user namespaces are blocked. On Ubuntu 23.10+ this is \
430 usually AppArmor rather than the kernel:\n \
431 sysctl kernel.apparmor_restrict_unprivileged_userns # 1 means blocked\n\
432 Either install an AppArmor profile for bwrap, or set \
433 `kernel.apparmor_restrict_unprivileged_userns=0` (system-wide, weaker), \
434 or use `kind = \"docker\"` instead."
435 .into()
436 }
437 Backend::Bwrap if stderr.contains("loopback") => {
438 "\n\nbwrap could not configure loopback in the new network namespace. \
439 Set `network = true` to share the host's, or use `kind = \"docker\"`."
440 .into()
441 }
442 Backend::Docker if stderr.contains("permission denied") => {
443 "\n\nThe docker socket is not accessible. Add yourself to the `docker` \
444 group, or use `kind = \"bwrap\"`."
445 .into()
446 }
447 Backend::Docker => {
448 "\n\nCheck the image exists (`docker pull <image>`) and the daemon is running.".into()
449 }
450 _ => String::new(),
451 }
452}
453
454fn absolute(path: &Path) -> Result<PathBuf> {
455 path.canonicalize()
456 .with_context(|| format!("cannot resolve {}", path.display()))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn cfg(kind: Backend) -> SandboxConfig {
464 SandboxConfig {
465 kind,
466 ..SandboxConfig::default()
467 }
468 }
469
470 #[test]
471 fn a_disabled_sandbox_runs_bash_directly() {
472 let sandbox = Sandbox::new(cfg(Backend::None));
473 assert!(!sandbox.is_enabled());
474 assert!(sandbox.can_reach_network());
476 assert!(sandbox.reaches_beyond_workspace());
477 }
478
479 #[test]
480 fn confinement_without_network_closes_the_exfiltration_route() {
481 let sandbox = Sandbox::new(cfg(Backend::Bwrap));
482 assert!(!sandbox.can_reach_network());
483 assert!(!sandbox.reaches_beyond_workspace());
484
485 let sandbox = Sandbox::new(SandboxConfig {
488 network: true,
489 ..cfg(Backend::Bwrap)
490 });
491 assert!(sandbox.can_reach_network());
492 }
493
494 #[test]
495 fn a_bind_outside_the_workspace_is_still_reach_beyond_it() {
496 let sandbox = Sandbox::new(SandboxConfig {
497 readable: vec![PathBuf::from("/opt/toolchain")],
498 ..cfg(Backend::Bwrap)
499 });
500 assert!(
501 sandbox.reaches_beyond_workspace(),
502 "an extra bind is exactly how private data gets back in reach"
503 );
504 }
505
506 #[test]
507 fn bwrap_confines_the_environment_and_the_network() {
508 let workspace = std::env::temp_dir();
509 let args = Sandbox::new(cfg(Backend::Bwrap))
510 .bwrap_args(&workspace, &workspace)
511 .unwrap();
512
513 assert!(
514 args.contains(&"--unshare-net".into()),
515 "no network by default"
516 );
517 assert!(
518 args.contains(&"--clearenv".into()),
519 "the parent env must not leak"
520 );
521 assert!(args.contains(&"--unshare-user".into()));
522 assert!(args.contains(&"--die-with-parent".into()));
523 assert!(args.contains(&"--new-session".into()));
525
526 let workspace = workspace.canonicalize().unwrap();
528 let binds: Vec<_> = args
529 .iter()
530 .enumerate()
531 .filter(|(_, a)| *a == "--bind")
532 .map(|(i, _)| args[i + 1].clone())
533 .collect();
534 assert_eq!(binds, vec![workspace.display().to_string()]);
535 }
536
537 #[test]
538 fn network_is_shared_only_when_asked_for() {
539 let workspace = std::env::temp_dir();
540 let args = Sandbox::new(SandboxConfig {
541 network: true,
542 ..cfg(Backend::Bwrap)
543 })
544 .bwrap_args(&workspace, &workspace)
545 .unwrap();
546 assert!(!args.contains(&"--unshare-net".into()));
547 }
548
549 #[test]
550 fn docker_drops_privileges_and_the_network() {
551 let workspace = std::env::temp_dir();
552 let args = Sandbox::new(cfg(Backend::Docker))
553 .docker_args(&workspace, &workspace)
554 .unwrap();
555
556 assert_eq!(args[0], "run");
557 assert!(
558 args.contains(&"--rm".into()),
559 "containers must not accumulate"
560 );
561 assert!(args
562 .windows(2)
563 .any(|w| w[0] == "--network" && w[1] == "none"));
564 assert!(args
565 .windows(2)
566 .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
567 assert!(args
568 .windows(2)
569 .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
570 assert!(args.iter().any(|a| a == "--user"));
572 }
573
574 #[test]
575 fn a_child_inherits_only_the_base_and_what_was_named() {
576 std::env::set_var("MECHA_TEST_TOKEN", "sk-should-not-cross");
581 std::env::set_var("MECHA_TEST_WANTED", "fine");
582
583 let names: Vec<String> = Sandbox::child_env(&["MECHA_TEST_WANTED".into()])
584 .into_iter()
585 .map(|(k, _)| k)
586 .collect();
587
588 assert!(names.contains(&"MECHA_TEST_WANTED".to_string()));
589 assert!(
590 !names.contains(&"MECHA_TEST_TOKEN".to_string()),
591 "an unnamed variable must not reach a third-party process"
592 );
593 assert!(names.contains(&"PATH".to_string()));
596 assert!(names.contains(&"HOME".to_string()));
597 }
598
599 #[test]
600 fn wrapping_an_argv_does_not_route_through_a_shell() {
601 let workspace = std::env::temp_dir();
604 let sandbox = Sandbox::new(cfg(Backend::Bwrap));
605 let command = sandbox
606 .wrap_argv(
607 "node",
608 &["server.js".into(), "--flag with space".into()],
609 &workspace,
610 &workspace,
611 )
612 .unwrap();
613
614 let argv: Vec<_> = command
615 .as_std()
616 .get_args()
617 .map(|a| a.to_string_lossy().into_owned())
618 .collect();
619 assert!(
620 !argv.iter().any(|a| a == "-lc"),
621 "no shell should be involved"
622 );
623 assert!(
624 argv.contains(&"--flag with space".to_string()),
625 "args stay one argv entry"
626 );
627 }
628
629 #[test]
630 fn only_named_environment_variables_cross_the_boundary() {
631 std::env::set_var("MECHA_TEST_ALLOWED", "yes");
632 std::env::set_var("MECHA_TEST_SECRET", "no");
633
634 let workspace = std::env::temp_dir();
635 let sandbox = Sandbox::new(SandboxConfig {
636 env: vec!["MECHA_TEST_ALLOWED".into()],
637 ..cfg(Backend::Bwrap)
638 });
639 let args = sandbox.bwrap_args(&workspace, &workspace).unwrap();
640
641 assert!(args.contains(&"MECHA_TEST_ALLOWED".into()));
642 assert!(
643 !args.iter().any(|a| a == "MECHA_TEST_SECRET" || a == "no"),
644 "an unlisted variable must not cross"
645 );
646 }
647}