Skip to main content

aperion_shield/sandbox/
mod.rs

1//! v1.0: upstream process confinement.
2//!
3//! Shield already owns the upstream MCP server's process lifecycle
4//! (we spawn it), which makes the spawn point the natural seam for
5//! OS-level confinement. Protocol filtering (the engine) and process
6//! confinement (this module) are complementary layers: the engine
7//! stops malicious *messages*, the sandbox limits what the server
8//! *process* can touch outside the protocol entirely.
9//!
10//! Platform support:
11//!   - macOS: Seatbelt via `sandbox-exec -p <profile>`. Deprecated by
12//!     Apple for third-party use but stable, universally present, and
13//!     still what Bazel/Chromium-class tooling uses for exactly this
14//!     job. No daemon, no privileges required.
15//!   - other platforms: graceful degrade -- warn loudly and run
16//!     unconfined (`SandboxLevel::Off` semantics) unless the user
17//!     passed `--sandbox strict`, in which case refusing to start is
18//!     the only honest behaviour.
19//!
20//! Levels:
21//!   - `off`     -- current pre-v1.0 behaviour, no confinement.
22//!   - `secrets` -- allow-by-default, deny read/write of credential
23//!     material: ~/.ssh, ~/.aws, ~/.gnupg, cloud CLI configs, kube
24//!     config, ~/.netrc, Docker creds. Low breakage: an MCP server
25//!     that legitimately needs one of these (e.g. a git server doing
26//!     SSH pushes) is exactly the server you want to consciously
27//!     exempt via `--sandbox-allow <path>`.
28//!   - `strict`  -- everything `secrets` does, PLUS deny-by-default
29//!     writes (working directory, /tmp, and the user cache dirs only)
30//!     and no network unless `--sandbox-allow-network`. Reads stay
31//!     broadly allowed (minus credential paths): a read deny-default
32//!     profile needs an exact enumeration of the dyld/runtime surface,
33//!     which differs per macOS release and breaks silently -- write +
34//!     network confinement is the part that holds reliably, and it is
35//!     the part that stops exfiltration and tampering.
36
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SandboxLevel {
41    Off,
42    Secrets,
43    Strict,
44}
45
46impl SandboxLevel {
47    pub fn parse(s: &str) -> anyhow::Result<Self> {
48        match s.to_ascii_lowercase().as_str() {
49            "off" => Ok(Self::Off),
50            "secrets" => Ok(Self::Secrets),
51            "strict" => Ok(Self::Strict),
52            other => anyhow::bail!(
53                "unknown --sandbox level '{}' (expected off | secrets | strict)",
54                other
55            ),
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct SandboxConfig {
62    pub level: SandboxLevel,
63    /// Paths exempted from the `secrets` deny-list, or granted
64    /// read+write in `strict` mode (beyond the defaults).
65    pub allow_paths: Vec<PathBuf>,
66    /// `strict` only: permit outbound/inbound network. `secrets`
67    /// leaves the network alone -- most MCP servers need it.
68    pub allow_network: bool,
69    /// Home directory override (tests use a temp dir).
70    pub home: Option<PathBuf>,
71}
72
73impl Default for SandboxConfig {
74    fn default() -> Self {
75        Self {
76            level: SandboxLevel::Off,
77            allow_paths: Vec::new(),
78            allow_network: false,
79            home: None,
80        }
81    }
82}
83
84/// How the upstream actually ended up confined, for the startup log
85/// and the audit trail.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Confinement {
88    None,
89    Seatbelt { level: SandboxLevel },
90}
91
92impl std::fmt::Display for Confinement {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Confinement::None => write!(f, "unconfined"),
96            Confinement::Seatbelt { level } => {
97                write!(f, "seatbelt:{}", match level {
98                    SandboxLevel::Off => "off",
99                    SandboxLevel::Secrets => "secrets",
100                    SandboxLevel::Strict => "strict",
101                })
102            }
103        }
104    }
105}
106
107/// Credential material the `secrets` level denies. Relative to $HOME.
108const SECRET_SUBPATHS: &[&str] = &[
109    ".ssh",
110    ".aws",
111    ".gnupg",
112    ".gcloud",
113    ".config/gcloud",
114    ".azure",
115    ".kube",
116    ".netrc",
117    ".docker/config.json",
118    ".npmrc",
119    ".pypirc",
120    ".cargo/credentials.toml",
121];
122
123fn sbpl_escape(p: &Path) -> String {
124    // SBPL string literals are double-quoted; escape embedded quotes
125    // and backslashes. Paths with either are vanishingly rare but a
126    // sandbox profile is the wrong place to be sloppy.
127    p.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"")
128}
129
130fn home_dir(cfg: &SandboxConfig) -> PathBuf {
131    cfg.home
132        .clone()
133        .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
134        .unwrap_or_else(|| PathBuf::from("/"))
135}
136
137/// Render the Seatbelt (SBPL) profile for a config. Public for tests.
138///
139/// SBPL evaluation: the LAST matching rule wins, so both levels open
140/// with `(allow default)` and stack targeted denies (and re-allows)
141/// after it. A `(deny default)` read profile was prototyped and
142/// rejected: the dyld/runtime read surface differs per macOS release
143/// and fails as SIGABRT before the upstream's main() -- unshippable.
144pub fn seatbelt_profile(cfg: &SandboxConfig) -> String {
145    let home = home_dir(cfg);
146    let mut out = String::from("(version 1)\n(allow default)\n");
147
148    // Both levels: deny credential material (minus exemptions).
149    for sub in SECRET_SUBPATHS {
150        let p = home.join(sub);
151        if cfg.allow_paths.iter().any(|a| p.starts_with(a) || a.starts_with(&p)) {
152            continue;
153        }
154        out.push_str(&format!(
155            "(deny file-read* file-write* (subpath \"{}\"))\n",
156            sbpl_escape(&p)
157        ));
158    }
159
160    if cfg.level == SandboxLevel::Strict {
161        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
162        // Deny-by-default WRITES, then re-open the working directory,
163        // scratch/cache space, and terminal devices.
164        out.push_str("(deny file-write*)\n");
165        out.push_str(&format!(
166            "(allow file-write* (subpath \"{}\"))\n",
167            sbpl_escape(&cwd)
168        ));
169        for p in ["/tmp", "/private/tmp", "/private/var/tmp", "/private/var/folders", "/dev/null", "/dev/tty"] {
170            let kind = if p.starts_with("/dev/") { "literal" } else { "subpath" };
171            out.push_str(&format!("(allow file-write* ({} \"{}\"))\n", kind, p));
172        }
173        for p in &cfg.allow_paths {
174            out.push_str(&format!(
175                "(allow file-read* file-write* (subpath \"{}\"))\n",
176                sbpl_escape(p)
177            ));
178        }
179        if !cfg.allow_network {
180            out.push_str("(deny network*)\n");
181        }
182    }
183    out
184}
185
186/// Wrap `cmd` in the platform sandbox launcher per `cfg`.
187///
188/// Returns the command to exec plus the achieved confinement. Degrades
189/// gracefully (warn + unconfined) when the platform has no sandbox,
190/// EXCEPT for `strict`, where silently running unconfined would be a
191/// lie -- there we refuse.
192pub fn wrap_command(cmd: &[String], cfg: &SandboxConfig) -> anyhow::Result<(Vec<String>, Confinement)> {
193    if cfg.level == SandboxLevel::Off || cmd.is_empty() {
194        return Ok((cmd.to_vec(), Confinement::None));
195    }
196
197    #[cfg(target_os = "macos")]
198    {
199        let profile = seatbelt_profile(cfg);
200        let mut wrapped = vec![
201            "/usr/bin/sandbox-exec".to_string(),
202            "-p".to_string(),
203            profile,
204        ];
205        wrapped.extend(cmd.iter().cloned());
206        Ok((wrapped, Confinement::Seatbelt { level: cfg.level }))
207    }
208
209    #[cfg(not(target_os = "macos"))]
210    {
211        match cfg.level {
212            SandboxLevel::Strict => anyhow::bail!(
213                "--sandbox strict is not supported on this platform yet \
214                 (macOS Seatbelt only); refusing to run unconfined when \
215                 strict confinement was requested"
216            ),
217            _ => {
218                log::warn!(
219                    "[shield] --sandbox {:?} requested but no sandbox backend \
220                     exists on this platform yet -- upstream runs UNCONFINED",
221                    cfg.level
222                );
223                Ok((cmd.to_vec(), Confinement::None))
224            }
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn cfg(level: SandboxLevel) -> SandboxConfig {
234        SandboxConfig {
235            level,
236            allow_paths: vec![],
237            allow_network: false,
238            home: Some(PathBuf::from("/Users/testhome")),
239        }
240    }
241
242    #[test]
243    fn off_is_passthrough() {
244        let cmd = vec!["echo".to_string(), "hi".to_string()];
245        let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Off)).unwrap();
246        assert_eq!(wrapped, cmd);
247        assert_eq!(conf, Confinement::None);
248    }
249
250    #[test]
251    fn secrets_profile_denies_credential_dirs() {
252        let p = seatbelt_profile(&cfg(SandboxLevel::Secrets));
253        assert!(p.starts_with("(version 1)\n(allow default)\n"));
254        assert!(p.contains("(deny file-read* file-write* (subpath \"/Users/testhome/.ssh\"))"));
255        assert!(p.contains("/Users/testhome/.aws"));
256        assert!(p.contains("/Users/testhome/.kube"));
257    }
258
259    #[test]
260    fn secrets_allow_path_exempts_dir() {
261        let mut c = cfg(SandboxLevel::Secrets);
262        c.allow_paths.push(PathBuf::from("/Users/testhome/.ssh"));
263        let p = seatbelt_profile(&c);
264        assert!(!p.contains("/Users/testhome/.ssh"));
265        assert!(p.contains("/Users/testhome/.aws")); // others still denied
266    }
267
268    #[test]
269    fn strict_profile_confines_writes_and_network() {
270        let p = seatbelt_profile(&cfg(SandboxLevel::Strict));
271        assert!(p.contains("(deny file-write*)"));
272        assert!(p.contains("(deny network*)"));
273        assert!(p.contains("/Users/testhome/.ssh")); // credential denies kept
274        let mut c = cfg(SandboxLevel::Strict);
275        c.allow_network = true;
276        assert!(!seatbelt_profile(&c).contains("(deny network*)"));
277    }
278
279    #[test]
280    fn profile_escapes_quotes_in_paths() {
281        let mut c = cfg(SandboxLevel::Secrets);
282        c.home = Some(PathBuf::from("/Users/we\"ird"));
283        let p = seatbelt_profile(&c);
284        assert!(p.contains("/Users/we\\\"ird/.ssh"));
285    }
286
287    #[cfg(target_os = "macos")]
288    #[test]
289    fn macos_wraps_with_sandbox_exec() {
290        let cmd = vec!["/bin/echo".to_string(), "hi".to_string()];
291        let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Secrets)).unwrap();
292        assert_eq!(wrapped[0], "/usr/bin/sandbox-exec");
293        assert_eq!(wrapped[1], "-p");
294        assert!(wrapped[2].contains("(deny file-read*"));
295        assert_eq!(&wrapped[3..], &cmd[..]);
296        assert_eq!(conf, Confinement::Seatbelt { level: SandboxLevel::Secrets });
297    }
298}