use crate::config::SandboxMode;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
pub(crate) trait SandboxProvider: Send + Sync {
fn mode(&self) -> SandboxMode;
fn command(&self, cwd: &Path, script: &str) -> Result<Command>;
}
pub(crate) fn provider(mode: SandboxMode) -> std::sync::Arc<dyn SandboxProvider> {
match mode {
SandboxMode::ReadOnly | SandboxMode::WorkspaceWrite => {
std::sync::Arc::new(NativeSandbox { mode })
}
SandboxMode::DangerFullAccess => std::sync::Arc::new(UnsafeHost),
}
}
pub(crate) fn danger_warning(mode: SandboxMode) -> Option<&'static str> {
#[cfg(windows)]
{
let _ = mode;
Some(
"WARNING: sandboxing is not available on Windows — shell commands run unsandboxed with full access to your files, processes, and the network",
)
}
#[cfg(not(windows))]
{
(mode == SandboxMode::DangerFullAccess).then_some(
"DANGER: danger-full-access (UNSAFE HOST) — shell commands can access and modify files, processes, and the network outside the workspace",
)
}
}
fn shell_command(cwd: &Path, script: &str) -> Command {
#[cfg(windows)]
{
let mut command = Command::new("powershell.exe");
command
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(script)
.current_dir(cwd);
command
}
#[cfg(not(windows))]
{
let mut command = Command::new("bash");
command.arg("-lc").arg(script).current_dir(cwd);
command
}
}
struct UnsafeHost;
impl SandboxProvider for UnsafeHost {
fn mode(&self) -> SandboxMode {
SandboxMode::DangerFullAccess
}
fn command(&self, cwd: &Path, script: &str) -> Result<Command> {
Ok(shell_command(cwd, script))
}
}
struct NativeSandbox {
mode: SandboxMode,
}
impl SandboxProvider for NativeSandbox {
fn mode(&self) -> SandboxMode {
self.mode
}
fn command(&self, cwd: &Path, script: &str) -> Result<Command> {
native_command(cwd, script, self.mode)
}
}
#[cfg(target_os = "macos")]
fn native_command(cwd: &Path, script: &str, mode: SandboxMode) -> Result<Command> {
let executable = Path::new("/usr/bin/sandbox-exec");
if !executable.is_file() {
anyhow::bail!(
"native sandbox unavailable: /usr/bin/sandbox-exec is missing; refusing to run unsandboxed. Set `sandbox_mode = \"danger-full-access\"` only inside an already isolated environment"
)
}
let temp = sandbox_temp_dir()?;
let home = sandbox_home_dir(&temp)?;
let profile = macos_profile(cwd, &temp, mode);
let mut command = Command::new(executable);
command
.arg("-p")
.arg(profile)
.arg("/bin/bash")
.arg("-lc")
.arg(script)
.current_dir(cwd);
apply_native_environment(&mut command, &home, &temp);
Ok(command)
}
#[cfg(target_os = "linux")]
fn native_command(cwd: &Path, script: &str, mode: SandboxMode) -> Result<Command> {
let temp = sandbox_temp_dir()?;
let home = sandbox_home_dir(&temp)?;
let executable = std::env::current_exe().context("resolve yolop sandbox worker executable")?;
let mut command = Command::new(executable);
command
.arg("__sandbox-exec")
.arg("--cwd")
.arg(cwd)
.arg("--temp")
.arg(&temp)
.arg("--mode")
.arg(mode.as_str())
.arg("--script")
.arg(script)
.current_dir(cwd);
apply_native_environment(&mut command, &home, &temp);
Ok(command)
}
#[cfg(target_os = "linux")]
pub(crate) fn run_linux_worker(
cwd: &Path,
temp: &Path,
mode: SandboxMode,
script: &str,
) -> Result<()> {
use landlock::{
ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus,
};
use seccompiler::{
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule,
};
use std::convert::TryInto;
use std::os::unix::process::CommandExt;
if mode == SandboxMode::DangerFullAccess {
anyhow::bail!("the sandbox worker cannot run danger-full-access");
}
std::env::set_current_dir(cwd)
.with_context(|| format!("enter sandbox workspace: {}", cwd.display()))?;
let abi = ABI::V3;
let ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))?
.create()?
.add_rule(PathBeneath::new(
PathFd::new("/")?,
AccessFs::from_read(abi),
))?
.add_rule(PathBeneath::new(
PathFd::new(temp)?,
AccessFs::from_all(abi),
))?;
let ruleset = if mode == SandboxMode::WorkspaceWrite {
ruleset
.add_rule(PathBeneath::new(PathFd::new(cwd)?, AccessFs::from_all(abi)))?
.add_rule(PathBeneath::new(
PathFd::new("/tmp")?,
AccessFs::from_all(abi),
))?
} else {
ruleset
};
let status = ruleset.restrict_self()?;
if status.ruleset != RulesetStatus::FullyEnforced || !status.no_new_privs {
anyhow::bail!(
"native sandbox unavailable: Landlock ABI v3 is not fully enforced by this Linux kernel; refusing to run unsandboxed. Set `sandbox_mode = \"danger-full-access\"` only inside an already isolated environment"
);
}
let socket_rules = [libc::AF_INET, libc::AF_INET6, libc::AF_PACKET]
.into_iter()
.map(|family| {
SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
family as u64,
)?])
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let filter: BpfProgram = SeccompFilter::new(
[(libc::SYS_socket, socket_rules)].into_iter().collect(),
SeccompAction::Allow,
SeccompAction::Errno(libc::EACCES as u32),
std::env::consts::ARCH.try_into()?,
)?
.try_into()?;
seccompiler::apply_filter(&filter)?;
Err(std::process::Command::new("/bin/bash")
.arg("-lc")
.arg(script)
.exec()
.into())
}
#[cfg(target_os = "windows")]
fn native_command(cwd: &Path, script: &str, _mode: SandboxMode) -> Result<Command> {
Ok(shell_command(cwd, script))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn native_command(_cwd: &Path, _script: &str, _mode: SandboxMode) -> Result<Command> {
anyhow::bail!(
"native sandbox is supported only on macOS and Linux; refusing to run unsandboxed. Set `sandbox_mode = \"danger-full-access\"` only inside an already isolated environment"
)
}
fn sandbox_temp_dir() -> Result<PathBuf> {
let path = std::env::temp_dir().join(format!("yolop-sandbox-{}", std::process::id()));
prepare_sandbox_temp(&path)?;
std::fs::canonicalize(&path)
.with_context(|| format!("canonicalize sandbox temp directory: {}", path.display()))
}
fn prepare_sandbox_temp(path: &Path) -> Result<()> {
use std::io::ErrorKind;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
match std::fs::create_dir(path) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let metadata = std::fs::symlink_metadata(path)
.with_context(|| format!("inspect sandbox temp directory: {}", path.display()))?;
if !metadata.file_type().is_dir() {
anyhow::bail!(
"sandbox temp path is not a directory (possible path-alias attack): {}",
path.display()
);
}
}
Err(error) => {
return Err(error)
.with_context(|| format!("create sandbox temp directory: {}", path.display()));
}
}
#[cfg(unix)]
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("secure sandbox temp directory: {}", path.display()))?;
Ok(())
}
fn sandbox_home_dir(temp: &Path) -> Result<PathBuf> {
let path = temp.join("home");
std::fs::create_dir_all(&path)
.with_context(|| format!("create sandbox home directory: {}", path.display()))?;
Ok(path)
}
fn apply_native_environment(command: &mut Command, home: &Path, temp: &Path) {
command.env_clear();
for (key, value) in std::env::vars_os() {
if safe_environment_key(&key.to_string_lossy()) {
command.env(key, value);
}
}
command.env("HOME", home).env("TMPDIR", temp);
}
fn safe_environment_key(key: &str) -> bool {
matches!(
key,
"PATH"
| "LANG"
| "LC_ALL"
| "LC_CTYPE"
| "TERM"
| "COLORTERM"
| "NO_COLOR"
| "FORCE_COLOR"
| "CARGO_HOME"
| "RUSTUP_HOME"
| "SDKROOT"
| "DEVELOPER_DIR"
| "PKG_CONFIG_PATH"
| "CPATH"
| "LIBRARY_PATH"
| "C_INCLUDE_PATH"
| "CPLUS_INCLUDE_PATH"
| "JAVA_HOME"
| "GOPATH"
| "GOROOT"
) || key.starts_with("LC_")
}
#[cfg(target_os = "macos")]
fn macos_profile(cwd: &Path, temp: &Path, mode: SandboxMode) -> String {
let workspace_write = if mode == SandboxMode::WorkspaceWrite {
format!(" (subpath \"{}\")", seatbelt_escape(cwd))
} else {
String::new()
};
let shared_temp_write = if mode == SandboxMode::WorkspaceWrite {
" (subpath \"/tmp\") (subpath \"/private/tmp\")"
} else {
""
};
format!(
"(version 1)\n(deny default)\n(allow process*)\n(allow file-read*)\n(allow sysctl-read)\n(allow mach-lookup)\n(allow file-write-data (require-all (path \"/dev/null\") (vnode-type CHARACTER-DEVICE)))\n(allow file-write*{} (subpath \"{}\"){})\n(deny network*)\n(deny file-write* (literal \"{}\") (subpath \"{}\"))",
workspace_write,
seatbelt_escape(temp),
shared_temp_write,
seatbelt_escape(&cwd.join(".git")),
seatbelt_escape(&cwd.join(".git")),
)
}
#[cfg(target_os = "macos")]
fn seatbelt_escape(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
pub(crate) fn configure_stdio(command: &mut Command) {
command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn sandbox_temp_rejects_a_preexisting_symlink() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("target");
let alias = root.path().join("alias");
std::fs::create_dir(&target).unwrap();
std::os::unix::fs::symlink(&target, &alias).unwrap();
let error = prepare_sandbox_temp(&alias).unwrap_err().to_string();
assert!(error.contains("path-alias attack"), "{error}");
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_paths_are_escaped() {
assert_eq!(
seatbelt_escape(Path::new("/tmp/a \\\"b")),
"/tmp/a \\\\\\\"b"
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_policy_allows_only_dev_null_device_writes() {
let profile = macos_profile(
Path::new("/workspace"),
Path::new("/private/tmp/sandbox"),
SandboxMode::WorkspaceWrite,
);
assert!(profile.contains(
r#"(allow file-write-data (require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))"#
));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_sandbox_temp_is_canonical_and_allowed_by_the_policy() {
let temp = sandbox_temp_dir().expect("sandbox temp should be created");
assert_eq!(
temp,
std::fs::canonicalize(&temp).expect("sandbox temp should remain canonical")
);
let profile = macos_profile(Path::new("/workspace"), &temp, SandboxMode::WorkspaceWrite);
assert!(profile.contains(&format!(r#"(subpath "{}")"#, temp.display())));
}
#[cfg(not(windows))]
#[test]
fn native_is_the_safe_default_provider() {
assert!(danger_warning(SandboxMode::WorkspaceWrite).is_none());
assert!(
danger_warning(SandboxMode::DangerFullAccess)
.unwrap()
.contains("UNSAFE HOST")
);
}
#[cfg(windows)]
#[test]
fn windows_warns_unsandboxed_for_every_mode() {
for mode in [
SandboxMode::ReadOnly,
SandboxMode::WorkspaceWrite,
SandboxMode::DangerFullAccess,
] {
assert!(danger_warning(mode).is_some());
}
}
#[test]
fn shell_command_uses_the_platform_shell() {
let command = shell_command(Path::new("."), "echo hi");
let program = command.as_std().get_program().to_string_lossy();
#[cfg(windows)]
assert!(
program.to_ascii_lowercase().contains("powershell"),
"expected PowerShell on Windows, got {program}"
);
#[cfg(not(windows))]
assert_eq!(program, "bash");
}
#[test]
fn native_environment_allowlist_excludes_credentials_and_agents() {
for key in [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DOPPLER_TOKEN",
"GITHUB_TOKEN",
"AWS_SECRET_ACCESS_KEY",
"SSH_AUTH_SOCK",
"GPG_AGENT_INFO",
] {
assert!(!safe_environment_key(key), "unexpectedly allowed {key}");
}
for key in [
"PATH",
"CARGO_HOME",
"RUSTUP_HOME",
"SDKROOT",
"LC_MESSAGES",
] {
assert!(safe_environment_key(key), "expected build env {key}");
}
}
}