use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::core::tools::{BaseTool, Tool, ToolError};
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PythonREPLInput {
pub code: String,
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct PythonREPLOutput {
pub code: String,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
const BLOCKED_IMPORTS: &[&str] = &[
"os",
"subprocess",
"sys",
"shutil",
"signal",
"ctypes",
"multiprocessing",
"socket",
"http.server",
"xmlrpc",
"pickle",
"shelve",
"importlib",
"code",
"codeop",
"compileall",
"pty",
"commands",
"pdb",
"webbrowser",
"antigravity",
];
fn contains_dangerous_import(code: &str) -> Option<String> {
for line in code.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let code_part = trimmed.split('#').next().unwrap_or(trimmed);
if code_part.contains("import") {
for blocked in BLOCKED_IMPORTS {
if code_part.contains(&format!("import {}", blocked))
|| code_part.contains(&format!("from {} ", blocked))
|| code_part.contains(&format!("from {}.", blocked))
|| code_part.contains(&format!("from {}import", blocked))
{
return Some(blocked.to_string());
}
}
}
}
None
}
pub struct PythonREPLTool {
python_path: String,
dangerously_allow: bool,
check_dangerous_imports: bool,
}
impl PythonREPLTool {
pub fn new() -> Self {
Self {
python_path: Self::find_python(),
dangerously_allow: false,
check_dangerous_imports: true,
}
}
pub fn with_python_path(path: impl Into<String>) -> Self {
Self {
python_path: path.into(),
dangerously_allow: false,
check_dangerous_imports: true,
}
}
pub fn with_dangerously_allow(mut self, allow: bool) -> Self {
self.dangerously_allow = allow;
self
}
pub fn with_skip_dangerous_imports_check(mut self, skip: bool) -> Self {
self.check_dangerous_imports = !skip;
self
}
fn find_python() -> String {
for candidate in &["python3", "python"] {
if std::process::Command::new(candidate)
.arg("--version")
.output()
.is_ok()
{
return candidate.to_string();
}
}
"python3".to_string()
}
}
impl Default for PythonREPLTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for PythonREPLTool {
type Input = PythonREPLInput;
type Output = PythonREPLOutput;
async fn invoke(&self, input: Self::Input) -> Result<Self::Output, ToolError> {
if input.code.trim().is_empty() {
return Err(ToolError::InvalidInput(
"Python code must not be empty".to_string(),
));
}
if !self.dangerously_allow {
return Err(ToolError::ExecutionFailed(
"PythonREPLTool is disabled by default for security. \
Call .with_dangerously_allow(true) to enable execution."
.to_string(),
));
}
if self.check_dangerous_imports {
if let Some(blocked) = contains_dangerous_import(&input.code) {
return Err(ToolError::ExecutionFailed(format!(
"Code contains dangerous import: '{}'. \
This module is blocked for security. \
Call .with_skip_dangerous_imports_check(true) to bypass (not recommended).",
blocked
)));
}
}
let timeout_secs = input.timeout_seconds.unwrap_or(30);
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Command::new(&self.python_path)
.arg("-c")
.arg(&input.code)
.output(),
)
.await
.map_err(|_| {
ToolError::ExecutionFailed(format!(
"Python execution timed out after {} seconds",
timeout_secs
))
})?
.map_err(|e| ToolError::ExecutionFailed(format!("Python execution failed: {}", e)))?;
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
let exit_code = result.status.code().unwrap_or(-1);
Ok(PythonREPLOutput {
code: input.code,
stdout,
stderr,
exit_code,
})
}
}
#[async_trait]
impl BaseTool for PythonREPLTool {
fn name(&self) -> &str {
"python_repl"
}
fn description(&self) -> &str {
"Python code execution tool. Runs code in a local Python environment and returns results.
Parameters:
- code: Python code string to execute
- timeout_seconds: Timeout in seconds (default: 30)
Supports any Python syntax, including math, data processing, plotting, etc.
SECURITY WARNING: Disabled by default. Must call .with_dangerously_allow(true) to enable.
Only use in controlled/sandboxed environments.
Examples:
- Simple calc: {\"code\": \"print(1 + 2)\"}
- List processing: {\"code\": \"print([x**2 for x in range(10)])\"}
- Math: {\"code\": \"import math; print(math.pi)\"}"
}
async fn run(&self, input: String) -> Result<String, ToolError> {
let parsed: PythonREPLInput = serde_json::from_str(&input)
.map_err(|e| ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
let output = self.invoke(parsed).await?;
let mut result = String::new();
if !output.stdout.is_empty() {
result.push_str(&format!("stdout:\n{}\n", output.stdout));
}
if !output.stderr.is_empty() {
result.push_str(&format!("stderr:\n{}\n", output.stderr));
}
result.push_str(&format!("exit_code: {}", output.exit_code));
Ok(result)
}
fn args_schema(&self) -> Option<serde_json::Value> {
use schemars::schema_for;
serde_json::to_value(schema_for!(PythonREPLInput)).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_python_repl_tool_properties() {
let tool = PythonREPLTool::new();
assert_eq!(tool.name(), "python_repl");
assert!(tool.description().contains("Python"));
assert!(BaseTool::args_schema(&tool).is_some());
}
#[tokio::test]
async fn test_python_repl_empty_code() {
let tool = PythonREPLTool::new().with_dangerously_allow(true);
let result = tool.run(r#"{"code": ""}"#.to_string()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_python_repl_disabled_by_default() {
let tool = PythonREPLTool::new();
let result = tool
.invoke(PythonREPLInput {
code: "print(1 + 2)".to_string(),
timeout_seconds: Some(10),
})
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("disabled by default"),
"Expected disabled error, got: {}",
err_msg
);
}
#[tokio::test]
async fn test_python_repl_basic_execution() {
let tool = PythonREPLTool::new().with_dangerously_allow(true);
let result = tool
.invoke(PythonREPLInput {
code: "print(1 + 2)".to_string(),
timeout_seconds: Some(10),
})
.await;
match result {
Ok(output) => {
if output.exit_code == 0 || !output.stdout.is_empty() {
} else {
eprintln!(
"Python may not be installed (exit_code={})",
output.exit_code
);
}
}
Err(e) => {
eprintln!("Python not available (may be expected): {}", e);
}
}
}
#[test]
fn test_dangerous_import_detection() {
assert!(contains_dangerous_import("import os").is_some());
assert!(contains_dangerous_import("import subprocess").is_some());
assert!(contains_dangerous_import("from sys import path").is_some());
assert!(contains_dangerous_import("from os.path import join").is_some());
assert!(contains_dangerous_import("import math").is_none());
assert!(contains_dangerous_import("import json").is_none());
assert!(contains_dangerous_import("from datetime import datetime").is_none());
assert!(contains_dangerous_import("# import os").is_none());
}
#[tokio::test]
async fn test_python_repl_blocks_dangerous_import() {
let tool = PythonREPLTool::new().with_dangerously_allow(true);
let result = tool
.invoke(PythonREPLInput {
code: "import os; print(os.getcwd())".to_string(),
timeout_seconds: Some(10),
})
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("dangerous import"),
"Expected dangerous import error, got: {}",
err_msg
);
}
#[tokio::test]
async fn test_python_repl_allows_safe_import() {
let tool = PythonREPLTool::new().with_dangerously_allow(true);
let result = tool
.invoke(PythonREPLInput {
code: "import math; print(math.pi)".to_string(),
timeout_seconds: Some(10),
})
.await;
match result {
Ok(output) => {
if output.exit_code == 0 {
assert!(output.stdout.contains("3.14"));
}
}
Err(e) => {
assert!(
!e.to_string().contains("dangerous import"),
"math should not be blocked: {}",
e
);
}
}
}
#[tokio::test]
async fn test_python_repl_with_error() {
let tool = PythonREPLTool::new().with_dangerously_allow(true);
let result = tool
.invoke(PythonREPLInput {
code: "print(undefined_var)".to_string(),
timeout_seconds: Some(10),
})
.await;
match result {
Ok(output) => {
if output.exit_code == 0 {
}
}
Err(_) => {
}
}
}
}