feat MCP Sandboxing for python
This commit is contained in:
parent
560e56b596
commit
3c677a13ab
@ -13,12 +13,9 @@ step-by-step methods so Streamlit can drive the loop via session_state:
|
|||||||
agent.reject(feedback) # skip action, inject user feedback
|
agent.reject(feedback) # skip action, inject user feedback
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ast
|
|
||||||
import inspect
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import re
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncio
|
import asyncio
|
||||||
import pprint
|
import pprint
|
||||||
@ -76,6 +73,8 @@ async def dispatch_tool(tool_name: str, arguments: dict) -> str:
|
|||||||
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
print(f"Trying to call tool '{tool_name}' in dispatch_tool through MCPToolAdapter...")
|
||||||
result = await adapter.call_tool(tool_name, arguments)
|
result = await adapter.call_tool(tool_name, arguments)
|
||||||
|
|
||||||
|
print(f"Raw result from tool '{tool_name}': {result}")
|
||||||
|
|
||||||
if result.isError:
|
if result.isError:
|
||||||
texts = [block.text for block in result.content if block.type == "text"]
|
texts = [block.text for block in result.content if block.type == "text"]
|
||||||
return f"Tool error: {' '.join(texts)}"
|
return f"Tool error: {' '.join(texts)}"
|
||||||
@ -102,7 +101,7 @@ analyze code, and execute Python scripts.
|
|||||||
You can call tools to interact with the workspace and get feedback.
|
You can call tools to interact with the workspace and get feedback.
|
||||||
You can write and read files, list directory contents, search for patterns,
|
You can write and read files, list directory contents, search for patterns,
|
||||||
validate Python syntax, and run Python code.
|
validate Python syntax, and run Python code.
|
||||||
Wou can access web search and page fetching tools to gather information from the internet.
|
You can access web search and page fetching tools to gather information from the internet.
|
||||||
You can use these capabilities to iteratively work towards completing the user's task.
|
You can use these capabilities to iteratively work towards completing the user's task.
|
||||||
</capabilities>
|
</capabilities>
|
||||||
|
|
||||||
@ -239,7 +238,9 @@ def extract_json(text: str) -> str:
|
|||||||
Returns the cleaned JSON string, or the original text as a fallback
|
Returns the cleaned JSON string, or the original text as a fallback
|
||||||
(so json.loads can raise a meaningful error with context).
|
(so json.loads can raise a meaningful error with context).
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
# 1. Strip markdown fences
|
# 1. Strip markdown fences
|
||||||
fenced = re.sub(r"```(?:json)?\s*([\s\S]*?)\s*```", r"\1", text.strip())
|
fenced = re.sub(r"```(?:json)?\s*([\s\S]*?)\s*```", r"\1", text.strip())
|
||||||
@ -282,6 +283,9 @@ def extract_json(text: str) -> str:
|
|||||||
|
|
||||||
def _strip_code_fences(text: str) -> str:
|
def _strip_code_fences(text: str) -> str:
|
||||||
"""Remove markdown code fences (```json ... ```) from a string."""
|
"""Remove markdown code fences (```json ... ```) from a string."""
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
|
|||||||
@ -1,9 +1,32 @@
|
|||||||
import ast
|
import ast
|
||||||
|
from datetime import datetime
|
||||||
import subprocess
|
import subprocess
|
||||||
import io
|
import io
|
||||||
from pyflakes.api import check
|
from pyflakes.api import check
|
||||||
from pyflakes.reporter import Reporter
|
from pyflakes.reporter import Reporter
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pathlib import Path
|
||||||
|
import venv
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# ── Sandbox venv ────────────────────────────────────────────────────────────
|
||||||
|
SERVER_BASE_DIR = Path(__file__).parent.resolve()
|
||||||
|
SANDBOX_DIR = SERVER_BASE_DIR / ".mcp_sandbox"
|
||||||
|
WORKSPACE_DIR = SERVER_BASE_DIR.parent.parent.parent.parent / "workspace"
|
||||||
|
|
||||||
|
def get_sandbox_paths():
|
||||||
|
"""Bestimmt die Executables innerhalb der Venv ohne os-Modul."""
|
||||||
|
if not SANDBOX_DIR.exists():
|
||||||
|
venv.create(SANDBOX_DIR, with_pip=True)
|
||||||
|
|
||||||
|
bin_folder = "Scripts" if Path("C:/").exists() else "bin" # Einfacher Check für Windows
|
||||||
|
|
||||||
|
python_exe = SANDBOX_DIR / bin_folder / "python"
|
||||||
|
pip_exe = SANDBOX_DIR / bin_folder / "pip"
|
||||||
|
|
||||||
|
return str(python_exe), str(pip_exe)
|
||||||
|
|
||||||
|
PYTHON_EXE, PIP_EXE = get_sandbox_paths()
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────────────────────
|
# ── Configuration ────────────────────────────────────────────────────────────
|
||||||
EXEC_TIMEOUT = 10 # seconds before killing the subprocess
|
EXEC_TIMEOUT = 10 # seconds before killing the subprocess
|
||||||
@ -41,6 +64,10 @@ BLOCKED_BUILTINS = {
|
|||||||
"globals", "locals", "vars", "memoryview", "type"
|
"globals", "locals", "vars", "memoryview", "type"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FORBIDDEN_SEQUENCES = ["../", "..\\", "/etc/", "/dev/",
|
||||||
|
"C:\\Windows", "C:\\Program Files", "C:\\Users",
|
||||||
|
"compile(", "__import__", "os.", "sys.", "subprocess."]
|
||||||
|
|
||||||
# ── Static Analysis ────────────────────────────────────────────────────
|
# ── Static Analysis ────────────────────────────────────────────────────
|
||||||
def check_code_safety(code: str) -> str | None:
|
def check_code_safety(code: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
@ -78,6 +105,10 @@ def check_code_safety(code: str) -> str | None:
|
|||||||
if isinstance(node.func, ast.Name):
|
if isinstance(node.func, ast.Name):
|
||||||
if node.func.id in BLOCKED_BUILTINS:
|
if node.func.id in BLOCKED_BUILTINS:
|
||||||
return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed."
|
return f"Blocked builtin: Use of builtin '{node.func.id}' is not allowed."
|
||||||
|
|
||||||
|
for seq in FORBIDDEN_SEQUENCES:
|
||||||
|
if seq in code:
|
||||||
|
return f"Blocked: Suspect path sequence '{seq}' detected."
|
||||||
|
|
||||||
return None # No violations found
|
return None # No violations found
|
||||||
|
|
||||||
@ -200,7 +231,84 @@ def lint_code(code: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def run_python_sandboxed(code: str) -> str:
|
def list_sandbox_packages() -> str:
|
||||||
|
"""
|
||||||
|
Lists all Python-Packages, that are installed in the Sandbox and their Version.
|
||||||
|
Helpful to determine if packages like 'pygame', 'numpy' or similair are already available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[PIP_EXE, "list"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"Error while listing the packages: {result.stderr}"
|
||||||
|
|
||||||
|
if not result.stdout.strip():
|
||||||
|
return "The Sandbox environment is empty (only Standard-Libraries are available)."
|
||||||
|
|
||||||
|
return f"Installed Packages: {result.stdout}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error trying to list packages from the Sandbox venv: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def install_package_into_sandbox(package_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Install a Python package into the sandbox environment using pip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
package_name: The name of the package to install (e.g., "requests").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A success message or an error message if installation fails.
|
||||||
|
"""
|
||||||
|
clean_name = "".join(e for e in package_name if e.isalnum() or e in "-_.")
|
||||||
|
|
||||||
|
if clean_name in BLOCKED_IMPORTS:
|
||||||
|
return f"Error: Installation of package '{clean_name}' is blocked due to security policies."
|
||||||
|
|
||||||
|
if clean_name in BLOCKED_BUILTINS:
|
||||||
|
return f"Error: Installation of package '{clean_name}' is blocked due to security policies."
|
||||||
|
|
||||||
|
if not clean_name:
|
||||||
|
return "Error: Invalid package name provided."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[PIP_EXE, "install", clean_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=EXEC_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return f"Package '{clean_name}' installed successfully in the sandbox."
|
||||||
|
else:
|
||||||
|
return (f"Error installing package '{clean_name}':\n"
|
||||||
|
f"{result.stdout}\n{result.stderr}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"Error: Package installation exceeded time limit of {EXEC_TIMEOUT} seconds and was terminated."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error during package installation: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def reset_sandbox() -> str:
|
||||||
|
"""Löscht die gesamte Sandbox und erstellt sie neu (Full Reset)."""
|
||||||
|
if SANDBOX_DIR.exists():
|
||||||
|
shutil.rmtree(SANDBOX_DIR)
|
||||||
|
get_sandbox_paths()
|
||||||
|
return "Sandbox wurde komplett zurückgesetzt."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def run_python_code_sandboxed(code: str) -> str:
|
||||||
"""
|
"""
|
||||||
Run Python code in a sandboxed environment.
|
Run Python code in a sandboxed environment.
|
||||||
|
|
||||||
@ -220,10 +328,24 @@ def run_python_sandboxed(code: str) -> str:
|
|||||||
static_safety = check_code_safety(code)
|
static_safety = check_code_safety(code)
|
||||||
if static_safety:
|
if static_safety:
|
||||||
return f"Code rejected:{static_safety}"
|
return f"Code rejected:{static_safety}"
|
||||||
|
|
||||||
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
jail_dir = WORKSPACE_DIR / f"sandbox_run_{run_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
jail_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
custom_env = {
|
||||||
|
"PYTHONPATH": str(WORKSPACE_DIR),
|
||||||
|
"PATH": str(Path(PYTHON_EXE).parent),
|
||||||
|
"HOME": str(jail_dir),
|
||||||
|
"TMPDIR": str(jail_dir)
|
||||||
|
}
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["python", "-c", code],
|
[PYTHON_EXE, "-c", code],
|
||||||
|
cwd=str(WORKSPACE_DIR),
|
||||||
|
env=custom_env,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=EXEC_TIMEOUT)
|
timeout=EXEC_TIMEOUT)
|
||||||
@ -243,6 +365,10 @@ def run_python_sandboxed(code: str) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error during code execution: {e}"
|
return f"Error during code execution: {e}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if jail_dir.exists():
|
||||||
|
shutil.rmtree(jail_dir)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def python_code_validation(code: str) -> str:
|
def python_code_validation(code: str) -> str:
|
||||||
|
|||||||
@ -200,7 +200,7 @@ def create_new_directory(path: str) -> str:
|
|||||||
if resolved.exists():
|
if resolved.exists():
|
||||||
return f"Error: File '{path}' already exists."
|
return f"Error: File '{path}' already exists."
|
||||||
|
|
||||||
if resolved.suffix != None:
|
if resolved.suffix != None and resolved.suffix != "":
|
||||||
return f"Error: can only create directories, got '{resolved.suffix}'."
|
return f"Error: can only create directories, got '{resolved.suffix}'."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user