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("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,
}
impl Sandbox {
pub fn new(enabled: bool, timeout_secs: u64) -> Self {
Self {
enabled,
timeout_secs,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
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
}
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 mut env: Vec<(String, String)> = Vec::new();
let host_env: std::collections::HashMap<String, String> = std::env::vars().collect();
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()));
}
}
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 = SANDBOX_OUTPUT_SIZE_LIMIT_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 {
return Err(ModuleExecutionError::OutputParseFailed {
module_id: module_id.to_string(),
reason: format!("sandbox output exceeded {} bytes", cap),
});
}
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_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:?}"
);
}
}