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