icebox-gov 0.2.6

Runtime governance for autonomous offensive security — the single seam every human operator, REST client, and LLM agent must pass through before anything touches a target.
Documentation
use crate::core::module::ModuleResult;
use bollard::container::LogOutput;
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::models::ContainerCreateBody;
use bollard::query_parameters::{
    CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
};
use bollard::Docker;
use futures_util::StreamExt;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEngineType {
    Docker,
}

#[derive(Debug)]
pub enum Sandbox {
    Docker(DockerSandbox),
}

#[derive(Debug, thiserror::Error)]
pub enum SandboxError {
    #[error("docker: {0}")]
    Docker(#[from] bollard::errors::Error),
    #[error(
        "sandbox worker binary not found. Run: cargo xtask build-sandbox-worker (requires Docker)"
    )]
    Unavailable,
    #[error("worker error: {0}")]
    Worker(String),
    #[error("engine {0} is not supported on this operating system")]
    UnsupportedOS(&'static str),
}

impl Sandbox {
    pub async fn freeze(
        engine: SandboxEngineType,
        target: &str,
        image: &str,
    ) -> Result<Self, SandboxError> {
        match engine {
            SandboxEngineType::Docker => {
                Ok(Sandbox::Docker(DockerSandbox::freeze(target, image).await?))
            }
        }
    }

    pub fn container_id(&self) -> &str {
        match self {
            Sandbox::Docker(d) => d.container_id(),
        }
    }

    pub fn target(&self) -> &str {
        match self {
            Sandbox::Docker(d) => d.target(),
        }
    }

    pub async fn ip_address(&self) -> Result<String, SandboxError> {
        match self {
            Sandbox::Docker(d) => d.ip_address().await,
        }
    }

    pub async fn capture_logs(&self) -> Vec<String> {
        match self {
            Sandbox::Docker(d) => d.capture_logs().await,
        }
    }

    pub async fn melt(self) -> Result<(), SandboxError> {
        match self {
            Sandbox::Docker(d) => d.melt().await,
        }
    }

    pub async fn exec_module(
        &self,
        name: &str,
        target: &str,
        options: &serde_json::Value,
    ) -> Result<ModuleResult, SandboxError> {
        match self {
            Sandbox::Docker(d) => d.exec_module(name, target, options).await,
        }
    }
}

#[derive(Debug)]
pub struct DockerSandbox {
    docker: Docker,
    container_id: String,
    target: String,
}

async fn ensure_image(docker: &Docker, image: &str) -> Result<(), SandboxError> {
    if docker.inspect_image(image).await.is_ok() {
        return Ok(());
    }
    if image == "icebox-sandbox:latest" {
        let worker = std::env::current_exe()
            .ok()
            .and_then(|p| p.parent().map(|d| d.join("icebox-worker")))
            .filter(|p| p.exists())
            .or_else(|| {
                let p = std::path::Path::new("dist/icebox-worker");
                p.exists().then(|| p.to_path_buf())
            });
        let Some(worker) = worker else {
            return Err(SandboxError::Unavailable);
        };
        let dir = std::env::temp_dir().join("icebox-sandbox-build");
        let _ = std::fs::create_dir_all(&dir);
        let _ = std::fs::write(
            dir.join("icebox-worker"),
            std::fs::read(&worker).map_err(|e| SandboxError::Worker(e.to_string()))?,
        );
        let _ = std::fs::write(
            dir.join("Dockerfile"),
            "FROM alpine:3.20\nCOPY icebox-worker /usr/local/bin/icebox-worker\nRUN chmod +x /usr/local/bin/icebox-worker\n",
        );
        let ok = std::process::Command::new("docker")
            .args(["build", "-t", "icebox-sandbox:latest", "."])
            .current_dir(&dir)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if ok && docker.inspect_image(image).await.is_ok() {
            return Ok(());
        }
        return Err(SandboxError::Unavailable);
    }
    let mut stream = docker.create_image(
        Some(bollard::query_parameters::CreateImageOptions {
            from_image: Some(image.to_string()),
            ..Default::default()
        }),
        None,
        None,
    );
    while stream.next().await.is_some() {}
    Ok(())
}

impl DockerSandbox {
    pub fn is_available() -> bool {
        Docker::connect_with_local_defaults()
            .and_then(|d| {
                std::thread::spawn(move || {
                    let rt = tokio::runtime::Runtime::new().map_err(|_| {
                        bollard::errors::Error::DockerResponseServerError {
                            status_code: 500,
                            message: "runtime creation failed".into(),
                        }
                    })?;
                    rt.block_on(d.ping())
                })
                .join()
                .map_err(|_| {
                    bollard::errors::Error::DockerResponseServerError {
                        status_code: 500,
                        message: "join failed".into(),
                    }
                })?
            })
            .is_ok()
    }

