apcore_cli/security/
sandbox.rs1use serde_json::Value;
5use thiserror::Error;
6
7const SANDBOX_ALLOWED_ENV_PREFIXES: &[&str] = &["APCORE_"];
13
14const SANDBOX_ALLOWED_ENV_KEYS: &[&str] = &["PATH", "LANG", "LC_ALL"];
16
17#[derive(Debug, Error)]
23pub enum ModuleExecutionError {
24 #[error("module '{module_id}' exited with code {exit_code}")]
26 NonZeroExit { module_id: String, exit_code: i32 },
27
28 #[error("module '{module_id}' timed out after {timeout_ms}ms")]
30 Timeout { module_id: String, timeout_ms: u64 },
31
32 #[error("failed to parse sandbox output for module '{module_id}': {reason}")]
34 OutputParseFailed { module_id: String, reason: String },
35
36 #[error("failed to spawn sandbox process: {0}")]
38 SpawnFailed(String),
39}
40
41pub struct Sandbox {
51 enabled: bool,
52 timeout_ms: u64,
53}
54
55impl Sandbox {
56 pub fn new(enabled: bool, timeout_ms: u64) -> Self {
62 Self {
63 enabled,
64 timeout_ms,
65 }
66 }
67
68 pub fn is_enabled(&self) -> bool {
70 self.enabled
71 }
72
73 pub async fn execute(
84 &self,
85 module_id: &str,
86 input_data: Value,
87 ) -> Result<Value, ModuleExecutionError> {
88 if !self.enabled {
89 return Err(ModuleExecutionError::SpawnFailed(
93 "in-process executor not wired (use Sandbox::execute_with)".to_string(),
94 ));
95 }
96 self._sandboxed_execute(module_id, input_data).await
97 }
98
99 async fn _sandboxed_execute(
100 &self,
101 module_id: &str,
102 input_data: Value,
103 ) -> Result<Value, ModuleExecutionError> {
104 use std::process::Stdio;
105 use tokio::io::AsyncWriteExt;
106 use tokio::process::Command;
107 use tokio::time::{timeout, Duration};
108
109 let mut env: Vec<(String, String)> = Vec::new();
111 let host_env: std::collections::HashMap<String, String> = std::env::vars().collect();
112
113 for key in SANDBOX_ALLOWED_ENV_KEYS {
114 if let Some(val) = host_env.get(*key) {
115 env.push((key.to_string(), val.clone()));
116 }
117 }
118 for (k, v) in &host_env {
119 if SANDBOX_ALLOWED_ENV_PREFIXES
120 .iter()
121 .any(|prefix| k.starts_with(prefix))
122 {
123 env.push((k.clone(), v.clone()));
124 }
125 }
126
127 let tmpdir = tempfile::TempDir::new()
129 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
130 let tmpdir_path = tmpdir.path().to_string_lossy().to_string();
131 env.push(("HOME".to_string(), tmpdir_path.clone()));
132 env.push(("TMPDIR".to_string(), tmpdir_path.clone()));
133
134 let input_json = serde_json::to_string(&input_data)
136 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
137
138 let binary = std::env::current_exe()
140 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
141
142 let mut child = Command::new(&binary)
143 .arg("--internal-sandbox-runner")
144 .arg(module_id)
145 .stdin(Stdio::piped())
146 .stdout(Stdio::piped())
147 .stderr(Stdio::piped())
148 .env_clear()
149 .envs(env)
150 .current_dir(&tmpdir_path)
151 .spawn()
152 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
153
154 if let Some(mut stdin) = child.stdin.take() {
156 stdin
157 .write_all(input_json.as_bytes())
158 .await
159 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
160 }
161
162 let timeout_dur = if self.timeout_ms > 0 {
164 Duration::from_millis(self.timeout_ms)
165 } else {
166 Duration::from_secs(300)
167 };
168
169 let output = timeout(timeout_dur, child.wait_with_output())
170 .await
171 .map_err(|_| ModuleExecutionError::Timeout {
172 module_id: module_id.to_string(),
173 timeout_ms: self.timeout_ms,
174 })?
175 .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
176
177 if !output.status.success() {
178 let exit_code = output.status.code().unwrap_or(-1);
179 return Err(ModuleExecutionError::NonZeroExit {
180 module_id: module_id.to_string(),
181 exit_code,
182 });
183 }
184
185 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
186 crate::_sandbox_runner::decode_result(&stdout).map_err(|e| {
187 ModuleExecutionError::OutputParseFailed {
188 module_id: module_id.to_string(),
189 reason: e.to_string(),
190 }
191 })
192 }
193}
194
195#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 #[tokio::test]
205 async fn test_sandbox_disabled_returns_passthrough_error() {
206 let sandbox = Sandbox::new(false, 5_000);
211 let result = sandbox.execute("test.module", json!({})).await;
212 assert!(!matches!(result, Err(ModuleExecutionError::Timeout { .. })));
213 match result {
216 Err(ModuleExecutionError::SpawnFailed(msg)) => {
217 assert!(
218 msg.contains("not wired"),
219 "expected 'not wired' message, got: {msg}"
220 );
221 }
222 other => panic!("expected SpawnFailed(not wired), got: {other:?}"),
223 }
224 }
225
226 #[tokio::test]
227 async fn test_sandbox_timeout_returns_error() {
228 let sandbox = Sandbox::new(true, 1); let result = sandbox.execute("__noop__", json!({})).await;
232 assert!(result.is_err());
233 }
234
235 #[test]
236 fn test_decode_result_valid_json() {
237 use crate::_sandbox_runner::decode_result;
238 let v = decode_result(r#"{"ok":true}"#).unwrap();
239 assert_eq!(v["ok"], true);
240 }
241
242 #[test]
243 fn test_decode_result_invalid_json() {
244 use crate::_sandbox_runner::decode_result;
245 assert!(decode_result("not json").is_err());
246 }
247
248 #[test]
249 fn test_encode_result_roundtrip() {
250 use crate::_sandbox_runner::{decode_result, encode_result};
251 let v = json!({"result": 42});
252 let encoded = encode_result(&v);
253 let decoded = decode_result(&encoded).unwrap();
254 assert_eq!(decoded["result"], 42);
255 }
256}