Skip to main content

lc_tools/sandbox/
local.rs

1// lc-tools/src/sandbox/local.rs
2//! Local process sandbox backend.
3
4use std::sync::LazyLock;
5use std::time::Instant;
6
7use async_trait::async_trait;
8use regex::Regex;
9use tokio::process::Command;
10
11use super::{CodeSandbox, Language, RunResult, SandboxError};
12
13/// Dangerous Python modules matched by a noise-filter substring blacklist.
14///
15/// Note: this is a *noise filter*, **not a security boundary** — a substring scan is
16/// trivially bypassed (`__import__("o" + "s")`, `import os;` variants, exec, ...).
17/// Untrusted code must not be run through [`LocalSandbox`] at all unless the process
18/// itself sits inside a real sandbox (container / VM / WASM).
19///
20/// The JS branch carries an equivalent noise filter ([`BLOCKED_JS_MODULES`] +
21/// [`BLOCKED_JS_CALL_REGEX`], H1) so it is not left completely open where Python has a guard.
22const BLOCKED_PYTHON_IMPORTS: &[&str] = &[
23    "os",
24    "subprocess",
25    "sys",
26    "shutil",
27    "signal",
28    "ctypes",
29    "multiprocessing",
30    "socket",
31    "http.server",
32    "xmlrpc",
33    "pickle",
34    "shelve",
35    "importlib",
36    "code",
37    "codeop",
38    "compileall",
39    "pty",
40    "commands",
41    "pdb",
42    "webbrowser",
43];
44
45/// Check if Python code contains dangerous imports.
46fn contains_dangerous_python_import(code: &str) -> Option<String> {
47    for line in code.lines() {
48        let trimmed = line.trim();
49        if trimmed.starts_with('#') {
50            continue;
51        }
52        let code_part = trimmed.split('#').next().unwrap_or(trimmed);
53        if code_part.contains("import") {
54            for blocked in BLOCKED_PYTHON_IMPORTS {
55                if code_part.contains(&format!("import {}", blocked))
56                    || code_part.contains(&format!("from {} ", blocked))
57                    || code_part.contains(&format!("from {}.", blocked))
58                    || code_part.contains(&format!("from {}import", blocked))
59                {
60                    return Some(blocked.to_string());
61                }
62            }
63        }
64    }
65    None
66}
67
68/// Dangerous Node.js modules for the JS branch, mirroring [`BLOCKED_PYTHON_IMPORTS`].
69///
70/// H1 (0.25.0): the JS branch previously executed `node -e <code>` with *no* guard while Python
71/// had at least the noise-filter blacklist — strictly worse, and it made the "sandbox" name even
72/// more misleading for a local raw process. This is the same **noise filter, not a security
73/// boundary**: a substring scan is trivially bypassed (`require('child'+'process')`,
74/// `globalThis['fs']`, unicode obfuscation...). Untrusted code must not run through
75/// [`LocalSandbox`] unless the process itself sits inside a real sandbox (container / VM / WASM).
76const BLOCKED_JS_MODULES: &[&str] = &[
77    "fs",
78    "child_process",
79    "net",
80    "dgram",
81    "tls",
82    "http",
83    "https",
84    "http2",
85    "worker_threads",
86    "vm",
87    "cluster",
88    "repl",
89    "readline",
90    "os",
91];
92
93/// Common dangerous Node.js **calls** not gated by an import: `process.env`/`process.exit`, the
94/// `eval`/`Function` constructors, `globalThis` dynamic reach, and `spawn`/`exec` (which a
95/// `node -e` one-liner can also reach via `child_process`, already blocked above). These are the
96/// JS analogues of [`BLOCKED_PYTHON_IMPORTS`]/builtin calls — word-boundary guarded so ordinary
97/// identifiers like `evaluate(`/`spawner(` do not false-positive.
98static BLOCKED_JS_CALL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
99    Regex::new(r"\b(?:process\.|eval\s*\(|Function\s*\(|globalThis|spawn\s*\(|exec\s*\()")
100        .expect("static JS regex literal must compile")
101});
102
103/// Check if JavaScript code reaches dangerous Node.js APIs (noise-filter, see above).
104fn contains_dangerous_javascript(code: &str) -> Option<String> {
105    for line in code.lines() {
106        let trimmed = line.trim();
107        if trimmed.starts_with("//") {
108            continue;
109        }
110        let code_part = trimmed.split("//").next().unwrap_or(trimmed);
111        // require('<module>') / require("<module>") for the blocked modules.
112        for m in BLOCKED_JS_MODULES {
113            if code_part.contains(&format!("require('{}')", m))
114                || code_part.contains(&format!("require(\"{}\")", m))
115            {
116                return Some(format!("require('{}')", m));
117            }
118        }
119        // statement-level dangerous access: process.* / eval( / Function( / globalThis / spawn / exec.
120        if let Some(m) = BLOCKED_JS_CALL_REGEX.find(code_part) {
121            return Some(m.as_str().to_string());
122        }
123    }
124    None
125}
126
127/// Local process sandbox using `tokio::process::Command`.
128pub struct LocalSandbox {
129    python_path: String,
130    node_path: String,
131}
132
133impl LocalSandbox {
134    /// Create a new local sandbox with auto-detected interpreter paths.
135    pub fn new() -> Self {
136        Self {
137            python_path: Self::find_python(),
138            node_path: "node".to_string(),
139        }
140    }
141
142    /// Use a custom Python interpreter path.
143    pub fn with_python_path(mut self, path: impl Into<String>) -> Self {
144        self.python_path = path.into();
145        self
146    }
147
148    /// Use a custom Node.js runtime path.
149    pub fn with_node_path(mut self, path: impl Into<String>) -> Self {
150        self.node_path = path.into();
151        self
152    }
153
154    /// Auto-detect the Python interpreter on the system.
155    fn find_python() -> String {
156        for candidate in &["python3", "python"] {
157            if std::process::Command::new(candidate)
158                .arg("--version")
159                .output()
160                .is_ok()
161            {
162                return candidate.to_string();
163            }
164        }
165        "python3".to_string()
166    }
167
168    /// Build the command for the given language and code.
169    fn build_command(&self, code: &str, language: Language) -> Result<Command, SandboxError> {
170        match language {
171            Language::Python => {
172                if let Some(blocked) = contains_dangerous_python_import(code) {
173                    return Err(SandboxError::Runtime(format!(
174                        "Code contains dangerous import: '{}'. \
175                         Blocked by the noise-filter blacklist (note: this is not a \
176                         security boundary; untrusted code must run in a real sandbox).",
177                        blocked
178                    )));
179                }
180                let mut cmd = Command::new(&self.python_path);
181                cmd.arg("-c").arg(code);
182                Ok(cmd)
183            }
184            Language::JavaScript => {
185                if let Some(dangerous) = contains_dangerous_javascript(code) {
186                    return Err(SandboxError::Runtime(format!(
187                        "Code contains dangerous Node.js API: '{}'. \
188                         Blocked by the noise-filter blacklist (note: this is not a \
189                         security boundary; untrusted code must run in a real sandbox).",
190                        dangerous
191                    )));
192                }
193                let mut cmd = Command::new(&self.node_path);
194                cmd.arg("-e").arg(code);
195                Ok(cmd)
196            }
197            Language::Rust => Err(SandboxError::UnsupportedLanguage(
198                "Rust compilation is not supported by LocalSandbox".to_string(),
199            )),
200        }
201    }
202}
203
204impl Default for LocalSandbox {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210#[async_trait]
211impl CodeSandbox for LocalSandbox {
212    async fn run(
213        &self,
214        code: &str,
215        language: Language,
216        timeout_ms: u64,
217    ) -> Result<RunResult, SandboxError> {
218        let mut cmd = self.build_command(code, language)?;
219
220        let start = Instant::now();
221
222        let result =
223            tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output())
224                .await
225                .map_err(|_| SandboxError::Timeout(timeout_ms))?
226                .map_err(|e| {
227                    SandboxError::Runtime(format!("failed to execute subprocess: {}", e))
228                })?;
229
230        let execution_time_ms = start.elapsed().as_millis() as u64;
231
232        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
233        let stderr = String::from_utf8_lossy(&result.stderr).to_string();
234        let exit_code = result.status.code().unwrap_or(-1);
235
236        Ok(RunResult {
237            stdout,
238            stderr,
239            exit_code,
240            execution_time_ms,
241        })
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_local_sandbox_default() {
251        let sandbox = LocalSandbox::default();
252        assert!(!sandbox.python_path.is_empty());
253        assert_eq!(sandbox.node_path, "node");
254    }
255
256    #[test]
257    fn test_local_sandbox_custom_paths() {
258        let sandbox = LocalSandbox::new()
259            .with_python_path("/usr/bin/python3.11")
260            .with_node_path("/usr/local/bin/node");
261        assert_eq!(sandbox.python_path, "/usr/bin/python3.11");
262        assert_eq!(sandbox.node_path, "/usr/local/bin/node");
263    }
264
265    #[test]
266    fn test_rust_unsupported() {
267        let sandbox = LocalSandbox::new();
268        let result = sandbox.build_command("fn main(){}", Language::Rust);
269        assert!(result.is_err());
270        match result.unwrap_err() {
271            SandboxError::UnsupportedLanguage(msg) => {
272                assert!(msg.contains("Rust"));
273            }
274            other => panic!("expected UnsupportedLanguage, got: {:?}", other),
275        }
276    }
277
278    #[test]
279    fn test_dangerous_python_import_detection() {
280        assert!(contains_dangerous_python_import("import os").is_some());
281        assert!(contains_dangerous_python_import("from sys import path").is_some());
282        assert!(contains_dangerous_python_import("import subprocess").is_some());
283        assert!(contains_dangerous_python_import("import math").is_none());
284        assert!(contains_dangerous_python_import("import json").is_none());
285        assert!(contains_dangerous_python_import("from datetime import datetime").is_none());
286        assert!(contains_dangerous_python_import("# import os").is_none());
287    }
288
289    #[test]
290    fn test_dangerous_javascript_detection() {
291        // module requires (H1)
292        assert!(contains_dangerous_javascript("require('fs').readFileSync('/etc/passwd')").is_some());
293        assert!(contains_dangerous_javascript("require(\"child_process\")").is_some());
294        assert!(contains_dangerous_javascript("const net = require('net')").is_some());
295        // statement-level dangerous calls
296        assert!(contains_dangerous_javascript("process.env").is_some());
297        assert!(contains_dangerous_javascript("eval('console.log(1)')").is_some());
298        assert!(contains_dangerous_javascript("new Function('x', 'return x')").is_some());
299        assert!(contains_dangerous_javascript("globalThis.foo = 1").is_some());
300        // benign code and comment lines pass
301        assert!(contains_dangerous_javascript("console.log(1 + 2)").is_none());
302        assert!(contains_dangerous_javascript("JSON.parse(x).map(v => v * 2)").is_none());
303        assert!(contains_dangerous_javascript("// require('fs')").is_none());
304        assert!(contains_dangerous_javascript("evaluate(fn).execute()").is_none());
305    }
306
307    #[tokio::test]
308    async fn test_javascript_dangerous_api_blocked() {
309        let sandbox = LocalSandbox::new();
310        let result = sandbox
311            .run(
312                "require('fs').readFileSync('/etc/passwd')",
313                Language::JavaScript,
314                10_000,
315            )
316            .await;
317        assert!(result.is_err());
318        let err = result.unwrap_err().to_string();
319        assert!(
320            err.contains("dangerous Node.js API"),
321            "Expected dangerous Node.js API error, got: {}",
322            err
323        );
324    }
325
326    #[tokio::test]
327    async fn test_python_dangerous_import_blocked() {
328        let sandbox = LocalSandbox::new();
329        let result = sandbox
330            .run("import os; print(os.getcwd())", Language::Python, 10_000)
331            .await;
332        assert!(result.is_err());
333        let err = result.unwrap_err().to_string();
334        assert!(
335            err.contains("dangerous import"),
336            "Expected dangerous import error, got: {}",
337            err
338        );
339    }
340
341    #[tokio::test]
342    async fn test_python_execution_if_available() {
343        let sandbox = LocalSandbox::new();
344        let result = sandbox.run("print(1 + 2)", Language::Python, 10_000).await;
345
346        match result {
347            Ok(run_result) => {
348                if run_result.exit_code == 0 {
349                    assert!(
350                        run_result.stdout.trim() == "3",
351                        "expected '3', got '{}'",
352                        run_result.stdout.trim()
353                    );
354                }
355            }
356            Err(SandboxError::Runtime(msg)) => {
357                eprintln!("Python not available (expected in some CI): {}", msg);
358            }
359            Err(other) => panic!("unexpected error: {:?}", other),
360        }
361    }
362
363    #[tokio::test]
364    async fn test_javascript_execution_if_available() {
365        let sandbox = LocalSandbox::new();
366        let result = sandbox
367            .run("console.log(1 + 2)", Language::JavaScript, 10_000)
368            .await;
369
370        match result {
371            Ok(run_result) => {
372                if run_result.exit_code == 0 {
373                    assert!(
374                        run_result.stdout.trim() == "3",
375                        "expected '3', got '{}'",
376                        run_result.stdout.trim()
377                    );
378                }
379            }
380            Err(SandboxError::Runtime(msg)) => {
381                eprintln!("Node.js not available (expected in some CI): {}", msg);
382            }
383            // Warm Windows runners occasionally let `node -e` blow the 10s
384            // budget (Defender scan / host load); this is an "if available"
385            // smoke test, so treat an environmental timeout like no runtime
386            // rather than a code regression (observed 2026-09-14, passed on
387            // the same code the two previous runs).
388            Err(SandboxError::Timeout(ms)) => {
389                eprintln!("Node.js present but too slow in this CI environment ({ms}ms)");
390            }
391            Err(other) => panic!("unexpected error: {:?}", other),
392        }
393    }
394
395    #[tokio::test]
396    async fn test_rust_execution_unsupported() {
397        let sandbox = LocalSandbox::new();
398        let result = sandbox.run("fn main(){}", Language::Rust, 10_000).await;
399        assert!(result.is_err());
400        match result.unwrap_err() {
401            SandboxError::UnsupportedLanguage(_) => {}
402            other => panic!("expected UnsupportedLanguage, got: {:?}", other),
403        }
404    }
405
406    #[tokio::test]
407    async fn test_execution_timeout() {
408        let sandbox = LocalSandbox::new();
409        let result = sandbox
410            .run("import time; time.sleep(10)", Language::Python, 100)
411            .await;
412
413        match result {
414            Err(SandboxError::Timeout(ms)) => {
415                assert_eq!(ms, 100);
416            }
417            Ok(_) => {
418                // Python not available, code didn't run — acceptable
419            }
420            Err(other) => panic!("expected Timeout, got: {:?}", other),
421        }
422    }
423
424    #[tokio::test]
425    async fn test_execution_time_is_recorded() {
426        let sandbox = LocalSandbox::new();
427        let result = sandbox.run("print('hi')", Language::Python, 10_000).await;
428
429        if let Ok(run_result) = result {
430            assert!(run_result.execution_time_ms < 10_000);
431        }
432    }
433
434    #[tokio::test]
435    async fn test_stderr_captured() {
436        let sandbox = LocalSandbox::new();
437        let result = sandbox
438            .run(
439                "import sys; print('error', file=sys.stderr)",
440                Language::Python,
441                10_000,
442            )
443            .await;
444
445        if let Ok(run_result) = result {
446            if run_result.exit_code == 0 {
447                assert!(
448                    run_result.stderr.contains("error"),
449                    "stderr should contain 'error', got: '{}'",
450                    run_result.stderr
451                );
452            }
453        }
454    }
455}