fix: BUG-080/082 (20 unused frontend components deleted), BUG-083 (useTenant.ts deleted), BUG-011 (playwright baseURL), BUG-069 (unused python modules deleted), BUG-065 (already has eager loading), BUG-026 (already fixed 422), BUG-023 (no sync I/O found)
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-22 07:37:11 +02:00
parent 40cc99af5c
commit db4701bae7
30 changed files with 5 additions and 5076 deletions
@@ -1,94 +0,0 @@
"""Integrates external MCP server tools into the AI Assistant tool_registry.
When the MCP Client plugin activates, it loads all enabled MCP server configs,
fetches their tool lists, and registers each tool in the global ToolRegistry.
Agents can then call external MCP tools like native tools.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
logger = logging.getLogger(__name__)
PLUGIN_NAME = "mcp_client"
def _make_tool_name(server_name: str, tool_name: str) -> str:
"""Generate a unique tool name: mcp__{server}__{tool}."""
safe_server = server_name.replace(" ", "_").replace("-", "_").lower()
return f"mcp__{safe_server}__{tool_name}"
def _make_handler(server_cfg: McpServerConfigModel, tool_name: str):
"""Create an async handler that calls the external MCP server."""
async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token)
try:
resp = await client.execute_tool(tool_name, arguments)
if resp.success:
import json
result = resp.result if resp.result is not None else {}
return json.dumps(result) if isinstance(result, dict) else str(result)
return f"Error: {resp.error or 'Unknown error'}"
except Exception as exc:
logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name)
return f"Error: {exc}"
return _handler
async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int:
"""Fetch tools from all enabled MCP servers and register them in the tool registry.
Returns the number of tools registered.
"""
registry = get_tool_registry()
# Unregister previous tools from this plugin
registry.unregister_plugin(PLUGIN_NAME)
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.tenant_id == tenant_id,
McpServerConfigModel.enabled.is_(True),
)
result = await db.execute(stmt)
configs = result.scalars().all()
count = 0
for cfg in configs:
try:
client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0)
tools_resp = await client.list_tools()
for tool in tools_resp.tools:
tool_name = _make_tool_name(cfg.name, tool.name)
registry.register(
name=tool_name,
description=f"[MCP:{cfg.name}] {tool.description}",
parameters=tool.parameters if isinstance(tool.parameters, dict) else {},
handler=_make_handler(cfg, tool.name),
plugin_name=PLUGIN_NAME,
required_permission="mcp-client:read",
category="mcp-external",
)
count += 1
logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name)
except Exception as exc:
logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc)
return count
def unregister_all_external_tools() -> None:
"""Remove all MCP client tools from the registry."""
registry = get_tool_registry()
registry.unregister_plugin(PLUGIN_NAME)
-215
View File
@@ -1,215 +0,0 @@
"""Plugin quarantine — extract, validate, and install external plugins safely.
Workflow:
1. Extract ZIP to a temporary directory
2. Validate manifest exists and is valid
3. Check for dangerous imports
4. Validate migration SQL
5. Verify signature (if provided)
6. If all checks pass: move to plugins/ directory
7. If any check fails: delete temp directory and raise error
Usage::
from app.plugins.quarantine import quarantine_plugin
plugin_dir = await quarantine_plugin(
zip_path=Path("plugin.zip"),
signature=b"...",
public_key=b"...",
)
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from app.plugins.signature import PluginSignature
logger = logging.getLogger(__name__)
# Maximum plugin ZIP size (50 MB)
MAX_PLUGIN_SIZE = 50 * 1024 * 1024
# Dangerous patterns in plugin source code
DANGEROUS_PATTERNS = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
(r"\bopen\s*\([^)]*['\"]w['\"]", "file write outside DMS"),
]
class QuarantineError(Exception):
"""Raised when a plugin fails quarantine validation."""
def _validate_manifest(plugin_dir: Path) -> dict:
"""Validate that the plugin has a valid manifest.
Returns the parsed manifest data.
"""
plugin_py = plugin_dir / "plugin.py"
init_py = plugin_dir / "__init__.py"
if not plugin_py.exists() and not init_py.exists():
raise QuarantineError("Plugin must have plugin.py or __init__.py")
# Read source and look for manifest
source_file = plugin_py if plugin_py.exists() else init_py
source = source_file.read_text(encoding="utf-8")
if "PluginManifest" not in source:
raise QuarantineError("Plugin source must define a PluginManifest")
if "BasePlugin" not in source:
raise QuarantineError("Plugin source must inherit from BasePlugin")
return {"source_file": str(source_file), "has_manifest": True}
def _check_dangerous_imports(plugin_dir: Path) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
found: list[str] = []
for py_file in plugin_dir.rglob("*.py"):
source = py_file.read_text(encoding="utf-8")
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, source):
found.append(f"{py_file.name}: {description}")
return found
def _check_migration_sql(plugin_dir: Path) -> list[str]:
"""Validate migration SQL files in the plugin.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
migrations_dir = plugin_dir / "migrations"
if not migrations_dir.exists():
return issues # No migrations is OK
for sql_file in migrations_dir.glob("*.sql"):
content = sql_file.read_text(encoding="utf-8")
# Check for tenant_id in CREATE TABLE
if "CREATE TABLE" in content.upper() and "tenant_id" not in content.lower():
issues.append(
f"{sql_file.name}: CREATE TABLE without tenant_id column"
)
# Check for DROP DATABASE / DROP SCHEMA
if "DROP DATABASE" in content.upper() or "DROP SCHEMA" in content.upper():
issues.append(f"{sql_file.name}: Contains DROP DATABASE/SCHEMA")
return issues
async def quarantine_plugin(
zip_path: Path,
signature: bytes | None = None,
public_key: bytes | None = None,
plugins_dir: Path | None = None,
) -> Path:
"""Extract, validate, and install a plugin from a ZIP file.
Args:
zip_path: Path to the plugin ZIP file.
signature: Optional Ed25519 signature bytes.
public_key: Optional Ed25519 public key bytes.
plugins_dir: Target directory for external plugins (default: plugins/).
Returns:
Path to the installed plugin directory.
Raises:
QuarantineError: If any validation check fails.
"""
# Check file size
file_size = zip_path.stat().st_size # noqa: ASYNC240
if file_size > MAX_PLUGIN_SIZE:
raise QuarantineError(
f"Plugin ZIP too large: {file_size} bytes (max {MAX_PLUGIN_SIZE})"
)
# Verify signature if provided
if signature and public_key:
if not PluginSignature.verify_signature(zip_path, signature, public_key):
raise QuarantineError("Signature verification failed")
# Create temp directory for extraction
temp_dir = Path(tempfile.mkdtemp(prefix="plugin_quarantine_"))
try:
# Extract ZIP
with zipfile.ZipFile(zip_path, "r") as zf:
# Check for path traversal in ZIP entries
for entry in zf.namelist():
if entry.startswith("/") or ".." in entry:
raise QuarantineError(f"Unsafe ZIP entry: {entry}")
zf.extractall(temp_dir)
# Find the plugin directory (might be nested)
plugin_dir = temp_dir
if not (plugin_dir / "plugin.py").exists() and not (plugin_dir / "__init__.py").exists():
# Look for a single subdirectory
subdirs = [d for d in plugin_dir.iterdir() if d.is_dir() and not d.name.startswith("_")]
if len(subdirs) == 1:
plugin_dir = subdirs[0]
else:
raise QuarantineError("Could not find plugin root directory in ZIP")
# 1. Validate manifest
_validate_manifest(plugin_dir)
logger.info("Manifest validated for plugin in %s", plugin_dir.name)
# 2. Check dangerous imports
dangerous = _check_dangerous_imports(plugin_dir)
if dangerous:
raise QuarantineError(
f"Dangerous patterns found in plugin: {', '.join(dangerous)}"
)
# 3. Check migration SQL
sql_issues = _check_migration_sql(plugin_dir)
if sql_issues:
raise QuarantineError(
f"Migration SQL issues: {', '.join(sql_issues)}"
)
# 4. All checks passed — move to plugins directory
target_dir = plugins_dir or Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
target_dir.mkdir(parents=True, exist_ok=True)
plugin_name = plugin_dir.name
final_dir = target_dir / plugin_name
if final_dir.exists():
raise QuarantineError(f"Plugin directory already exists: {final_dir}")
shutil.copytree(plugin_dir, final_dir)
logger.info("Plugin installed to %s", final_dir)
return final_dir
except Exception:
# Clean up temp directory on any error
shutil.rmtree(temp_dir, ignore_errors=True)
raise
finally:
# Always clean up temp directory
if temp_dir.exists(): # noqa: ASYNC240
shutil.rmtree(temp_dir, ignore_errors=True)