1use 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#[derive(Debug, Deserialize, JsonSchema)]
27pub struct PythonREPLInput {
28 pub code: String,
30 pub timeout_seconds: Option<u64>,
32}
33
34#[derive(Debug, Serialize)]
36pub struct PythonREPLOutput {
37 pub code: String,
39 pub stdout: String,
41 pub stderr: String,
43 pub exit_code: i32,
45}
46
47const 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
72const 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
92static DANGEROUS_CALL_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
100 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
109fn 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 let code_part = trimmed.split('#').next().unwrap_or(trimmed);
123
124 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 if let Some(call) = DANGEROUS_CALL_REGEX.find(code_part) {
140 return Some(call.as_str().to_string());
141 }
142 }
143 None
144}
145
146pub struct PythonREPLTool {
155 python_path: String,
156 dangerously_allow: bool,
158 check_dangerous_imports: bool,
160}
161
162impl PythonREPLTool {
163 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 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 pub fn with_dangerously_allow(mut self, allow: bool) -> Self {
183 self.dangerously_allow = allow;
184 self
185 }
186
187 pub fn with_skip_dangerous_imports_check(mut self, skip: bool) -> Self {
189 self.check_dangerous_imports = !skip;
190 self
191 }
192
193 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 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 } 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 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 assert!(contains_dangerous_code("# import os").is_none());
406 }
407
408 #[test]
409 fn test_dangerous_builtin_calls_detected() {
410 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 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 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 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 }
498 }
499 Err(_) => {
500 }
502 }
503 }
504}