Skip to main content

apcore_cli/security/
sandbox.rs

1// apcore-cli — Subprocess sandbox for module execution.
2// Protocol spec: SEC-04 (Sandbox, ModuleExecutionError)
3
4use serde_json::Value;
5use thiserror::Error;
6
7// ---------------------------------------------------------------------------
8// Constants
9// ---------------------------------------------------------------------------
10
11/// Environment variable prefixes allowed through the sandbox env whitelist.
12const SANDBOX_ALLOWED_ENV_PREFIXES: &[&str] = &["APCORE_"];
13
14/// Exact environment variable names allowed through the sandbox env whitelist.
15const SANDBOX_ALLOWED_ENV_KEYS: &[&str] = &["PATH", "LANG", "LC_ALL"];
16
17// ---------------------------------------------------------------------------
18// ModuleExecutionError
19// ---------------------------------------------------------------------------
20
21/// Errors produced during sandboxed module execution.
22#[derive(Debug, Error)]
23pub enum ModuleExecutionError {
24    /// The subprocess exited with a non-zero exit code.
25    #[error("module '{module_id}' exited with code {exit_code}")]
26    NonZeroExit { module_id: String, exit_code: i32 },
27
28    /// The subprocess timed out.
29    #[error("module '{module_id}' timed out after {timeout_ms}ms")]
30    Timeout { module_id: String, timeout_ms: u64 },
31
32    /// The subprocess output could not be parsed.
33    #[error("failed to parse sandbox output for module '{module_id}': {reason}")]
34    OutputParseFailed { module_id: String, reason: String },
35
36    /// Failed to spawn the sandbox subprocess.
37    #[error("failed to spawn sandbox process: {0}")]
38    SpawnFailed(String),
39}
40
41// ---------------------------------------------------------------------------
42// Sandbox
43// ---------------------------------------------------------------------------
44
45/// Executes modules in an isolated subprocess for security isolation.
46///
47/// When `enabled` is `false`, execution is performed in-process (no sandbox).
48/// When `enabled` is `true`, a child process running `_sandbox_runner` handles
49/// the execution and communicates results via JSON over stdin/stdout.
50pub struct Sandbox {
51    enabled: bool,
52    timeout_ms: u64,
53}
54
55impl Sandbox {
56    /// Create a new `Sandbox`.
57    ///
58    /// # Arguments
59    /// * `enabled`    — enable subprocess isolation
60    /// * `timeout_ms` — subprocess timeout in milliseconds (0 = use default 300 s)
61    pub fn new(enabled: bool, timeout_ms: u64) -> Self {
62        Self {
63            enabled,
64            timeout_ms,
65        }
66    }
67
68    /// Return `true` when subprocess isolation is enabled.
69    pub fn is_enabled(&self) -> bool {
70        self.enabled
71    }
72
73    /// Execute a module, optionally in an isolated subprocess.
74    ///
75    /// # Arguments
76    /// * `module_id`  — identifier of the module to execute
77    /// * `input_data` — JSON input for the module
78    ///
79    /// Returns the module output as a `serde_json::Value`.
80    ///
81    /// # Errors
82    /// Returns `ModuleExecutionError` on timeout, non-zero exit, or parse failure.
83    pub async fn execute(
84        &self,
85        module_id: &str,
86        input_data: Value,
87    ) -> Result<Value, ModuleExecutionError> {
88        if !self.enabled {
89            // In-process execution — caller is responsible for wiring the executor.
90            // Real wiring happens in the integration task when Sandbox is connected
91            // to the Executor via a callback or trait object.
92            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        // Build restricted environment from whitelist.
110        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        // Create temp dir for HOME/TMPDIR isolation.
128        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        // Serialise input.
135        let input_json = serde_json::to_string(&input_data)
136            .map_err(|e| ModuleExecutionError::SpawnFailed(e.to_string()))?;
137
138        // Locate current binary.
139        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        // Write input to stdin.
155        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        // Await with timeout.
163        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// ---------------------------------------------------------------------------
196// Unit tests
197// ---------------------------------------------------------------------------
198
199#[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        // When disabled, execute() must NOT spawn a subprocess.
207        // Verify the sandbox disabled path does NOT return Timeout or SpawnFailed
208        // with a spawn-related OS error — it returns SpawnFailed with the
209        // "not wired" message (no subprocess involved).
210        let sandbox = Sandbox::new(false, 5_000);
211        let result = sandbox.execute("test.module", json!({})).await;
212        assert!(!matches!(result, Err(ModuleExecutionError::Timeout { .. })));
213        // The disabled path returns SpawnFailed("in-process executor not wired …")
214        // which is NOT a real spawn attempt, so confirm it IS that specific variant.
215        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        // Use a 1 ms timeout — spawn a real subprocess that will time out.
229        // Either timeout or spawn-failed (binary not yet wired) — accept both.
230        let sandbox = Sandbox::new(true, 1); // 1 ms timeout
231        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}