Skip to main content

lean_ctx/core/addons/
sandbox.rs

1//! Opt-in OS sandbox for the stdio MCP servers an addon spawns (#865).
2//!
3//! A stdio addon is a child process with the user's full privileges. When
4//! `addons.sandbox` is enabled, lean-ctx wraps that child in an OS-native
5//! sandbox launcher before spawning it (the single spawn point is
6//! [`crate::core::mcp_catalog::client`]):
7//!
8//! - **macOS** → `sandbox-exec` with a generated SBPL profile,
9//! - **Linux** → `bwrap` (bubblewrap) with a read-only root + network unshare.
10//!
11//! Local stdio tools rarely need the network, so the highest-value, lowest-
12//! breakage control is **outbound-network isolation** (`auto`); `strict` also
13//! makes the filesystem read-only except a scratch tmp and **refuses to spawn**
14//! if no launcher is available (fail-closed). Default is [`SandboxMode::Off`]
15//! → zero behavioural change. The argv-building is pure + unit-tested; the
16//! enforcement is delegated to the OS launcher.
17//!
18//! The OS sandbox enforces two dimensions — outbound network and filesystem
19//! writes — and child processes **inherit** the profile, so any subprocess an
20//! addon spawns is bound by the same network/filesystem restrictions. The
21//! declared `exec` capability is therefore *not* an OS control here: it is
22//! disclosed, audited and surfaced for consent (see [`super::capabilities`] /
23//! [`super::audit`]), while the data-safety guarantees come from the inherited
24//! network/filesystem profile. Path-allowlisting `execve` is also not portable
25//! (`bwrap`/seccomp cannot do it) and breaks interpreted servers (the
26//! interpreter chain is itself a `process-exec`), so lean-ctx does not attempt
27//! it.
28
29use std::path::Path;
30
31use super::capabilities::AddonCapabilities;
32
33/// The two enforceable dimensions of an OS sandbox profile. Both the legacy
34/// global [`SandboxMode`] and a per-addon [`AddonCapabilities`] declaration are
35/// projected onto these, so one set of pure profile builders serves both paths.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Dims {
38    /// Allow outbound network when `true`; otherwise the sandbox blocks egress.
39    pub network_allowed: bool,
40    /// Allow filesystem writes when `true`; otherwise read-only (+ scratch tmp).
41    pub fs_writable: bool,
42}
43
44impl Dims {
45    /// Nothing left to enforce at the OS level (everything is permitted).
46    #[must_use]
47    fn is_noop(self) -> bool {
48        self.network_allowed && self.fs_writable
49    }
50}
51
52/// Project a legacy [`SandboxMode`] onto sandbox [`Dims`]. `Off` is permissive
53/// (callers short-circuit before wrapping); `Auto` blocks network; `Strict`
54/// also makes the filesystem read-only.
55#[must_use]
56fn dims_for_mode(mode: SandboxMode) -> Dims {
57    match mode {
58        SandboxMode::Off => Dims {
59            network_allowed: true,
60            fs_writable: true,
61        },
62        SandboxMode::Auto => Dims {
63            network_allowed: false,
64            fs_writable: true,
65        },
66        SandboxMode::Strict => Dims {
67            network_allowed: false,
68            fs_writable: false,
69        },
70    }
71}
72
73/// Project declared [`AddonCapabilities`] onto sandbox [`Dims`].
74#[must_use]
75fn dims_for_caps(caps: &AddonCapabilities) -> Dims {
76    Dims {
77        network_allowed: caps.network_allowed(),
78        fs_writable: caps.filesystem_writable(),
79    }
80}
81
82/// How aggressively to sandbox a spawned stdio server.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum SandboxMode {
85    /// No sandbox — spawn the command directly (default).
86    #[default]
87    Off,
88    /// Best-effort: wrap if a launcher exists, else run directly with a warning.
89    /// Blocks outbound network.
90    Auto,
91    /// Network blocked + read-only filesystem; **refuses** to spawn if no
92    /// launcher is available.
93    Strict,
94}
95
96impl SandboxMode {
97    #[must_use]
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Self::Off => "off",
101            Self::Auto => "auto",
102            Self::Strict => "strict",
103        }
104    }
105
106    /// Parse from config text; unknown / empty → [`Self::Off`].
107    #[must_use]
108    pub fn parse(s: &str) -> Self {
109        match s.trim().to_ascii_lowercase().as_str() {
110            "auto" => Self::Auto,
111            "strict" => Self::Strict,
112            _ => Self::Off,
113        }
114    }
115}
116
117/// An OS sandbox launcher available on this host.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Launcher {
120    /// macOS `sandbox-exec` (SBPL profile via `-p`).
121    SandboxExec,
122    /// Linux `bwrap` (bubblewrap).
123    Bwrap,
124}
125
126/// What to do for a given (mode, launcher) pair — pure, so it is fully tested.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Plan {
129    /// Spawn the command unchanged.
130    Direct,
131    /// Wrap the command with `launcher`.
132    Wrap(Launcher),
133    /// Refuse to spawn (strict mode, no launcher). Carries the reason.
134    Refuse(String),
135}
136
137/// Decide the plan for `mode` given whether a launcher was detected. Pure.
138#[must_use]
139pub fn plan(mode: SandboxMode, launcher: Option<Launcher>) -> Plan {
140    match (mode, launcher) {
141        (SandboxMode::Off, _) | (SandboxMode::Auto, None) => Plan::Direct,
142        (_, Some(l)) => Plan::Wrap(l),
143        (SandboxMode::Strict, None) => Plan::Refuse(
144            "addons.sandbox = strict but no OS sandbox launcher (sandbox-exec / bwrap) is available"
145                .to_string(),
146        ),
147    }
148}
149
150/// Detect an available launcher for the current OS, or `None`.
151#[must_use]
152pub fn detect_launcher() -> Option<Launcher> {
153    if cfg!(target_os = "macos") && which("sandbox-exec") {
154        Some(Launcher::SandboxExec)
155    } else if cfg!(target_os = "linux") && which("bwrap") {
156        Some(Launcher::Bwrap)
157    } else {
158        None
159    }
160}
161
162/// Build the final `(command, args)` for a [`Plan::Wrap`], prefixing the
163/// original invocation with the launcher + a profile derived from `mode`. Pure.
164#[must_use]
165pub fn wrap_argv(
166    launcher: Launcher,
167    mode: SandboxMode,
168    command: &str,
169    args: &[String],
170) -> (String, Vec<String>) {
171    wrap_argv_dims(launcher, dims_for_mode(mode), command, args)
172}
173
174/// Build the final `(command, args)` for a [`Plan::Wrap`] from explicit
175/// [`Dims`] (network + filesystem). The OS sandbox enforces exactly these two
176/// dimensions; child processes inherit the profile, so a subprocess the addon
177/// spawns is bound by the same network/filesystem restrictions. Pure.
178#[must_use]
179fn wrap_argv_dims(
180    launcher: Launcher,
181    dims: Dims,
182    command: &str,
183    args: &[String],
184) -> (String, Vec<String>) {
185    match launcher {
186        Launcher::SandboxExec => {
187            let profile = sbpl_profile_dims(dims);
188            let mut v = vec!["-p".to_string(), profile, command.to_string()];
189            v.extend(args.iter().cloned());
190            ("sandbox-exec".to_string(), v)
191        }
192        Launcher::Bwrap => {
193            let mut v = bwrap_flags_dims(dims);
194            v.push(command.to_string());
195            v.extend(args.iter().cloned());
196            ("bwrap".to_string(), v)
197        }
198    }
199}
200
201/// macOS SBPL profile for `mode` (test-only wrapper over [`sbpl_profile_dims`];
202/// the runtime path goes through [`wrap_argv`] → [`wrap_argv_dims`]).
203#[cfg(test)]
204fn sbpl_profile(mode: SandboxMode) -> String {
205    sbpl_profile_dims(dims_for_mode(mode))
206}
207
208/// macOS SBPL profile for explicit [`Dims`]. `allow default` keeps the tool
209/// working; the denies are the security wins. Last-match-wins, so the tmp
210/// re-allow follows the deny.
211fn sbpl_profile_dims(dims: Dims) -> String {
212    let mut p = String::from("(version 1)\n(allow default)\n");
213    if !dims.network_allowed {
214        p.push_str("(deny network*)\n");
215    }
216    if !dims.fs_writable {
217        p.push_str("(deny file-write*)\n");
218        p.push_str("(allow file-write* (subpath \"/tmp\") (subpath \"/private/tmp\") (subpath \"/var/folders\"))\n");
219    }
220    p
221}
222
223/// bubblewrap flags for `mode` (test-only wrapper over [`bwrap_flags_dims`];
224/// the runtime path goes through [`wrap_argv`] → [`wrap_argv_dims`]).
225#[cfg(test)]
226fn bwrap_flags(mode: SandboxMode) -> Vec<String> {
227    bwrap_flags_dims(dims_for_mode(mode))
228}
229
230/// bubblewrap flags for explicit [`Dims`]: unshare the network unless allowed;
231/// bind the root read-only (with a writable tmpfs at `/tmp`) unless writable.
232fn bwrap_flags_dims(dims: Dims) -> Vec<String> {
233    let mut f: Vec<String> = vec!["--die-with-parent".into()];
234    if !dims.network_allowed {
235        f.push("--unshare-net".into());
236    }
237    if dims.fs_writable {
238        f.extend(
239            ["--bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
240                .iter()
241                .map(|s| (*s).to_string()),
242        );
243    } else {
244        f.extend(
245            [
246                "--ro-bind",
247                "/",
248                "/",
249                "--dev",
250                "/dev",
251                "--proc",
252                "/proc",
253                "--tmpfs",
254                "/tmp",
255            ]
256            .iter()
257            .map(|s| (*s).to_string()),
258        );
259    }
260    f
261}
262
263/// Resolve the configured sandbox mode and rewrite `(command, args)` for the
264/// gateway spawn point. Returns the original invocation when sandboxing is off
265/// or unavailable in `auto`; an `Err` when `strict` cannot be honoured (the
266/// caller must then refuse to spawn). Reads the global-only `[addons]` config.
267pub fn apply(command: &str, args: &[String]) -> Result<(String, Vec<String>), String> {
268    let mode = crate::core::config::Config::load().addons.sandbox_mode();
269    if mode == SandboxMode::Off {
270        return Ok((command.to_string(), args.to_vec()));
271    }
272    match plan(mode, detect_launcher()) {
273        Plan::Direct => {
274            if mode != SandboxMode::Off {
275                tracing::warn!(
276                    "addons.sandbox = {} but no OS sandbox launcher is available — \
277                     spawning `{command}` UNSANDBOXED",
278                    mode.as_str()
279                );
280            }
281            Ok((command.to_string(), args.to_vec()))
282        }
283        Plan::Wrap(launcher) => {
284            tracing::debug!(
285                "sandboxing `{command}` via {:?} ({} mode)",
286                launcher,
287                mode.as_str()
288            );
289            Ok(wrap_argv(launcher, mode, command, args))
290        }
291        Plan::Refuse(reason) => Err(reason),
292    }
293}
294
295/// Resolve the sandbox for a spawn, preferring per-addon declared
296/// [`AddonCapabilities`] over the legacy global `addons.sandbox` mode.
297///
298/// - `Some(caps)` → enforce exactly the declared network + filesystem profile
299///   (secure-by-default for the platform/marketplace path); child processes
300///   inherit it. `exec` is disclosed/audited, not OS-enforced (see module docs).
301///   If the profile restricts anything but no OS launcher is available, fail
302///   closed when `addons.enforce_capabilities` is set, otherwise run unsandboxed.
303/// - `None` → fall back to [`apply`] (the legacy `addons.sandbox` behaviour), so
304///   addons that predate the capability model keep working unchanged.
305pub fn apply_for(
306    command: &str,
307    args: &[String],
308    capabilities: Option<&AddonCapabilities>,
309) -> Result<(String, Vec<String>), String> {
310    match capabilities {
311        Some(caps) => apply_caps(command, args, caps),
312        None => apply(command, args),
313    }
314}
315
316/// Enforce a per-addon capability profile at the spawn point. The OS sandbox
317/// enforces the network + filesystem dimensions (and child processes inherit
318/// them); `exec` is a declared + audited + consented capability, not an OS
319/// control — see the module docs for why path-allowlisting `execve` is neither
320/// portable nor compatible with interpreted servers. Pure decision +
321/// OS-launcher detection; the wrapping argv is unit-tested.
322fn apply_caps(
323    command: &str,
324    args: &[String],
325    caps: &AddonCapabilities,
326) -> Result<(String, Vec<String>), String> {
327    let dims = dims_for_caps(caps);
328
329    // Network + filesystem unrestricted → nothing for the OS sandbox to add
330    // (env scrubbing still happens at the spawn point).
331    if dims.is_noop() {
332        return Ok((command.to_string(), args.to_vec()));
333    }
334
335    let enforce = crate::core::config::Config::load()
336        .addons
337        .enforce_capabilities;
338
339    let Some(launcher) = detect_launcher() else {
340        // No OS launcher: fail closed only when the org opted in, else warn.
341        if enforce {
342            return Err(format!(
343                "addons.enforce_capabilities = true but no OS sandbox launcher \
344                 (sandbox-exec / bwrap) is available to honour `{command}`'s declared \
345                 restricted capabilities"
346            ));
347        }
348        tracing::warn!(
349            "addon `{command}` declares restricted capabilities but no OS sandbox \
350             launcher is available — running UNSANDBOXED (set \
351             addons.enforce_capabilities = true to fail closed)"
352        );
353        return Ok((command.to_string(), args.to_vec()));
354    };
355
356    tracing::debug!(
357        "sandboxing `{command}` via {:?} (net={}, fs_write={})",
358        launcher,
359        dims.network_allowed,
360        dims.fs_writable
361    );
362    Ok(wrap_argv_dims(launcher, dims, command, args))
363}
364
365fn which(bin: &str) -> bool {
366    let Ok(path) = std::env::var("PATH") else {
367        return false;
368    };
369    std::env::split_paths(&path).any(|dir| {
370        let p = dir.join(bin);
371        p.is_file() && is_executable(&p)
372    })
373}
374
375#[cfg(unix)]
376fn is_executable(p: &Path) -> bool {
377    use std::os::unix::fs::PermissionsExt;
378    std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
379}
380
381#[cfg(not(unix))]
382fn is_executable(_p: &Path) -> bool {
383    true
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn mode_parse_roundtrip() {
392        assert_eq!(SandboxMode::parse("auto"), SandboxMode::Auto);
393        assert_eq!(SandboxMode::parse("STRICT"), SandboxMode::Strict);
394        assert_eq!(SandboxMode::parse(""), SandboxMode::Off);
395        assert_eq!(SandboxMode::parse("nonsense"), SandboxMode::Off);
396        assert_eq!(SandboxMode::Strict.as_str(), "strict");
397    }
398
399    #[test]
400    fn plan_off_is_always_direct() {
401        assert_eq!(plan(SandboxMode::Off, Some(Launcher::Bwrap)), Plan::Direct);
402        assert_eq!(plan(SandboxMode::Off, None), Plan::Direct);
403    }
404
405    #[test]
406    fn plan_auto_without_launcher_runs_direct() {
407        assert_eq!(plan(SandboxMode::Auto, None), Plan::Direct);
408    }
409
410    #[test]
411    fn plan_strict_without_launcher_refuses() {
412        assert!(matches!(plan(SandboxMode::Strict, None), Plan::Refuse(_)));
413    }
414
415    #[test]
416    fn plan_wraps_when_launcher_present() {
417        assert_eq!(
418            plan(SandboxMode::Auto, Some(Launcher::SandboxExec)),
419            Plan::Wrap(Launcher::SandboxExec)
420        );
421    }
422
423    #[test]
424    fn sandbox_exec_argv_prepends_profile_and_command() {
425        let (cmd, args) = wrap_argv(
426            Launcher::SandboxExec,
427            SandboxMode::Auto,
428            "my-mcp",
429            &["serve".into()],
430        );
431        assert_eq!(cmd, "sandbox-exec");
432        assert_eq!(args[0], "-p");
433        assert!(args[1].contains("(deny network*)"));
434        assert_eq!(args[2], "my-mcp");
435        assert_eq!(args[3], "serve");
436    }
437
438    #[test]
439    fn strict_sbpl_restricts_writes() {
440        let p = sbpl_profile(SandboxMode::Strict);
441        assert!(p.contains("(deny file-write*)"));
442        assert!(p.contains("/tmp"));
443        let auto = sbpl_profile(SandboxMode::Auto);
444        assert!(!auto.contains("(deny file-write*)"));
445    }
446
447    #[test]
448    fn bwrap_argv_unshares_network() {
449        let (cmd, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Auto, "my-mcp", &["x".into()]);
450        assert_eq!(cmd, "bwrap");
451        assert!(args.iter().any(|a| a == "--unshare-net"));
452        assert!(args.iter().any(|a| a == "my-mcp"));
453        assert!(args.iter().any(|a| a == "x"));
454    }
455
456    #[test]
457    fn bwrap_strict_is_readonly_root() {
458        let (_c, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Strict, "m", &[]);
459        assert!(args.iter().any(|a| a == "--ro-bind"));
460        assert!(args.iter().any(|a| a == "--tmpfs"));
461    }
462
463    // --- capability-derived profiles (P1) ---
464
465    use super::super::capabilities::{
466        AddonCapabilities, ExecAccess, FilesystemAccess, NetworkAccess,
467    };
468
469    #[test]
470    fn minimal_caps_block_network_and_writes() {
471        let dims = dims_for_caps(&AddonCapabilities::default());
472        assert!(!dims.network_allowed);
473        assert!(!dims.fs_writable);
474        let sbpl = sbpl_profile_dims(dims);
475        assert!(sbpl.contains("(deny network*)"));
476        assert!(sbpl.contains("(deny file-write*)"));
477    }
478
479    #[test]
480    fn full_network_caps_omit_network_deny() {
481        let caps = AddonCapabilities {
482            network: NetworkAccess::Full,
483            filesystem: FilesystemAccess::ReadOnly,
484            env: vec![],
485            exec: ExecAccess::default(),
486        };
487        let dims = dims_for_caps(&caps);
488        assert!(dims.network_allowed);
489        let sbpl = sbpl_profile_dims(dims);
490        assert!(!sbpl.contains("(deny network*)"));
491        assert!(sbpl.contains("(deny file-write*)"));
492        // bwrap must NOT unshare the network when egress is allowed.
493        let flags = bwrap_flags_dims(dims);
494        assert!(!flags.iter().any(|f| f == "--unshare-net"));
495        assert!(flags.iter().any(|f| f == "--ro-bind"));
496    }
497
498    #[test]
499    fn permissive_net_fs_is_a_noop_regardless_of_exec() {
500        // exec is not an OS-sandbox dimension: an unrestricted network +
501        // filesystem profile is a true no-op even when the exec declaration is
502        // restricted (the addon — and its interpreter chain — must start).
503        let caps = AddonCapabilities {
504            network: NetworkAccess::Full,
505            filesystem: FilesystemAccess::ReadWrite,
506            env: vec![],
507            exec: ExecAccess::default(), // `none` — a restricted declaration
508        };
509        assert!(caps.exec_restricted());
510        assert!(dims_for_caps(&caps).is_noop());
511        // apply_for returns the command unchanged — exec is never OS-enforced.
512        let (cmd, args) = apply_for("my-mcp", &["serve".into()], Some(&caps)).expect("noop");
513        assert_eq!(cmd, "my-mcp");
514        assert_eq!(args, vec!["serve".to_string()]);
515    }
516
517    #[test]
518    fn caps_wrap_argv_prepends_launcher() {
519        let dims = dims_for_caps(&AddonCapabilities::default());
520        let (cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &["x".into()]);
521        assert_eq!(cmd, "sandbox-exec");
522        assert_eq!(args[0], "-p");
523        assert!(args[1].contains("(deny network*)"));
524        assert_eq!(args[2], "my-mcp");
525        assert_eq!(args[3], "x");
526    }
527
528    // --- exec is declared/audited, NOT OS-enforced (see module docs) ---
529
530    #[test]
531    fn sandbox_profile_never_emits_process_exec() {
532        // Whatever the exec declaration, the generated SBPL profile only ever
533        // governs network + filesystem — never `process-exec`. Path-allowlisting
534        // execve is not portable (bwrap/seccomp can't) and breaks interpreted
535        // servers (the interpreter chain is itself a process-exec).
536        let dims = dims_for_caps(&AddonCapabilities::default());
537        let (_cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &[]);
538        assert!(args[1].contains("(deny network*)"));
539        assert!(args[1].contains("(deny file-write*)"));
540        assert!(!args[1].contains("process-exec"));
541    }
542
543    #[test]
544    fn mode_path_unchanged_via_dims() {
545        // Back-compat: the mode wrappers still produce the historical profiles.
546        assert!(sbpl_profile(SandboxMode::Auto).contains("(deny network*)"));
547        assert!(!sbpl_profile(SandboxMode::Auto).contains("(deny file-write*)"));
548        assert!(sbpl_profile(SandboxMode::Strict).contains("(deny file-write*)"));
549        assert!(
550            bwrap_flags(SandboxMode::Auto)
551                .iter()
552                .any(|f| f == "--unshare-net")
553        );
554    }
555}