use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use serde_json::Value;
use thiserror::Error;
const SANDBOX_ALLOWED_ENV_PREFIXES: &[&str] = &["APCORE_"];
const SANDBOX_ALLOWED_ENV_KEYS: &[&str] = &["PATH", "LANG", "LC_ALL"];
const SANDBOX_DENIED_ENV_PREFIXES: &[&str] = &["APCORE_AUTH_"];
const SANDBOX_DENIED_ENV_KEYS: &[&str] = &["APCORE_AUTH_API_KEY"];
const SANDBOX_OUTPUT_SIZE_LIMIT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Error)]
pub enum ModuleExecutionError {
#[error("module '{module_id}' exited with code {exit_code}{}",
if stderr.is_empty() { String::new() } else { format!(": {stderr}") })]
NonZeroExit {
module_id: String,
exit_code: i32,
stderr: String,
},
#[error("module '{module_id}' timed out after {timeout_secs}s")]
Timeout {
module_id: String,
timeout_secs: u64,
},
#[error("failed to parse sandbox output for module '{module_id}': {reason}")]
OutputParseFailed { module_id: String, reason: String },
#[error("Module '{module_id}' {overflow_stream} exceeded the {}MiB sandbox limit.",
limit_bytes / (1024 * 1024))]
OutputSizeExceeded {
module_id: String,
limit_bytes: usize,
overflow_stream: String,
},
#[error("failed to spawn sandbox process: {0}")]
SpawnFailed(String),
#[error(transparent)]
ModuleError(#[from] apcore::errors::ModuleError),
}
#[derive(Debug, Error)]
#[error("Module not found: {module_id}")]
pub struct ModuleNotFoundError {
pub module_id: String,
}
#[derive(Debug, Error)]
#[error("Schema validation error: {detail}")]
pub struct SchemaValidationError {
pub detail: String,
}
pub struct Sandbox {
enabled: bool,
timeout_secs: u64,
extensions_root: Option<PathBuf>,
max_output_bytes: usize,
}
impl Sandbox {
pub fn new(enabled: bool, timeout_secs: u64) -> Self {
Self {
enabled,
timeout_secs,
extensions_root: None,
max_output_bytes: SANDBOX_OUTPUT_SIZE_LIMIT_BYTES,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn with_extensions_root(mut self, extensions_root: Option<PathBuf>) -> Self {
self.extensions_root = extensions_root;
self
}
pub fn with_max_output_bytes(mut self, max_output_bytes: usize) -> Self {
self.max_output_bytes = max_output_bytes;
self
}
#[doc(hidden)]
pub fn extensions_root(&self) -> Option<&PathBuf> {
self.extensions_root.as_ref()
}
#[doc(hidden)]
pub fn max_output_bytes(&self) -> usize {
self.max_output_bytes
}
pub async fn execute(
&self,
module_id: &str,
input_data: Value,
executor: &apcore::Executor,
) -> Result<Value, ModuleExecutionError> {
if !self.enabled {
return executor
.call(module_id, input_data, None, None)
.await
.map_err(ModuleExecutionError::ModuleError);
}
self._sandboxed_execute(module_id, input_data).await
}
fn build_sandbox_env(
&self,
host_env: &std::collections::HashMap<String, String>,
) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = Vec::new();
for key in SANDBOX_ALLOWED_ENV_KEYS {
if let Some(val) = host_env.get(*key) {
env.push((key.to_string(), val.clone()));
}
}
for (k, v) in host_env {
if SANDBOX_ALLOWED_ENV_PREFIXES
.iter()
.any(|prefix| k.starts_with(prefix))
&& !SANDBOX_DENIED_ENV_PREFIXES
.iter()
.any(|prefix| k.starts_with(prefix))
&& !SANDBOX_DENIED_ENV_KEYS.contains(&k.as_str())
{
env.push((k.clone(), v.clone()));
}
}
if let Some(ref ext_root) = self.extensions_root {
let resolved = std::fs::canonicalize(ext_root).unwrap_or_else(|_| ext_root.clone());
env.retain(|(k, _)| k != "APCORE_EXTENSIONS_ROOT");
env.push((
"APCORE_EXTENSIONS_ROOT".to_string(),
resolved.to_string_lossy().into_owned(),
));
} else {
if let Some(idx) = env.iter().position(|(k, _)| k == "APCORE_EXTENSIONS_ROOT") {
let raw = env[idx].1.clone();
let p = std::path::PathBuf::from(&raw);
if let Ok(canon) = std::fs::canonicalize(&p) {
env[idx].1 = canon.to_string_lossy().into_owned();
}
}
}
env
}
async fn _sandboxed_execute(
&self,
module_id: &str,
input_data: Value,
) -> Result<Value, ModuleExecutionError> {
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
let host_env: std::collections::HashMap<String, String> = std::env::vars().collect();
let mut env = self.build_sandbox_env(&host_env);
let tmpdir = tempfile::TempDir::new()
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
let tmpdir_path = tmpdir.path().to_string_lossy().to_string();
env.push(("HOME".to_string(), tmpdir_path.clone()));
env.push(("TMPDIR".to_string(), tmpdir_path.clone()));
let input_json = serde_json::to_string(&input_data)
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
let binary = std::env::current_exe()
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
let mut child = Command::new(&binary)
.arg("--internal-sandbox-runner")
.arg(module_id)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.envs(env)
.current_dir(&tmpdir_path)
.kill_on_drop(true)
.spawn()
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input_json.as_bytes())
.await
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
}
let timeout_dur = if self.timeout_secs > 0 {
Duration::from_secs(self.timeout_secs)
} else {
Duration::from_secs(300)
};
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let cap = self.max_output_bytes;
let collect_result = timeout(timeout_dur, async {
let (stdout_res, stderr_res) = tokio::join!(
async {
let mut buf = Vec::new();
if let Some(r) = stdout_pipe {
let _ = r.take(cap as u64 + 1).read_to_end(&mut buf).await;
}
buf
},
async {
let mut buf = Vec::new();
if let Some(r) = stderr_pipe {
let _ = r.take(cap as u64 + 1).read_to_end(&mut buf).await;
}
buf
},
);
let status = child
.wait()
.await
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
Ok::<_, ModuleExecutionError>((stdout_res, stderr_res, status))
})
.await
.map_err(|_| ModuleExecutionError::Timeout {
module_id: module_id.to_string(),
timeout_secs: self.timeout_secs,
})??;
let (stdout_bytes, stderr_bytes, status) = collect_result;
if stdout_bytes.len() > cap || stderr_bytes.len() > cap {
let overflow_stream = match (stdout_bytes.len() > cap, stderr_bytes.len() > cap) {
(true, true) => "stdout+stderr",
(true, false) => "stdout",
(false, true) => "stderr",
(false, false) => "stdout",
}
.to_string();
return Err(ModuleExecutionError::OutputSizeExceeded {
module_id: module_id.to_string(),
limit_bytes: cap,
overflow_stream,
});
}
if !status.success() {
let exit_code = status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
return Err(ModuleExecutionError::NonZeroExit {
module_id: module_id.to_string(),
exit_code,
stderr,
});
}
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
crate::sandbox_runner::decode_result(&stdout).map_err(|e| {
ModuleExecutionError::OutputParseFailed {
module_id: module_id.to_string(),
reason: e.to_string(),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_sandbox_disabled_delegates_to_executor() {
let sandbox = Sandbox::new(false, 5); let _check: fn(&Sandbox, &str, Value, &apcore::Executor) = |s, id, v, e| {
drop(s.execute(id, v, e));
};
let _ = sandbox; }
#[tokio::test]
async fn test_sandbox_enabled_path_still_runs_subprocess() {
let sandbox = Sandbox::new(true, 1); let _check: fn(&Sandbox, &str, Value, &apcore::Executor) = |s, id, v, e| {
drop(s.execute(id, v, e));
};
let _ = sandbox;
}
#[test]
fn test_decode_result_valid_json() {
use crate::sandbox_runner::decode_result;
let v = decode_result(r#"{"ok":true}"#).unwrap();
assert_eq!(v["ok"], true);
}
#[test]
fn test_decode_result_invalid_json() {
use crate::sandbox_runner::decode_result;
assert!(decode_result("not json").is_err());
}
#[test]
fn test_encode_result_roundtrip() {
use crate::sandbox_runner::{decode_result, encode_result};
let v = json!({"result": 42});
let encoded = encode_result(&v);
let decoded = decode_result(&encoded).unwrap();
assert_eq!(decoded["result"], 42);
}
#[test]
fn test_sandbox_default_max_output_bytes_is_64mib() {
let s = Sandbox::new(false, 5);
assert_eq!(s.max_output_bytes(), 64 * 1024 * 1024);
}
#[test]
fn test_sandbox_default_extensions_root_is_none() {
let s = Sandbox::new(false, 5);
assert!(s.extensions_root().is_none());
}
#[test]
fn test_sandbox_with_max_output_bytes_sets_field() {
let s = Sandbox::new(false, 5).with_max_output_bytes(1024);
assert_eq!(s.max_output_bytes(), 1024);
}
#[test]
fn test_sandbox_with_extensions_root_sets_field() {
let path = PathBuf::from("/tmp/extensions");
let s = Sandbox::new(false, 5).with_extensions_root(Some(path.clone()));
assert_eq!(s.extensions_root(), Some(&path));
}
#[test]
fn test_sandbox_builder_chains() {
let path = PathBuf::from("/tmp/ext");
let s = Sandbox::new(true, 30)
.with_extensions_root(Some(path.clone()))
.with_max_output_bytes(2048);
assert!(s.is_enabled());
assert_eq!(s.extensions_root(), Some(&path));
assert_eq!(s.max_output_bytes(), 2048);
}
#[test]
fn test_inherited_extensions_root_canonicalised_when_no_builder_override() {
let tmp = tempfile::tempdir().unwrap();
let abs = tmp.path().to_path_buf();
let cwd_before = std::env::current_dir().unwrap();
let parent = abs.parent().unwrap().to_path_buf();
let basename = abs.file_name().unwrap().to_string_lossy().into_owned();
std::env::set_current_dir(&parent).unwrap();
let relative_form = format!("./{basename}");
let mut host_env: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
host_env.insert("APCORE_EXTENSIONS_ROOT".to_string(), relative_form.clone());
let s = Sandbox::new(true, 5); let env = s.build_sandbox_env(&host_env);
std::env::set_current_dir(&cwd_before).unwrap();
let resolved = env
.iter()
.find(|(k, _)| k == "APCORE_EXTENSIONS_ROOT")
.map(|(_, v)| v.clone())
.expect("APCORE_EXTENSIONS_ROOT must be forwarded by the prefix whitelist");
let expected = std::fs::canonicalize(&abs)
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
resolved, expected,
"inherited relative APCORE_EXTENSIONS_ROOT must be canonicalised to the absolute path"
);
assert!(
std::path::Path::new(&resolved).is_absolute(),
"post-canonicalisation value must be absolute, got {resolved:?}"
);
}
#[test]
fn test_sandbox_env_does_not_include_auth_api_key() {
unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "secret-key-12345") };
let host_env: std::collections::HashMap<String, String> = std::env::vars().collect();
let mut env: Vec<(String, String)> = Vec::new();
for key in SANDBOX_ALLOWED_ENV_KEYS {
if let Some(val) = host_env.get(*key) {
env.push((key.to_string(), val.clone()));
}
}
for (k, v) in &host_env {
if SANDBOX_ALLOWED_ENV_PREFIXES
.iter()
.any(|prefix| k.starts_with(prefix))
&& !SANDBOX_DENIED_ENV_PREFIXES
.iter()
.any(|prefix| k.starts_with(prefix))
&& !SANDBOX_DENIED_ENV_KEYS.contains(&k.as_str())
{
env.push((k.clone(), v.clone()));
}
}
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
assert!(
!env.iter().any(|(k, _)| k == "APCORE_AUTH_API_KEY"),
"APCORE_AUTH_API_KEY must not be forwarded to the sandbox environment"
);
}
#[test]
fn test_sandbox_env_does_not_include_auth_prefix() {
unsafe {
std::env::set_var("APCORE_AUTH_TOKEN", "bearer-xyz");
std::env::set_var("APCORE_AUTH_SECRET", "shh");
}
let host_env: std::collections::HashMap<String, String> = std::env::vars().collect();
let env: Vec<(String, String)> = host_env
.iter()
.filter(|(k, _)| {
SANDBOX_ALLOWED_ENV_PREFIXES
.iter()
.any(|p| k.starts_with(p))
&& !SANDBOX_DENIED_ENV_PREFIXES.iter().any(|p| k.starts_with(p))
&& !SANDBOX_DENIED_ENV_KEYS.contains(&k.as_str())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
unsafe {
std::env::remove_var("APCORE_AUTH_TOKEN");
std::env::remove_var("APCORE_AUTH_SECRET");
}
let leaked: Vec<_> = env
.iter()
.filter(|(k, _)| k.starts_with("APCORE_AUTH_"))
.collect();
assert!(
leaked.is_empty(),
"APCORE_AUTH_* vars must not leak into sandbox env: {leaked:?}"
);
}
}