Skip to main content

earl_protocol_bash/
sandbox.rs

1use std::path::{Component, Path};
2
3use anyhow::Result;
4use tokio::process::Command;
5
6use crate::ResolvedBashSandbox;
7
8/// Returns `true` when a supported sandbox tool is available on this platform.
9pub fn sandbox_available() -> bool {
10    #[cfg(target_os = "linux")]
11    {
12        which("bwrap")
13    }
14    #[cfg(target_os = "macos")]
15    {
16        which("sandbox-exec")
17    }
18    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
19    {
20        false
21    }
22}
23
24/// Human-readable name of the sandbox back-end for diagnostic messages.
25pub fn sandbox_tool_name() -> &'static str {
26    #[cfg(target_os = "linux")]
27    {
28        "bwrap (bubblewrap)"
29    }
30    #[cfg(target_os = "macos")]
31    {
32        "sandbox-exec"
33    }
34    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
35    {
36        "unsupported"
37    }
38}
39
40/// Build a [`Command`] that will execute `script` inside a sandbox.
41///
42/// On Linux the command uses `bwrap` with targeted read-only mounts, isolated
43/// network, PID, IPC, and UTS namespaces, a tmpfs `/tmp`, and real `/dev` + `/proc`.
44///
45/// On macOS the command uses `sandbox-exec` with a dynamically generated
46/// Seatbelt profile that scopes `file-read*` and `mach-lookup` to required paths.
47pub fn build_sandboxed_command(
48    script: &str,
49    env: &[(String, String)],
50    cwd: Option<&str>,
51    sandbox: &ResolvedBashSandbox,
52) -> Result<Command> {
53    #[cfg(target_os = "linux")]
54    {
55        build_linux_command(script, env, cwd, sandbox)
56    }
57    #[cfg(target_os = "macos")]
58    {
59        build_macos_command(script, env, cwd, sandbox)
60    }
61    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
62    {
63        let _ = (script, env, cwd, sandbox);
64        anyhow::bail!(
65            "bash sandbox is not supported on this platform; \
66             only Linux (bwrap) and macOS (sandbox-exec) are supported"
67        );
68    }
69}
70
71// -- Linux (bwrap) -----------------------------------------------------------
72
73#[cfg(target_os = "linux")]
74fn build_linux_command(
75    script: &str,
76    env: &[(String, String)],
77    cwd: Option<&str>,
78    sandbox: &ResolvedBashSandbox,
79) -> Result<Command> {
80    let mut cmd = Command::new("bwrap");
81
82    // Targeted read-only mounts instead of --ro-bind / /
83    for dir in &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] {
84        if Path::new(dir).exists() {
85            cmd.args(["--ro-bind", dir, dir]);
86        }
87    }
88
89    cmd.args(["--tmpfs", "/tmp"]);
90    cmd.args(["--dev", "/dev"]);
91    cmd.args(["--proc", "/proc"]);
92
93    // Namespace isolation
94    cmd.arg("--unshare-pid");
95    cmd.arg("--unshare-ipc");
96    cmd.arg("--unshare-uts");
97    if !sandbox.network {
98        cmd.arg("--unshare-net");
99    }
100    cmd.arg("--die-with-parent");
101
102    if let Some(dir) = cwd {
103        validate_sandbox_cwd(dir)?;
104        // Mount cwd read-only by default
105        cmd.args(["--ro-bind", dir, dir]);
106        cmd.args(["--chdir", dir]);
107
108        // Mount writable sub-paths with --bind
109        for writable in &sandbox.writable_paths {
110            let full_path = Path::new(dir).join(writable);
111            let full_str = full_path.to_string_lossy();
112            if full_path.exists() {
113                cmd.args(["--bind", &full_str, &full_str]);
114            }
115        }
116    }
117
118    cmd.args(["--", "bash", "-c", script]);
119    for (key, value) in env {
120        // codeql[rust/cleartext-logging] - Secrets are passed as subprocess environment variables
121        // by design; env vars are the standard safe mechanism for providing credentials to scripts.
122        cmd.env(key, value);
123    }
124    Ok(cmd)
125}
126
127// -- macOS (sandbox-exec) ----------------------------------------------------
128
129#[cfg(target_os = "macos")]
130fn build_macos_command(
131    script: &str,
132    env: &[(String, String)],
133    cwd: Option<&str>,
134    sandbox: &ResolvedBashSandbox,
135) -> Result<Command> {
136    let profile = build_seatbelt_profile(cwd, sandbox);
137    let mut cmd = Command::new("sandbox-exec");
138    cmd.args(["-p", &profile, "bash", "-c", script]);
139    for (key, value) in env {
140        // codeql[rust/cleartext-logging] - Secrets are passed as subprocess environment variables
141        // by design; env vars are the standard safe mechanism for providing credentials to scripts.
142        cmd.env(key, value);
143    }
144    if let Some(dir) = cwd {
145        validate_sandbox_cwd(dir)?;
146        cmd.current_dir(dir);
147    }
148    Ok(cmd)
149}
150
151#[cfg(target_os = "macos")]
152fn build_seatbelt_profile(cwd: Option<&str>, sandbox: &ResolvedBashSandbox) -> String {
153    let mut profile = String::from(
154        "(version 1)\n\
155         (deny default)\n",
156    );
157
158    // Network access
159    if sandbox.network {
160        profile.push_str("(allow network*)\n");
161    } else {
162        profile.push_str("(deny network*)\n");
163    }
164
165    // Process execution
166    profile.push_str("(allow process-exec)\n");
167    profile.push_str("(allow process-fork)\n");
168    profile.push_str("(allow sysctl-read)\n");
169
170    // Allow read-only file access. Bash and its subprocesses need access to
171    // system libraries, the dyld shared cache, and various OS metadata paths
172    // that are impractical to fully enumerate. Read-only access is low risk.
173    profile.push_str("(allow file-read*)\n");
174
175    if let Some(dir) = cwd {
176        // Allow write to writable sub-paths only
177        for writable in &sandbox.writable_paths {
178            let full_path = std::path::Path::new(dir).join(writable);
179            let full_str = full_path.to_string_lossy();
180            profile.push_str(&format!("(allow file-write* (subpath \"{full_str}\"))\n"));
181        }
182    }
183
184    // Allow file-write to /dev/null and /dev/tty (standard shell operations).
185    profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
186    profile.push_str("(allow file-write* (literal \"/dev/tty\"))\n");
187
188    // Scoped mach-lookup to required services. The old profile allowed all
189    // mach-lookup which is overly broad. We allow a curated set needed for
190    // basic shell operations and common system services.
191    for service in &[
192        "com.apple.system.logger",
193        "com.apple.system.notification_center",
194        "com.apple.SecurityServer",
195        "com.apple.CoreServices.coreservicesd",
196        "com.apple.lsd.mapdb",
197    ] {
198        profile.push_str(&format!(
199            "(allow mach-lookup (global-name \"{service}\"))\n"
200        ));
201    }
202
203    profile
204}
205
206// -- helpers -----------------------------------------------------------------
207
208/// Validate that a sandbox cwd does not contain path traversal via `..` components.
209pub fn validate_sandbox_cwd(dir: &str) -> Result<()> {
210    if Path::new(dir)
211        .components()
212        .any(|c| matches!(c, Component::ParentDir))
213    {
214        anyhow::bail!("sandbox cwd must not contain `..` path components");
215    }
216    Ok(())
217}
218
219fn which(name: &str) -> bool {
220    std::process::Command::new("which")
221        .arg(name)
222        .stdout(std::process::Stdio::null())
223        .stderr(std::process::Stdio::null())
224        .status()
225        .map(|s| s.success())
226        .unwrap_or(false)
227}