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//! The [procage](https://github.com/hupe1980/procage) library confines
16//! every command (module `sandbox::procage`): Seatbelt on macOS, Landlock,
17//! seccomp and, where the kernel allows user namespaces, mounts on Linux.
18//! Commands that run to completion are spawned in this process; long-running
19//! servers start through the `speccoder` binary as a helper (`__sandbox`),
20//! which confines itself and becomes the server. Where the host cannot
21//! enforce part of the profile, that part is left out by name and the
22//! runner says so.
23//!
24//! When a sandboxed command fails, its outcome carries what the sandbox
25//! refused it (module `denials`): shown under a failed check, in the next
26//! attempt's digest and in the model's `run_command` result. Linux lists
27//! refused system calls only; macOS lists every denial it logged, which
28//! under heavy load is not every denial.
29
30use std::path::{Path, PathBuf};
31use std::sync::OnceLock;
32
33use serde::{Deserialize, Serialize};
34
35use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
36
37#[cfg(any(target_os = "macos", target_os = "linux"))]
38mod denials;
39#[cfg(any(target_os = "macos", target_os = "linux"))]
40pub mod procage;
41
42/// What a sandboxed command may reach on the network.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Network {
46    /// Nothing (on Linux also not loopback).
47    Off,
48    /// Loopback only: local test servers work, the outside does not. On
49    /// Linux this is [`Network::Off`] (seccomp cannot tell addresses apart).
50    Loopback,
51    /// Everything.
52    On,
53}
54
55/// Whether to sandbox.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Mode {
58    /// Sandbox where the OS supports it; otherwise run unsandboxed and say so.
59    Auto,
60    /// Refuse to run commands unsandboxed.
61    Required,
62    /// Do not sandbox.
63    Off,
64}
65
66/// `[sandbox]` in `speccoder.toml`.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Config {
69    /// Whether to sandbox.
70    pub mode: Mode,
71    /// The network for every command.
72    pub network: Network,
73    /// Exact commands that get the network (`[["cargo", "fetch"]]`).
74    pub network_allow: Vec<Vec<String>>,
75    /// Command prefixes (two elements or more) that get the network.
76    pub network_allow_prefix: Vec<Vec<String>>,
77    /// More writable paths (`~` is the home directory).
78    pub writable: Vec<String>,
79}
80
81impl Default for Config {
82    fn default() -> Self {
83        Self {
84            mode: Mode::Auto,
85            network: Network::Loopback,
86            network_allow: Vec::new(),
87            network_allow_prefix: Vec::new(),
88            writable: Vec::new(),
89        }
90    }
91}
92
93#[derive(Debug, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95struct Raw {
96    mode: Option<String>,
97    network: Option<String>,
98    #[serde(default)]
99    network_allow: Vec<Vec<String>>,
100    #[serde(default)]
101    network_allow_prefix: Vec<Vec<String>>,
102    #[serde(default)]
103    writable: Vec<String>,
104}
105
106/// Reads `[sandbox]`.
107pub fn parse(text: &str) -> Result<Config, String> {
108    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
109    let Some(v) = table.get("sandbox") else {
110        return Ok(Config::default());
111    };
112    let raw: Raw = v
113        .clone()
114        .try_into()
115        .map_err(|e| format!("[sandbox]: {e}"))?;
116    let mode = match raw.mode.as_deref() {
117        None | Some("auto") => Mode::Auto,
118        Some("required") => Mode::Required,
119        Some("off") => Mode::Off,
120        Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
121    };
122    let network = match raw.network.as_deref() {
123        None | Some("loopback") => Network::Loopback,
124        Some("off") => Network::Off,
125        Some("on") => Network::On,
126        Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
127    };
128    if let Some(why) = raw
129        .network_allow_prefix
130        .iter()
131        .find_map(|p| crate::tools::policy::prefix_refusal(p))
132    {
133        return Err(format!("[sandbox] network_allow_prefix: {why}"));
134    }
135    Ok(Config {
136        mode,
137        network,
138        network_allow: raw
139            .network_allow
140            .into_iter()
141            .filter(|p| !p.is_empty())
142            .collect(),
143        network_allow_prefix: raw.network_allow_prefix,
144        writable: raw.writable,
145    })
146}
147
148/// Credentials a command never reads, relative to the home directory.
149pub const HIDDEN: &[&str] = &[
150    ".ssh",
151    ".aws",
152    ".azure",
153    ".gnupg",
154    ".netrc",
155    ".docker",
156    ".kube",
157    ".npmrc",
158    ".pypirc",
159    ".git-credentials",
160    ".password-store",
161    ".config/gh",
162    ".config/gcloud",
163    ".config/hub",
164    ".cargo/credentials",
165    ".cargo/credentials.toml",
166    ".local/share/keyrings",
167    "Library/Keychains",
168];
169
170/// Package caches builds write to, relative to the home directory. Not
171/// `~/.cargo` as a whole: `~/.cargo/bin` would let a test replace `cargo`.
172pub const CACHES: &[&str] = &[
173    ".cargo/registry",
174    ".cargo/git",
175    ".cargo/.package-cache",
176    ".cargo/.package-cache-mutate",
177    ".cargo/.global-cache",
178    ".cache",
179    ".npm",
180    ".pnpm-store",
181    ".yarn/berry/cache",
182    "go/pkg/mod",
183    "Library/Caches",
184];
185
186/// One command's confinement.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Profile {
190    /// Writable trees (the workspace first).
191    pub writable: Vec<PathBuf>,
192    /// Unreadable trees.
193    pub hidden: Vec<PathBuf>,
194    /// Trees inside the writable ones that stay read-only (in place: the
195    /// checkout's `.git`).
196    pub read_only: Vec<PathBuf>,
197    /// The network.
198    pub network: Network,
199    /// The private temporary directory (`TMPDIR`), writable.
200    pub tmp: PathBuf,
201    /// What this host cannot enforce and the profile leaves out
202    /// (`sandbox::procage::fit`).
203    #[serde(default)]
204    pub unenforced: Unenforced,
205}
206
207/// Parts of a profile left out: because the host cannot enforce them (Linux
208/// without user namespaces or with an older kernel; the runner's notice
209/// names each), or on purpose
210/// (the person's own commands may write git hooks and agent configuration).
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Unenforced {
214    /// Git hooks and shell startup files in writable trees stay writable.
215    pub trust_handoff: bool,
216    /// Unix sockets in writable trees are not reachable either.
217    pub workspace_sockets: bool,
218    /// Commands can signal every process of the user, not only their own
219    /// (Linux older than 6.12 cannot keep signals within the run).
220    pub signal_scope: bool,
221}
222
223fn canonical(p: &Path) -> PathBuf {
224    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
225}
226
227impl Profile {
228    /// The profile for commands working in `root`, for the home directory `home`.
229    #[must_use]
230    pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
231        let root = canonical(root);
232        let expand = |p: &str| -> Option<PathBuf> {
233            match (p.strip_prefix("~/"), home) {
234                (Some(rest), Some(h)) => Some(h.join(rest)),
235                (Some(_), None) => None,
236                (None, _) => Some(PathBuf::from(p)),
237            }
238        };
239        let home = home.map(canonical);
240        let home = home.as_deref();
241        let mut writable = vec![root.clone()];
242        writable.extend(
243            home.into_iter()
244                .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
245        );
246        writable.extend(
247            config
248                .writable
249                .iter()
250                .filter_map(|p| expand(p))
251                .map(|p| canonical(&p)),
252        );
253        let hidden = home
254            .into_iter()
255            .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
256            .collect();
257        let digest = {
258            use sha2::{Digest as _, Sha256};
259            let d = Sha256::digest(root.to_string_lossy().as_bytes());
260            d.iter()
261                .take(6)
262                .map(|b| format!("{b:02x}"))
263                .collect::<String>()
264        };
265        // Short (Unix socket paths are limited to ~104 bytes) and per workspace.
266        let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
267        Self {
268            writable,
269            hidden,
270            read_only: Vec::new(),
271            network: config.network,
272            tmp,
273            unenforced: Unenforced::default(),
274        }
275    }
276
277    /// The same profile with `read_only` trees carved out of the writable
278    /// ones, and `hidden` added to the unreadable ones.
279    #[must_use]
280    pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
281        self.read_only
282            .extend(read_only.iter().map(|p| canonical(p)));
283        self.hidden.extend(hidden.iter().map(|p| canonical(p)));
284        self
285    }
286
287    /// The same profile with another network.
288    #[must_use]
289    pub fn with_network(mut self, network: Network) -> Self {
290        self.network = network;
291        self
292    }
293}
294
295/// This machine's sandbox: procage, which confines with Seatbelt on macOS
296/// and Landlock and seccomp on Linux. Long-running servers start through the
297/// `speccoder` binary at `helper`, when there is one (commands that run to
298/// completion need none).
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct Backend {
301    /// The `speccoder` binary that serves as the helper.
302    pub helper: Option<PathBuf>,
303}
304
305impl Backend {
306    /// The OS mechanism, for people and run records: `Seatbelt` or
307    /// `Landlock+seccomp`.
308    #[must_use]
309    pub fn mechanism(&self) -> &'static str {
310        if cfg!(target_os = "macos") {
311            "Seatbelt"
312        } else {
313            "Landlock+seccomp"
314        }
315    }
316}
317
318/// The helper subcommand (`speccoder __sandbox <profile> -- argv…`).
319pub const HELPER_ARG: &str = "__sandbox";
320
321/// The sandbox this machine offers, or why there is none.
322pub fn backend() -> Result<Backend, String> {
323    static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
324    CACHED.get_or_init(detect).clone()
325}
326
327fn detect() -> Result<Backend, String> {
328    available()?;
329    Ok(Backend {
330        helper: helper_binary(),
331    })
332}
333
334/// Whether this machine can sandbox commands at all (whatever process asks).
335///
336/// # Errors
337///
338/// Why not.
339pub fn available() -> Result<(), String> {
340    #[cfg(any(target_os = "macos", target_os = "linux"))]
341    return procage::available();
342    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
343    Err(format!("no sandbox for {}", std::env::consts::OS))
344}
345
346/// The `speccoder` binary that serves as the helper: `SPECCODER_SANDBOX_HELPER`,
347/// or this executable when it is `speccoder`.
348fn helper_binary() -> Option<PathBuf> {
349    std::env::var_os("SPECCODER_SANDBOX_HELPER")
350        .map(PathBuf::from)
351        .or_else(|| {
352            std::env::current_exe()
353                .ok()
354                .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
355        })
356}
357
358/// The profile this machine enforces for `profile`, and a notice for what it
359/// leaves out.
360///
361/// # Errors
362///
363/// Why this machine cannot confine the profile at all.
364pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
365    #[cfg(any(target_os = "macos", target_os = "linux"))]
366    return procage::fit(profile);
367    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
368    {
369        let _ = profile;
370        Err(format!("no sandbox for {}", std::env::consts::OS))
371    }
372}
373
374/// `argv` confined by `p` through the helper on `backend`.
375pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
376    // Without the helper a server cannot be confined: never start it plain.
377    let helper = backend.helper.as_ref().ok_or_else(|| {
378        "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
379            .to_owned()
380    })?;
381    let mut out = vec![
382        helper.to_string_lossy().into_owned(),
383        HELPER_ARG.to_owned(),
384        serde_json::to_string(p).map_err(|e| e.to_string())?,
385        "--".to_owned(),
386    ];
387    out.extend_from_slice(argv);
388    Ok(out)
389}
390
391/// A runner that confines every command.
392#[derive(Debug, Clone)]
393pub struct Sandboxed {
394    /// The confinement.
395    pub profile: Profile,
396    /// The OS mechanism.
397    pub backend: Backend,
398    /// Exact commands that get the network.
399    pub network_allow: Vec<Vec<String>>,
400    /// Prefixes (two elements or more) that get it.
401    pub network_allow_prefix: Vec<Vec<String>>,
402}
403
404impl Sandboxed {
405    /// The network `argv` gets: its profile's, or all of it for an allowed
406    /// command (exactly, or extending an allowed prefix).
407    #[must_use]
408    pub fn network_for(&self, argv: &[String]) -> Network {
409        if self.network_allow.iter().any(|p| p.as_slice() == argv)
410            || self
411                .network_allow_prefix
412                .iter()
413                .any(|p| crate::tools::policy::prefix_matches(p, argv))
414        {
415            Network::On
416        } else {
417            self.profile.network
418        }
419    }
420
421    /// Runs `inv` with `network` instead of the profile's.
422    #[must_use]
423    pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
424        let profile = self.profile.clone().with_network(network);
425        let failed = |e: String| Outcome {
426            exit_code: None,
427            timed_out: false,
428            spawn_error: Some(e),
429            started: std::time::SystemTime::now(),
430            duration: std::time::Duration::ZERO,
431            stdout: crate::verify::process::Captured::default(),
432            stderr: crate::verify::process::Captured::default(),
433            sandbox: Default::default(),
434        };
435        // Through the wrapper a missing program would be the wrapper's
436        // failure; report it as the plain runner does.
437        if let Some(program) = inv.argv.first()
438            && !resolvable(program, &inv.cwd, &inv.env)
439        {
440            return failed(format!(
441                "could not start `{program}`: No such file or directory"
442            ));
443        }
444        if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
445            return failed(format!("the sandbox's temporary directory: {e}"));
446        }
447        let tmp = profile.tmp.to_string_lossy().into_owned();
448        let mut env: Vec<(String, String)> = inv
449            .env
450            .iter()
451            .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
452            .cloned()
453            .collect();
454        env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
455        #[cfg(any(target_os = "macos", target_os = "linux"))]
456        return procage::run(&profile, &Invocation { env, ..inv.clone() });
457        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
458        {
459            let _ = env;
460            failed(format!("no sandbox for {}", std::env::consts::OS))
461        }
462    }
463}
464
465/// The executable `program` runs as in `cwd` with `env`: a program with a
466/// `/` against `cwd`, else the first file of that name on `PATH` (relative
467/// `PATH` entries against `cwd` too) — canonicalised; `None` when there is
468/// none. A file without an execute bit is skipped, as `execvp` skips it:
469/// the executable named is the one that runs. What a command's approval
470/// names, and what it is checked against again when it runs.
471#[must_use]
472pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
473    let candidate = |p: PathBuf| {
474        executable_file(&p)
475            .then(|| std::fs::canonicalize(&p).ok())
476            .flatten()
477    };
478    if program.is_empty() {
479        return None;
480    }
481    if program.contains('/') {
482        return candidate(cwd.join(program));
483    }
484    let path = env
485        .iter()
486        .find(|(k, _)| k == "PATH")
487        .map(|(_, v)| v.as_str());
488    path.unwrap_or("/usr/bin:/bin")
489        .split(':')
490        .filter(|d| !d.is_empty())
491        .find_map(|d| candidate(cwd.join(d).join(program)))
492}
493
494/// A regular file (through symlinks) that someone may execute.
495fn executable_file(p: &Path) -> bool {
496    #[cfg(unix)]
497    {
498        use std::os::unix::fs::PermissionsExt as _;
499        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
500    }
501    #[cfg(not(unix))]
502    {
503        p.is_file()
504    }
505}
506
507/// Whether `program` names an executable file, directly or on `PATH`.
508fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
509    resolve_executable(program, cwd, env).is_some()
510}
511
512impl CommandRunner for Sandboxed {
513    fn run(&self, inv: &Invocation) -> Outcome {
514        self.run_with_network(inv, self.network_for(&inv.argv))
515    }
516}
517
518/// The runner for commands working in `root`: sandboxed when configured and
519/// possible; otherwise the plain runner and a notice (or, with `required`,
520/// an error).
521pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
522    if config.mode == Mode::Off {
523        return Ok((
524            Runner::Plain,
525            Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
526        ));
527    }
528    let home = std::env::var_os("HOME").map(PathBuf::from);
529    let profile = Profile::new(root, config, home.as_deref());
530    // What this machine cannot enforce is left out by name; anything else it
531    // cannot confine means no sandbox, and `mode` decides.
532    match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
533        Ok((backend, (profile, notice))) => Ok((
534            Runner::Sandboxed(Sandboxed {
535                profile,
536                backend,
537                network_allow: config.network_allow.clone(),
538                network_allow_prefix: config.network_allow_prefix.clone(),
539            }),
540            notice,
541        )),
542        Err(e) if config.mode == Mode::Required => Err(format!(
543            "[sandbox] mode = \"required\", but there is no sandbox: {e}"
544        )),
545        Err(e) => Ok((
546            Runner::Plain,
547            Some(format!("commands run unsandboxed: {e}")),
548        )),
549    }
550}
551
552/// [`runner`] with `[sandbox]` read from the `speccoder.toml` that governs `root`.
553pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
554    let text = crate::verify::find_config(root)
555        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
556        .unwrap_or_default();
557    let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
558    runner(root, &config)
559}
560
561impl Runner {
562    /// In place: `read_only` and `hidden` carved out of the profile, and
563    /// what the person must know when this machine cannot. A plain runner
564    /// confines nothing, so there is nothing to carve.
565    #[must_use]
566    pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
567        match self {
568            Self::Sandboxed(mut s) => {
569                let carved = s.profile.clone().carve(read_only, hidden);
570                match fit(&carved) {
571                    Ok((fitted, notice)) => {
572                        s.profile = fitted;
573                        (Self::Sandboxed(s), notice)
574                    }
575                    Err(e) => (
576                        Self::Sandboxed(s),
577                        Some(format!(
578                            "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
579                        )),
580                    ),
581                }
582            }
583            Self::Plain => (Self::Plain, None),
584        }
585    }
586}
587
588/// A sandboxed or a plain runner.
589#[derive(Debug, Clone)]
590pub enum Runner {
591    /// Confined.
592    Sandboxed(Sandboxed),
593    /// Not confined.
594    Plain,
595}
596
597impl Runner {
598    /// Whether commands are confined (the network rule then matters).
599    #[must_use]
600    pub fn is_sandboxed(&self) -> bool {
601        matches!(self, Self::Sandboxed(_))
602    }
603
604    /// The same runner, with git hooks, git config and agent configuration
605    /// (the trust-handoff names) writable in its trees: for the tools that
606    /// create a project (`git init`, a format's init), never for the model's
607    /// commands.
608    #[must_use]
609    pub fn with_trust_handoff_writable(self) -> Self {
610        match self {
611            Self::Sandboxed(mut s) => {
612                s.profile.unenforced.trust_handoff = true;
613                Self::Sandboxed(s)
614            }
615            Self::Plain => Self::Plain,
616        }
617    }
618
619    /// The same runner, `dir` writable too.
620    #[must_use]
621    pub fn with_writable(self, dir: &Path) -> Self {
622        match self {
623            Self::Sandboxed(mut s) => {
624                s.profile.writable.push(canonical(dir));
625                Self::Sandboxed(s)
626            }
627            Self::Plain => Self::Plain,
628        }
629    }
630
631    /// Whether `argv` gets the network by `[sandbox] network_allow` (or a
632    /// prefix): never for a plain runner, which asks nobody.
633    #[must_use]
634    pub fn allows_network(&self, argv: &[String]) -> bool {
635        matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
636    }
637
638    /// Runs `inv`; `network` (a person's approval) widens the network.
639    #[must_use]
640    pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
641        match self {
642            Self::Plain => LocalRunner.run(inv),
643            Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
644            Self::Sandboxed(s) => s.run(inv),
645        }
646    }
647
648    /// Where commands may write scratch files: the sandbox's private
649    /// temporary directory (created), or `None` unsandboxed.
650    #[must_use]
651    pub fn scratch(&self) -> Option<PathBuf> {
652        match self {
653            Self::Plain => None,
654            Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
655                .ok()
656                .map(|()| s.profile.tmp.clone()),
657        }
658    }
659
660    /// `argv` as it will be started (for long-running servers started elsewhere).
661    pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
662        match self {
663            Self::Plain => Ok((argv.to_vec(), None)),
664            Self::Sandboxed(s) => {
665                let profile = s.profile.clone().with_network(s.network_for(argv));
666                std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
667                Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
668            }
669        }
670    }
671}
672
673impl CommandRunner for Runner {
674    fn run(&self, inv: &Invocation) -> Outcome {
675        self.run_with(inv, false)
676    }
677}
678
679/// The helper's entry (`speccoder __sandbox <profile> -- argv…`): confines
680/// this process and becomes the command; returns only on failure.
681pub fn helper_main(args: &[String]) -> String {
682    let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
683        return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
684    };
685    let profile: Profile = match serde_json::from_str(profile) {
686        Ok(p) => p,
687        Err(e) => return format!("the sandbox profile: {e}"),
688    };
689    let argv = &args[2..];
690    if argv.is_empty() {
691        return "nothing to run".to_owned();
692    }
693    #[cfg(any(target_os = "macos", target_os = "linux"))]
694    return procage::exec(&profile, argv);
695    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
696    format!("no sandbox for {}", std::env::consts::OS)
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn config_parses_and_defaults_to_loopback() {
705        assert_eq!(parse("").unwrap_or_default(), Config::default());
706        let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
707            .unwrap_or_default();
708        assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
709        assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
710    }
711
712    #[test]
713    fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
714        let home = Path::new("/home/u");
715        let p = Profile::new(
716            Path::new("/s/proj/worktrees/001"),
717            &Config::default(),
718            Some(home),
719        );
720        assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
721        assert!(
722            !p.writable
723                .iter()
724                .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
725        );
726        assert!(p.hidden.contains(&home.join(".ssh")));
727        assert!(p.writable.contains(&home.join(".cargo/registry")));
728        assert_eq!(p.unenforced, Unenforced::default());
729    }
730
731    /// A profile written before the signal scope could be left out
732    /// enforces it; a fitted one keeps the choice through the helper.
733    #[test]
734    fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
735        let p = Profile::new(Path::new("/w"), &Config::default(), None);
736        let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
737        json["unenforced"] =
738            serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
739        let old: Profile = serde_json::from_value(json).unwrap();
740        assert!(!old.unenforced.signal_scope);
741        let mut fitted = p.clone();
742        fitted.unenforced.signal_scope = true;
743        let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
744        assert!(back.unenforced.signal_scope);
745    }
746
747    #[test]
748    fn the_executable_is_the_first_on_the_path_canonicalised() {
749        use std::os::unix::fs::PermissionsExt;
750        let dir = tempfile::tempdir().unwrap();
751        let root = std::fs::canonicalize(dir.path()).unwrap();
752        for d in ["bin", "usr"] {
753            std::fs::create_dir_all(root.join(d)).unwrap();
754            let f = root.join(d).join("make");
755            std::fs::write(&f, "#!/bin/sh\n").unwrap();
756            std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
757        }
758        let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
759        let usr = format!("{}", root.join("usr").display());
760        assert_eq!(
761            resolve_executable("make", &root, &env(&usr)),
762            Some(root.join("usr/make"))
763        );
764        // A worktree directory earlier on the path shadows it.
765        assert_eq!(
766            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
767            Some(root.join("bin/make"))
768        );
769        assert_eq!(
770            resolve_executable("./bin/make", &root, &env("")),
771            Some(root.join("bin/make"))
772        );
773        assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
774        assert!(!resolvable("nope", &root, &env(&usr)));
775        // A file without an execute bit is skipped, as execvp skips it.
776        std::fs::set_permissions(
777            root.join("bin/make"),
778            std::fs::Permissions::from_mode(0o644),
779        )
780        .unwrap();
781        assert_eq!(
782            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
783            Some(root.join("usr/make"))
784        );
785        assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
786    }
787
788    /// The experimental `engine` key is gone: procage is the only engine.
789    #[test]
790    fn the_engine_key_is_refused_by_name() {
791        let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
792        assert!(e.contains("engine"), "{e}");
793    }
794
795    /// A profile this machine cannot confine at all (here: a writable path
796    /// with a control character, which Seatbelt cannot express) means no
797    /// sandbox: `auto` runs commands plain and says why, `required` refuses.
798    #[cfg(target_os = "macos")]
799    #[test]
800    fn an_unconfinable_profile_follows_the_mode() {
801        if backend().is_err() {
802            return; // no sandbox on this machine at all
803        }
804        let dir = tempfile::tempdir().unwrap();
805        let odd = dir.path().join("procage\u{1}odd");
806        std::fs::create_dir_all(&odd).unwrap();
807        let mut config = Config {
808            writable: vec![odd.to_string_lossy().into_owned()],
809            ..Config::default()
810        };
811        let (plain, notice) = runner(dir.path(), &config).unwrap();
812        assert!(!plain.is_sandboxed());
813        assert!(
814            notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
815            "{plain:?}"
816        );
817        config.mode = Mode::Required;
818        let e = runner(dir.path(), &config).unwrap_err();
819        assert!(e.contains("mode = \"required\""), "{e}");
820    }
821
822    #[test]
823    fn a_confinable_profile_is_sandboxed() {
824        let dir = tempfile::tempdir().unwrap();
825        let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
826            assert!(
827                backend().is_err(),
828                "a sandbox exists but the runner is plain"
829            );
830            return;
831        };
832        assert_eq!(s.backend, backend().unwrap());
833    }
834
835    #[test]
836    fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
837        let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
838        let s = Sandboxed {
839            profile: Profile::new(Path::new("/w"), &c, None),
840            backend: Backend { helper: None },
841            network_allow: c.network_allow.clone(),
842            network_allow_prefix: c.network_allow_prefix.clone(),
843        };
844        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
845        assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
846        assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
847        assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
848        assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
849    }
850}