use std::ffi::OsString;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use std::time::Duration;
use serde::Serialize;
use clap::ValueEnum;
use crate::error::{Result, SkadooshError};
use crate::tools::ToolExecutor;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
pub const CODE_EXEC_TOOL_NAME: &str = "code_exec";
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
#[default]
Subprocess,
Docker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeLanguage {
Python,
Shell,
Binary,
}
#[derive(Debug, Clone, Copy)]
pub struct ResourceLimits {
pub cpu_secs: u64,
pub as_kb: u64,
pub nofile: u64,
}
#[derive(Debug, Clone)]
pub struct DockerImages {
pub python: String,
pub shell: String,
pub binary: String,
}
impl Default for DockerImages {
fn default() -> Self {
Self {
python: "python:3-slim".to_string(),
shell: "bash:latest".to_string(),
binary: "ubuntu:22.04".to_string(),
}
}
}
#[derive(Debug, Serialize)]
struct ExecResult {
stdout: String,
stderr: String,
exit_code: Option<i32>,
timed_out: bool,
}
pub fn code_exec_tool_definition() -> crate::llm::Tool {
crate::llm::Tool::function(
CODE_EXEC_TOOL_NAME,
"Execute a short code snippet in a sandboxed subprocess and return \
its stdout, stderr, and exit code. Use for quick computations, \
checks, or scripting. Keep snippets short and self-contained; \
network access is blocked.",
serde_json::json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": ["python", "shell", "binary"],
"description": "Runner to use: 'python' (python3 -c), \
'shell' (bash -c), or 'binary' (run an \
executable)."
},
"code": {
"type": "string",
"description": "The code to run (python source or shell \
script), or the executable path when \
language is 'binary'."
},
"args": {
"type": "array",
"items": {"type": "string"},
"description": "Arguments for the executable; only used \
when language is 'binary'."
}
},
"required": ["language", "code"]
}),
)
}
pub(crate) fn parse_code_exec_args(arguments: &str) -> Result<(CodeLanguage, String, Vec<String>)> {
let v: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
let lang_str = v
.get("language")
.and_then(|x| x.as_str())
.unwrap_or("python");
let language = parse_language(lang_str).ok_or_else(|| {
SkadooshError::Other(anyhow::anyhow!(
"code_exec: unknown language '{lang_str}' \
(expected one of: python, shell, binary)"
))
})?;
let code = if language == CodeLanguage::Binary {
v.get("command")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.or_else(|| v.get("code").and_then(|x| x.as_str()))
.unwrap_or("")
.to_string()
} else {
v.get("code")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string()
};
let args: Vec<String> = v
.get("args")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok((language, code, args))
}
fn parse_language(s: &str) -> Option<CodeLanguage> {
match s.to_lowercase().as_str() {
"python" | "python3" | "py" => Some(CodeLanguage::Python),
"shell" | "bash" | "sh" => Some(CodeLanguage::Shell),
"binary" | "bin" | "exec" => Some(CodeLanguage::Binary),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct SandboxExecutor {
timeout_secs: u64,
mode: SandboxMode,
limits: ResourceLimits,
network_isolated: bool,
unshare_available: bool,
docker_available: bool,
docker_images: DockerImages,
}
impl Default for SandboxExecutor {
fn default() -> Self {
Self::new(DEFAULT_TIMEOUT_SECS, SandboxMode::Subprocess)
}
}
impl SandboxExecutor {
pub fn new(timeout_secs: u64, mode: SandboxMode) -> Self {
let mut limits = ResourceLimits {
cpu_secs: timeout_secs,
as_kb: 1_048_576, nofile: 256,
};
clamp_to_hard_limits(&mut limits);
let unshare_available = unshare_network_available();
let docker_available = if mode == SandboxMode::Docker {
docker_available()
} else {
false
};
Self {
timeout_secs,
mode,
limits,
network_isolated: true,
unshare_available,
docker_available,
docker_images: DockerImages::default(),
}
}
pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
self.limits = limits;
self
}
pub fn with_network_isolated(mut self, isolated: bool) -> Self {
self.network_isolated = isolated;
self
}
pub fn with_docker_images(mut self, images: DockerImages) -> Self {
self.docker_images = images;
self
}
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs
}
pub fn mode(&self) -> SandboxMode {
self.mode
}
pub fn unshare_available(&self) -> bool {
self.unshare_available
}
fn build_argv(&self, language: CodeLanguage, code: &str, args: &[String]) -> Vec<OsString> {
build_subprocess_argv(
self.network_isolated && self.unshare_available,
self.limits,
language,
code,
args,
)
}
fn run(&self, language: CodeLanguage, code: &str, args: &[String]) -> Result<String> {
match self.mode {
SandboxMode::Subprocess => self.run_subprocess(language, code, args),
SandboxMode::Docker => self.run_docker(language, code, args),
}
}
fn run_subprocess(
&self,
language: CodeLanguage,
code: &str,
args: &[String],
) -> Result<String> {
let workdir = create_temp_dir()?;
let _guard = TempDir(workdir.clone());
let mut argv = self.build_argv(language, code, args);
if self.timeout_secs > 0 {
let mut wrapped: Vec<OsString> = Vec::with_capacity(argv.len() + 5);
wrapped.push("timeout".into());
wrapped.push("--kill-after".into());
wrapped.push("5".into());
wrapped.push(self.timeout_secs.to_string().into());
wrapped.append(&mut argv);
argv = wrapped;
}
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
scrub_env(&mut cmd, &workdir);
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd.spawn().map_err(|e| {
SkadooshError::Other(anyhow::anyhow!(
"code_exec: failed to spawn sandbox subprocess: {e}"
))
})?;
let stdout_h = child.stdout.take();
let stderr_h = child.stderr.take();
let out_thread = std::thread::spawn(move || read_all(stdout_h));
let err_thread = std::thread::spawn(move || read_all(stderr_h));
let (status, mut timed_out) = wait_with_timeout(child, self.timeout_secs);
if !timed_out && status.as_ref().and_then(|s| s.code()) == Some(124) {
timed_out = true;
}
let stdout = out_thread.join().unwrap_or_default();
let stderr = err_thread.join().unwrap_or_default();
Ok(format_result(&stdout, &stderr, status, timed_out))
}
fn run_docker(&self, language: CodeLanguage, code: &str, args: &[String]) -> Result<String> {
if !self.docker_available {
return Err(SkadooshError::Other(anyhow::anyhow!(
"code_exec: Docker sandbox requested but the Docker daemon \
is not available"
)));
}
let workdir = create_temp_dir()?;
let _guard = TempDir(workdir.clone());
let image = match language {
CodeLanguage::Python => &self.docker_images.python,
CodeLanguage::Shell => &self.docker_images.shell,
CodeLanguage::Binary => &self.docker_images.binary,
};
let mut argv: Vec<OsString> = Vec::new();
if self.timeout_secs > 0 {
argv.push("timeout".into());
argv.push("-s".into());
argv.push("TERM".into());
argv.push("--kill-after".into());
argv.push("10".into());
argv.push(self.timeout_secs.to_string().into());
}
argv.push("docker".into());
argv.push("run".into());
argv.push("--rm".into());
argv.push("--network".into());
argv.push("none".into());
argv.push("--read-only".into());
argv.push("--cap-drop=ALL".into());
argv.push("--security-opt".into());
argv.push("no-new-privileges".into());
argv.push("--cpus".into());
argv.push("1".into());
if self.limits.as_kb > 0 {
argv.push("--memory".into());
argv.push(format!("{}m", self.limits.as_kb / 1024).into());
}
push_ulimit(&mut argv, "nofile", self.limits.nofile);
push_ulimit(&mut argv, "cpu", self.limits.cpu_secs);
push_ulimit(&mut argv, "as", self.limits.as_kb);
argv.push("-v".into());
argv.push(format!("{}:/work", workdir.display()).into());
argv.push("-w".into());
argv.push("/work".into());
argv.push(image.into());
match language {
CodeLanguage::Python => {
argv.push("python3".into());
argv.push("-c".into());
argv.push(code.into());
}
CodeLanguage::Shell => {
argv.push("bash".into());
argv.push("-c".into());
argv.push(code.into());
}
CodeLanguage::Binary => {
argv.push(code.into());
for a in args {
argv.push(a.into());
}
}
}
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
SkadooshError::Other(anyhow::anyhow!("code_exec: failed to spawn docker: {e}"))
})?;
let stdout_h = child.stdout.take();
let stderr_h = child.stderr.take();
let out_thread = std::thread::spawn(move || read_all(stdout_h));
let err_thread = std::thread::spawn(move || read_all(stderr_h));
let outer = self.timeout_secs.saturating_add(30);
let (status, _killed) = wait_with_timeout(child, outer);
let stdout = out_thread.join().unwrap_or_default();
let stderr = err_thread.join().unwrap_or_default();
let timed_out = status.and_then(|s| s.code()) == Some(124);
Ok(format_result(&stdout, &stderr, status, timed_out))
}
}
impl ToolExecutor for SandboxExecutor {
fn execute(&self, name: &str, arguments: &str) -> Result<String> {
tracing::debug!(tool = %name, args = %arguments, "code_exec invoked");
let (language, code, args) = parse_code_exec_args(arguments)?;
self.run(language, &code, &args)
}
}
#[cfg(unix)]
fn build_subprocess_argv(
unshare: bool,
limits: ResourceLimits,
language: CodeLanguage,
code: &str,
args: &[String],
) -> Vec<OsString> {
let mut argv: Vec<OsString> = Vec::new();
if unshare {
argv.push("unshare".into());
argv.push("-n".into());
}
let ulimits = ulimit_prefix(limits);
let exec_tail = match language {
CodeLanguage::Python => "exec python3 -c \"$1\"",
CodeLanguage::Shell => "exec bash -c \"$1\"",
CodeLanguage::Binary => "exec \"$@\"",
};
let script = format!("{ulimits}; {exec_tail}");
argv.push("bash".into());
argv.push("-c".into());
argv.push(script.into());
argv.push("skadoosh".into()); if language == CodeLanguage::Binary {
argv.push(code.into()); for a in args {
argv.push(a.into());
}
} else {
argv.push(code.into()); }
argv
}
#[cfg(not(unix))]
fn build_subprocess_argv(
_unshare: bool,
_limits: ResourceLimits,
language: CodeLanguage,
code: &str,
args: &[String],
) -> Vec<OsString> {
match language {
CodeLanguage::Python => vec!["python3".into(), "-c".into(), code.into()],
CodeLanguage::Shell => vec!["bash".into(), "-c".into(), code.into()],
CodeLanguage::Binary => {
let mut v: Vec<OsString> = vec![code.into()];
for a in args {
v.push(a.into());
}
v
}
}
}
fn ulimit_prefix(limits: ResourceLimits) -> String {
[
format!("ulimit -t {}", fmt_limit(limits.cpu_secs)),
format!("ulimit -v {}", fmt_limit(limits.as_kb)),
format!("ulimit -n {}", fmt_limit(limits.nofile)),
]
.join("; ")
}
fn fmt_limit(n: u64) -> String {
if n == 0 {
"unlimited".to_string()
} else {
n.to_string()
}
}
fn push_ulimit(argv: &mut Vec<OsString>, name: &str, val: u64) {
if val > 0 {
argv.push("--ulimit".into());
argv.push(format!("{name}={val}").into());
}
}
fn scrub_env(cmd: &mut Command, workdir: &PathBuf) {
cmd.env_clear();
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
} else if let Ok(profile) = std::env::var("USERPROFILE") {
cmd.env("HOME", profile);
}
cmd.env("TMPDIR", workdir);
}
fn create_temp_dir() -> Result<PathBuf> {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"skadoosh-sandbox-{}-{}-{}",
std::process::id(),
n,
nanos
));
std::fs::create_dir_all(&dir).map_err(|e| {
SkadooshError::Other(anyhow::anyhow!(
"code_exec: failed to create sandbox workdir: {e}"
))
})?;
Ok(dir)
}
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn read_all(mut r: Option<impl Read>) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(reader) = r.as_mut() {
let _ = reader.read_to_end(&mut buf);
}
buf
}
fn wait_with_timeout(mut child: Child, timeout_secs: u64) -> (Option<ExitStatus>, bool) {
if timeout_secs == 0 {
let status = child.wait().ok();
return (status, false);
}
let timed_out = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&timed_out);
let (done_tx, done_rx) = mpsc::channel::<()>();
let deadline = Duration::from_secs(timeout_secs);
let watchdog = std::thread::spawn(move || {
if done_rx.recv_timeout(deadline).is_ok() {
return;
}
flag.store(true, Ordering::Relaxed);
});
let mut status: Option<ExitStatus> = None;
let mut killed = false;
loop {
match child.try_wait() {
Ok(Some(s)) => {
status = Some(s);
break;
}
Ok(None) => {
if timed_out.load(Ordering::Relaxed) {
killed = true;
kill_tree(&mut child);
status = child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => break,
}
}
let _ = done_tx.send(());
let _ = watchdog.join();
(status, killed)
}
#[cfg(unix)]
fn kill_tree(child: &mut Child) {
let pid = child.id();
let _ = Command::new("kill")
.arg("-KILL")
.arg(format!("-{pid}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let _ = child.kill();
}
#[cfg(not(unix))]
fn kill_tree(child: &mut Child) {
let _ = child.kill();
}
fn format_result(
stdout: &[u8],
stderr: &[u8],
status: Option<ExitStatus>,
timed_out: bool,
) -> String {
let exit_code = if timed_out {
Some(124)
} else {
status.and_then(|s| s.code())
};
let res = ExecResult {
stdout: String::from_utf8_lossy(stdout).into_owned(),
stderr: String::from_utf8_lossy(stderr).into_owned(),
exit_code,
timed_out,
};
serde_json::to_string(&res)
.unwrap_or_else(|_| "{\"error\":\"code_exec result serialization failed\"}".to_string())
}
#[cfg(unix)]
fn unshare_network_available() -> bool {
Command::new("unshare")
.arg("-n")
.arg("/bin/true")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
fn unshare_network_available() -> bool {
false
}
fn docker_available() -> bool {
let mut cmd = Command::new("docker");
cmd.arg("info")
.arg("--format")
.arg("{{.ServerVersion}}")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
cmd.process_group(0);
let child = match cmd.spawn() {
Ok(c) => c,
Err(_) => return false,
};
let (status, _killed) = wait_with_timeout(child, 5);
status.map(|s| s.success()).unwrap_or(false)
}
#[cfg(unix)]
fn clamp_to_hard_limits(limits: &mut ResourceLimits) {
use rlimit::Resource;
if let Ok(hard) = Resource::CPU.get_hard() {
if hard != rlimit::INFINITY && limits.cpu_secs > hard {
limits.cpu_secs = hard;
}
}
if let Ok(hard) = Resource::AS.get_hard() {
let as_bytes = limits.as_kb.saturating_mul(1024);
if hard != rlimit::INFINITY && as_bytes > hard {
limits.as_kb = hard / 1024;
}
}
if let Ok(hard) = Resource::NOFILE.get_hard() {
if hard != rlimit::INFINITY && limits.nofile > hard {
limits.nofile = hard;
}
}
}
#[cfg(not(unix))]
fn clamp_to_hard_limits(_limits: &mut ResourceLimits) {}
#[cfg(test)]
mod tests {
use super::*;
fn osvec(v: &[&str]) -> Vec<OsString> {
v.iter().map(OsString::from).collect()
}
#[test]
fn parse_language_accepts_aliases() {
assert_eq!(parse_language("python"), Some(CodeLanguage::Python));
assert_eq!(parse_language("Python3"), Some(CodeLanguage::Python));
assert_eq!(parse_language("sh"), Some(CodeLanguage::Shell));
assert_eq!(parse_language("BASH"), Some(CodeLanguage::Shell));
assert_eq!(parse_language("binary"), Some(CodeLanguage::Binary));
assert_eq!(parse_language("exec"), Some(CodeLanguage::Binary));
assert_eq!(parse_language("rust"), None);
}
#[test]
fn parse_code_exec_args_defaults_language_to_python() {
let (lang, code, args) = parse_code_exec_args(r#"{"code":"print(1)"}"#).expect("parses");
assert_eq!(lang, CodeLanguage::Python);
assert_eq!(code, "print(1)");
assert!(args.is_empty());
}
#[test]
fn parse_code_exec_args_reads_binary_args_and_command_alias() {
let (lang, code, args) = parse_code_exec_args(
r#"{"language":"binary","command":"/bin/ls","args":["-la","/work"]}"#,
)
.expect("parses");
assert_eq!(lang, CodeLanguage::Binary);
assert_eq!(code, "/bin/ls");
assert_eq!(args, vec!["-la".to_string(), "/work".to_string()]);
}
#[test]
fn parse_code_exec_args_rejects_unknown_language() {
assert!(parse_code_exec_args(r#"{"language":"rust","code":"x"}"#).is_err());
}
#[cfg(unix)]
#[test]
fn build_subprocess_argv_python_passes_code_as_positional() {
let limits = ResourceLimits {
cpu_secs: 30,
as_kb: 1_048_576,
nofile: 256,
};
let argv = build_subprocess_argv(false, limits, CodeLanguage::Python, "print('hi')", &[]);
assert_eq!(argv[0], OsString::from("bash"));
assert_eq!(argv[1], OsString::from("-c"));
let script = argv[2].to_string_lossy();
assert!(script.contains("ulimit -t 30"), "script: {script}");
assert!(script.contains("ulimit -v 1048576"), "script: {script}");
assert!(script.contains("ulimit -n 256"), "script: {script}");
assert!(
script.contains("exec python3 -c \"$1\""),
"script: {script}"
);
assert_eq!(argv[3], OsString::from("skadoosh")); assert_eq!(argv[4], OsString::from("print('hi')")); }
#[cfg(unix)]
#[test]
fn build_subprocess_argv_prepends_unshare_when_isolated() {
let argv = build_subprocess_argv(
true,
ResourceLimits {
cpu_secs: 5,
as_kb: 0,
nofile: 0,
},
CodeLanguage::Shell,
"echo hi",
&[],
);
assert_eq!(argv[0], OsString::from("unshare"));
assert_eq!(argv[1], OsString::from("-n"));
assert_eq!(argv[2], OsString::from("bash"));
let script = argv[4].to_string_lossy();
assert!(script.contains("ulimit -t 5"), "script: {script}");
assert!(script.contains("ulimit -v unlimited"), "script: {script}");
assert!(script.contains("ulimit -n unlimited"), "script: {script}");
assert!(script.contains("exec bash -c \"$1\""), "script: {script}");
assert_eq!(argv[5], OsString::from("skadoosh"));
assert_eq!(argv[6], OsString::from("echo hi"));
}
#[cfg(unix)]
#[test]
fn build_subprocess_argv_binary_passes_path_and_args_positionally() {
let argv = build_subprocess_argv(
false,
ResourceLimits {
cpu_secs: 0,
as_kb: 0,
nofile: 0,
},
CodeLanguage::Binary,
"/bin/echo",
&["hello".to_string(), "world".to_string()],
);
let script = argv[2].to_string_lossy();
assert!(script.contains("exec \"$@\""), "script: {script}");
assert_eq!(argv[3], OsString::from("skadoosh"));
assert_eq!(argv[4], OsString::from("/bin/echo"));
assert_eq!(argv[5], OsString::from("hello"));
assert_eq!(argv[6], OsString::from("world"));
}
#[test]
fn fmt_limit_zero_is_unlimited() {
assert_eq!(fmt_limit(0), "unlimited");
assert_eq!(fmt_limit(42), "42");
}
#[test]
fn code_exec_tool_definition_has_expected_shape() {
let tool = code_exec_tool_definition();
assert_eq!(tool.function.name, CODE_EXEC_TOOL_NAME);
let params = &tool.function.parameters;
assert_eq!(params["type"], "object");
assert_eq!(params["properties"]["language"]["enum"][0], "python");
assert_eq!(params["properties"]["code"]["type"], "string");
assert_eq!(params["required"][0], "language");
assert_eq!(params["required"][1], "code");
}
#[test]
fn new_clamps_limits_and_probes_unshare() {
let exec = SandboxExecutor::new(2, SandboxMode::Subprocess);
assert_eq!(exec.timeout_secs(), 2);
assert_eq!(exec.mode(), SandboxMode::Subprocess);
let _ = exec.unshare_available();
}
#[test]
fn osvec_helper() {
assert_eq!(
osvec(&["a", "b"]),
vec![OsString::from("a"), OsString::from("b")]
);
}
}