1use 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#[derive(Debug, Deserialize, JsonSchema)]
26pub struct PythonREPLInput {
27 pub code: String,
29 pub timeout_seconds: Option<u64>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct PythonREPLOutput {
36 pub code: String,
38 pub stdout: String,
40 pub stderr: String,
42 pub exit_code: i32,
44}
45
46const 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
71const DANGEROUS_BUILTIN_CALLS: &[&str] = &[
73 "__import__",
74 "import_module",
75 "eval",
76 "exec",
77 "execfile",
78 "compile",
79];
80
81static DANGEROUS_CALL_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
88 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
97fn 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 let code_part = trimmed.split('#').next().unwrap_or(trimmed);
111
112 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 if let Some(call) = DANGEROUS_CALL_REGEX.find(code_part) {
127 return Some(call.as_str().to_string());
128 }
129 }
130 None
131}
132
133pub struct PythonREPLTool {
142 python_path: String,
143 dangerously_allow: bool,
145 check_dangerous_imports: bool,
147}
148
149impl PythonREPLTool {
150 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 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 pub fn with_dangerously_allow(mut self, allow: bool) -> Self {
170 self.dangerously_allow = allow;
171 self
172 }
173
174 pub fn with_skip_dangerous_imports_check(mut self, skip: bool) -> Self {
176 self.check_dangerous_imports = !skip;
177 self
178 }
179
180 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 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 } 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 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 assert!(contains_dangerous_code("# import os").is_none());
393 }
394
395 #[test]
396 fn test_dangerous_builtin_calls_detected() {
397 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 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 }
473 }
474 Err(_) => {
475 }
477 }
478 }
479}