use std::{path::Path, process::ExitCode};
use anyhow::{Context, bail};
use sbe_core::{
config::{expand_path, load_configs, resolve_profile},
detect::{self, Ecosystem},
profile::{DomainPattern, ProfileOverrides, SandboxProfile},
sbpl,
};
use sbe_proxy::{ProxyServer, allowlist::DomainAllowlist};
use tempfile::NamedTempFile;
use tokio::{process::Command, sync::watch};
use tracing::{debug, info, warn};
use crate::{audit::AuditLogger, cli::RunArgs};
const EXIT_SBE_ERROR: u8 = 125;
const EXIT_SANDBOX_FAILED: u8 = 126;
pub async fn execute(args: &RunArgs) -> ExitCode {
match execute_inner(args).await {
Ok(code) => code,
Err(e) => {
eprintln!("sbe: {e:#}");
ExitCode::from(EXIT_SBE_ERROR)
}
}
}
async fn execute_inner(args: &RunArgs) -> anyhow::Result<ExitCode> {
verify_platform()?;
let pwd = std::env::current_dir().context("failed to get current directory")?;
let home = dirs::home_dir().context("could not determine home directory")?;
let command_name = &args.command[0];
let ecosystem = resolve_ecosystem(command_name, &args.profile, &pwd)?;
info!(ecosystem = %ecosystem, command = %command_name, "detected ecosystem");
let mut profile = SandboxProfile::for_ecosystem(ecosystem, &home, &pwd);
let configs = load_configs(&pwd, args.config.as_deref()).await?;
resolve_profile(&mut profile, &configs, &home, &pwd);
let overrides = build_overrides(args, &home, &pwd);
profile.merge_overrides(&overrides);
profile.finalize();
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let proxy_port = start_proxy_if_needed(&profile, shutdown_rx).await?;
let sbpl_content = sbpl::generate(&profile, proxy_port);
if args.dry_run {
print_inspect_output(&profile, &sbpl_content);
let _ = shutdown_tx.send(true);
return Ok(ExitCode::SUCCESS);
}
let sbpl_file = write_sbpl_tempfile(&sbpl_content).await?;
let sbpl_path = sbpl_file.path().to_path_buf();
debug!(path = %sbpl_path.display(), "wrote SBPL profile");
let audit_handle = if args.audit || args.audit_log.is_some() {
let logger = AuditLogger::new(args.audit_log.as_deref()).await?;
Some(logger.start())
} else {
None
};
let status = run_sandboxed_command(args, &sbpl_path, &profile, proxy_port).await?;
let _ = shutdown_tx.send(true);
if let Some(handle) = audit_handle {
handle.stop_and_summarize().await;
}
let code = match status.code() {
Some(code) if (0..=255).contains(&code) => code as u8,
Some(_) => EXIT_SBE_ERROR,
None => {
warn!("sandboxed process terminated by signal");
EXIT_SANDBOX_FAILED
}
};
if matches!(code, 71 | 126) && !args.audit {
eprintln!(
"sbe: command exited with code {code} (likely a sandbox denial). Re-run with --audit \
to see details, or add allowExec/allowFetch to .sbe.yaml"
);
}
Ok(ExitCode::from(code))
}
fn verify_platform() -> anyhow::Result<()> {
if cfg!(not(target_os = "macos")) {
bail!("sbe requires macOS — sandbox-exec is not available on this platform");
}
if !Path::new("/usr/bin/sandbox-exec").exists() {
bail!(
"sandbox-exec not found at /usr/bin/sandbox-exec.\nThis may indicate System Integrity \
Protection (SIP) issues or a non-standard macOS installation."
);
}
Ok(())
}
fn resolve_ecosystem(
command_name: &str,
profile_flag: &Option<String>,
pwd: &Path,
) -> anyhow::Result<Ecosystem> {
if let Some(profile_name) = profile_flag {
profile_name
.parse::<Ecosystem>()
.map_err(|e| anyhow::anyhow!("{e}"))
} else {
detect::detect(command_name, pwd).ok_or_else(|| {
anyhow::anyhow!(
"could not detect ecosystem from command '{command_name}' or working \
directory.\nSupported ecosystems: node, rust, python, elixir, java\nUse \
--profile <ecosystem> to specify explicitly."
)
})
}
}
async fn start_proxy_if_needed(
profile: &SandboxProfile,
shutdown_rx: watch::Receiver<bool>,
) -> anyhow::Result<Option<u16>> {
if !profile.enable_proxy || profile.allow_all_network || profile.allow_domains.is_empty() {
return Ok(None);
}
let domain_strings: Vec<String> = profile.allow_domains.iter().map(|d| d.0.clone()).collect();
let allowlist = DomainAllowlist::new(&domain_strings);
let (server, port) = ProxyServer::bind(allowlist, shutdown_rx).await?;
info!(port, "proxy started");
tokio::spawn(async move {
if let Err(e) = server.run().await {
eprintln!("sbe: proxy error: {e}");
}
});
Ok(Some(port))
}
async fn run_sandboxed_command(
args: &RunArgs,
sbpl_path: &Path,
profile: &SandboxProfile,
proxy_port: Option<u16>,
) -> anyhow::Result<std::process::ExitStatus> {
let mut cmd = Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f").arg(sbpl_path);
cmd.arg(&args.command[0]);
cmd.args(&args.command[1..]);
if let Some(port) = proxy_port {
let proxy_url = format!("http://127.0.0.1:{port}");
cmd.env("HTTP_PROXY", &proxy_url);
cmd.env("HTTPS_PROXY", &proxy_url);
cmd.env("http_proxy", &proxy_url);
cmd.env("https_proxy", &proxy_url);
cmd.env("NO_PROXY", "localhost,127.0.0.1");
cmd.env("no_proxy", "localhost,127.0.0.1");
}
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.stdin(std::process::Stdio::inherit());
cmd.stdout(std::process::Stdio::inherit());
cmd.stderr(std::process::Stdio::inherit());
cmd.status().await.context("failed to spawn sandbox-exec")
}
fn print_inspect_output(profile: &SandboxProfile, sbpl_content: &str) {
eprintln!("--- Resolved profile ---");
if let Ok(yaml) = serde_yaml::to_string(profile) {
eprintln!("{yaml}");
}
println!("{sbpl_content}");
}
fn build_overrides(args: &RunArgs, home: &Path, pwd: &Path) -> ProfileOverrides {
ProfileOverrides {
allow_write: args
.allow_write
.iter()
.map(|p| expand_path(&p.to_string_lossy(), home, pwd))
.collect(),
deny_read: args
.deny_read
.iter()
.map(|p| expand_path(&p.to_string_lossy(), home, pwd))
.collect(),
allow_domains: args
.allow_domain
.iter()
.map(|d| DomainPattern(d.clone()))
.collect(),
deny_domains: args
.deny_domain
.iter()
.map(|d| DomainPattern(d.clone()))
.collect(),
allow_exec: args
.allow_exec
.iter()
.map(|p| expand_path(&p.to_string_lossy(), home, pwd))
.collect(),
deny_exec: args
.deny_exec
.iter()
.map(|p| expand_path(&p.to_string_lossy(), home, pwd))
.collect(),
allow_fetch: args
.allow_fetch
.iter()
.map(|d| DomainPattern(d.clone()))
.collect(),
allow_all_network: args.allow_all_network,
no_proxy: args.no_proxy,
env: Default::default(),
}
}
async fn write_sbpl_tempfile(content: &str) -> anyhow::Result<NamedTempFile> {
let file = NamedTempFile::with_prefix("sbe-").context("failed to create SBPL temp file")?;
tokio::fs::write(file.path(), content)
.await
.context("failed to write SBPL temp file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o400);
tokio::fs::set_permissions(file.path(), perms)
.await
.context("failed to set SBPL file permissions")?;
}
Ok(file)
}
pub fn print_profiles() {
let home = dirs::home_dir().unwrap_or_else(|| "/Users/user".into());
let pwd = std::env::current_dir().unwrap_or_else(|_| "/tmp".into());
for eco in Ecosystem::ALL {
let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
println!("=== {} ===", profile.name);
println!(" Write paths:");
for p in &profile.allow_write {
println!(" - {p}");
}
println!(" Denied read paths:");
for p in &profile.deny_read {
println!(" - {p}");
}
println!(" Allowed domains:");
for d in &profile.allow_domains {
println!(" - {d}");
}
println!(" Denied executables:");
for p in &profile.deny_exec {
println!(" - {p}");
}
println!(" Allowed executables:");
for p in &profile.allow_exec {
println!(" - {p}");
}
println!();
}
}