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),
#[error("Permission denied for module '{module_id}'")]
AclDenied { module_id: String },
}
#[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,
}
fn absolutize_sandbox_path(path: &std::path::Path) -> PathBuf {
if let Ok(canon) = std::fs::canonicalize(path) {
return canon;
}
if path.is_absolute() {
return path.to_path_buf();
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => path.to_path_buf(),
}
}
async fn read_one_chunk<R: tokio::io::AsyncRead + Unpin>(
pipe: &mut Option<R>,
buf: &mut [u8],
) -> usize {
match pipe.as_mut() {
Some(r) => r.read(buf).await.unwrap_or(0),
None => 0,
}
}
async fn collect_capped_output(
mut child: tokio::process::Child,
cap: usize,
module_id: &str,
) -> Result<(Vec<u8>, Vec<u8>, std::process::ExitStatus), ModuleExecutionError> {
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let mut stdout_buf: Vec<u8> = Vec::new();
let mut stderr_buf: Vec<u8> = Vec::new();
let mut stdout_chunk = [0u8; 65536];
let mut stderr_chunk = [0u8; 65536];
let overflowed = loop {
if stdout_pipe.is_none() && stderr_pipe.is_none() {
break false;
}
tokio::select! {
n = read_one_chunk(&mut stdout_pipe, &mut stdout_chunk), if stdout_pipe.is_some() => {
if n == 0 {
stdout_pipe = None;
} else {
stdout_buf.extend_from_slice(&stdout_chunk[..n]);
}
}
n = read_one_chunk(&mut stderr_pipe, &mut stderr_chunk), if stderr_pipe.is_some() => {
if n == 0 {
stderr_pipe = None;
} else {
stderr_buf.extend_from_slice(&stderr_chunk[..n]);
}
}
}
if stdout_buf.len() > cap || stderr_buf.len() > cap {
break true;
}
};
if overflowed {
let _ = child.start_kill();
let _ = child.wait().await;
let overflow_stream = match (stdout_buf.len() > cap, stderr_buf.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,
});
}
let status = child
.wait()
.await
.map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
Ok((stdout_buf, stderr_buf, status))
}
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);
}
if let Some(acl) = executor.acl.as_deref() {
let gate_ctx = crate::acl_cmd::delegated_gate_context();
let projection = apcore::acl::GovernanceProjection::from_arguments(&input_data);
let decision = acl.check_access(None, module_id, Some(&gate_ctx), Some(&projection));
if decision.access == "deny" {
return Err(ModuleExecutionError::AclDenied {
module_id: module_id.to_string(),
});
}
}
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 = absolutize_sandbox_path(ext_root);
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);
env[idx].1 = absolutize_sandbox_path(&p).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 cap = self.max_output_bytes;
let (stdout_bytes, stderr_bytes, status) =
timeout(timeout_dur, collect_capped_output(child, cap, module_id))
.await
.map_err(|_| ModuleExecutionError::Timeout {
module_id: module_id.to_string(),
timeout_secs: self.timeout_secs,
})??;
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:?}"
);
}
#[tokio::test]
async fn test_sandbox_execute_denies_when_acl_denies_module_without_spawning() {
use apcore::acl::{ACLRule, ACL};
let rule = ACLRule::new(
vec!["@external".to_string()],
vec!["denied.module".to_string()],
"deny",
);
let acl = ACL::try_new(vec![rule], "allow", None).expect("well-formed ACL");
let mut executor =
apcore::Executor::new(apcore::Registry::new(), apcore::Config::default());
executor.set_acl(acl);
let sandbox = Sandbox::new(true, 5);
let result = sandbox.execute("denied.module", json!({}), &executor).await;
match result {
Err(ModuleExecutionError::AclDenied { module_id }) => {
assert_eq!(module_id, "denied.module");
}
other => panic!("expected AclDenied without spawning a subprocess, got: {other:?}"),
}
}
#[tokio::test]
async fn test_sandbox_execute_allows_when_acl_allows_module() {
use apcore::acl::{ACLRule, ACL};
let rule = ACLRule::new(
vec!["@external".to_string()],
vec!["some.other.module".to_string()],
"deny",
);
let acl = ACL::try_new(vec![rule], "allow", None).expect("well-formed ACL");
let mut executor =
apcore::Executor::new(apcore::Registry::new(), apcore::Config::default());
executor.set_acl(acl);
let sandbox = Sandbox::new(true, 1);
let result = sandbox
.execute("allowed.module", json!({}), &executor)
.await;
assert!(
!matches!(result, Err(ModuleExecutionError::AclDenied { .. })),
"an allowed module must not be rejected by the ACL gate, got: {result:?}"
);
}
#[test]
fn test_with_extensions_root_absolutizes_a_nonexistent_relative_path() {
let relative = PathBuf::from("no-such-extensions-dir-abc987");
assert!(!relative.exists(), "premise: the path must not exist");
let s = Sandbox::new(true, 5).with_extensions_root(Some(relative.clone()));
let env = s.build_sandbox_env(&std::collections::HashMap::new());
let forwarded = env
.iter()
.find(|(k, _)| k == "APCORE_EXTENSIONS_ROOT")
.map(|(_, v)| v.clone())
.expect("APCORE_EXTENSIONS_ROOT must be forwarded");
assert_ne!(
forwarded,
relative.to_string_lossy(),
"must not silently forward the unresolved relative path when canonicalize fails"
);
assert!(
std::path::Path::new(&forwarded).is_absolute(),
"forwarded extensions root must be absolute even when the target \
doesn't exist yet, got {forwarded:?}"
);
assert_eq!(
std::path::Path::new(&forwarded),
std::env::current_dir().unwrap().join(&relative),
"must fall back to a lexical absolutize against the parent's cwd"
);
}
#[tokio::test]
async fn test_collect_capped_output_kills_runaway_child_promptly_on_overflow() {
use std::process::Stdio;
use tokio::process::Command;
let child = Command::new("sh")
.arg("-c")
.arg("head -c 2000 /dev/zero; while true; do :; done")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("spawn test child");
let started = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_secs(15),
collect_capped_output(child, 1024, "overflow.module"),
)
.await
.expect(
"must resolve well before a long outer sandbox timeout would, not hang \
waiting for a runaway child to exit on its own",
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"overflow must be detected and the child killed promptly, took {:?}",
started.elapsed()
);
match result {
Err(ModuleExecutionError::OutputSizeExceeded {
overflow_stream, ..
}) => {
assert_eq!(overflow_stream, "stdout");
}
other => panic!("expected OutputSizeExceeded, got: {other:?}"),
}
}
}