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