use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::{Component, Path, PathBuf};
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub image: String,
pub working_dir: PathBuf,
pub env: Vec<(String, String)>,
pub command_timeout_secs: u64,
pub persistent: bool,
pub memory: Option<String>,
pub pids_limit: Option<u64>,
pub cpus: Option<String>,
pub drop_capabilities: bool,
pub read_only_rootfs: bool,
pub run_as_user: Option<String>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
image: "python:3.11-slim".into(),
working_dir: PathBuf::from("."),
env: Vec::new(),
command_timeout_secs: 120,
persistent: true,
memory: Some("512m".into()),
pids_limit: Some(256),
cpus: Some("1.0".into()),
drop_capabilities: true,
read_only_rootfs: false,
run_as_user: None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SandboxError {
#[error("Docker not available: {0}")]
DockerNotAvailable(String),
#[error("Container failed: {0}")]
ContainerFailed(String),
#[error("Command timeout after {0}s")]
Timeout(u64),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub struct SandboxExecutor {
config: SandboxConfig,
container_id: Mutex<Option<String>>,
read_files: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl SandboxExecutor {
pub fn new(config: SandboxConfig) -> Self {
Self {
config,
container_id: Mutex::new(None),
read_files: std::sync::Mutex::new(std::collections::HashSet::new()),
}
}
pub fn for_dir(working_dir: impl Into<PathBuf>) -> Self {
Self::new(SandboxConfig {
working_dir: working_dir.into(),
..Default::default()
})
}
async fn ensure_container(&self) -> Result<String, String> {
let mut guard = self.container_id.lock().await;
if let Some(ref id) = *guard {
let check = tokio::process::Command::new("docker")
.args(["inspect", "--format", "{{.State.Running}}", id])
.output()
.await
.map_err(|e| format!("docker inspect failed: {}", e))?;
if String::from_utf8_lossy(&check.stdout).trim() == "true" {
return Ok(id.clone());
}
}
let cwd = std::fs::canonicalize(&self.config.working_dir)
.unwrap_or_else(|_| self.config.working_dir.clone());
let container_name = format!(
"car-sandbox-{}",
uuid::Uuid::new_v4().to_string().split('-').next().unwrap()
);
let mut args = vec![
"run".to_string(),
"-d".into(),
"--name".into(),
container_name.clone(),
"-v".into(),
format!("{}:/workspace", cwd.display()),
"-w".into(),
"/workspace".into(),
"--network".into(),
"none".into(), ];
args.extend(hardening_args(&self.config));
for (k, v) in &self.config.env {
args.push("-e".into());
args.push(format!("{}={}", k, v));
}
args.push(self.config.image.clone());
args.push("sleep".into());
args.push("infinity".into());
let output = tokio::process::Command::new("docker")
.args(&args)
.output()
.await
.map_err(|e| format!("failed to start container: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("docker run failed: {}", stderr));
}
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
tracing::info!(container_id = %id, image = %self.config.image, "sandbox container started");
*guard = Some(id.clone());
Ok(id)
}
async fn exec_in_container(&self, cmd: &[&str]) -> Result<(String, String, i32), String> {
let container_id = self.ensure_container().await?;
let timeout = std::time::Duration::from_secs(self.config.command_timeout_secs);
let result = tokio::time::timeout(timeout, async {
tokio::process::Command::new("docker")
.args(["exec", &container_id])
.args(cmd)
.output()
.await
})
.await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(-1);
Ok((stdout, stderr, code))
}
Ok(Err(e)) => Err(format!("exec failed: {}", e)),
Err(_) => Err(format!(
"command timed out after {}s",
self.config.command_timeout_secs
)),
}
}
pub async fn cleanup(&self) {
let mut guard = self.container_id.lock().await;
if let Some(ref id) = *guard {
let _ = tokio::process::Command::new("docker")
.args(["rm", "-f", id])
.output()
.await;
tracing::info!(container_id = %id, "sandbox container removed");
}
*guard = None;
}
async fn exec_shell(&self, params: &Value) -> Result<Value, String> {
let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or("missing 'command' parameter")?;
let (stdout, stderr, exit_code) = self.exec_in_container(&["sh", "-c", command]).await?;
Ok(json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
}))
}
async fn exec_in_container_stdin(
&self,
cmd: &[&str],
stdin_data: &[u8],
) -> Result<(String, String, i32), String> {
use tokio::io::AsyncWriteExt;
let container_id = self.ensure_container().await?;
let timeout = std::time::Duration::from_secs(self.config.command_timeout_secs);
let run = async {
let mut child = tokio::process::Command::new("docker")
.args(["exec", "-i", &container_id])
.args(cmd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("exec spawn failed: {e}"))?;
if let Some(mut si) = child.stdin.take() {
si.write_all(stdin_data)
.await
.map_err(|e| format!("stdin write: {e}"))?;
let _ = si.shutdown().await; }
child
.wait_with_output()
.await
.map_err(|e| format!("exec wait: {e}"))
};
match tokio::time::timeout(timeout, run).await {
Ok(Ok(output)) => Ok((
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code().unwrap_or(-1),
)),
Ok(Err(e)) => Err(e),
Err(_) => Err(format!(
"command timed out after {}s",
self.config.command_timeout_secs
)),
}
}
async fn exec_read_file(&self, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let cpath = container_path(path)?;
let (content, stderr, code) = self.exec_in_container(&["cat", "--", &cpath]).await?;
if code != 0 {
return Err(format!("failed to read file: {}", stderr.trim()));
}
let total_lines = content.lines().count();
let result = if offset > 0 || limit.is_some() {
let lines: Vec<&str> = content.lines().collect();
let start = offset.min(lines.len());
let end = limit
.map(|l| (start + l).min(lines.len()))
.unwrap_or(lines.len());
lines[start..end].join("\n")
} else {
content
};
if let Ok(mut set) = self.read_files.lock() {
set.insert(path.to_string());
}
Ok(json!({ "content": result, "total_lines": total_lines }))
}
async fn exec_write_file(&self, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or("missing 'content' parameter")?;
let cpath = container_path(path)?;
if let Some(parent) = std::path::Path::new(&cpath).parent() {
let parent = parent.to_string_lossy().to_string();
let _ = self.exec_in_container(&["mkdir", "-p", "--", &parent]).await;
}
let (_out, stderr, code) = self
.exec_in_container_stdin(&["tee", "--", &cpath], content.as_bytes())
.await?;
if code != 0 {
return Err(format!("failed to write file: {}", stderr.trim()));
}
if let Ok(mut set) = self.read_files.lock() {
set.insert(path.to_string());
}
Ok(json!({ "written": path }))
}
async fn exec_edit_file(&self, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let old_text = params
.get("old_text")
.and_then(|v| v.as_str())
.ok_or("missing 'old_text' parameter")?;
let new_text = params
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("missing 'new_text' parameter")?;
let cpath = container_path(path)?;
let (content, stderr, code) = self.exec_in_container(&["cat", "--", &cpath]).await?;
if code != 0 {
return Err(format!("failed to read file: {}", stderr.trim()));
}
let count = content.matches(old_text).count();
if count == 0 {
let normalize = |s: &str| -> String {
s.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
};
let norm_content = normalize(&content);
let norm_old = normalize(old_text);
if norm_content.matches(&norm_old).count() == 1 {
let norm_pos = norm_content.find(&norm_old).unwrap();
let start_line = norm_content[..norm_pos].matches('\n').count();
let old_line_count = old_text.lines().count();
let actual_lines: Vec<&str> = content.lines().collect();
let end_line = (start_line + old_line_count).min(actual_lines.len());
let actual_old = actual_lines[start_line..end_line].join("\n");
if content.matches(&actual_old).count() == 1 {
let new_content = content.replacen(&actual_old, new_text, 1);
let (_o, e2, c2) = self
.exec_in_container_stdin(&["tee", "--", &cpath], new_content.as_bytes())
.await?;
if c2 != 0 {
return Err(format!("failed to write: {}", e2.trim()));
}
return Ok(json!({
"edited": path,
"diff_summary": "whitespace-normalized match",
}));
}
}
let first_line = old_text.lines().next().unwrap_or("").trim();
let mut hint = String::new();
if !first_line.is_empty() {
for (i, line) in content.lines().enumerate() {
if line.contains(first_line) {
let lines: Vec<&str> = content.lines().collect();
let start = i.saturating_sub(2);
let end = (i + old_text.lines().count() + 2).min(lines.len());
hint = format!(
"\nActual content at lines {}-{}:\n```\n{}\n```",
start + 1,
end,
lines[start..end].join("\n")
);
break;
}
}
}
return Err(format!("old_text not found in {}.{}", path, hint));
}
if count > 1 {
return Err(format!(
"old_text found {} times in {} — must be unique",
count, path
));
}
let new_content = content.replacen(old_text, new_text, 1);
let (_o, e2, c2) = self
.exec_in_container_stdin(&["tee", "--", &cpath], new_content.as_bytes())
.await?;
if c2 != 0 {
return Err(format!("failed to write: {}", e2.trim()));
}
let old_lines = old_text.lines().count();
let new_lines = new_text.lines().count();
let diff_summary = format!("replaced {} lines with {} lines", old_lines, new_lines);
Ok(json!({
"edited": path,
"diff_summary": diff_summary,
"new_content": if new_text.len() > 2000 { &new_text[..2000] } else { new_text },
}))
}
async fn exec_list_dir(&self, params: &Value) -> Result<Value, String> {
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let cpath = container_path(path)?;
let (stdout, stderr, code) = self.exec_in_container(&["ls", "-1p", "--", &cpath]).await?;
if code != 0 {
return Err(format!("failed to read dir: {}", stderr.trim()));
}
let mut entries = Vec::new();
for line in stdout.lines() {
if line.is_empty() {
continue;
}
let (name, is_dir) = match line.strip_suffix('/') {
Some(n) => (n, true),
None => (line, false),
};
entries.push(json!({ "name": name, "is_dir": is_dir }));
}
Ok(json!({ "entries": entries }))
}
async fn exec_grep_files(&self, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let cpath = container_path(path)?;
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
let script = "grep -rn -E \
--include='*.py' --include='*.rs' --include='*.js' --include='*.ts' \
--include='*.go' --include='*.java' --include='*.toml' --include='*.json' \
--include='*.yaml' --include='*.yml' --include='*.html' --include='*.css' \
--include='*.sh' --include='*.txt' --include='*.cfg' --include='*.ini' \
-- \"$1\" \"$2\" | head -n \"$3\"";
let max_str = max_results.to_string();
let (stdout, _stderr, _code) = self
.exec_in_container(&["sh", "-c", script, "sh", pattern, &cpath, &max_str])
.await?;
let lines: Vec<&str> = stdout.lines().take(max_results).collect();
Ok(json!({
"matches": lines,
"count": lines.len(),
"truncated": stdout.lines().count() > max_results,
}))
}
async fn exec_find_files(&self, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let cpath = container_path(path)?;
let script = "find \"$1\" -name \"$2\" \
-not -path '*/.*' -not -path '*/node_modules/*' -not -path '*/__pycache__/*' \
| head -50";
let (stdout, _stderr, _code) = self
.exec_in_container(&["sh", "-c", script, "sh", &cpath, pattern])
.await?;
let files: Vec<&str> = stdout.lines().collect();
Ok(json!({ "files": files, "count": files.len() }))
}
}
#[async_trait]
impl car_engine::ToolExecutor for SandboxExecutor {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"shell" => self.exec_shell(params).await,
"read_file" => self.exec_read_file(params).await,
"write_file" => self.exec_write_file(params).await,
"edit_file" => self.exec_edit_file(params).await,
"list_dir" => self.exec_list_dir(params).await,
"grep_files" => self.exec_grep_files(params).await,
"find_files" => self.exec_find_files(params).await,
"git_status" | "git_diff" | "git_log" | "git_add" | "git_commit" => {
let git_cmd = match tool {
"git_status" => "git status --short",
"git_diff" => {
let staged = params
.get("staged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if staged {
"git diff --cached"
} else {
"git diff"
}
}
"git_log" => "git log --oneline -20",
_ => return Err(format!("git tool {} not implemented in sandbox", tool)),
};
self.exec_shell(&json!({ "command": git_cmd })).await
}
_ => Err(format!("unknown tool in sandbox: {}", tool)),
}
}
}
impl Drop for SandboxExecutor {
fn drop(&mut self) {
if let Ok(guard) = self.container_id.try_lock() {
if let Some(ref id) = *guard {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", id])
.output();
}
}
}
}
fn container_path(path: &str) -> Result<String, String> {
if path.is_empty() {
return Err("path must not be empty (use \".\" for the workspace root)".to_string());
}
let rel = Path::new(path);
if rel.is_absolute() {
return Err(format!("path '{path}' must be relative to the workspace"));
}
let mut out = String::from("/workspace");
for comp in rel.components() {
match comp {
Component::CurDir => {}
Component::Normal(s) => {
out.push('/');
out.push_str(&s.to_string_lossy());
}
Component::ParentDir => {
return Err(format!("path '{path}' may not contain '..'"))
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!("invalid workspace path '{path}'"))
}
}
}
Ok(out)
}
fn hardening_args(config: &SandboxConfig) -> Vec<String> {
let mut args = Vec::new();
if let Some(mem) = &config.memory {
args.push("--memory".into());
args.push(mem.clone());
args.push("--memory-swap".into());
args.push(mem.clone());
}
if let Some(pids) = config.pids_limit {
args.push("--pids-limit".into());
args.push(pids.to_string());
}
if let Some(cpus) = &config.cpus {
args.push("--cpus".into());
args.push(cpus.clone());
}
if config.drop_capabilities {
args.push("--cap-drop".into());
args.push("ALL".into());
args.push("--security-opt".into());
args.push("no-new-privileges".into());
}
if config.read_only_rootfs {
args.push("--read-only".into());
args.push("--tmpfs".into());
args.push("/tmp:rw,size=512m".into());
}
if let Some(user) = &config.run_as_user {
args.push("--user".into());
args.push(user.clone());
}
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sandbox_config_defaults() {
let config = SandboxConfig::default();
assert_eq!(config.image, "python:3.11-slim");
assert!(config.persistent);
assert_eq!(config.command_timeout_secs, 120);
assert_eq!(config.memory.as_deref(), Some("512m"));
assert_eq!(config.pids_limit, Some(256));
assert_eq!(config.cpus.as_deref(), Some("1.0"));
assert!(config.drop_capabilities);
assert!(!config.read_only_rootfs);
}
#[test]
fn default_hardening_flags_are_emitted() {
let args = hardening_args(&SandboxConfig::default());
let joined = args.join(" ");
assert!(joined.contains("--memory 512m"));
assert!(joined.contains("--memory-swap 512m"));
assert!(joined.contains("--pids-limit 256"));
assert!(joined.contains("--cpus 1.0"));
assert!(joined.contains("--cap-drop ALL"));
assert!(joined.contains("--security-opt no-new-privileges"));
assert!(!joined.contains("--read-only"));
}
#[test]
fn run_as_user_emitted_when_set() {
let config = SandboxConfig {
run_as_user: Some("1000:1000".into()),
..Default::default()
};
let joined = hardening_args(&config).join(" ");
assert!(joined.contains("--user 1000:1000"));
assert!(!hardening_args(&SandboxConfig::default())
.join(" ")
.contains("--user"));
}
#[test]
fn read_only_rootfs_adds_tmpfs_when_enabled() {
let config = SandboxConfig {
read_only_rootfs: true,
..Default::default()
};
let joined = hardening_args(&config).join(" ");
assert!(joined.contains("--read-only"));
assert!(joined.contains("--tmpfs /tmp:rw,size=512m"));
}
#[test]
fn limits_can_be_disabled() {
let config = SandboxConfig {
memory: None,
pids_limit: None,
cpus: None,
drop_capabilities: false,
..Default::default()
};
assert!(hardening_args(&config).is_empty());
}
#[test]
fn container_path_maps_relative_paths_under_workspace() {
assert_eq!(container_path("file.txt").unwrap(), "/workspace/file.txt");
assert_eq!(
container_path("sub/dir/file.txt").unwrap(),
"/workspace/sub/dir/file.txt"
);
assert_eq!(container_path(".").unwrap(), "/workspace");
assert_eq!(container_path("./a").unwrap(), "/workspace/a");
}
#[test]
fn container_path_rejects_absolute_and_traversal() {
assert!(container_path("/etc/passwd")
.unwrap_err()
.contains("must be relative"));
assert!(container_path("../../etc/passwd")
.unwrap_err()
.contains("'..'"));
assert!(container_path("sub/../../etc")
.unwrap_err()
.contains("'..'"));
assert!(container_path("").unwrap_err().contains("must not be empty"));
}
}