use std::path::PathBuf;
use std::time::Duration;
use crate::core::error::AgnosaiError;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ProcessResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ProcessSandboxConfig {
pub executable: PathBuf,
pub args: Vec<String>,
pub work_dir: Option<PathBuf>,
pub timeout: Duration,
pub env: Vec<(String, String)>,
pub clean_env: bool,
}
impl Default for ProcessSandboxConfig {
fn default() -> Self {
Self {
executable: PathBuf::from("/bin/sh"),
args: vec!["-c".into()],
work_dir: None,
timeout: Duration::from_secs(30),
env: Vec::new(),
clean_env: false,
}
}
}
pub struct ProcessSandbox {
config: ProcessSandboxConfig,
}
impl ProcessSandbox {
pub fn new(config: ProcessSandboxConfig) -> Self {
Self { config }
}
pub fn shell(timeout: Duration) -> Self {
Self::new(ProcessSandboxConfig {
executable: PathBuf::from("/bin/sh"),
args: vec!["-c".into()],
timeout,
..Default::default()
})
}
#[tracing::instrument(skip_all)]
pub async fn execute_argv(
&self,
argv: &[&str],
input: &str,
) -> crate::core::Result<ProcessResult> {
use std::process::Stdio;
if argv.is_empty() {
return Err(AgnosaiError::Sandbox("empty argv".into()));
}
debug!(
executable = argv[0],
args = ?&argv[1..],
"spawning sandboxed process (argv mode)"
);
let mut cmd = Command::new(argv[0]);
for arg in &argv[1..] {
cmd.arg(arg);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for var in crate::sandbox::SANITIZED_ENV_VARS {
cmd.env_remove(var);
}
if let Some(ref dir) = self.config.work_dir {
cmd.current_dir(dir);
}
if self.config.clean_env {
cmd.env_clear();
}
for (k, v) in &self.config.env {
cmd.env(k, v);
}
let mut child = cmd
.spawn()
.map_err(|e| AgnosaiError::Sandbox(format!("failed to spawn {}: {e}", argv[0])))?;
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(input.as_bytes()).await
{
warn!("failed to write to process stdin: {e}");
}
match tokio::time::timeout(self.config.timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
debug!(exit_code, "sandboxed process (argv) finished");
Ok(ProcessResult {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code,
timed_out: false,
})
}
Ok(Err(e)) => Err(AgnosaiError::Sandbox(format!("process I/O error: {e}"))),
Err(_) => {
warn!(
timeout_secs = self.config.timeout.as_secs(),
"sandboxed process (argv) timed out"
);
Ok(ProcessResult {
stdout: String::new(),
stderr: String::new(),
exit_code: -1,
timed_out: true,
})
}
}
}
pub async fn execute(&self, command: &str, input: &str) -> crate::core::Result<ProcessResult> {
use std::process::Stdio;
debug!(
executable = %self.config.executable.display(),
command,
"spawning sandboxed process"
);
let mut cmd = Command::new(&self.config.executable);
for arg in &self.config.args {
cmd.arg(arg);
}
cmd.arg(command);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for var in crate::sandbox::SANITIZED_ENV_VARS {
cmd.env_remove(var);
}
if let Some(ref dir) = self.config.work_dir {
cmd.current_dir(dir);
}
if self.config.clean_env {
cmd.env_clear();
}
for (k, v) in &self.config.env {
cmd.env(k, v);
}
let mut child = cmd.spawn().map_err(|e| {
AgnosaiError::Sandbox(format!(
"failed to spawn {}: {e}",
self.config.executable.display()
))
})?;
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(input.as_bytes()).await
{
warn!("failed to write to process stdin: {e}");
}
match tokio::time::timeout(self.config.timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
debug!(exit_code, "sandboxed process finished");
Ok(ProcessResult {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code,
timed_out: false,
})
}
Ok(Err(e)) => Err(AgnosaiError::Sandbox(format!("process I/O error: {e}"))),
Err(_) => {
warn!(
timeout_secs = self.config.timeout.as_secs(),
"sandboxed process timed out, killing"
);
Ok(ProcessResult {
stdout: String::new(),
stderr: String::new(),
exit_code: -1,
timed_out: true,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn echo_command() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox
.execute("echo hello", "")
.await
.expect("should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
assert_eq!(result.stdout.trim(), "hello");
}
#[tokio::test]
async fn stdin_passthrough() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox
.execute("cat", "input data")
.await
.expect("should succeed");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "input data");
}
#[tokio::test]
async fn nonzero_exit() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox
.execute("exit 42", "")
.await
.expect("should succeed");
assert_eq!(result.exit_code, 42);
assert!(!result.timed_out);
}
#[tokio::test]
async fn stderr_captured() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox
.execute("echo err >&2", "")
.await
.expect("should succeed");
assert_eq!(result.exit_code, 0);
assert!(result.stderr.contains("err"));
}
#[tokio::test]
async fn timeout_kills_process() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(1));
let result = sandbox
.execute("sleep 60", "")
.await
.expect("should succeed even on timeout");
assert!(result.timed_out);
assert_eq!(result.exit_code, -1);
}
#[tokio::test]
async fn clean_env() {
let config = ProcessSandboxConfig {
executable: PathBuf::from("/bin/sh"),
args: vec!["-c".into()],
timeout: Duration::from_secs(5),
env: vec![("SANDBOX_VAR".into(), "yes".into())],
clean_env: true,
work_dir: None,
};
let sandbox = ProcessSandbox::new(config);
let result = sandbox
.execute("echo $SANDBOX_VAR", "")
.await
.expect("should succeed");
assert_eq!(result.stdout.trim(), "yes");
}
#[tokio::test]
async fn invalid_executable() {
let config = ProcessSandboxConfig {
executable: PathBuf::from("/nonexistent/binary"),
args: Vec::new(),
timeout: Duration::from_secs(5),
env: Vec::new(),
clean_env: false,
work_dir: None,
};
let sandbox = ProcessSandbox::new(config);
let result = sandbox.execute("", "");
assert!(result.await.is_err());
}
#[tokio::test]
async fn execute_argv_no_shell_interpretation() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox
.execute_argv(&["echo", "hello; echo injected"], "")
.await
.expect("should succeed");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "hello; echo injected");
}
#[tokio::test]
async fn execute_argv_empty_returns_error() {
let sandbox = ProcessSandbox::shell(Duration::from_secs(5));
let result = sandbox.execute_argv(&[], "").await;
assert!(result.is_err());
}
}