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