Skip to main content

lc_tools/sandbox/
local.rs

1// lc-tools/src/sandbox/local.rs
2//! Local process sandbox backend.
3
4use std::time::Instant;
5
6use async_trait::async_trait;
7use tokio::process::Command;
8
9use super::{CodeSandbox, Language, RunResult, SandboxError};
10
11/// Dangerous Python modules matched by a noise-filter substring blacklist.
12///
13/// Note: this is a *noise filter*, **not a security boundary** — a substring scan is
14/// trivially bypassed (`__import__("o" + "s")`, `import os;` variants, exec, ...).
15/// Untrusted code must not be run through [`LocalSandbox`] at all unless the process
16/// itself sits inside a real sandbox (container / VM / WASM).
17const BLOCKED_PYTHON_IMPORTS: &[&str] = &[
18    "os",
19    "subprocess",
20    "sys",
21    "shutil",
22    "signal",
23    "ctypes",
24    "multiprocessing",
25    "socket",
26    "http.server",
27    "xmlrpc",
28    "pickle",
29    "shelve",
30    "importlib",
31    "code",
32    "codeop",
33    "compileall",
34    "pty",
35    "commands",
36    "pdb",
37    "webbrowser",
38];
39
40/// Check if Python code contains dangerous imports.
41fn contains_dangerous_python_import(code: &str) -> Option<String> {
42    for line in code.lines() {
43        let trimmed = line.trim();
44        if trimmed.starts_with('#') {
45            continue;
46        }
47        let code_part = trimmed.split('#').next().unwrap_or(trimmed);
48        if code_part.contains("import") {
49            for blocked in BLOCKED_PYTHON_IMPORTS {
50                if code_part.contains(&format!("import {}", blocked))
51                    || code_part.contains(&format!("from {} ", blocked))
52                    || code_part.contains(&format!("from {}.", blocked))
53                    || code_part.contains(&format!("from {}import", blocked))
54                {
55                    return Some(blocked.to_string());
56                }
57            }
58        }
59    }
60    None
61}
62
63/// Local process sandbox using `tokio::process::Command`.
64pub struct LocalSandbox {
65    python_path: String,
66    node_path: String,
67}
68
69impl LocalSandbox {
70    /// Create a new local sandbox with auto-detected interpreter paths.
71    pub fn new() -> Self {
72        Self {
73            python_path: Self::find_python(),
74            node_path: "node".to_string(),
75        }
76    }
77
78    /// Use a custom Python interpreter path.
79    pub fn with_python_path(mut self, path: impl Into<String>) -> Self {
80        self.python_path = path.into();
81        self
82    }
83
84    /// Use a custom Node.js runtime path.
85    pub fn with_node_path(mut self, path: impl Into<String>) -> Self {
86        self.node_path = path.into();
87        self
88    }
89
90    /// Auto-detect the Python interpreter on the system.
91    fn find_python() -> String {
92        for candidate in &["python3", "python"] {
93            if std::process::Command::new(candidate)
94                .arg("--version")
95                .output()
96                .is_ok()
97            {
98                return candidate.to_string();
99            }
100        }
101        "python3".to_string()
102    }
103
104    /// Build the command for the given language and code.
105    fn build_command(&self, code: &str, language: Language) -> Result<Command, SandboxError> {
106        match language {
107            Language::Python => {
108                if let Some(blocked) = contains_dangerous_python_import(code) {
109                    return Err(SandboxError::Runtime(format!(
110                        "Code contains dangerous import: '{}'. \
111                         Blocked by the noise-filter blacklist (note: this is not a \
112                         security boundary; untrusted code must run in a real sandbox).",
113                        blocked
114                    )));
115                }
116                let mut cmd = Command::new(&self.python_path);
117                cmd.arg("-c").arg(code);
118                Ok(cmd)
119            }
120            Language::JavaScript => {
121                let mut cmd = Command::new(&self.node_path);
122                cmd.arg("-e").arg(code);
123                Ok(cmd)
124            }
125            Language::Rust => Err(SandboxError::UnsupportedLanguage(
126                "Rust compilation is not supported by LocalSandbox".to_string(),
127            )),
128        }
129    }
130}
131
132impl Default for LocalSandbox {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[async_trait]
139impl CodeSandbox for LocalSandbox {
140    async fn run(
141        &self,
142        code: &str,
143        language: Language,
144        timeout_ms: u64,
145    ) -> Result<RunResult, SandboxError> {
146        let mut cmd = self.build_command(code, language)?;
147
148        let start = Instant::now();
149
150        let result =
151            tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output())
152                .await
153                .map_err(|_| SandboxError::Timeout(timeout_ms))?
154                .map_err(|e| {
155                    SandboxError::Runtime(format!("failed to execute subprocess: {}", e))
156                })?;
157
158        let execution_time_ms = start.elapsed().as_millis() as u64;
159
160        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
161        let stderr = String::from_utf8_lossy(&result.stderr).to_string();
162        let exit_code = result.status.code().unwrap_or(-1);
163
164        Ok(RunResult {
165            stdout,
166            stderr,
167            exit_code,
168            execution_time_ms,
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_local_sandbox_default() {
179        let sandbox = LocalSandbox::default();
180        assert!(!sandbox.python_path.is_empty());
181        assert_eq!(sandbox.node_path, "node");
182    }
183
184    #[test]
185    fn test_local_sandbox_custom_paths() {
186        let sandbox = LocalSandbox::new()
187            .with_python_path("/usr/bin/python3.11")
188            .with_node_path("/usr/local/bin/node");
189        assert_eq!(sandbox.python_path, "/usr/bin/python3.11");
190        assert_eq!(sandbox.node_path, "/usr/local/bin/node");
191    }
192
193    #[test]
194    fn test_rust_unsupported() {
195        let sandbox = LocalSandbox::new();
196        let result = sandbox.build_command("fn main(){}", Language::Rust);
197        assert!(result.is_err());
198        match result.unwrap_err() {
199            SandboxError::UnsupportedLanguage(msg) => {
200                assert!(msg.contains("Rust"));
201            }
202            other => panic!("expected UnsupportedLanguage, got: {:?}", other),
203        }
204    }
205
206    #[test]
207    fn test_dangerous_python_import_detection() {
208        assert!(contains_dangerous_python_import("import os").is_some());
209        assert!(contains_dangerous_python_import("from sys import path").is_some());
210        assert!(contains_dangerous_python_import("import subprocess").is_some());
211        assert!(contains_dangerous_python_import("import math").is_none());
212        assert!(contains_dangerous_python_import("import json").is_none());
213        assert!(contains_dangerous_python_import("from datetime import datetime").is_none());
214        assert!(contains_dangerous_python_import("# import os").is_none());
215    }
216
217    #[tokio::test]
218    async fn test_python_dangerous_import_blocked() {
219        let sandbox = LocalSandbox::new();
220        let result = sandbox
221            .run("import os; print(os.getcwd())", Language::Python, 10_000)
222            .await;
223        assert!(result.is_err());
224        let err = result.unwrap_err().to_string();
225        assert!(
226            err.contains("dangerous import"),
227            "Expected dangerous import error, got: {}",
228            err
229        );
230    }
231
232    #[tokio::test]
233    async fn test_python_execution_if_available() {
234        let sandbox = LocalSandbox::new();
235        let result = sandbox.run("print(1 + 2)", Language::Python, 10_000).await;
236
237        match result {
238            Ok(run_result) => {
239                if run_result.exit_code == 0 {
240                    assert!(
241                        run_result.stdout.trim() == "3",
242                        "expected '3', got '{}'",
243                        run_result.stdout.trim()
244                    );
245                }
246            }
247            Err(SandboxError::Runtime(msg)) => {
248                eprintln!("Python not available (expected in some CI): {}", msg);
249            }
250            Err(other) => panic!("unexpected error: {:?}", other),
251        }
252    }
253
254    #[tokio::test]
255    async fn test_javascript_execution_if_available() {
256        let sandbox = LocalSandbox::new();
257        let result = sandbox
258            .run("console.log(1 + 2)", Language::JavaScript, 10_000)
259            .await;
260
261        match result {
262            Ok(run_result) => {
263                if run_result.exit_code == 0 {
264                    assert!(
265                        run_result.stdout.trim() == "3",
266                        "expected '3', got '{}'",
267                        run_result.stdout.trim()
268                    );
269                }
270            }
271            Err(SandboxError::Runtime(msg)) => {
272                eprintln!("Node.js not available (expected in some CI): {}", msg);
273            }
274            Err(other) => panic!("unexpected error: {:?}", other),
275        }
276    }
277
278    #[tokio::test]
279    async fn test_rust_execution_unsupported() {
280        let sandbox = LocalSandbox::new();
281        let result = sandbox.run("fn main(){}", Language::Rust, 10_000).await;
282        assert!(result.is_err());
283        match result.unwrap_err() {
284            SandboxError::UnsupportedLanguage(_) => {}
285            other => panic!("expected UnsupportedLanguage, got: {:?}", other),
286        }
287    }
288
289    #[tokio::test]
290    async fn test_execution_timeout() {
291        let sandbox = LocalSandbox::new();
292        let result = sandbox
293            .run("import time; time.sleep(10)", Language::Python, 100)
294            .await;
295
296        match result {
297            Err(SandboxError::Timeout(ms)) => {
298                assert_eq!(ms, 100);
299            }
300            Ok(_) => {
301                // Python not available, code didn't run — acceptable
302            }
303            Err(other) => panic!("expected Timeout, got: {:?}", other),
304        }
305    }
306
307    #[tokio::test]
308    async fn test_execution_time_is_recorded() {
309        let sandbox = LocalSandbox::new();
310        let result = sandbox.run("print('hi')", Language::Python, 10_000).await;
311
312        if let Ok(run_result) = result {
313            assert!(run_result.execution_time_ms < 10_000);
314        }
315    }
316
317    #[tokio::test]
318    async fn test_stderr_captured() {
319        let sandbox = LocalSandbox::new();
320        let result = sandbox
321            .run(
322                "import sys; print('error', file=sys.stderr)",
323                Language::Python,
324                10_000,
325            )
326            .await;
327
328        if let Ok(run_result) = result {
329            if run_result.exit_code == 0 {
330                assert!(
331                    run_result.stderr.contains("error"),
332                    "stderr should contain 'error', got: '{}'",
333                    run_result.stderr
334                );
335            }
336        }
337    }
338}