    pub async fn freeze(target: &str, image: &str) -> Result<Self, SandboxError> {
        let docker =
            Docker::connect_with_local_defaults().map_err(|_| SandboxError::Unavailable)?;
        docker.ping().await.map_err(|_| SandboxError::Unavailable)?;

        ensure_image(&docker, image).await?;

        let name = format!("icebox-sandbox-{}", std::process::id());

        let cmd = Some(vec!["sleep".to_string(), "3600".to_string()]);

        let config = ContainerCreateBody {
            image: Some(image.to_string()),
            cmd,
            hostname: Some("icebox-sandbox".to_string()),
            ..Default::default()
        };

        let resp = docker
            .create_container(
                Some(CreateContainerOptions {
                    name: Some(name.clone()),
                    ..Default::default()
                }),
                config,
            )
            .await?;

        docker
            .start_container(&resp.id, None::<StartContainerOptions>)
            .await?;

        Ok(DockerSandbox {
            docker,
            container_id: resp.id,
            target: target.to_string(),
        })
    }

    pub fn container_id(&self) -> &str {
        &self.container_id
    }

    pub fn target(&self) -> &str {
        &self.target
    }

    pub async fn ip_address(&self) -> Result<String, SandboxError> {
        let inspect = self
            .docker
            .inspect_container(&self.container_id, None)
            .await?;
        if let Some(ns) = inspect.network_settings {
            if let Some(networks) = ns.networks {
                if let Some(bridge) = networks.get("bridge") {
                    if let Some(ip) = bridge.ip_address.as_deref() {
                        if !ip.is_empty() {
                            return Ok(ip.to_string());
                        }
                    }
                }
            }
        }
        Err(SandboxError::Unavailable)
    }

    pub async fn capture_logs(&self) -> Vec<String> {
        let opts = LogsOptions {
            stdout: true,
            stderr: true,
            ..Default::default()
        };
        let mut stream = self.docker.logs(&self.container_id, Some(opts));
        let mut lines = Vec::new();
        while let Some(Ok(chunk)) = stream.next().await {
            let text = match chunk {
                LogOutput::StdOut { message } => String::from_utf8_lossy(&message).to_string(),
                LogOutput::StdErr { message } => String::from_utf8_lossy(&message).to_string(),
                _ => continue,
            };
            for line in text.lines() {
                if !line.is_empty() {
                    lines.push(format!("[SANDBOX-DOCKER] {line}"));
                }
            }
        }
        lines
    }

    pub async fn melt(self) -> Result<(), SandboxError> {
        self.docker
            .remove_container(
                &self.container_id,
                Some(RemoveContainerOptions {
                    force: true,
                    ..Default::default()
                }),
            )
            .await?;
        Ok(())
    }

    pub async fn exec_module(
        &self,
        name: &str,
        target: &str,
        options: &serde_json::Value,
    ) -> Result<ModuleResult, SandboxError> {
        let opts = serde_json::to_string(options).unwrap_or_else(|_| "{}".into());
        let cmd = vec![
            "icebox-worker".to_string(),
            "worker".to_string(),
            "--module".to_string(),
            name.to_string(),
            "--target".to_string(),
            target.to_string(),
            "--options".to_string(),
            opts,
        ];
        let create = self
            .docker
            .create_exec(
                &self.container_id,
                CreateExecOptions {
                    cmd: Some(cmd),
                    attach_stdout: Some(true),
                    attach_stderr: Some(true),
                    ..Default::default()
                },
            )
            .await?;
        let id = create.id;
        let mut stdout: Vec<u8> = Vec::new();
        let stream = self
            .docker
            .start_exec(&id, None::<StartExecOptions>)
            .await?;
        if let StartExecResults::Attached { mut output, .. } = stream {
            while let Some(chunk) = output.next().await {
                match chunk {
                    Ok(LogOutput::StdOut { message }) => stdout.extend_from_slice(&message),
                    Ok(LogOutput::StdErr { message }) => {
                        tracing::info!("[SANDBOX-WORKER] {}", String::from_utf8_lossy(&message));
                    }
                    _ => {}
                }
            }
        }
        let inspect = self.docker.inspect_exec(&id).await?;
        if inspect.exit_code.unwrap_or(1) != 0 {
            return Err(SandboxError::Worker(format!(
                "worker exited with code {}",
                inspect.exit_code.unwrap_or(-1)
            )));
        }
        serde_json::from_slice::<ModuleResult>(&stdout)
            .map_err(|e| SandboxError::Worker(format!("failed to parse worker output: {e}")))
    }
}