use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxLevel {
Off,
Secrets,
Strict,
}
impl SandboxLevel {
pub fn parse(s: &str) -> anyhow::Result<Self> {
match s.to_ascii_lowercase().as_str() {
"off" => Ok(Self::Off),
"secrets" => Ok(Self::Secrets),
"strict" => Ok(Self::Strict),
other => anyhow::bail!(
"unknown --sandbox level '{}' (expected off | secrets | strict)",
other
),
}
}
}
#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub level: SandboxLevel,
pub allow_paths: Vec<PathBuf>,
pub allow_network: bool,
pub home: Option<PathBuf>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
level: SandboxLevel::Off,
allow_paths: Vec::new(),
allow_network: false,
home: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Confinement {
None,
Seatbelt { level: SandboxLevel },
}
impl std::fmt::Display for Confinement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Confinement::None => write!(f, "unconfined"),
Confinement::Seatbelt { level } => {
write!(f, "seatbelt:{}", match level {
SandboxLevel::Off => "off",
SandboxLevel::Secrets => "secrets",
SandboxLevel::Strict => "strict",
})
}
}
}
}
const SECRET_SUBPATHS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".gcloud",
".config/gcloud",
".azure",
".kube",
".netrc",
".docker/config.json",
".npmrc",
".pypirc",
".cargo/credentials.toml",
];
fn sbpl_escape(p: &Path) -> String {
p.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"")
}
fn home_dir(cfg: &SandboxConfig) -> PathBuf {
cfg.home
.clone()
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("/"))
}
pub fn seatbelt_profile(cfg: &SandboxConfig) -> String {
let home = home_dir(cfg);
let mut out = String::from("(version 1)\n(allow default)\n");
for sub in SECRET_SUBPATHS {
let p = home.join(sub);
if cfg.allow_paths.iter().any(|a| p.starts_with(a) || a.starts_with(&p)) {
continue;
}
out.push_str(&format!(
"(deny file-read* file-write* (subpath \"{}\"))\n",
sbpl_escape(&p)
));
}
if cfg.level == SandboxLevel::Strict {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
out.push_str("(deny file-write*)\n");
out.push_str(&format!(
"(allow file-write* (subpath \"{}\"))\n",
sbpl_escape(&cwd)
));
for p in ["/tmp", "/private/tmp", "/private/var/tmp", "/private/var/folders", "/dev/null", "/dev/tty"] {
let kind = if p.starts_with("/dev/") { "literal" } else { "subpath" };
out.push_str(&format!("(allow file-write* ({} \"{}\"))\n", kind, p));
}
for p in &cfg.allow_paths {
out.push_str(&format!(
"(allow file-read* file-write* (subpath \"{}\"))\n",
sbpl_escape(p)
));
}
if !cfg.allow_network {
out.push_str("(deny network*)\n");
}
}
out
}
pub fn wrap_command(cmd: &[String], cfg: &SandboxConfig) -> anyhow::Result<(Vec<String>, Confinement)> {
if cfg.level == SandboxLevel::Off || cmd.is_empty() {
return Ok((cmd.to_vec(), Confinement::None));
}
#[cfg(target_os = "macos")]
{
let profile = seatbelt_profile(cfg);
let mut wrapped = vec![
"/usr/bin/sandbox-exec".to_string(),
"-p".to_string(),
profile,
];
wrapped.extend(cmd.iter().cloned());
Ok((wrapped, Confinement::Seatbelt { level: cfg.level }))
}
#[cfg(not(target_os = "macos"))]
{
match cfg.level {
SandboxLevel::Strict => anyhow::bail!(
"--sandbox strict is not supported on this platform yet \
(macOS Seatbelt only); refusing to run unconfined when \
strict confinement was requested"
),
_ => {
tracing::warn!(
"[shield] --sandbox {:?} requested but no sandbox backend \
exists on this platform yet -- upstream runs UNCONFINED",
cfg.level
);
Ok((cmd.to_vec(), Confinement::None))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(level: SandboxLevel) -> SandboxConfig {
SandboxConfig {
level,
allow_paths: vec![],
allow_network: false,
home: Some(PathBuf::from("/Users/testhome")),
}
}
#[test]
fn off_is_passthrough() {
let cmd = vec!["echo".to_string(), "hi".to_string()];
let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Off)).unwrap();
assert_eq!(wrapped, cmd);
assert_eq!(conf, Confinement::None);
}
#[test]
fn secrets_profile_denies_credential_dirs() {
let p = seatbelt_profile(&cfg(SandboxLevel::Secrets));
assert!(p.starts_with("(version 1)\n(allow default)\n"));
assert!(p.contains("(deny file-read* file-write* (subpath \"/Users/testhome/.ssh\"))"));
assert!(p.contains("/Users/testhome/.aws"));
assert!(p.contains("/Users/testhome/.kube"));
}
#[test]
fn secrets_allow_path_exempts_dir() {
let mut c = cfg(SandboxLevel::Secrets);
c.allow_paths.push(PathBuf::from("/Users/testhome/.ssh"));
let p = seatbelt_profile(&c);
assert!(!p.contains("/Users/testhome/.ssh"));
assert!(p.contains("/Users/testhome/.aws")); }
#[test]
fn strict_profile_confines_writes_and_network() {
let p = seatbelt_profile(&cfg(SandboxLevel::Strict));
assert!(p.contains("(deny file-write*)"));
assert!(p.contains("(deny network*)"));
assert!(p.contains("/Users/testhome/.ssh")); let mut c = cfg(SandboxLevel::Strict);
c.allow_network = true;
assert!(!seatbelt_profile(&c).contains("(deny network*)"));
}
#[test]
fn profile_escapes_quotes_in_paths() {
let mut c = cfg(SandboxLevel::Secrets);
c.home = Some(PathBuf::from("/Users/we\"ird"));
let p = seatbelt_profile(&c);
assert!(p.contains("/Users/we\\\"ird/.ssh"));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_wraps_with_sandbox_exec() {
let cmd = vec!["/bin/echo".to_string(), "hi".to_string()];
let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Secrets)).unwrap();
assert_eq!(wrapped[0], "/usr/bin/sandbox-exec");
assert_eq!(wrapped[1], "-p");
assert!(wrapped[2].contains("(deny file-read*"));
assert_eq!(&wrapped[3..], &cmd[..]);
assert_eq!(conf, Confinement::Seatbelt { level: SandboxLevel::Secrets });
}
}