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}
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    /// Let confined commands reach the network.
72    ///
73    /// Off by default, and this is the single most valuable line in the file:
74    /// with it off, `shell` stops being an exfiltration route, which is what
75    /// lets the trifecta interlock relax rather than tighten.
76    pub network: bool,
77    /// Extra paths mounted writable, on top of the workspace.
78    pub writable: Vec<PathBuf>,
79    /// Extra paths mounted read-only. Use for a toolchain or a cache that
80    /// lives outside the workspace.
81    pub readable: Vec<PathBuf>,
82    /// Environment variables passed through by name. Nothing else survives —
83    /// an allowlist, because the interesting variables are the secret ones.
84    pub env: Vec<String>,
85    /// Container image for the `docker` backend.
86    pub image: String,
87    /// Memory ceiling in megabytes (`docker` only).
88    pub memory_mb: Option<u64>,
89    /// CPU ceiling (`docker` only), e.g. `2.0`.
90    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            // Small, ubiquitous, and has a shell and coreutils. Anything the
102            // agent actually needs to build with should be a different image.
103            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    /// The same policy with the network decided differently.
125    ///
126    /// Exists because network is otherwise one switch for everything, and the
127    /// common case wants them split: a third-party MCP server that has to reach
128    /// its own API, confined, while `shell` still has no way out. Sharing one
129    /// flag would mean opening `shell` to satisfy the server.
130    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    /// Can a confined command still reach off this machine?
144    ///
145    /// This is what decides whether `shell` counts as an `external_send` sink.
146    /// Unconfined, it always can. Confined without network, it cannot — and the
147    /// interlock should stop treating it as a way out, because it isn't one.
148    pub fn can_reach_network(&self) -> bool {
149        !self.is_enabled() || self.cfg.network
150    }
151
152    /// Can a confined command read data outside the workspace?
153    ///
154    /// Unconfined it reads your whole home directory. Confined it sees the
155    /// workspace and a read-only system — the same reach `fs_read` already has,
156    /// which is classified `private` on the same reasoning.
157    pub fn reaches_beyond_workspace(&self) -> bool {
158        !self.is_enabled() || !self.cfg.writable.is_empty() || !self.cfg.readable.is_empty()
159    }
160
161    /// Build the process that will run `command`.
162    ///
163    /// `workspace` is mounted writable; `cwd` must be inside it — the caller has
164    /// already proved that through `ToolCtx::resolve`.
165    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    /// Confine an explicit argv, with no shell in between.
182    ///
183    /// For long-lived children — an MCP server — where routing through
184    /// `bash -lc` would mean quoting caller-supplied arguments correctly, and
185    /// getting that wrong is a command-injection bug rather than a typo.
186    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    /// The environment a child should get, given a passthrough allowlist.
215    ///
216    /// Inheriting mecha's environment hands a third-party process every secret
217    /// you have exported — provider keys first among them. So the rule is the
218    /// same as inside the sandbox: a minimal base, plus what was named.
219    ///
220    /// `HOME` and `PATH` are in the base because without them most runtimes
221    /// (node, python) cannot find their own modules, and a server that cannot
222    /// start teaches the operator to turn this off.
223    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    /// Arguments up to (but not including) the command itself. Split out so the
234    /// policy can be asserted on in tests without spawning anything.
235    pub fn bwrap_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
236        let mut a: Vec<String> = Vec::new();
237
238        // `--die-with-parent` so a wedged command cannot outlive mecha.
239        // `--new-session` detaches the controlling terminal, which blocks the
240        // TIOCSTI trick of pushing characters into the parent's input queue.
241        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        // The system, read-only. `/bin`, `/lib` and friends are symlinks into
256        // `/usr` on merged systems and real directories on older ones, so ask
257        // rather than assume — binding a symlink as a directory fails.
258        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        // A private /tmp, so a command cannot leave anything behind for the
275        // next one or read what the last one left.
276        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        // Nothing from the parent environment survives unless it is named.
291        // API keys live in the environment; a confined command that inherits
292        // them is confined in the least interesting way.
293        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    /// Arguments up to (but not including) the command itself.
313    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        // Root inside a container writing into a bind-mounted workspace leaves
325        // root-owned files behind on the host, which the user then cannot
326        // delete. Run as the caller.
327        #[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    /// Prove the sandbox actually works, by running something through it.
373    ///
374    /// Called once at startup rather than on the first tool call, so a
375    /// misconfiguration is a clear message at launch instead of a confusing
376    /// tool error twenty turns into a run.
377    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
421/// Turn a backend's error into something the operator can act on.
422///
423/// The Ubuntu 24.04 case is the one worth spelling out: `bwrap` is installed,
424/// `unprivileged_userns_clone` is 1, and it still fails, because AppArmor
425/// gained a separate switch that nothing mentions.
426fn 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        // Nothing is confined, so every capability `shell` declares still holds.
475        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        // ...and asking for the network opens it again. This is the whole
486        // basis for relaxing the interlock, so it must not be sloppy.
487        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        // Blocks TIOCSTI input injection into the parent's terminal.
524        assert!(args.contains(&"--new-session".into()));
525
526        // The workspace is the only writable bind.
527        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        // Running as root would leave root-owned files in the user's workspace.
571        assert!(args.iter().any(|a| a == "--user"));
572    }
573
574    #[test]
575    fn a_child_inherits_only_the_base_and_what_was_named() {
576        // The leak this closes: `Command::envs()` adds to the inherited
577        // environment rather than replacing it, so an MCP server used to start
578        // holding every provider key mecha had. Nothing crosses now unless the
579        // config named it.
580        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        // PATH and HOME are the base: without them most runtimes cannot start,
594        // and a server that will not start teaches people to turn this off.
595        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        // A server's args are config, but quoting them into `bash -lc` would
602        // still be a command-injection bug waiting for one entry with a space.
603        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}