Skip to main content

lc_tools/
python_repl.rs

1// lc-tools/src/python_repl.rs
2//! Python code execution tool
3//!
4//! Invokes the system Python interpreter to run code and return the result.
5//! Python must be installed on the system.
6//!
7//! # Security warning
8//! This tool is **disabled** by default; call `with_dangerously_allow(true)` to enable it explicitly.
9//! Once enabled it executes arbitrary Python code and should only be used in controlled/sandboxed environments.
10//!
11//! The built-in "dangerous import blacklist" is only **noise filtering, not a security boundary** —
12//! it cannot stop encoding obfuscation such as `__import__`/`eval`/`exec`/string concatenation,
13//! and it also has false positives on string literals. `open(` is filtered but is *not* a real
14//! guarantee against file access (obfuscation can still reach it); real isolation must go through
15//! the sandbox ([`crate::sandbox`]). The blacklist only reduces noise reaching it.
16
17use async_trait::async_trait;
18use regex::Regex;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use tokio::process::Command;
22
23use lc_core::tools::{BaseTool, Tool, ToolError};
24
25/// Python REPL tool input
26#[derive(Debug, Deserialize, JsonSchema)]
27pub struct PythonREPLInput {
28    /// The Python code to execute
29    pub code: String,
30    /// Timeout in seconds (default: 30)
31    pub timeout_seconds: Option<u64>,
32}
33
34/// Python REPL tool output
35#[derive(Debug, Serialize)]
36pub struct PythonREPLOutput {
37    /// The code that was executed
38    pub code: String,
39    /// Standard output
40    pub stdout: String,
41    /// Standard error
42    pub stderr: String,
43    /// Exit code
44    pub exit_code: i32,
45}
46
47/// Dangerous Python modules that are blocked for security.
48const BLOCKED_IMPORTS: &[&str] = &[
49    "os",
50    "subprocess",
51    "sys",
52    "shutil",
53    "signal",
54    "ctypes",
55    "multiprocessing",
56    "socket",
57    "http.server",
58    "xmlrpc",
59    "pickle",
60    "shelve",
61    "importlib",
62    "code",
63    "codeop",
64    "compileall",
65    "pty",
66    "commands",
67    "pdb",
68    "webbrowser",
69    "antigravity",
70];
71
72/// Common dangerous builtin calls that bypass the import blacklist (word boundary + function-call form).
73///
74/// `open` / `breakpoint` / `input` are added because they are file/interactive escape
75/// vectors that no import statement is required for: `open('/etc/shadow').read()` reads
76/// any file a data-processing run in practice relies on `open` for, so enabling this
77/// check trades a few legitimate `open(...)` file reads (work around them with
78/// `with_skip_dangerous_imports_check(true)` or a sandbox) for closing the file-access
79/// hole entirely.
80const DANGEROUS_BUILTIN_CALLS: &[&str] = &[
81    "__import__",
82    "import_module",
83    "eval",
84    "exec",
85    "execfile",
86    "compile",
87    "open",
88    "breakpoint",
89    "input",
90];
91
92/// Matches dangerous calls like `__import__(` / `import_module(` / `eval(` / `exec(` / `compile(` /
93/// `open(` / `breakpoint(` / `input(`.
94///
95/// `\b` prevents false positives on ordinary words that contain these as substrings, such as
96/// `evaluate(` / `execute(` / `length(`; however it still matches the literal text `"eval(...)"`
97/// inside string literals — an inherent limitation of string-level interception,
98/// see the security-positioning note on [`contains_dangerous_code`].
99static DANGEROUS_CALL_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
100    // INVARIANT: DANGEROUS_BUILTIN_CALLS 全是字母/下划线常量标识符,join("|")
101    // 不可能引入正则元字符,模板其余部分是静态字面量,编译必然成功。
102    Regex::new(&format!(
103        r"\b(?:{})\s*\(",
104        DANGEROUS_BUILTIN_CALLS.join("|")
105    ))
106    .expect("pattern built from word-only const list must compile")
107});
108
109/// Check if Python code contains dangerous imports or builtin calls.
110///
111/// **Security positioning**: this is a noise-filter layer, **not a security boundary**.
112/// Line-by-line substring/regex matching can always be bypassed by unicode obfuscation,
113/// `"o"+"s"` concatenation, `().__class__` reflection, etc., and it also has false
114/// positives on string literals. Untrusted code must go through the sandbox ([`crate::sandbox`]).
115fn contains_dangerous_code(code: &str) -> Option<String> {
116    for line in code.lines() {
117        let trimmed = line.trim();
118        if trimmed.starts_with('#') {
119            continue;
120        }
121        // Strip the inline comment (the part after `#`) so comment content does not cause false positives.
122        let code_part = trimmed.split('#').next().unwrap_or(trimmed);
123
124        // 1) Dangerous import check (BLOCKED_IMPORTS)
125        if code_part.contains("import") {
126            for blocked in BLOCKED_IMPORTS {
127                if code_part.contains(&format!("import {}", blocked))
128                    || code_part.contains(&format!("from {} ", blocked))
129                    || code_part.contains(&format!("from {}.", blocked))
130                    || code_part.contains(&format!("from {}import", blocked))
131                {
132                    return Some(blocked.to_string());
133                }
134            }
135        }
136
137        // 2) Dangerous builtin-call check (common bypasses like __import__ / import_module / eval /
138        //    exec / compile / open / breakpoint / input)
139        if let Some(call) = DANGEROUS_CALL_REGEX.find(code_part) {
140            return Some(call.as_str().to_string());
141        }
142    }
143    None
144}
145
146/// Python code execution tool
147///
148/// Executes code in a local Python environment and returns the result.
149/// Suitable for scenarios needing the Python ecosystem, such as math and data processing.
150///
151/// # Security warning
152/// This tool is **disabled** by default. Call [`PythonREPLTool::with_dangerously_allow`]
153/// to enable code execution. In production, ensure it runs inside a sandboxed environment.
154pub struct PythonREPLTool {
155    python_path: String,
156    /// Whether code execution is allowed (default false, must explicitly opt in)
157    dangerously_allow: bool,
158    /// Whether the dangerous-import check is enabled (default true)
159    check_dangerous_imports: bool,
160}
161
162impl PythonREPLTool {
163    /// Creates a Python code execution tool (execution disabled by default).
164    pub fn new() -> Self {
165        Self {
166            python_path: Self::find_python(),
167            dangerously_allow: false,
168            check_dangerous_imports: true,
169        }
170    }
171
172    /// Uses a custom Python path
173    pub fn with_python_path(path: impl Into<String>) -> Self {
174        Self {
175            python_path: path.into(),
176            dangerously_allow: false,
177            check_dangerous_imports: true,
178        }
179    }
180
181    /// Explicitly enables code execution (disabled by default)
182    pub fn with_dangerously_allow(mut self, allow: bool) -> Self {
183        self.dangerously_allow = allow;
184        self
185    }
186
187    /// Disable dangerous import checking (default: enabled).
188    pub fn with_skip_dangerous_imports_check(mut self, skip: bool) -> Self {
189        self.check_dangerous_imports = !skip;
190        self
191    }
192
193    /// Automatically finds the system Python
194    fn find_python() -> String {
195        for candidate in &["python3", "python"] {
196            if std::process::Command::new(candidate)
197                .arg("--version")
198                .output()
199                .is_ok()
200            {
201                return candidate.to_string();
202            }
203        }
204        "python3".to_string()
205    }
206}
207
208impl Default for PythonREPLTool {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214#[async_trait]
215impl Tool for PythonREPLTool {
216    type Input = PythonREPLInput;
217    type Output = PythonREPLOutput;
218
219    async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
220        if input.code.trim().is_empty() {
221            return Err(ToolError::InvalidInput(
222                "Python code must not be empty".to_string(),
223            ));
224        }
225
226        if !self.dangerously_allow {
227            return Err(ToolError::ExecutionFailed(
228                "PythonREPLTool is disabled by default for security. \
229                 Call .with_dangerously_allow(true) to enable execution."
230                    .to_string(),
231            ));
232        }
233
234        if self.check_dangerous_imports {
235            if let Some(blocked) = contains_dangerous_code(&input.code) {
236                return Err(ToolError::ExecutionFailed(format!(
237                    "Code contains dangerous import or builtin call: '{}'. \
238                     Blocked by the noise-filter blacklist (note: this is not a security boundary; \
239                     untrusted code must run in a sandbox). \
240                     Call .with_skip_dangerous_imports_check(true) to bypass (not recommended).",
241                    blocked
242                )));
243            }
244        }
245
246        let timeout_secs = input.timeout_seconds.unwrap_or(30);
247
248        // 0.22.0 H-A3: `kill_on_drop(true)` terminates the subprocess when the
249        // timeout fires and the `.output()` future is dropped. Without it the
250        // child kept running as an orphan after the parent gave up waiting,
251        // an infinite loop burned CPU in the background forever.
252        let result = tokio::time::timeout(
253            std::time::Duration::from_secs(timeout_secs),
254            Command::new(&self.python_path)
255                .arg("-c")
256                .arg(&input.code)
257                .kill_on_drop(true)
258                .output(),
259        )
260        .await
261        .map_err(|_| {
262            ToolError::ExecutionFailed(format!(
263                "Python execution timed out after {} seconds",
264                timeout_secs
265            ))
266        })?
267        .map_err(|e| ToolError::ExecutionFailed(format!("Python execution failed: {}", e)))?;
268
269        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
270        let stderr = String::from_utf8_lossy(&result.stderr).to_string();
271        let exit_code = result.status.code().unwrap_or(-1);
272
273        Ok(PythonREPLOutput {
274            code: input.code,
275            stdout,
276            stderr,
277            exit_code,
278        })
279    }
280}
281
282#[async_trait]
283impl BaseTool for PythonREPLTool {
284    fn name(&self) -> &str {
285        "python_repl"
286    }
287
288    fn description(&self) -> &str {
289        "Python code execution tool. Runs code in a local Python environment and returns results.
290
291Parameters:
292- code: Python code string to execute
293- timeout_seconds: Timeout in seconds (default: 30)
294
295Supports any Python syntax, including math, data processing, plotting, etc.
296
297SECURITY WARNING: Disabled by default. Must call .with_dangerously_allow(true) to enable.
298Only use in controlled/sandboxed environments.
299
300Examples:
301- Simple calc: {\"code\": \"print(1 + 2)\"}
302- List processing: {\"code\": \"print([x**2 for x in range(10)])\"}
303- Math: {\"code\": \"import math; print(math.pi)\"}"
304    }
305
306    async fn run(&self, input: String) -> Result<String, ToolError> {
307        let parsed: PythonREPLInput = serde_json::from_str(&input)
308            .map_err(|e| ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
309
310        let output = self.invoke(parsed).await?;
311
312        let mut result = String::new();
313        if !output.stdout.is_empty() {
314            result.push_str(&format!("stdout:\n{}\n", output.stdout));
315        }
316        if !output.stderr.is_empty() {
317            result.push_str(&format!("stderr:\n{}\n", output.stderr));
318        }
319        result.push_str(&format!("exit_code: {}", output.exit_code));
320
321        Ok(result)
322    }
323
324    fn args_schema(&self) -> Option<serde_json::Value> {
325        use schemars::schema_for;
326        serde_json::to_value(schema_for!(PythonREPLInput)).ok()
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_python_repl_tool_properties() {
336        let tool = PythonREPLTool::new();
337        assert_eq!(tool.name(), "python_repl");
338        assert!(tool.description().contains("Python"));
339        assert!(BaseTool::args_schema(&tool).is_some());
340    }
341
342    #[tokio::test]
343    async fn test_python_repl_empty_code() {
344        let tool = PythonREPLTool::new().with_dangerously_allow(true);
345        let result = tool.run(r#"{"code": ""}"#.to_string()).await;
346        assert!(result.is_err());
347    }
348
349    #[tokio::test]
350    async fn test_python_repl_disabled_by_default() {
351        let tool = PythonREPLTool::new();
352        let result = tool
353            .invoke(PythonREPLInput {
354                code: "print(1 + 2)".to_string(),
355                timeout_seconds: Some(10),
356            })
357            .await;
358        assert!(result.is_err());
359        let err_msg = result.unwrap_err().to_string();
360        assert!(
361            err_msg.contains("disabled by default"),
362            "Expected disabled error, got: {}",
363            err_msg
364        );
365    }
366
367    #[tokio::test]
368    async fn test_python_repl_basic_execution() {
369        let tool = PythonREPLTool::new().with_dangerously_allow(true);
370        let result = tool
371            .invoke(PythonREPLInput {
372                code: "print(1 + 2)".to_string(),
373                timeout_seconds: Some(10),
374            })
375            .await;
376
377        match result {
378            Ok(output) => {
379                if output.exit_code == 0 || !output.stdout.is_empty() {
380                    // Python available, verify functionality
381                } else {
382                    eprintln!(
383                        "Python may not be installed (exit_code={})",
384                        output.exit_code
385                    );
386                }
387            }
388            Err(e) => {
389                eprintln!("Python not available (may be expected): {}", e);
390            }
391        }
392    }
393
394    #[test]
395    fn test_dangerous_import_detection() {
396        assert!(contains_dangerous_code("import os").is_some());
397        assert!(contains_dangerous_code("import subprocess").is_some());
398        assert!(contains_dangerous_code("from sys import path").is_some());
399        assert!(contains_dangerous_code("from os.path import join").is_some());
400        // Safe imports should pass
401        assert!(contains_dangerous_code("import math").is_none());
402        assert!(contains_dangerous_code("import json").is_none());
403        assert!(contains_dangerous_code("from datetime import datetime").is_none());
404        // Comments should be ignored
405        assert!(contains_dangerous_code("# import os").is_none());
406    }
407
408    #[test]
409    fn test_dangerous_builtin_calls_detected() {
410        // Common bypass: call builtin/imported functions directly without an import statement
411        assert!(contains_dangerous_code("__import__('os').system('ls')").is_some());
412        assert!(contains_dangerous_code("importlib.import_module('os')").is_some());
413        assert!(contains_dangerous_code("eval('os')").is_some());
414        assert!(contains_dangerous_code("exec('import os')").is_some());
415        assert!(contains_dangerous_code("compile('import os', '<x>', 'exec')").is_some());
416        assert!(contains_dangerous_code("execfile('/tmp/x.py')").is_some());
417    }
418
419    #[test]
420    fn test_dangerous_builtin_file_and_interactive_vectors_blocked() {
421        // File access / interactive escape builtins require no import and were previously able
422        // to reach /etc/passwd or a REPL even with the check enabled.
423        assert!(contains_dangerous_code("open('/etc/shadow').read()").is_some());
424        assert!(contains_dangerous_code("breakpoint()").is_some());
425        assert!(contains_dangerous_code("input()").is_some());
426        // Word boundary: no false positive on identifiers containing these as substrings.
427        assert!(contains_dangerous_code("opened = []").is_none());
428        assert!(contains_dangerous_code("check_input(arg)").is_none());
429    }
430
431    #[test]
432    fn test_dangerous_builtin_calls_no_false_positive_on_words() {
433        // Word boundary: no false positives on ordinary words containing these as substrings, like evaluate / execute / length
434        assert!(contains_dangerous_code("print('evaluate the result')").is_none());
435        assert!(contains_dangerous_code("result = execute_query()").is_none());
436        assert!(contains_dangerous_code("print(len([1, 2, 3]))").is_none());
437        assert!(contains_dangerous_code("x = len('hello')").is_none());
438    }
439
440    #[tokio::test]
441    async fn test_python_repl_blocks_dangerous_import() {
442        let tool = PythonREPLTool::new().with_dangerously_allow(true);
443        let result = tool
444            .invoke(PythonREPLInput {
445                code: "import os; print(os.getcwd())".to_string(),
446                timeout_seconds: Some(10),
447            })
448            .await;
449        assert!(result.is_err());
450        let err_msg = result.unwrap_err().to_string();
451        assert!(
452            err_msg.contains("dangerous import"),
453            "Expected dangerous import error, got: {}",
454            err_msg
455        );
456    }
457
458    #[tokio::test]
459    async fn test_python_repl_allows_safe_import() {
460        let tool = PythonREPLTool::new().with_dangerously_allow(true);
461        let result = tool
462            .invoke(PythonREPLInput {
463                code: "import math; print(math.pi)".to_string(),
464                timeout_seconds: Some(10),
465            })
466            .await;
467        match result {
468            Ok(output) => {
469                if output.exit_code == 0 {
470                    assert!(output.stdout.contains("3.14"));
471                }
472            }
473            Err(e) => {
474                assert!(
475                    !e.to_string().contains("dangerous import"),
476                    "math should not be blocked: {}",
477                    e
478                );
479            }
480        }
481    }
482
483    #[tokio::test]
484    async fn test_python_repl_with_error() {
485        let tool = PythonREPLTool::new().with_dangerously_allow(true);
486        let result = tool
487            .invoke(PythonREPLInput {
488                code: "print(undefined_var)".to_string(),
489                timeout_seconds: Some(10),
490            })
491            .await;
492
493        match result {
494            Ok(output) => {
495                if output.exit_code == 0 {
496                    // occasionally Python available but no error reported
497                }
498            }
499            Err(_) => {
500                // No Python available, skip
501            }
502        }
503    }
504}