use std::path::Path;
use crate::{SandboxConfig, SandboxExecutor};
pub const SENSITIVE_ENV_VARS: &[&str] = &[
"PATH",
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"PYTHONPATH",
"PYTHONHOME",
"NODE_OPTIONS",
"NODE_PATH",
"RUBY_LIB",
"PERL5LIB",
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
"GITHUB_TOKEN",
"GH_TOKEN",
"NPM_TOKEN",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GCP_SERVICE_ACCOUNT_KEY",
"AZURE_CLIENT_SECRET",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
];
pub fn filter_sensitive_env(env: &[(String, String)]) -> Vec<(String, String)> {
env.iter()
.filter(|(k, _)| !SENSITIVE_ENV_VARS.contains(&k.as_str()))
.cloned()
.collect()
}
#[derive(Debug, Clone)]
pub enum PreflightResult {
Ok,
DockerMissing(String),
DockerDaemonDown(String),
ImageUnavailable(String),
}
impl PreflightResult {
pub fn is_ok(&self) -> bool {
matches!(self, PreflightResult::Ok)
}
pub fn message(&self) -> String {
match self {
PreflightResult::Ok => "Sandbox ready.".into(),
PreflightResult::DockerMissing(e) => format!(
"Docker CLI not found on PATH. Install Docker Desktop (macOS/Windows) \
or docker-ce (Linux), then retry. Underlying error: {}",
e
),
PreflightResult::DockerDaemonDown(e) => format!(
"Docker is installed but the daemon is not running. Start Docker Desktop \
(or `sudo systemctl start docker` on Linux), then retry. Error: {}",
e
),
PreflightResult::ImageUnavailable(img) => format!(
"Docker image '{}' not available locally and pull failed. \
Check network connectivity, or pre-pull the image with `docker pull {}`.",
img, img
),
}
}
}
pub async fn preflight(image: &str) -> PreflightResult {
match tokio::process::Command::new("docker")
.arg("--version")
.output()
.await
{
Err(e) => return PreflightResult::DockerMissing(e.to_string()),
Ok(o) if !o.status.success() => {
return PreflightResult::DockerMissing(String::from_utf8_lossy(&o.stderr).to_string());
}
Ok(_) => {}
}
match tokio::process::Command::new("docker")
.args(["info", "--format", "{{.ServerVersion}}"])
.output()
.await
{
Err(e) => return PreflightResult::DockerDaemonDown(e.to_string()),
Ok(o) if !o.status.success() => {
return PreflightResult::DockerDaemonDown(
String::from_utf8_lossy(&o.stderr).to_string(),
);
}
Ok(_) => {}
}
if let Ok(o) = tokio::process::Command::new("docker")
.args(["image", "inspect", image])
.output()
.await
{
if o.status.success() {
return PreflightResult::Ok;
}
}
match tokio::process::Command::new("docker")
.args(["pull", image])
.output()
.await
{
Ok(o) if o.status.success() => PreflightResult::Ok,
_ => PreflightResult::ImageUnavailable(image.to_string()),
}
}
#[derive(Debug, Clone)]
pub struct SandboxPolicy {
pub image: String,
pub command_timeout_secs: u64,
pub env_allowlist: Vec<String>,
}
impl Default for SandboxPolicy {
fn default() -> Self {
Self {
image: "python:3.11-slim".into(),
command_timeout_secs: 120,
env_allowlist: Vec::new(),
}
}
}
impl SandboxPolicy {
pub fn with_image(mut self, image: impl Into<String>) -> Self {
self.image = image.into();
self
}
pub fn with_env_allowlist<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.env_allowlist = names.into_iter().map(Into::into).collect();
self
}
pub fn resolve_env(&self) -> Vec<(String, String)> {
let allowlisted: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| self.env_allowlist.iter().any(|a| a == k))
.collect();
filter_sensitive_env(&allowlisted)
}
pub fn to_config(&self, working_dir: &Path) -> SandboxConfig {
SandboxConfig {
image: self.image.clone(),
working_dir: working_dir.to_path_buf(),
env: self.resolve_env(),
command_timeout_secs: self.command_timeout_secs,
..SandboxConfig::default()
}
}
pub fn build_executor(&self, working_dir: &Path) -> SandboxExecutor {
SandboxExecutor::new(self.to_config(working_dir))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_strips_aws_keys() {
let env = vec![
("FOO".into(), "bar".into()),
("AWS_ACCESS_KEY_ID".into(), "secret".into()),
("CARGO_TARGET_DIR".into(), "target".into()),
];
let filtered = filter_sensitive_env(&env);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|(k, _)| k != "AWS_ACCESS_KEY_ID"));
}
#[test]
fn filter_strips_ld_preload() {
let env = vec![
("LD_PRELOAD".into(), "/evil.so".into()),
("HOME".into(), "/home/x".into()),
];
let filtered = filter_sensitive_env(&env);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "HOME");
}
#[test]
fn policy_default_image() {
let p = SandboxPolicy::default();
assert_eq!(p.image, "python:3.11-slim");
assert_eq!(p.command_timeout_secs, 120);
assert!(p.env_allowlist.is_empty());
}
#[test]
fn policy_builder_sets_image() {
let p = SandboxPolicy::default().with_image("node:22");
assert_eq!(p.image, "node:22");
}
#[test]
fn policy_builder_allowlist() {
let p = SandboxPolicy::default().with_env_allowlist(["CARGO_HOME", "RUSTUP_HOME"]);
assert_eq!(p.env_allowlist, vec!["CARGO_HOME", "RUSTUP_HOME"]);
}
#[test]
fn policy_to_config_maps_fields() {
let dir = std::path::Path::new("/tmp/test");
let p = SandboxPolicy::default().with_image("alpine:3");
let cfg = p.to_config(dir);
assert_eq!(cfg.image, "alpine:3");
assert_eq!(cfg.working_dir, dir);
assert_eq!(cfg.command_timeout_secs, 120);
}
#[test]
fn preflight_ok_message() {
assert!(PreflightResult::Ok.is_ok());
assert!(!PreflightResult::DockerMissing("x".into()).is_ok());
}
#[test]
fn preflight_messages_are_actionable() {
assert!(PreflightResult::DockerMissing("cmd".into())
.message()
.contains("Install Docker"));
assert!(PreflightResult::DockerDaemonDown("x".into())
.message()
.contains("daemon is not running"));
assert!(PreflightResult::ImageUnavailable("alpine:latest".into())
.message()
.contains("docker pull alpine:latest"));
}
}