Skip to main content

speccoder_runtime/
sandbox.rs

1//! The OS sandbox for commands speccoder starts.
2//!
3//! Checks, the model's `run_command`, the formatter, acceptance tests and MCP
4//! servers run code the model may have written. The policy decides *which*
5//! commands run; the sandbox confines *what they do*:
6//!
7//! - writes only to the workspace, a private temporary directory and the
8//!   package caches builds need (never the rest of the state directory —
9//!   approvals live there — and never `~/.cargo/bin`);
10//! - credentials unreadable (`~/.ssh`, `~/.aws`, `~/.cargo/credentials*`, …);
11//! - no network beyond loopback, unless the command is allowed it
12//!   (exactly, `[sandbox] network_allow`, or by a prefix in
13//!   `[sandbox] network_allow_prefix`) or a person approved it.
14//!
15//! macOS: Seatbelt (`sandbox-exec`) with a generated profile. Linux: the
16//! `speccoder` binary re-executes itself as a helper (`__sandbox`) that
17//! applies Landlock and a seccomp filter to itself, sets `no_new_privs` and
18//! executes the command — so the workspace keeps `unsafe_code` forbidden.
19
20use std::path::{Path, PathBuf};
21use std::sync::OnceLock;
22
23use serde::{Deserialize, Serialize};
24
25use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
26
27/// What a sandboxed command may reach on the network.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Network {
31    /// Nothing (on Linux also not loopback).
32    Off,
33    /// Loopback only: local test servers work, the outside does not. On
34    /// Linux this is [`Network::Off`] (seccomp cannot tell addresses apart).
35    Loopback,
36    /// Everything.
37    On,
38}
39
40/// Whether to sandbox.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43    /// Sandbox where the OS supports it; otherwise run unsandboxed and say so.
44    Auto,
45    /// Refuse to run commands unsandboxed.
46    Required,
47    /// Do not sandbox.
48    Off,
49}
50
51/// `[sandbox]` in `speccoder.toml`.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Config {
54    /// Whether to sandbox.
55    pub mode: Mode,
56    /// The network for every command.
57    pub network: Network,
58    /// Exact commands that get the network (`[["cargo", "fetch"]]`).
59    pub network_allow: Vec<Vec<String>>,
60    /// Command prefixes (two elements or more) that get the network.
61    pub network_allow_prefix: Vec<Vec<String>>,
62    /// More writable paths (`~` is the home directory).
63    pub writable: Vec<String>,
64}
65
66impl Default for Config {
67    fn default() -> Self {
68        Self {
69            mode: Mode::Auto,
70            network: Network::Loopback,
71            network_allow: Vec::new(),
72            network_allow_prefix: Vec::new(),
73            writable: Vec::new(),
74        }
75    }
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(deny_unknown_fields)]
80struct Raw {
81    mode: Option<String>,
82    network: Option<String>,
83    #[serde(default)]
84    network_allow: Vec<Vec<String>>,
85    #[serde(default)]
86    network_allow_prefix: Vec<Vec<String>>,
87    #[serde(default)]
88    writable: Vec<String>,
89}
90
91/// Reads `[sandbox]`.
92pub fn parse(text: &str) -> Result<Config, String> {
93    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
94    let Some(v) = table.get("sandbox") else {
95        return Ok(Config::default());
96    };
97    let raw: Raw = v
98        .clone()
99        .try_into()
100        .map_err(|e| format!("[sandbox]: {e}"))?;
101    let mode = match raw.mode.as_deref() {
102        None | Some("auto") => Mode::Auto,
103        Some("required") => Mode::Required,
104        Some("off") => Mode::Off,
105        Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
106    };
107    let network = match raw.network.as_deref() {
108        None | Some("loopback") => Network::Loopback,
109        Some("off") => Network::Off,
110        Some("on") => Network::On,
111        Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
112    };
113    if let Some(why) = raw
114        .network_allow_prefix
115        .iter()
116        .find_map(|p| crate::tools::policy::prefix_refusal(p))
117    {
118        return Err(format!("[sandbox] network_allow_prefix: {why}"));
119    }
120    Ok(Config {
121        mode,
122        network,
123        network_allow: raw
124            .network_allow
125            .into_iter()
126            .filter(|p| !p.is_empty())
127            .collect(),
128        network_allow_prefix: raw.network_allow_prefix,
129        writable: raw.writable,
130    })
131}
132
133/// Credentials a command never reads, relative to the home directory.
134pub const HIDDEN: &[&str] = &[
135    ".ssh",
136    ".aws",
137    ".azure",
138    ".gnupg",
139    ".netrc",
140    ".docker",
141    ".kube",
142    ".npmrc",
143    ".pypirc",
144    ".git-credentials",
145    ".password-store",
146    ".config/gh",
147    ".config/gcloud",
148    ".config/hub",
149    ".cargo/credentials",
150    ".cargo/credentials.toml",
151    ".local/share/keyrings",
152    "Library/Keychains",
153];
154
155/// Package caches builds write to, relative to the home directory. Not
156/// `~/.cargo` as a whole: `~/.cargo/bin` would let a test replace `cargo`.
157pub const CACHES: &[&str] = &[
158    ".cargo/registry",
159    ".cargo/git",
160    ".cargo/.package-cache",
161    ".cargo/.package-cache-mutate",
162    ".cargo/.global-cache",
163    ".cache",
164    ".npm",
165    ".pnpm-store",
166    ".yarn/berry/cache",
167    "go/pkg/mod",
168    "Library/Caches",
169];
170
171/// One command's confinement.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Profile {
174    /// Writable trees (the workspace first).
175    pub writable: Vec<PathBuf>,
176    /// Unreadable trees.
177    pub hidden: Vec<PathBuf>,
178    /// Trees inside the writable ones that stay read-only (in place: the
179    /// checkout's `.git`).
180    pub read_only: Vec<PathBuf>,
181    /// The network.
182    pub network: Network,
183    /// The private temporary directory (`TMPDIR`), writable.
184    pub tmp: PathBuf,
185}
186
187fn canonical(p: &Path) -> PathBuf {
188    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
189}
190
191impl Profile {
192    /// The profile for commands working in `root`, for the home directory `home`.
193    #[must_use]
194    pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
195        let root = canonical(root);
196        let expand = |p: &str| -> Option<PathBuf> {
197            match (p.strip_prefix("~/"), home) {
198                (Some(rest), Some(h)) => Some(h.join(rest)),
199                (Some(_), None) => None,
200                (None, _) => Some(PathBuf::from(p)),
201            }
202        };
203        let home = home.map(canonical);
204        let home = home.as_deref();
205        let mut writable = vec![root.clone()];
206        writable.extend(
207            home.into_iter()
208                .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
209        );
210        writable.extend(
211            config
212                .writable
213                .iter()
214                .filter_map(|p| expand(p))
215                .map(|p| canonical(&p)),
216        );
217        let hidden = home
218            .into_iter()
219            .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
220            .collect();
221        let digest = {
222            use sha2::{Digest as _, Sha256};
223            let d = Sha256::digest(root.to_string_lossy().as_bytes());
224            d.iter()
225                .take(6)
226                .map(|b| format!("{b:02x}"))
227                .collect::<String>()
228        };
229        // Short (Unix socket paths are limited to ~104 bytes) and per workspace.
230        let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
231        Self {
232            writable,
233            hidden,
234            read_only: Vec::new(),
235            network: config.network,
236            tmp,
237        }
238    }
239
240    /// The same profile with `read_only` trees carved out of the writable
241    /// ones, and `hidden` added to the unreadable ones.
242    #[must_use]
243    pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
244        self.read_only
245            .extend(read_only.iter().map(|p| canonical(p)));
246        self.hidden.extend(hidden.iter().map(|p| canonical(p)));
247        self
248    }
249
250    /// The same profile with another network.
251    #[must_use]
252    pub fn with_network(mut self, network: Network) -> Self {
253        self.network = network;
254        self
255    }
256}
257
258/// How this machine sandboxes.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Backend {
261    /// macOS Seatbelt.
262    Seatbelt,
263    /// Linux Landlock + seccomp through the `speccoder` helper at this path.
264    Landlock(PathBuf),
265}
266
267const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
268
269/// The helper subcommand (`speccoder __sandbox <profile> -- argv…`).
270pub const HELPER_ARG: &str = "__sandbox";
271
272/// The sandbox this machine offers, or why there is none.
273pub fn backend() -> Result<Backend, String> {
274    static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
275    CACHED.get_or_init(detect).clone()
276}
277
278fn detect() -> Result<Backend, String> {
279    if cfg!(target_os = "macos") {
280        return if Path::new(SANDBOX_EXEC).exists() {
281            Ok(Backend::Seatbelt)
282        } else {
283            Err(format!("{SANDBOX_EXEC} is missing"))
284        };
285    }
286    if cfg!(target_os = "linux") {
287        let helper = std::env::var_os("SPECCODER_SANDBOX_HELPER")
288            .map(PathBuf::from)
289            .or_else(|| {
290                std::env::current_exe()
291                    .ok()
292                    .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
293            })
294            .ok_or_else(|| "the speccoder helper binary was not found".to_owned())?;
295        return with_helper(helper);
296    }
297    Err(format!("no sandbox for {}", std::env::consts::OS))
298}
299
300/// The Linux backend with the helper binary at `helper`, if the kernel
301/// supports Landlock.
302pub fn with_helper(helper: PathBuf) -> Result<Backend, String> {
303    linux_supported()?;
304    Ok(Backend::Landlock(helper))
305}
306
307#[cfg(target_os = "linux")]
308fn linux_supported() -> Result<(), String> {
309    linux::probe()
310}
311
312#[cfg(not(target_os = "linux"))]
313fn linux_supported() -> Result<(), String> {
314    Err("not Linux".to_owned())
315}
316
317fn quote(p: &Path) -> String {
318    let s = p.to_string_lossy();
319    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
320}
321
322/// `text` as a literal in a Seatbelt (POSIX extended) regular expression.
323fn regex_escape(text: &str) -> String {
324    let mut out = String::with_capacity(text.len());
325    for c in text.chars() {
326        if "\\^$.|?*+()[]{}\"".contains(c) {
327            out.push('\\');
328        }
329        out.push(c);
330    }
331    out
332}
333
334/// The Seatbelt profile for `p`. Later rules win.
335#[must_use]
336pub fn seatbelt(p: &Profile) -> String {
337    let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
338    out.push_str("(allow file-write*");
339    for w in p.writable.iter().chain([&p.tmp]) {
340        out.push_str(&format!(" (subpath {})", quote(w)));
341    }
342    // macOS's `mktemp` ignores `TMPDIR` and creates `tmp.XXXXXXXX` in the
343    // per-user temporary directory (the private one's parent): only those
344    // names, and what is inside them, are writable there.
345    if let Some(user_tmp) = p.tmp.parent() {
346        out.push_str(&format!(
347            " (regex #\"^{}/tmp\\.[A-Za-z0-9]+(/|$)\")",
348            regex_escape(&user_tmp.to_string_lossy())
349        ));
350    }
351    out.push_str(
352        " (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
353    );
354    if !p.read_only.is_empty() {
355        out.push_str("(deny file-write*");
356        for r in &p.read_only {
357            out.push_str(&format!(" (subpath {})", quote(r)));
358        }
359        out.push_str(")\n");
360    }
361    if !p.hidden.is_empty() {
362        out.push_str("(deny file-read* file-write*");
363        for h in &p.hidden {
364            out.push_str(&format!(" (subpath {})", quote(h)));
365        }
366        out.push_str(")\n");
367    }
368    // Opening applications (and Apple events) would run code outside the sandbox.
369    out.push_str("(deny appleevent-send)\n");
370    out.push_str("(deny mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\") (global-name \"com.apple.coreservices.launchservicesd\") (global-name \"com.apple.coreservices.appleevents\"))\n");
371    match p.network {
372        Network::On => {}
373        Network::Off | Network::Loopback => {
374            out.push_str("(deny network*)\n");
375            if p.network == Network::Loopback {
376                // Not `(local ip "localhost:*")` for all operations: it also
377                // matches outbound connections to anywhere.
378                out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
379            }
380            // Unix sockets inside the writable trees (not the Docker socket).
381            out.push_str("(allow network-outbound network-bind network-inbound");
382            for w in p.writable.iter().chain([&p.tmp]) {
383                out.push_str(&format!(
384                    " (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
385                    q = quote(w)
386                ));
387            }
388            out.push_str(")\n");
389        }
390    }
391    out
392}
393
394/// `argv` confined by `p` on `backend`.
395pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
396    match backend {
397        Backend::Seatbelt => {
398            let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
399            out.extend_from_slice(argv);
400            Ok(out)
401        }
402        Backend::Landlock(helper) => {
403            let mut out = vec![
404                helper.to_string_lossy().into_owned(),
405                HELPER_ARG.to_owned(),
406                serde_json::to_string(p).map_err(|e| e.to_string())?,
407                "--".to_owned(),
408            ];
409            out.extend_from_slice(argv);
410            Ok(out)
411        }
412    }
413}
414
415/// A runner that confines every command.
416#[derive(Debug, Clone)]
417pub struct Sandboxed {
418    /// The confinement.
419    pub profile: Profile,
420    /// The OS mechanism.
421    pub backend: Backend,
422    /// Exact commands that get the network.
423    pub network_allow: Vec<Vec<String>>,
424    /// Prefixes (two elements or more) that get it.
425    pub network_allow_prefix: Vec<Vec<String>>,
426}
427
428impl Sandboxed {
429    /// The network `argv` gets: its profile's, or all of it for an allowed
430    /// command (exactly, or extending an allowed prefix).
431    #[must_use]
432    pub fn network_for(&self, argv: &[String]) -> Network {
433        if self.network_allow.iter().any(|p| p.as_slice() == argv)
434            || self
435                .network_allow_prefix
436                .iter()
437                .any(|p| crate::tools::policy::prefix_matches(p, argv))
438        {
439            Network::On
440        } else {
441            self.profile.network
442        }
443    }
444
445    /// Runs `inv` with `network` instead of the profile's.
446    #[must_use]
447    pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
448        let profile = self.profile.clone().with_network(network);
449        let failed = |e: String| Outcome {
450            exit_code: None,
451            timed_out: false,
452            spawn_error: Some(e),
453            started: std::time::SystemTime::now(),
454            duration: std::time::Duration::ZERO,
455            stdout: crate::verify::process::Captured::default(),
456            stderr: crate::verify::process::Captured::default(),
457        };
458        // Through the wrapper a missing program would be the wrapper's
459        // failure; report it as the plain runner does.
460        if let Some(program) = inv.argv.first()
461            && !resolvable(program, &inv.cwd, &inv.env)
462        {
463            return failed(format!(
464                "could not start `{program}`: No such file or directory"
465            ));
466        }
467        if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
468            return failed(format!("the sandbox's temporary directory: {e}"));
469        }
470        let argv = match wrap(&profile, &self.backend, &inv.argv) {
471            Ok(a) => a,
472            Err(e) => return failed(e),
473        };
474        let tmp = profile.tmp.to_string_lossy().into_owned();
475        let mut env: Vec<(String, String)> = inv
476            .env
477            .iter()
478            .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
479            .cloned()
480            .collect();
481        env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
482        LocalRunner.run(&Invocation {
483            argv,
484            env,
485            ..inv.clone()
486        })
487    }
488}
489
490/// The executable `program` runs as in `cwd` with `env`: a program with a
491/// `/` against `cwd`, else the first file of that name on `PATH` (relative
492/// `PATH` entries against `cwd` too) — canonicalised; `None` when there is
493/// none. A file without an execute bit is skipped, as `execvp` skips it:
494/// the executable named is the one that runs. What a command's approval
495/// names, and what it is checked against again when it runs.
496#[must_use]
497pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
498    let candidate = |p: PathBuf| {
499        executable_file(&p)
500            .then(|| std::fs::canonicalize(&p).ok())
501            .flatten()
502    };
503    if program.is_empty() {
504        return None;
505    }
506    if program.contains('/') {
507        return candidate(cwd.join(program));
508    }
509    let path = env
510        .iter()
511        .find(|(k, _)| k == "PATH")
512        .map(|(_, v)| v.as_str());
513    path.unwrap_or("/usr/bin:/bin")
514        .split(':')
515        .filter(|d| !d.is_empty())
516        .find_map(|d| candidate(cwd.join(d).join(program)))
517}
518
519/// A regular file (through symlinks) that someone may execute.
520fn executable_file(p: &Path) -> bool {
521    #[cfg(unix)]
522    {
523        use std::os::unix::fs::PermissionsExt as _;
524        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
525    }
526    #[cfg(not(unix))]
527    {
528        p.is_file()
529    }
530}
531
532/// Whether `program` names an executable file, directly or on `PATH`.
533fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
534    resolve_executable(program, cwd, env).is_some()
535}
536
537impl CommandRunner for Sandboxed {
538    fn run(&self, inv: &Invocation) -> Outcome {
539        self.run_with_network(inv, self.network_for(&inv.argv))
540    }
541}
542
543/// The runner for commands working in `root`: sandboxed when configured and
544/// possible; otherwise the plain runner and a notice (or, with `required`,
545/// an error).
546pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
547    if config.mode == Mode::Off {
548        return Ok((
549            Runner::Plain,
550            Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
551        ));
552    }
553    match backend() {
554        Ok(backend) => {
555            let home = std::env::var_os("HOME").map(PathBuf::from);
556            Ok((
557                Runner::Sandboxed(Sandboxed {
558                    profile: Profile::new(root, config, home.as_deref()),
559                    backend,
560                    network_allow: config.network_allow.clone(),
561                    network_allow_prefix: config.network_allow_prefix.clone(),
562                }),
563                None,
564            ))
565        }
566        Err(e) if config.mode == Mode::Required => Err(format!(
567            "[sandbox] mode = \"required\", but there is no sandbox: {e}"
568        )),
569        Err(e) => Ok((
570            Runner::Plain,
571            Some(format!("commands run unsandboxed: {e}")),
572        )),
573    }
574}
575
576/// [`runner`] with `[sandbox]` read from the `speccoder.toml` that governs `root`.
577pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
578    let text = crate::verify::find_config(root)
579        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
580        .unwrap_or_default();
581    let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
582    runner(root, &config)
583}
584
585impl Runner {
586    /// In place: `read_only` and `hidden` carved out of the profile, and
587    /// what the person must know when this machine cannot. A plain runner
588    /// confines nothing, so there is nothing to carve.
589    #[must_use]
590    pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
591        match self {
592            Self::Sandboxed(mut s) => {
593                // Linux carves with mounts in a user namespace, which some
594                // hosts do not allow (container seccomp, AppArmor).
595                if let Backend::Landlock(helper) = &s.backend
596                    && !mounts_supported(helper)
597                {
598                    return (
599                        Self::Sandboxed(s),
600                        Some("this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so commands can write `.git`, read ignored secrets in place and reach daemon sockets such as Docker's; the run stops if `.git` changes".to_owned()),
601                    );
602                }
603                s.profile = s.profile.carve(read_only, hidden);
604                (Self::Sandboxed(s), None)
605            }
606            Self::Plain => (Self::Plain, None),
607        }
608    }
609}
610
611/// A sandboxed or a plain runner.
612#[derive(Debug, Clone)]
613pub enum Runner {
614    /// Confined.
615    Sandboxed(Sandboxed),
616    /// Not confined.
617    Plain,
618}
619
620impl Runner {
621    /// Whether commands are confined (the network rule then matters).
622    #[must_use]
623    pub fn is_sandboxed(&self) -> bool {
624        matches!(self, Self::Sandboxed(_))
625    }
626
627    /// The same runner, `dir` writable too.
628    #[must_use]
629    pub fn with_writable(self, dir: &Path) -> Self {
630        match self {
631            Self::Sandboxed(mut s) => {
632                s.profile.writable.push(canonical(dir));
633                Self::Sandboxed(s)
634            }
635            Self::Plain => Self::Plain,
636        }
637    }
638
639    /// Whether `argv` gets the network by `[sandbox] network_allow` (or a
640    /// prefix): never for a plain runner, which asks nobody.
641    #[must_use]
642    pub fn allows_network(&self, argv: &[String]) -> bool {
643        matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
644    }
645
646    /// Runs `inv`; `network` (a person's approval) widens the network.
647    #[must_use]
648    pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
649        match self {
650            Self::Plain => LocalRunner.run(inv),
651            Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
652            Self::Sandboxed(s) => s.run(inv),
653        }
654    }
655
656    /// Where commands may write scratch files: the sandbox's private
657    /// temporary directory (created), or `None` unsandboxed.
658    #[must_use]
659    pub fn scratch(&self) -> Option<PathBuf> {
660        match self {
661            Self::Plain => None,
662            Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
663                .ok()
664                .map(|()| s.profile.tmp.clone()),
665        }
666    }
667
668    /// `argv` as it will be started (for long-running servers started elsewhere).
669    pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
670        match self {
671            Self::Plain => Ok((argv.to_vec(), None)),
672            Self::Sandboxed(s) => {
673                let profile = s.profile.clone().with_network(s.network_for(argv));
674                std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
675                Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
676            }
677        }
678    }
679}
680
681impl CommandRunner for Runner {
682    fn run(&self, inv: &Invocation) -> Outcome {
683        self.run_with(inv, false)
684    }
685}
686
687/// The helper's entry (`speccoder __sandbox <profile> -- argv…`): confines
688/// this process and executes the command; returns only on failure.
689pub fn helper_main(args: &[String]) -> String {
690    if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
691        return match probe_mounts() {
692            Ok(()) => std::process::exit(0),
693            Err(e) => e,
694        };
695    }
696    let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
697        return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
698    };
699    let profile: Profile = match serde_json::from_str(profile) {
700        Ok(p) => p,
701        Err(e) => return format!("the sandbox profile: {e}"),
702    };
703    let argv = &args[2..];
704    if argv.is_empty() {
705        return "nothing to run".to_owned();
706    }
707    enter(&profile, argv)
708}
709
710#[cfg(target_os = "linux")]
711fn enter(profile: &Profile, argv: &[String]) -> String {
712    linux::enter(profile, argv)
713}
714
715/// The helper's argument that only checks the mount carve-outs work.
716const PROBE_MOUNTS: &str = "--probe-mounts";
717
718/// Whether the Linux helper can carve with mounts here (checked once, in a
719/// child: the namespace never touches this process).
720fn mounts_supported(helper: &Path) -> bool {
721    static CACHED: OnceLock<bool> = OnceLock::new();
722    *CACHED.get_or_init(|| {
723        std::process::Command::new(helper)
724            .args([HELPER_ARG, PROBE_MOUNTS])
725            .stdout(std::process::Stdio::null())
726            .stderr(std::process::Stdio::null())
727            .status()
728            .is_ok_and(|s| s.success())
729    })
730}
731
732#[cfg(target_os = "linux")]
733fn probe_mounts() -> Result<(), String> {
734    linux::probe_mounts()
735}
736
737#[cfg(not(target_os = "linux"))]
738fn probe_mounts() -> Result<(), String> {
739    Err("mount carve-outs are for Linux".to_owned())
740}
741
742#[cfg(not(target_os = "linux"))]
743fn enter(_profile: &Profile, _argv: &[String]) -> String {
744    format!("the {HELPER_ARG} helper is for Linux")
745}
746
747#[cfg(target_os = "linux")]
748mod linux {
749    //! Landlock (filesystem) and seccomp (network) for this process, then exec.
750
751    use std::collections::BTreeMap;
752    use std::path::{Path, PathBuf};
753
754    use landlock::{
755        ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
756        RulesetCreatedAttr, RulesetStatus,
757    };
758    use seccompiler::{
759        SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
760        SeccompRule, TargetArch,
761    };
762
763    use super::{Network, Profile};
764
765    const ABI_LEVEL: ABI = ABI::V3;
766
767    pub(super) fn probe() -> Result<(), String> {
768        Ruleset::default()
769            .set_compatibility(CompatLevel::HardRequirement)
770            .handle_access(AccessFs::from_all(ABI_LEVEL))
771            .and_then(|r| r.create())
772            .map(|_| ())
773            .map_err(|e| format!("Landlock is not available: {e}"))
774    }
775
776    /// Landlock cannot take back what a rule grants beneath it, so the
777    /// carve-outs inside a writable tree are mounts in a namespace of this
778    /// process alone: `read_only` trees bound read-only, `hidden` ones
779    /// inside a writable tree covered (a file by `/dev/null`, a directory
780    /// by an empty read-only tmpfs).
781    fn mount_carve(profile: &Profile) -> Result<(), String> {
782        use nix::mount::{MsFlags, mount};
783        let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
784        let hidden: Vec<&PathBuf> = profile
785            .hidden
786            .iter()
787            .filter(|h| inside(h) && h.exists())
788            .collect();
789        let sockets: Vec<PathBuf> = daemon_sockets()
790            .into_iter()
791            .filter(|p| !inside(p))
792            .collect();
793        let carved = !profile.read_only.is_empty() || !hidden.is_empty();
794        if !carved && sockets.is_empty() {
795            return Ok(());
796        }
797        // Without user namespaces (AppArmor on Ubuntu 23.10+) the runner has
798        // already said so and asked for no carve-outs: the sockets stay
799        // reachable rather than every command failing.
800        if let Err(e) = private_namespace() {
801            return if carved { Err(e) } else { Ok(()) };
802        }
803        let none: Option<&str> = None;
804        for p in profile.read_only.iter().filter(|p| p.exists()) {
805            mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
806                .map_err(|e| format!("bind {}: {e}", p.display()))?;
807            mount(
808                none,
809                p,
810                none,
811                MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
812                none,
813            )
814            .map_err(|e| format!("read-only {}: {e}", p.display()))?;
815        }
816        for h in hidden {
817            if h.is_dir() {
818                mount(
819                    Some("tmpfs"),
820                    h,
821                    Some("tmpfs"),
822                    MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
823                    Some("size=4k"),
824                )
825            } else {
826                mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
827            }
828            .map_err(|e| format!("hide {}: {e}", h.display()))?;
829        }
830        // Landlock does not govern connecting to a Unix socket and seccomp
831        // cannot see its path: a daemon's socket (Docker's is root on the
832        // host) is covered instead, so connecting to it fails.
833        for p in &sockets {
834            mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
835                .map_err(|e| format!("cover {}: {e}", p.display()))?;
836        }
837        Ok(())
838    }
839
840    /// The Unix sockets in the places daemons keep them (`/run`, `/var/run`,
841    /// `/tmp`, `$XDG_RUNTIME_DIR`, three levels deep), each once.
842    fn daemon_sockets() -> Vec<PathBuf> {
843        use std::os::unix::fs::FileTypeExt as _;
844        fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
845            let Ok(entries) = std::fs::read_dir(dir) else {
846                return;
847            };
848            for e in entries.flatten() {
849                let Ok(t) = e.file_type() else { continue };
850                if t.is_socket() {
851                    out.push(e.path());
852                } else if t.is_dir() && depth > 0 {
853                    walk(&e.path(), depth - 1, out);
854                }
855            }
856        }
857        let mut roots = vec![
858            PathBuf::from("/run"),
859            PathBuf::from("/var/run"),
860            PathBuf::from("/tmp"),
861        ];
862        if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
863            roots.push(PathBuf::from(x));
864        }
865        let mut seen = std::collections::BTreeSet::new();
866        let mut out = Vec::new();
867        for root in roots {
868            // `/var/run` is usually a link to `/run`: walk each tree once.
869            let Ok(real) = root.canonicalize() else {
870                continue;
871            };
872            if !seen.insert(real.clone()) {
873                continue;
874            }
875            walk(&real, 3, &mut out);
876        }
877        out.sort();
878        out.dedup();
879        out
880    }
881
882    /// A user and mount namespace for this process, mapped to its own ids,
883    /// with no mount propagating out.
884    fn private_namespace() -> Result<(), String> {
885        use nix::mount::{MsFlags, mount};
886        use nix::sched::{CloneFlags, unshare};
887        let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
888        unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
889            .map_err(|e| format!("user namespace: {e}"))?;
890        let write = |file: &str, text: String| {
891            std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
892        };
893        write("/proc/self/setgroups", "deny".to_owned())?;
894        write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
895        write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
896        let none: Option<&str> = None;
897        mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
898            .map_err(|e| format!("private mounts: {e}"))
899    }
900
901    /// The flags a bind mount inherits and may not drop in a user namespace.
902    fn locked(p: &Path) -> nix::mount::MsFlags {
903        use nix::mount::MsFlags;
904        use nix::sys::statvfs::{FsFlags, statvfs};
905        let Ok(st) = statvfs(p) else {
906            return MsFlags::empty();
907        };
908        let f = st.flags();
909        let mut out = MsFlags::empty();
910        for (fs, ms) in [
911            (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
912            (FsFlags::ST_NODEV, MsFlags::MS_NODEV),
913            (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
914            (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
915            (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
916            (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
917        ] {
918            if f.contains(fs) {
919                out |= ms;
920            }
921        }
922        out
923    }
924
925    /// Whether [`mount_carve`] works here: a read-only bind of a temporary
926    /// directory in a namespace of this (probe) process.
927    pub(super) fn probe_mounts() -> Result<(), String> {
928        let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
929        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
930        let profile = Profile {
931            writable: Vec::new(),
932            hidden: Vec::new(),
933            read_only: vec![dir.clone()],
934            network: Network::On,
935            tmp: dir.clone(),
936        };
937        let carved = mount_carve(&profile);
938        let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
939        let _ = std::fs::remove_dir_all(&dir);
940        carved.and_then(|()| {
941            refused
942                .then_some(())
943                .ok_or_else(|| "the read-only bind did not hold".to_owned())
944        })
945    }
946
947    /// Readable everywhere except the hidden trees: rules for every path
948    /// that contains no hidden tree, descending into those that do.
949    fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
950        if hidden.iter().any(|h| h == dir) {
951            return;
952        }
953        if !hidden.iter().any(|h| h.starts_with(dir)) {
954            out.push(dir.to_path_buf());
955            return;
956        }
957        if let Ok(entries) = std::fs::read_dir(dir) {
958            for e in entries.flatten() {
959                readable(&e.path(), hidden, out);
960            }
961        }
962    }
963
964    pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
965        match mount_carve(profile).and_then(|()| confine(profile)) {
966            Ok(()) => {
967                use std::os::unix::process::CommandExt as _;
968                let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
969                format!("could not start `{}`: {e}", argv[0])
970            }
971            Err(e) => format!("the sandbox could not be applied: {e}"),
972        }
973    }
974
975    fn confine(profile: &Profile) -> Result<(), String> {
976        let read = AccessFs::from_read(ABI_LEVEL);
977        let all = AccessFs::from_all(ABI_LEVEL);
978        let mut readable_paths = Vec::new();
979        readable(Path::new("/"), &profile.hidden, &mut readable_paths);
980        let mut ruleset = Ruleset::default()
981            .set_compatibility(CompatLevel::HardRequirement)
982            .handle_access(all)
983            .and_then(|r| r.create())
984            .map_err(|e| e.to_string())?;
985        for p in &readable_paths {
986            if let Ok(fd) = PathFd::new(p) {
987                let access = if p.is_dir() {
988                    read
989                } else {
990                    read & AccessFs::from_file(ABI_LEVEL)
991                };
992                ruleset = ruleset
993                    .add_rule(PathBeneath::new(fd, access))
994                    .map_err(|e| e.to_string())?;
995            }
996        }
997        for w in profile.writable.iter().chain([&profile.tmp]) {
998            if let Ok(fd) = PathFd::new(w) {
999                let access = if w.is_dir() {
1000                    all
1001                } else {
1002                    AccessFs::from_file(ABI_LEVEL)
1003                };
1004                ruleset = ruleset
1005                    .add_rule(PathBeneath::new(fd, access))
1006                    .map_err(|e| e.to_string())?;
1007            }
1008        }
1009        for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
1010            if let Ok(fd) = PathFd::new(dev) {
1011                ruleset = ruleset
1012                    .add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
1013                    .map_err(|e| e.to_string())?;
1014            }
1015        }
1016        let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
1017        if status.ruleset != RulesetStatus::FullyEnforced {
1018            return Err(format!("Landlock only {:?}", status.ruleset));
1019        }
1020        if profile.network != Network::On {
1021            no_inet()?;
1022        }
1023        Ok(())
1024    }
1025
1026    /// `socket(AF_INET | AF_INET6, …)` fails with EACCES.
1027    fn no_inet() -> Result<(), String> {
1028        let rule = |domain: u64| {
1029            SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
1030                .and_then(|c| SeccompRule::new(vec![c]))
1031                .map_err(|e| e.to_string())
1032        };
1033        let rules = vec![
1034            rule(u64::from(libc::AF_INET.unsigned_abs()))?,
1035            rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
1036        ];
1037        let arch: TargetArch = std::env::consts::ARCH
1038            .try_into()
1039            .map_err(|e: seccompiler::BackendError| e.to_string())?;
1040        let filter = SeccompFilter::new(
1041            BTreeMap::from([(libc::SYS_socket, rules)]),
1042            SeccompAction::Allow,
1043            SeccompAction::Errno(libc::EACCES.unsigned_abs()),
1044            arch,
1045        )
1046        .map_err(|e| e.to_string())?;
1047        let program: seccompiler::BpfProgram = filter
1048            .try_into()
1049            .map_err(|e: seccompiler::BackendError| e.to_string())?;
1050        seccompiler::apply_filter(&program).map_err(|e| e.to_string())
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    #[test]
1059    fn config_parses_and_defaults_to_loopback() {
1060        assert_eq!(parse("").unwrap_or_default(), Config::default());
1061        let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
1062            .unwrap_or_default();
1063        assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
1064        assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
1065    }
1066
1067    #[test]
1068    fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
1069        let home = Path::new("/home/u");
1070        let p = Profile::new(
1071            Path::new("/s/proj/worktrees/001"),
1072            &Config::default(),
1073            Some(home),
1074        );
1075        assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
1076        assert!(
1077            !p.writable
1078                .iter()
1079                .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
1080        );
1081        assert!(p.hidden.contains(&home.join(".ssh")));
1082        let sb = seatbelt(&p);
1083        assert!(sb.contains("(deny network*)"));
1084        assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
1085        assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
1086    }
1087
1088    #[test]
1089    fn the_executable_is_the_first_on_the_path_canonicalised() {
1090        use std::os::unix::fs::PermissionsExt;
1091        let dir = tempfile::tempdir().unwrap();
1092        let root = std::fs::canonicalize(dir.path()).unwrap();
1093        for d in ["bin", "usr"] {
1094            std::fs::create_dir_all(root.join(d)).unwrap();
1095            let f = root.join(d).join("make");
1096            std::fs::write(&f, "#!/bin/sh\n").unwrap();
1097            std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
1098        }
1099        let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
1100        let usr = format!("{}", root.join("usr").display());
1101        assert_eq!(
1102            resolve_executable("make", &root, &env(&usr)),
1103            Some(root.join("usr/make"))
1104        );
1105        // A worktree directory earlier on the path shadows it.
1106        assert_eq!(
1107            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1108            Some(root.join("bin/make"))
1109        );
1110        assert_eq!(
1111            resolve_executable("./bin/make", &root, &env("")),
1112            Some(root.join("bin/make"))
1113        );
1114        assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
1115        assert!(!resolvable("nope", &root, &env(&usr)));
1116        // A file without an execute bit is skipped, as execvp skips it.
1117        std::fs::set_permissions(
1118            root.join("bin/make"),
1119            std::fs::Permissions::from_mode(0o644),
1120        )
1121        .unwrap();
1122        assert_eq!(
1123            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1124            Some(root.join("usr/make"))
1125        );
1126        assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
1127    }
1128
1129    #[test]
1130    fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1131        let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1132        let s = Sandboxed {
1133            profile: Profile::new(Path::new("/w"), &c, None),
1134            backend: Backend::Seatbelt,
1135            network_allow: c.network_allow.clone(),
1136            network_allow_prefix: c.network_allow_prefix.clone(),
1137        };
1138        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1139        assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1140        assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1141        assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1142        assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1143    }
1144}