//! The compose deployment never hands the raw Docker socket to the //! server: an allow-listed socket proxy (tecnativa/docker-socket-proxy) //! sits between them. This suite runs the REAL proxy with the production //! allowlist and proves (a) the full sandbox lifecycle works through it //! and (b) operations outside the allowlist are refused. use std::process::Command; use cm_sandbox::{DockerDriver, SandboxDriver, SandboxSpec}; const PROXY_IMAGE: &str = "tecnativa/docker-socket-proxy:0.3"; const IMAGE: &str = "clawmates/agent-base:dev"; fn ensure_agent_image() { let exists = Command::new("docker") .args(["image", "inspect", IMAGE]) .output() .expect("docker available") .status .success(); if !exists { let root = env!("CARGO_MANIFEST_DIR"); let status = Command::new("docker") .args([ "build", "-t", IMAGE, "-f", &format!("{root}/../../images/agent-base/Dockerfile"), &format!("{root}/../../images/agent-base"), ]) .status() .expect("docker build runs"); assert!(status.success()); } } /// Starts the proxy with EXACTLY the allowlist the compose file ships. fn spawn_proxy() -> (String, String) { let name = format!("tc-sockproxy-{}", std::process::id()); Command::new("docker") .args(["rm", "-f", &name]) .output() .ok(); let output = Command::new("docker") .args([ "run", "-d", "--name", &name, "-p", "0:2375", "-v", "/var/run/docker.sock:/var/run/docker.sock:ro", // The production allowlist (deploy/compose/docker-compose.yml): // container lifecycle + exec, nothing else. "-e", "CONTAINERS=1", "-e", "POST=1", "-e", "EXEC=1", "-e", "DELETE=1", "-e", "VERSION=1", PROXY_IMAGE, ]) .output() .expect("docker run"); assert!( output.status.success(), "proxy start: {}", String::from_utf8_lossy(&output.stderr) ); let port = Command::new("docker") .args(["port", &name, "2375"]) .output() .expect("docker port"); let mapping = String::from_utf8_lossy(&port.stdout); let port = mapping .lines() .next() .and_then(|line| line.rsplit(':').next()) .expect("mapped port") .trim() .to_owned(); (name, format!("tcp://127.0.0.1:{port}")) } #[tokio::test] async fn the_sandbox_lifecycle_works_through_the_allowlisted_proxy() { ensure_agent_image(); let (proxy_name, docker_host) = spawn_proxy(); // The driver honors DOCKER_HOST — exactly how the compose server // reaches the proxy. let driver = DockerDriver::connect_to(&docker_host).expect("proxy reachable"); // The proxy needs a moment to come up. let mut handle = None; let spec = SandboxSpec { name: format!("tc-proxy-test-{}", std::process::id()), image: IMAGE.into(), memory_bytes: 256 * 1024 * 1024, nano_cpus: 500_000_000, pids_limit: 64, egress: false, kind: cm_sandbox::SandboxKind::Agent, mounts: Vec::new(), }; for _ in 0..20 { match driver.provision(&spec).await { Ok(h) => { handle = Some(h); break; } Err(_) => tokio::time::sleep(std::time::Duration::from_millis(500)).await, } } let handle = handle.expect("provision through proxy"); let result = driver.exec(&handle, &["id", "-u"]).await.unwrap(); assert_eq!(result.stdout.trim(), "10001"); assert!(driver.health(&handle).await.unwrap()); driver.destroy(&handle).await.unwrap(); assert!(!driver.health(&handle).await.unwrap()); // Outside the allowlist: building images, listing networks, reading // swarm secrets — the §15 blast-radius cap if the server is owned. let client = reqwest::Client::new(); let base = docker_host.replace("tcp://", "http://"); for forbidden in ["/v1.43/networks", "/v1.43/secrets", "/v1.43/images/json"] { let status = client .get(format!("{base}{forbidden}")) .send() .await .unwrap() .status(); assert_eq!( status, 403, "{forbidden} must be refused by the proxy allowlist" ); } Command::new("docker") .args(["rm", "-f", &proxy_name]) .output() .ok(); }