car-sandbox 0.15.2

Sandboxed execution surface for CAR — process isolation primitives for untrusted agent steps
Documentation
//! Pre-flight checks and environment hygiene for sandbox execution.
//!
//! These helpers sit above `SandboxExecutor` and let callers validate the
//! host before launching a container (so failure is fast and actionable)
//! and strip dangerous environment variables before forwarding them into
//! the sandbox.
//!
//! The underlying `SandboxExecutor` already provides strong defaults —
//! network off, no bind mounts beyond the working directory, configurable
//! command timeouts. This module adds the ergonomic layer on top: "tell me
//! now if Docker is missing" and "don't let AWS keys leak into the
//! container."

use std::path::Path;

use crate::{SandboxConfig, SandboxExecutor};

/// Environment variable names that are NEVER forwarded into a sandbox.
///
/// Falls into three categories:
/// 1. **Process-hijacking loaders** — anything that lets a parent process
///    inject code into children (LD_PRELOAD, NODE_OPTIONS, PYTHONHOME…).
/// 2. **Agent/auth tokens** — cloud provider credentials, SSH agent sockets,
///    personal access tokens, platform API keys.
/// 3. **Model provider keys** — mirrors the common set of LLM API keys so
///    an agent-authored payload can't exfiltrate them by echoing env.
///
/// The list is conservative; the right default is "deny unless allowlisted."
pub const SENSITIVE_ENV_VARS: &[&str] = &[
    // Loader hijacks
    "PATH",
    "LD_PRELOAD",
    "LD_LIBRARY_PATH",
    "DYLD_LIBRARY_PATH",
    "DYLD_INSERT_LIBRARIES",
    "PYTHONPATH",
    "PYTHONHOME",
    "NODE_OPTIONS",
    "NODE_PATH",
    "RUBY_LIB",
    "PERL5LIB",
    // Auth sockets / PATs
    "SSH_AUTH_SOCK",
    "SSH_AGENT_PID",
    "GITHUB_TOKEN",
    "GH_TOKEN",
    "NPM_TOKEN",
    // Cloud credentials
    "AWS_ACCESS_KEY_ID",
    "AWS_SECRET_ACCESS_KEY",
    "AWS_SESSION_TOKEN",
    "GCP_SERVICE_ACCOUNT_KEY",
    "AZURE_CLIENT_SECRET",
    // Model provider keys
    "ANTHROPIC_API_KEY",
    "OPENAI_API_KEY",
    "GOOGLE_API_KEY",
];

/// Filter env pairs, removing any whose key is in `SENSITIVE_ENV_VARS`.
pub fn filter_sensitive_env(env: &[(String, String)]) -> Vec<(String, String)> {
    env.iter()
        .filter(|(k, _)| !SENSITIVE_ENV_VARS.contains(&k.as_str()))
        .cloned()
        .collect()
}

/// Result of a pre-flight sandbox check.
#[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)
    }

    /// Human-readable, actionable message. Each failure mode tells the
    /// caller exactly what command to run to fix it.
    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
            ),
        }
    }
}

/// Run a pre-flight check before launching the sandbox.
/// Fast (<500ms typical) — verifies `docker --version`, `docker info`,
/// and the availability (or pullability) of the target image.
pub async fn preflight(image: &str) -> PreflightResult {
    // Step 1: docker CLI present?
    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(_) => {}
    }

    // Step 2: daemon reachable?
    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(_) => {}
    }

    // Step 3: image available locally or pullable?
    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()),
    }
}

/// Caller-facing policy for sandbox configuration — wraps [`SandboxConfig`]
/// with an env allowlist and strips sensitive variables.
///
/// Network isolation is **always on** — `SandboxExecutor` hard-codes
/// `--network none` in its docker args and this crate deliberately does
/// not expose a toggle. The sandbox's entire safety model depends on it;
/// agents that need network access should run without `--sandbox` and
/// use higher-level policy (permission rules, inspectors) instead.
#[derive(Debug, Clone)]
pub struct SandboxPolicy {
    /// Docker image to use.
    pub image: String,
    /// Command timeout in seconds.
    pub command_timeout_secs: u64,
    /// Whitelist of environment variable names to forward. Keys not in this
    /// list (and not in [`SENSITIVE_ENV_VARS`]) are stripped.
    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
    }

    /// Build the effective env set to forward into the sandbox.
    /// Reads the current process env, filters by allowlist, strips sensitive.
    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)
    }

    /// Compose a [`SandboxConfig`] from this policy for a given working dir.
    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()
        }
    }

    /// Convenience: go straight from policy to a ready-to-run executor.
    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"));
    }
}