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 Landlock,
75}
76
77impl Backend {
78 pub fn as_str(self) -> &'static str {
79 match self {
80 Backend::None => "none",
81 Backend::Bwrap => "bwrap",
82 Backend::Docker => "docker",
83 Backend::Landlock => "landlock",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct SandboxConfig {
91 pub kind: Backend,
92 pub network: bool,
98 pub writable: Vec<PathBuf>,
100 pub readable: Vec<PathBuf>,
103 pub env: Vec<String>,
106 pub image: String,
108 pub memory_mb: Option<u64>,
110 pub cpus: Option<f64>,
112}
113
114impl Default for SandboxConfig {
115 fn default() -> Self {
116 SandboxConfig {
117 kind: Backend::None,
118 network: false,
119 writable: Vec::new(),
120 readable: Vec::new(),
121 env: Vec::new(),
122 image: "debian:stable-slim".into(),
125 memory_mb: None,
126 cpus: None,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default)]
132pub struct Sandbox {
133 cfg: SandboxConfig,
134}
135
136impl Sandbox {
137 pub fn new(cfg: SandboxConfig) -> Self {
138 Sandbox { cfg }
139 }
140
141 pub fn backend(&self) -> Backend {
142 self.cfg.kind
143 }
144
145 pub fn with_network(&self, network: bool) -> Self {
152 Sandbox {
153 cfg: SandboxConfig {
154 network,
155 ..self.cfg.clone()
156 },
157 }
158 }
159
160 pub fn is_enabled(&self) -> bool {
161 self.cfg.kind != Backend::None
162 }
163
164 pub fn can_reach_network(&self) -> bool {
170 match self.cfg.kind {
171 Backend::None => true,
172 Backend::Landlock => true,
180 _ => self.cfg.network,
181 }
182 }
183
184 pub fn reaches_beyond_workspace(&self) -> bool {
190 !self.is_enabled() || !self.cfg.writable.is_empty() || !self.cfg.readable.is_empty()
191 }
192
193 pub fn command(
198 &self,
199 command: &str,
200 workspace: &Path,
201 cwd: &Path,
202 ) -> Result<tokio::process::Command> {
203 match self.cfg.kind {
204 Backend::None => {
205 let mut c = tokio::process::Command::new("bash");
206 c.arg("-lc").arg(command).current_dir(cwd);
207 Ok(c)
208 }
209 _ => self.wrap_argv("bash", &["-lc".into(), command.into()], workspace, cwd),
210 }
211 }
212
213 pub fn wrap_argv(
219 &self,
220 program: &str,
221 args: &[String],
222 workspace: &Path,
223 cwd: &Path,
224 ) -> Result<tokio::process::Command> {
225 match self.cfg.kind {
226 Backend::None => {
227 let mut c = tokio::process::Command::new(program);
228 c.args(args).current_dir(cwd);
229 Ok(c)
230 }
231 Backend::Bwrap => {
232 let mut c = tokio::process::Command::new("bwrap");
233 c.args(self.bwrap_args(workspace, cwd)?);
234 c.arg("--").arg(program).args(args);
235 Ok(c)
236 }
237 Backend::Docker => {
238 let mut c = tokio::process::Command::new("docker");
239 c.args(self.docker_args(workspace, cwd)?);
240 c.arg(program).args(args);
241 Ok(c)
242 }
243 Backend::Landlock => self.landlock_command(program, args, workspace, cwd),
244 }
245 }
246
247 #[cfg(target_os = "linux")]
260 fn landlock_command(
261 &self,
262 program: &str,
263 args: &[String],
264 workspace: &Path,
265 cwd: &Path,
266 ) -> Result<tokio::process::Command> {
267 use landlock::RulesetStatus;
268
269 let workspace = absolute(workspace)?;
270 let ruleset = self.landlock_ruleset(&workspace)?;
271
272 let mut c = tokio::process::Command::new(program);
273 c.args(args).current_dir(absolute(cwd)?);
274
275 c.env_clear();
279 c.env(
280 "PATH",
281 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
282 );
283 c.env("HOME", workspace.as_os_str());
284 for name in &self.cfg.env {
285 if let Ok(value) = std::env::var(name) {
286 c.env(name, value);
287 }
288 }
289
290 let mut ruleset = Some(ruleset);
291 unsafe {
292 c.pre_exec(move || {
293 let rs = ruleset
296 .take()
297 .ok_or_else(|| std::io::Error::other("landlock ruleset already consumed"))?;
298 let status = rs.restrict_self().map_err(std::io::Error::other)?;
299 if status.ruleset == RulesetStatus::NotEnforced {
300 return Err(std::io::Error::other(
301 "the kernel did not enforce the landlock ruleset",
302 ));
303 }
304 Ok(())
305 });
306 }
307 Ok(c)
308 }
309
310 #[cfg(not(target_os = "linux"))]
311 fn landlock_command(
312 &self,
313 _program: &str,
314 _args: &[String],
315 _workspace: &Path,
316 _cwd: &Path,
317 ) -> Result<tokio::process::Command> {
318 anyhow::bail!("the landlock sandbox is Linux-only; use `kind = \"docker\"` here")
319 }
320
321 #[cfg(target_os = "linux")]
332 fn landlock_ruleset(&self, workspace: &Path) -> Result<landlock::RulesetCreated> {
333 use landlock::{
334 Access, AccessFs, AccessNet, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset,
335 RulesetAttr, RulesetCreatedAttr, ABI,
336 };
337
338 let abi = ABI::V3;
339 let read = AccessFs::from_read(abi);
340 let full = AccessFs::from_all(abi);
341
342 let base = Ruleset::default()
343 .set_compatibility(CompatLevel::HardRequirement)
344 .handle_access(full)
345 .context("this kernel cannot enforce the landlock file policy (needs 6.2+)")?;
346 let mut created = if self.cfg.network {
347 base.create()
348 } else {
349 base.set_compatibility(CompatLevel::BestEffort)
350 .handle_access(AccessNet::BindTcp | AccessNet::ConnectTcp)
351 .context("declaring the TCP restriction")?
352 .create()
353 }
354 .context("creating the landlock ruleset")?;
355
356 for dir in [
361 "/usr", "/etc", "/opt", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/proc", "/run",
362 ] {
363 if let Ok(fd) = PathFd::new(dir) {
364 created = created.add_rule(PathBeneath::new(fd, read))?;
365 }
366 }
367 for path in &self.cfg.readable {
368 if let Ok(fd) = PathFd::new(path) {
369 created = created.add_rule(PathBeneath::new(fd, read))?;
370 }
371 }
372
373 created = created.add_rule(PathBeneath::new(
380 PathFd::new(workspace)
381 .with_context(|| format!("opening the workspace {}", workspace.display()))?,
382 full,
383 ))?;
384 for path in &self.cfg.writable {
385 if let Ok(fd) = PathFd::new(path) {
386 created = created.add_rule(PathBeneath::new(fd, full))?;
387 }
388 }
389 for dir in ["/dev", "/tmp"] {
390 if let Ok(fd) = PathFd::new(dir) {
391 created = created.add_rule(PathBeneath::new(fd, full))?;
392 }
393 }
394
395 Ok(created)
396 }
397
398 pub fn child_env(passthrough: &[String]) -> Vec<(String, String)> {
408 const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
409
410 BASE.iter()
411 .map(|s| s.to_string())
412 .chain(passthrough.iter().cloned())
413 .filter_map(|name| std::env::var(&name).ok().map(|v| (name, v)))
414 .collect()
415 }
416
417 pub fn bwrap_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
420 let mut a: Vec<String> = Vec::new();
421
422 args!(
426 a,
427 "--die-with-parent",
428 "--new-session",
429 "--unshare-user",
430 "--unshare-pid",
431 "--unshare-ipc",
432 "--unshare-uts",
433 "--unshare-cgroup-try",
434 );
435 if !self.cfg.network {
436 args!(a, "--unshare-net");
437 }
438
439 for dir in ["/usr", "/etc", "/opt"] {
443 if Path::new(dir).is_dir() {
444 args!(a, "--ro-bind-try", dir, dir);
445 }
446 }
447 for dir in ["/bin", "/sbin", "/lib", "/lib32", "/lib64"] {
448 match std::fs::symlink_metadata(dir) {
449 Ok(meta) if meta.file_type().is_symlink() => {
450 let target = std::fs::read_link(dir)?;
451 args!(a, "--symlink", target.to_string_lossy(), dir);
452 }
453 Ok(_) => args!(a, "--ro-bind-try", dir, dir),
454 Err(_) => {}
455 }
456 }
457
458 args!(a, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp");
461
462 for path in &self.cfg.readable {
463 args!(a, "--ro-bind-try", path.display(), path.display());
464 }
465
466 let workspace = absolute(workspace)?;
467 args!(a, "--bind", workspace.display(), workspace.display());
468 for path in &self.cfg.writable {
469 args!(a, "--bind-try", path.display(), path.display());
470 }
471
472 args!(a, "--chdir", absolute(cwd)?.display());
473
474 args!(
478 a,
479 "--clearenv",
480 "--setenv",
481 "PATH",
482 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
483 "--setenv",
484 "HOME",
485 workspace.display(),
486 );
487 for name in &self.cfg.env {
488 if let Ok(value) = std::env::var(name) {
489 args!(a, "--setenv", name, value);
490 }
491 }
492
493 Ok(a)
494 }
495
496 pub fn docker_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
498 let workspace = absolute(workspace)?;
499 let mut a: Vec<String> = Vec::new();
500 args!(a, "run", "--rm", "-i");
501
502 args!(
503 a,
504 "--network",
505 if self.cfg.network { "bridge" } else { "none" }
506 );
507
508 #[cfg(unix)]
512 {
513 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
514 args!(a, "--user", format!("{uid}:{gid}"));
515 }
516
517 args!(
518 a,
519 "--security-opt",
520 "no-new-privileges",
521 "--cap-drop",
522 "ALL"
523 );
524
525 if let Some(mb) = self.cfg.memory_mb {
526 args!(a, "--memory", format!("{mb}m"));
527 }
528 if let Some(cpus) = self.cfg.cpus {
529 args!(a, "--cpus", cpus);
530 }
531
532 for path in &self.cfg.readable {
533 args!(a, "-v", format!("{}:{}:ro", path.display(), path.display()));
534 }
535 args!(
536 a,
537 "-v",
538 format!("{}:{}", workspace.display(), workspace.display())
539 );
540 for path in &self.cfg.writable {
541 args!(a, "-v", format!("{}:{}", path.display(), path.display()));
542 }
543
544 args!(a, "-w", absolute(cwd)?.display());
545
546 for name in &self.cfg.env {
547 if let Ok(value) = std::env::var(name) {
548 args!(a, "-e", format!("{name}={value}"));
549 }
550 }
551
552 args!(a, self.cfg.image);
553 Ok(a)
554 }
555
556 pub async fn preflight(&self, workspace: &Path) -> Result<()> {
562 if !self.is_enabled() {
563 return Ok(());
564 }
565
566 let marker = "mecha-sandbox-ok";
567 let mut command = self
568 .command(&format!("echo {marker}"), workspace, workspace)
569 .context("building the sandbox command")?;
570
571 let output = tokio::time::timeout(
572 std::time::Duration::from_secs(60),
573 command.stdin(std::process::Stdio::null()).output(),
574 )
575 .await
576 .map_err(|_| anyhow::anyhow!("the {} sandbox timed out starting", self.cfg.kind.as_str()))?
577 .with_context(|| {
578 format!(
579 "cannot run `{}` — is it installed?",
580 match self.cfg.kind {
581 Backend::Docker => "docker",
582 Backend::Landlock => "bash",
585 _ => "bwrap",
586 }
587 )
588 })?;
589
590 if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(marker) {
591 self.prove_landlock_containment(workspace).await?;
592 return Ok(());
593 }
594
595 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
596 anyhow::bail!(
597 "the {} sandbox does not work here: {}{}",
598 self.cfg.kind.as_str(),
599 if stderr.is_empty() {
600 "no output".into()
601 } else {
602 stderr.clone()
603 },
604 diagnose(self.cfg.kind, &stderr)
605 )
606 }
607
608 async fn prove_landlock_containment(&self, workspace: &Path) -> Result<()> {
618 if self.cfg.kind != Backend::Landlock {
619 return Ok(());
620 }
621 let Some(home) = dirs::home_dir() else {
622 return Ok(());
623 };
624 let probe = home.join(format!(".mecha-landlock-probe-{}", uuid::Uuid::new_v4()));
625 let ws = workspace
626 .canonicalize()
627 .unwrap_or_else(|_| workspace.to_path_buf());
628 if probe.starts_with(&ws) {
629 return Ok(());
630 }
631 if std::fs::write(&probe, "canary").is_err() {
632 return Ok(());
633 }
634
635 let read = async {
636 let mut command = self
637 .command(&format!("cat '{}'", probe.display()), workspace, workspace)
638 .context("building the containment probe")?;
639 let out = command
640 .stdin(std::process::Stdio::null())
641 .output()
642 .await
643 .context("running the containment probe")?;
644 anyhow::Ok(out.status.success())
645 }
646 .await;
647 std::fs::remove_file(&probe).ok();
648
649 if read? {
650 anyhow::bail!(
651 "the landlock sandbox is not actually confining: a confined command read \
652 {} — a file outside every rule. Refusing to run with decorative \
653 confinement; use `kind = \"bwrap\"` or `\"docker\"`, and please report \
654 this.",
655 probe.display()
656 );
657 }
658 Ok(())
659 }
660}
661
662pub fn landlock_supported() -> bool {
667 #[cfg(target_os = "linux")]
668 {
669 use landlock::{Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, ABI};
670 Ruleset::default()
671 .set_compatibility(CompatLevel::HardRequirement)
672 .handle_access(AccessFs::from_all(ABI::V3))
673 .and_then(|r| r.create())
674 .is_ok()
675 }
676 #[cfg(not(target_os = "linux"))]
677 false
678}
679
680fn diagnose(kind: Backend, stderr: &str) -> String {
686 match kind {
687 Backend::Bwrap if stderr.contains("uid map") || stderr.contains("user namespace") => {
688 "\n\nUnprivileged user namespaces are blocked. On Ubuntu 23.10+ this is \
689 usually AppArmor rather than the kernel:\n \
690 sysctl kernel.apparmor_restrict_unprivileged_userns # 1 means blocked\n\
691 Either install an AppArmor profile for bwrap, or set \
692 `kernel.apparmor_restrict_unprivileged_userns=0` (system-wide, weaker), \
693 or use `kind = \"docker\"` instead."
694 .into()
695 }
696 Backend::Bwrap if stderr.contains("loopback") => {
697 "\n\nbwrap could not configure loopback in the new network namespace. \
698 Set `network = true` to share the host's, or use `kind = \"docker\"`."
699 .into()
700 }
701 Backend::Docker if stderr.contains("permission denied") => {
702 "\n\nThe docker socket is not accessible. Add yourself to the `docker` \
703 group, or use `kind = \"bwrap\"`."
704 .into()
705 }
706 Backend::Docker => {
707 "\n\nCheck the image exists (`docker pull <image>`) and the daemon is running.".into()
708 }
709 Backend::Landlock => "\n\nLandlock needs the LSM enabled on a 6.2+ kernel: `cat \
710 /sys/kernel/security/lsm` should include `landlock`. Where it is \
711 unavailable, use `kind = \"bwrap\"` or `\"docker\"`."
712 .into(),
713 _ => String::new(),
714 }
715}
716
717fn absolute(path: &Path) -> Result<PathBuf> {
718 path.canonicalize()
719 .with_context(|| format!("cannot resolve {}", path.display()))
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 fn cfg(kind: Backend) -> SandboxConfig {
727 SandboxConfig {
728 kind,
729 ..SandboxConfig::default()
730 }
731 }
732
733 #[test]
734 fn a_disabled_sandbox_runs_bash_directly() {
735 let sandbox = Sandbox::new(cfg(Backend::None));
736 assert!(!sandbox.is_enabled());
737 assert!(sandbox.can_reach_network());
739 assert!(sandbox.reaches_beyond_workspace());
740 }
741
742 #[test]
743 fn confinement_without_network_closes_the_exfiltration_route() {
744 let sandbox = Sandbox::new(cfg(Backend::Bwrap));
745 assert!(!sandbox.can_reach_network());
746 assert!(!sandbox.reaches_beyond_workspace());
747
748 let sandbox = Sandbox::new(SandboxConfig {
751 network: true,
752 ..cfg(Backend::Bwrap)
753 });
754 assert!(sandbox.can_reach_network());
755 }
756
757 #[test]
763 fn landlock_never_earns_the_network_narrowing() {
764 let sandbox = Sandbox::new(cfg(Backend::Landlock));
765 assert!(sandbox.is_enabled());
766 assert!(
767 sandbox.can_reach_network(),
768 "a landlocked shell must stay an external_send sink"
769 );
770 let explicit = Sandbox::new(SandboxConfig {
773 network: false,
774 ..cfg(Backend::Landlock)
775 });
776 assert!(explicit.can_reach_network());
777 }
778
779 #[test]
780 fn a_bind_outside_the_workspace_is_still_reach_beyond_it() {
781 let sandbox = Sandbox::new(SandboxConfig {
782 readable: vec![PathBuf::from("/opt/toolchain")],
783 ..cfg(Backend::Bwrap)
784 });
785 assert!(
786 sandbox.reaches_beyond_workspace(),
787 "an extra bind is exactly how private data gets back in reach"
788 );
789 }
790
791 #[test]
792 fn bwrap_confines_the_environment_and_the_network() {
793 let workspace = std::env::temp_dir();
794 let args = Sandbox::new(cfg(Backend::Bwrap))
795 .bwrap_args(&workspace, &workspace)
796 .unwrap();
797
798 assert!(
799 args.contains(&"--unshare-net".into()),
800 "no network by default"
801 );
802 assert!(
803 args.contains(&"--clearenv".into()),
804 "the parent env must not leak"
805 );
806 assert!(args.contains(&"--unshare-user".into()));
807 assert!(args.contains(&"--die-with-parent".into()));
808 assert!(args.contains(&"--new-session".into()));
810
811 let workspace = workspace.canonicalize().unwrap();
813 let binds: Vec<_> = args
814 .iter()
815 .enumerate()
816 .filter(|(_, a)| *a == "--bind")
817 .map(|(i, _)| args[i + 1].clone())
818 .collect();
819 assert_eq!(binds, vec![workspace.display().to_string()]);
820 }
821
822 #[test]
823 fn network_is_shared_only_when_asked_for() {
824 let workspace = std::env::temp_dir();
825 let args = Sandbox::new(SandboxConfig {
826 network: true,
827 ..cfg(Backend::Bwrap)
828 })
829 .bwrap_args(&workspace, &workspace)
830 .unwrap();
831 assert!(!args.contains(&"--unshare-net".into()));
832 }
833
834 #[test]
835 fn docker_drops_privileges_and_the_network() {
836 let workspace = std::env::temp_dir();
837 let args = Sandbox::new(cfg(Backend::Docker))
838 .docker_args(&workspace, &workspace)
839 .unwrap();
840
841 assert_eq!(args[0], "run");
842 assert!(
843 args.contains(&"--rm".into()),
844 "containers must not accumulate"
845 );
846 assert!(args
847 .windows(2)
848 .any(|w| w[0] == "--network" && w[1] == "none"));
849 assert!(args
850 .windows(2)
851 .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
852 assert!(args
853 .windows(2)
854 .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
855 assert!(args.iter().any(|a| a == "--user"));
857 }
858
859 #[test]
860 fn a_child_inherits_only_the_base_and_what_was_named() {
861 std::env::set_var("MECHA_TEST_TOKEN", "sk-should-not-cross");
866 std::env::set_var("MECHA_TEST_WANTED", "fine");
867
868 let names: Vec<String> = Sandbox::child_env(&["MECHA_TEST_WANTED".into()])
869 .into_iter()
870 .map(|(k, _)| k)
871 .collect();
872
873 assert!(names.contains(&"MECHA_TEST_WANTED".to_string()));
874 assert!(
875 !names.contains(&"MECHA_TEST_TOKEN".to_string()),
876 "an unnamed variable must not reach a third-party process"
877 );
878 assert!(names.contains(&"PATH".to_string()));
881 assert!(names.contains(&"HOME".to_string()));
882 }
883
884 #[test]
885 fn wrapping_an_argv_does_not_route_through_a_shell() {
886 let workspace = std::env::temp_dir();
889 let sandbox = Sandbox::new(cfg(Backend::Bwrap));
890 let command = sandbox
891 .wrap_argv(
892 "node",
893 &["server.js".into(), "--flag with space".into()],
894 &workspace,
895 &workspace,
896 )
897 .unwrap();
898
899 let argv: Vec<_> = command
900 .as_std()
901 .get_args()
902 .map(|a| a.to_string_lossy().into_owned())
903 .collect();
904 assert!(
905 !argv.iter().any(|a| a == "-lc"),
906 "no shell should be involved"
907 );
908 assert!(
909 argv.contains(&"--flag with space".to_string()),
910 "args stay one argv entry"
911 );
912 }
913
914 #[test]
915 fn only_named_environment_variables_cross_the_boundary() {
916 std::env::set_var("MECHA_TEST_ALLOWED", "yes");
917 std::env::set_var("MECHA_TEST_SECRET", "no");
918
919 let workspace = std::env::temp_dir();
920 let sandbox = Sandbox::new(SandboxConfig {
921 env: vec!["MECHA_TEST_ALLOWED".into()],
922 ..cfg(Backend::Bwrap)
923 });
924 let args = sandbox.bwrap_args(&workspace, &workspace).unwrap();
925
926 assert!(args.contains(&"MECHA_TEST_ALLOWED".into()));
927 assert!(
928 !args.iter().any(|a| a == "MECHA_TEST_SECRET" || a == "no"),
929 "an unlisted variable must not cross"
930 );
931 }
932}