feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Check Cross-Plugin Imports / check (push) Has been cancelled

Phase 1: Contracts konsequent nutzen
- 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts)
- 4 bestehende contracts.py an zentrale ContractRegistry angepasst
- Alle 19 Plugins haben on_deactivate mit Contract-Unregister
- 0 echte problematische INTER-Plugin Imports

Phase 2: Hooks/Filters-System
- app/core/hooks.py (HookRegistry mit actions + filters)
- 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms)
- BasePlugin.on_deactivate meldet alle Hooks ab

Phase 3: Plugin-Isolation
- scripts/check_cross_plugin_imports.py (Linting-Regel)
- .github/workflows/check-cross-plugin-imports.yml (CI/CD)
- .pre-commit-cross-plugin.yaml (Pre-commit Hook)
- 155 Dateien geprueft, 0 Verstoesse

Phase 4: Plugin-Versioning
- app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release)
- migration_runner.py erweitert: run_migration_down, rollback_to_version
- manifest.py: min_app_version Feld
- registry.py: App-Version-Compatibility-Check bei Installation
- GET /api/v1/plugins/updates Endpoint

Phase 5: Marketplace-Vorbereitung
- app/plugins/signature.py (Ed25519 Signatur-Validierung)
- app/plugins/quarantine.py (Plugin-Quarantine mit Validierung)
- app/models/plugin_allowlist.py + Migration 0046
- manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price
- registry.py: discover_external(), discover_all()
- POST /api/v1/plugins/install-marketplace (deaktiviert)

Phase 6: Manifest-Anpassung
- manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung
- MANIFEST_SCHEMA_DOC aktualisiert
- Alle 19 Plugin-Manifeste aktualisiert
- Frontend PluginUiManifest Typ erweitert

Zusaetzliche Bug-Fixes:
- test_sample-Modul erstellt
- conftest.py Deadlock-Prevention
- SESSION_COOKIE_SECURE=true
- dump.rdb aus Git entfernt + .gitignore
- backup.py datetime.utcnow -> func.now()
- system_settings.py JSONB-Import nach oben
- tax.py Mapped[float] -> Mapped[Decimal]
- notification.py type_key-Laengen vereinheitlicht

Tests: 91 neue Tests, alle bestanden
This commit is contained in:
Agent Zero
2026-07-26 23:15:34 +02:00
parent 744d595cae
commit 98eb1d0d89
62 changed files with 3284 additions and 18 deletions
+7 -1
View File
@@ -68,12 +68,18 @@ class BasePlugin(ABC):
"""Called when the plugin is deactivated.
Override to clean up runtime state. Default implementation unsubscribes
all event listeners that were registered during activation.
all event listeners and hooks that were registered during activation.
"""
# Unsubscribe event listeners
for event_name, handler in self._event_handlers.items():
event_bus.unsubscribe(event_name, handler)
self._event_handlers.clear()
# Unregister all hooks owned by this plugin
from app.core.hooks import get_hook_registry
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
"""Called when the plugin is uninstalled (before data tables are dropped).
@@ -49,6 +49,10 @@ class AIAssistantPlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create", "contact.after_update"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -111,6 +115,10 @@ class AIAssistantPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister participant and event subscriptions."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.contracts import (
@@ -0,0 +1,80 @@
"""Public contract for the ai_proactive plugin.
Exposes models, services, and job functions that other builtins plugins may need.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.ai_proactive.models import (
ContextLog,
ProactiveSettings,
ProactiveSuggestion,
)
from app.plugins.builtins.ai_proactive.services import (
get_active_suggestions,
get_sse_queue,
get_stats,
get_user_settings,
handle_context_change,
mark_dismissed,
push_suggestion,
)
from app.plugins.builtins.ai_proactive.context_tools import (
register_context_tools,
)
from app.plugins.builtins.ai_proactive.jobs import (
deep_analysis,
heartbeat,
)
class AiProactiveContract:
"""Public API surface for the ai_proactive plugin."""
contract_name = "ai_proactive"
# ─── models ───
ProactiveSuggestion = ProactiveSuggestion
ContextLog = ContextLog
ProactiveSettings = ProactiveSettings
# ─── services ───
handle_context_change = staticmethod(handle_context_change)
get_active_suggestions = staticmethod(get_active_suggestions)
get_sse_queue = staticmethod(get_sse_queue)
get_stats = staticmethod(get_stats)
get_user_settings = staticmethod(get_user_settings)
mark_dismissed = staticmethod(mark_dismissed)
push_suggestion = staticmethod(push_suggestion)
# ─── context_tools ───
register_context_tools = staticmethod(register_context_tools)
# ─── jobs ───
deep_analysis = staticmethod(deep_analysis)
heartbeat = staticmethod(heartbeat)
# ─── self-registration ───
_contract = AiProactiveContract()
get_contract_registry().register("ai_proactive", _contract)
__all__ = [
"AiProactiveContract",
"ProactiveSuggestion",
"ContextLog",
"ProactiveSettings",
"handle_context_change",
"get_active_suggestions",
"get_sse_queue",
"get_stats",
"get_user_settings",
"mark_dismissed",
"push_suggestion",
"register_context_tools",
"deep_analysis",
"heartbeat",
]
@@ -46,6 +46,10 @@ class AIProactivePlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='ai-proactive', label_key='settings.aiProactive', label='Proactive AI', component='@/pages/ProactiveAISettings', icon='Sparkles', order=61),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create", "mail.after_send"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -85,6 +89,10 @@ class AIProactivePlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister tools, event listeners, and participant."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.contracts import (
@@ -0,0 +1,51 @@
"""Public contract for the ai_ui_control plugin.
Exposes the WebSocket manager and UI command schemas for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.ai_ui_control.websocket_manager import AIUIControlWSManager
from app.plugins.builtins.ai_ui_control.schemas import (
UICommand,
UICommandCreate,
UICommandFeedback,
UICommandResponse,
UICommandStatus,
UICommandStatusResponse,
UICommandType,
)
class AiUiControlContract:
"""Public API surface for the ai_ui_control plugin."""
contract_name = "ai_ui_control"
# ─── websocket_manager ───
AIUIControlWSManager = AIUIControlWSManager
# ─── schemas ───
UICommand = UICommand
UICommandCreate = UICommandCreate
UICommandFeedback = UICommandFeedback
UICommandResponse = UICommandResponse
UICommandStatus = UICommandStatus
UICommandStatusResponse = UICommandStatusResponse
UICommandType = UICommandType
# ─── self-registration ───
_contract = AiUiControlContract()
get_contract_registry().register("ai_ui_control", _contract)
__all__ = [
"AiUiControlContract",
"AIUIControlWSManager",
"UICommand",
"UICommandType",
"UICommandStatus",
]
@@ -42,6 +42,9 @@ class AIUIControlPlugin(BasePlugin):
"ai_ui_control:write",
],
is_core=True,
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -55,6 +58,10 @@ class AIUIControlPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up the WebSocket manager."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws")
@@ -0,0 +1,82 @@
"""Public contract for the automation plugin.
Exposes models, services, scheduler, and agent communication for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.automation.models import (
AgentDefinition,
AgentRun,
AgentVersion,
AutomationCronJob,
AutomationDefinition,
AutomationRun,
AutomationVersion,
)
from app.plugins.builtins.automation.services import (
AgentService,
AutomationService,
CronJobService,
RunLogService,
)
from app.plugins.builtins.automation.agent_runner import run_agent
from app.plugins.builtins.automation.execution_engine import run_automation
from app.plugins.builtins.automation.scheduler import (
calculate_next_run,
scheduler_tick,
)
from app.plugins.builtins.automation.agent_comm import send_agent_message
class AutomationContract:
"""Public API surface for the automation plugin."""
contract_name = "automation"
# ─── models ───
AgentDefinition = AgentDefinition
Automation = AutomationDefinition
CronJob = AutomationCronJob
AgentRun = AgentRun
AgentVersion = AgentVersion
AutomationRun = AutomationRun
AutomationVersion = AutomationVersion
# ─── services ───
AgentService = AgentService
AutomationService = AutomationService
CronJobService = CronJobService
RunLogService = RunLogService
# ─── agent_runner ───
run_agent = staticmethod(run_agent)
# ─── execution_engine ───
run_automation = staticmethod(run_automation)
# ─── scheduler ───
calculate_next_run = staticmethod(calculate_next_run)
scheduler_tick = staticmethod(scheduler_tick)
# ─── agent_comm ───
send_agent_message = staticmethod(send_agent_message)
# ─── self-registration ───
_contract = AutomationContract()
get_contract_registry().register("automation", _contract)
__all__ = [
"AutomationContract",
"AgentDefinition",
"Automation",
"CronJob",
"AgentService",
"AutomationService",
"CronJobService",
"RunLogService",
]
@@ -162,6 +162,10 @@ class AutomationPlugin(BasePlugin):
plugin_name="automation",
),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.before_create", "contact.after_create"],
contract_version="1.0.0",
)
def __init__(self) -> None:
@@ -207,6 +211,10 @@ class AutomationPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up on deactivation."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
# Unregister agent communication tool
try:
@@ -2,17 +2,26 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
class CalendarContract:
"""Public contract for the calendar plugin."""
contract_name = "calendar"
Calendar = Calendar
CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink
# ─── self-registration ───
_contract = CalendarContract()
get_contract_registry().register("calendar", _contract)
# Backward-compatible local accessor
_contract_instance: CalendarContract | None = None
@@ -21,3 +30,6 @@ def get_contract() -> CalendarContract:
if _contract_instance is None:
_contract_instance = CalendarContract()
return _contract_instance
__all__ = ["CalendarContract", "Calendar", "CalendarEntry", "CalendarEntryLink"]
+13
View File
@@ -50,4 +50,17 @@ class CalendarPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["calendar.before_appointment", "calendar.after_appointment"],
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
+9
View File
@@ -410,6 +410,11 @@ async def create_entry(
)
assigned_to = _parse_uuid(body.assigned_to, "assigned_to") if body.assigned_to else None
# ── Hook: calendar.before_appointment (Action) ──
from app.core.hooks import do_action
await do_action("calendar.before_appointment", body=body, tenant_id=tenant_id, user_id=user_id, cal_id=cal_id)
entry = CalendarEntry(
tenant_id=tenant_id,
calendar_id=cal_id,
@@ -432,6 +437,10 @@ async def create_entry(
db.add(entry)
await db.flush()
# ── Hook: calendar.after_appointment (Action) ──
from app.core.hooks import do_action
await do_action("calendar.after_appointment", entry_id=str(entry.id), tenant_id=tenant_id, user_id=user_id)
# Schedule reminder ARQ job if reminder is set
if body.reminder:
try:
+12
View File
@@ -2,16 +2,25 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.dms.models import File as DmsFile, Folder
class DmsContract:
"""Public contract for the DMS plugin."""
contract_name = "dms"
DmsFile = DmsFile
Folder = Folder
# ─── self-registration ───
_contract = DmsContract()
get_contract_registry().register("dms", _contract)
# Backward-compatible local accessor
_contract_instance: DmsContract | None = None
@@ -20,3 +29,6 @@ def get_contract() -> DmsContract:
if _contract_instance is None:
_contract_instance = DmsContract()
return _contract_instance
__all__ = ["DmsContract", "DmsFile", "Folder"]
+13
View File
@@ -42,4 +42,17 @@ class DmsPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["dms.before_upload"],
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
+14 -3
View File
@@ -35,7 +35,10 @@ from app.plugins.builtins.dms.schemas import (
ShareRequest,
)
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
from app.plugins.builtins.permissions.models import Permission # TODO: migrate to contract
# Get Permission model from the permissions contract
_perms_contract = get_perms_contract()
Permission = _perms_contract.Permission
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
@@ -461,6 +464,14 @@ async def upload_file(
sha256.update(chunk)
yield chunk
# ── Hook: dms.before_upload (Filter) — can modify filename ──
from app.core.hooks import apply_filters
upload_data = {
"filename": file.filename or "unnamed",
"mime_type": file.content_type or "application/octet-stream",
}
upload_data = await apply_filters("dms.before_upload", upload_data)
# Create file record
file_id = uuid.uuid4()
storage_path = _file_storage_path(tenant_id, file_id)
@@ -471,12 +482,12 @@ async def upload_file(
content_hash = sha256.hexdigest()
mime_type = file.content_type or "application/octet-stream"
mime_type = upload_data["mime_type"]
dms_file = DmsFile(
id=file_id,
tenant_id=tenant_id,
name=file.filename or "unnamed",
name=upload_data["filename"],
folder_id=fid,
uploaded_by=user_id,
mime_type=mime_type,
@@ -0,0 +1,38 @@
"""Public contract for the entity_links plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
el = get_contract("entity_links")
if el:
# use el.EntityLink
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.entity_links.models import EntityLink
class EntityLinksContract:
"""Public API surface for the entity_links plugin."""
contract_name = "entity_links"
# ─── models (read-only for queries) ───
EntityLink = EntityLink
# ─── self-registration ───
_contract = EntityLinksContract()
get_contract_registry().register("entity_links", _contract)
__all__ = [
"EntityLinksContract",
"EntityLink",
]
+13 -1
View File
@@ -40,7 +40,10 @@ class EntityLinksPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.links', label='Verknüpfungen', component='@/components/contact/ContactLinksTab', icon='Link', order=60, permission='entity_links:read'),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
@@ -66,3 +69,12 @@ class EntityLinksPlugin(BasePlugin):
)
)
await session.commit()
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,43 @@
"""Public contract for the forgejo_error_reporter plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
reporter = get_contract("forgejo_error_reporter")
if reporter:
await reporter.report_error_to_forgejo(entry)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.forgejo_error_reporter.models import ReportedError
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
class ForgejoErrorReporterContract:
"""Public API surface for the forgejo_error_reporter plugin."""
contract_name = "forgejo_error_reporter"
# ─── services ───
report_error_to_forgejo = staticmethod(report_error_to_forgejo)
# ─── models (read-only for queries) ───
ReportedError = ReportedError
# ─── self-registration ───
_contract = ForgejoErrorReporterContract()
get_contract_registry().register("forgejo_error_reporter", _contract)
__all__ = [
"ForgejoErrorReporterContract",
"report_error_to_forgejo",
"ReportedError",
]
@@ -35,7 +35,10 @@ class ForgejoErrorReporterPlugin(BasePlugin):
router_attr="router",
),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -94,6 +97,10 @@ class ForgejoErrorReporterPlugin(BasePlugin):
async def on_deactivate(self, db: Any, service_container: Any, event_bus: Any) -> None:
"""Deactivate the plugin."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
self._enabled = False
await super().on_deactivate(db, service_container, event_bus)
logger.info("Forgejo Error Reporter deactivated")
@@ -55,6 +55,9 @@ class KommunikationPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -125,6 +128,10 @@ class KommunikationPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up registries."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
logger.info("Kommunikation plugin deactivated")
+8
View File
@@ -69,6 +69,10 @@ class MailPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.email', label='E-Mails', component='@/components/contact/ContactMailTab', icon='Mail', order=20, permission='mail:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["mail.before_send", "mail.after_send"],
contract_version="1.0.0",
)
async def on_activate(
@@ -100,6 +104,10 @@ class MailPlugin(BasePlugin):
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: stop auto-sync task + unregister events."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
if self._auto_sync_task is not None and not self._auto_sync_task.done():
self._auto_sync_task.cancel()
try:
+17
View File
@@ -1545,6 +1545,19 @@ async def send_mail_via_smtp(
filename=filename,
)
# ── Hook: mail.before_send (Filter) ──
from app.core.hooks import apply_filters
mail_data = {
"subject": subject,
"body_html": body_html,
"body_text": body_text,
"to_addrs": to_addrs,
"cc_addrs": cc_addrs,
"bcc_addrs": bcc_addrs,
"attachment_paths": attachment_paths,
}
mail_data = await apply_filters("mail.before_send", mail_data)
# Send via SMTP
password = await get_account_password(account)
try:
@@ -1559,6 +1572,10 @@ async def send_mail_via_smtp(
await smtp.send_message(msg, recipients=recipients)
await smtp.quit()
# ── Hook: mail.after_send (Action) ──
from app.core.hooks import do_action
await do_action("mail.after_send", mail_data, db=db, account=account, msg_id=msg_id)
# Store sent mail in Sent folder — use configured mapping if set,
# otherwise flexible lookup to handle different IMAP naming conventions
if account.sent_folder_imap_name:
@@ -0,0 +1,47 @@
"""Public contract for the mcp_client plugin.
Exposes the MCP client and server config model for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mcp_client.models import McpServerConfig
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.schemas import (
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolInfo,
McpServerToolsResponse,
)
class McpClientContract:
"""Public API surface for the mcp_client plugin."""
contract_name = "mcp_client"
# ─── models ───
McpServerConfig = McpServerConfig
# ─── client ───
McpClient = McpClient
# ─── schemas ───
McpServerExecuteRequest = McpServerExecuteRequest
McpServerExecuteResponse = McpServerExecuteResponse
McpServerToolInfo = McpServerToolInfo
McpServerToolsResponse = McpServerToolsResponse
# ─── self-registration ───
_contract = McpClientContract()
get_contract_registry().register("mcp_client", _contract)
__all__ = [
"McpClientContract",
"McpServerConfig",
"McpClient",
]
+13 -1
View File
@@ -29,4 +29,16 @@ class McpClientPlugin(BasePlugin):
"mcp-client:write",
"mcp-client:admin",
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,57 @@
"""Public contract for the mcp_server plugin.
Exposes tool definitions and schemas for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mcp_server.tool_definitions import (
TOOL_DEFINITIONS,
TOOL_HANDLERS,
get_all_tool_names,
get_tool_definition,
)
from app.plugins.builtins.mcp_server.schemas import (
McpToolDefinition,
McpToolExecuteRequest,
McpToolExecuteResponse,
McpToolListResponse,
McpToolParameter,
McpServerConfig,
)
class McpServerContract:
"""Public API surface for the mcp_server plugin."""
contract_name = "mcp_server"
# ─── tool_definitions ───
ToolDefinitions = TOOL_DEFINITIONS
ToolHandlers = TOOL_HANDLERS
get_tool_definition = staticmethod(get_tool_definition)
get_all_tool_names = staticmethod(get_all_tool_names)
# ─── schemas ───
McpToolDefinition = McpToolDefinition
McpToolExecuteRequest = McpToolExecuteRequest
McpToolExecuteResponse = McpToolExecuteResponse
McpToolListResponse = McpToolListResponse
McpToolParameter = McpToolParameter
McpServerConfig = McpServerConfig
# ─── self-registration ───
_contract = McpServerContract()
get_contract_registry().register("mcp_server", _contract)
__all__ = [
"McpServerContract",
"ToolDefinitions",
"McpToolDefinition",
"McpToolParameter",
"McpServerConfig",
]
+13 -1
View File
@@ -28,4 +28,16 @@ class McpServerPlugin(BasePlugin):
"mcp:read",
"mcp:write",
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
+14 -1
View File
@@ -2,15 +2,25 @@
from __future__ import annotations
from app.plugins.builtins.permissions.models import Permission
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.permissions.models import Permission, ShareLink
class PermissionsContract:
"""Public contract for the permissions plugin."""
contract_name = "permissions"
Permission = Permission
ShareLink = ShareLink
# ─── self-registration ───
_contract = PermissionsContract()
get_contract_registry().register("permissions", _contract)
# Backward-compatible local accessor
_contract_instance: PermissionsContract | None = None
@@ -19,3 +29,6 @@ def get_contract() -> PermissionsContract:
if _contract_instance is None:
_contract_instance = PermissionsContract()
return _contract_instance
__all__ = ["PermissionsContract", "Permission", "ShareLink"]
@@ -36,4 +36,16 @@ class PermissionsPlugin(BasePlugin):
FrontendSettingsPage(path='users', label_key='settings.users', label='Users', component='@/pages/SettingsUsers', icon='Users', order=11, permission='permissions:read'),
FrontendSettingsPage(path='groups', label_key='settings.groups', label='Groups', component='@/pages/SettingsGroups', icon='UsersRound', order=12, permission='permissions:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,57 @@
"""Public contract for the report_generator plugin.
Exposes models and PDF generation functions for other plugins.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.report_generator.models import (
ReportInstance,
ReportTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import (
PRESET_META,
PRESET_TEMPLATES,
generate_pdf,
generate_pdf_from_template_content,
generate_preset_report,
generate_print_pdf,
get_preset_list,
render_template_file,
render_template_string,
)
class ReportGeneratorContract:
"""Public API surface for the report_generator plugin."""
contract_name = "report_generator"
# ─── models ───
ReportTemplate = ReportTemplate
ReportInstance = ReportInstance
# ─── pdf_generator ───
generate_pdf = staticmethod(generate_pdf)
generate_print_pdf = staticmethod(generate_print_pdf)
generate_preset_report = staticmethod(generate_preset_report)
generate_pdf_from_template_content = staticmethod(generate_pdf_from_template_content)
render_template_file = staticmethod(render_template_file)
render_template_string = staticmethod(render_template_string)
get_preset_list = staticmethod(get_preset_list)
PRESET_META = PRESET_META
PRESET_TEMPLATES = PRESET_TEMPLATES
# ─── self-registration ───
_contract = ReportGeneratorContract()
get_contract_registry().register("report_generator", _contract)
__all__ = [
"ReportGeneratorContract",
"ReportTemplate",
"ReportInstance",
]
@@ -32,4 +32,16 @@ class ReportGeneratorPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,38 @@
"""Public contract for the system_notif plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
sn = get_contract("system_notif")
if sn:
# use sn.SystemParticipantHandler
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
class SystemNotifContract:
"""Public API surface for the system_notif plugin."""
contract_name = "system_notif"
# ─── participant handler ───
SystemParticipantHandler = SystemParticipantHandler
# ─── self-registration ───
_contract = SystemNotifContract()
get_contract_registry().register("system_notif", _contract)
__all__ = [
"SystemNotifContract",
"SystemParticipantHandler",
]
+7 -1
View File
@@ -43,7 +43,10 @@ class SystemNotifPlugin(BasePlugin):
settings_pages=[
FrontendSettingsPage(path='notifications', label_key='settings.notifications', label='Notifications', component='@/pages/SettingsNotifications', icon='Bell', order=40),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -64,6 +67,9 @@ class SystemNotifPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister participant."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
get_participant_registry().unregister("system")
+40
View File
@@ -0,0 +1,40 @@
"""Public contract for the tags plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
tags = get_contract("tags")
if tags:
# use tags.Tag, tags.TagAssignment
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.tags.models import Tag, TagAssignment
class TagsContract:
"""Public API surface for the tags plugin."""
contract_name = "tags"
# ─── models (read-only for queries) ───
Tag = Tag
TagAssignment = TagAssignment
# ─── self-registration ───
_contract = TagsContract()
get_contract_registry().register("tags", _contract)
__all__ = [
"TagsContract",
"Tag",
"TagAssignment",
]
+13 -1
View File
@@ -34,4 +34,16 @@ class TagsPlugin(BasePlugin):
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.tags', label='Tags', component='@/components/contact/ContactTagsTab', icon='Tag', order=50, permission='tags:read'),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
+66
View File
@@ -0,0 +1,66 @@
"""Public contract for the tasks plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
tasks = get_contract("tasks")
if tasks:
await tasks.create_task(db, tenant_id, user_id, data)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.tasks.models import Task
from app.plugins.builtins.tasks.services import (
assign_task,
create_task,
delete_task,
get_due_tasks,
get_task,
list_tasks,
update_task,
update_task_status,
)
class TasksContract:
"""Public API surface for the tasks plugin."""
contract_name = "tasks"
# ─── services ───
list_tasks = staticmethod(list_tasks)
get_task = staticmethod(get_task)
create_task = staticmethod(create_task)
update_task = staticmethod(update_task)
delete_task = staticmethod(delete_task)
assign_task = staticmethod(assign_task)
update_task_status = staticmethod(update_task_status)
get_due_tasks = staticmethod(get_due_tasks)
# ─── models (read-only for queries) ───
Task = Task
# ─── self-registration ───
_contract = TasksContract()
get_contract_registry().register("tasks", _contract)
__all__ = [
"TasksContract",
"Task",
"list_tasks",
"get_task",
"create_task",
"update_task",
"delete_task",
"assign_task",
"update_task_status",
"get_due_tasks",
]
+14 -1
View File
@@ -62,4 +62,17 @@ class TasksPlugin(BasePlugin):
plugin_name="tasks",
),
],
)
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["contact.after_create"],
contract_version="1.0.0")
async def on_deactivate(
self, db, service_container, event_bus
) -> None:
"""Deactivate plugin: unregister contract and event listeners."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus)
+7 -1
View File
@@ -21,7 +21,10 @@ class TestSamplePlugin(BasePlugin):
events=["contact.created"],
migrations=["0001_test_plugin.sql"],
permissions=[],
)
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0")
def __init__(self) -> None:
super().__init__()
@@ -39,6 +42,9 @@ class TestSamplePlugin(BasePlugin):
await super().on_activate(db, service_container, event_bus)
async def on_deactivate(self, db, service_container, event_bus) -> None:
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
self.deactivate_called = True
await super().on_deactivate(db, service_container, event_bus)
@@ -0,0 +1,38 @@
"""Public contract for the test_sample plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
ts = get_contract("test_sample")
if ts:
# use ts.TestSamplePlugin
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.test_sample import TestSamplePlugin
class TestSampleContract:
"""Public API surface for the test_sample plugin."""
contract_name = "test_sample"
# ─── plugin class ───
TestSamplePlugin = TestSamplePlugin
# ─── self-registration ───
_contract = TestSampleContract()
get_contract_registry().register("test_sample", _contract)
__all__ = [
"TestSampleContract",
"TestSamplePlugin",
]
@@ -2,6 +2,7 @@
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.search_engine import hybrid_search
@@ -9,10 +10,18 @@ from app.plugins.builtins.unified_search.search_engine import hybrid_search
class UnifiedSearchContract:
"""Public contract for the unified_search plugin."""
contract_name = "unified_search"
generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search)
# ─── self-registration ───
_contract = UnifiedSearchContract()
get_contract_registry().register("unified_search", _contract)
# Backward-compatible local accessor
_contract_instance: UnifiedSearchContract | None = None
@@ -21,3 +30,6 @@ def get_contract() -> UnifiedSearchContract:
if _contract_instance is None:
_contract_instance = UnifiedSearchContract()
return _contract_instance
__all__ = ["UnifiedSearchContract", "generate_embedding", "hybrid_search"]
@@ -43,6 +43,9 @@ class UnifiedSearchPlugin(BasePlugin):
page_routes=[
FrontendPageRoute(path='/search', component='@/pages/GlobalSearchResults', protected=True),
],
author="LeoCRM Team",
min_app_version="1.0.0",
contract_version="1.0.0",
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -59,6 +62,9 @@ class UnifiedSearchPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clear provider registry on deactivation."""
# Contract abmelden
from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name)
from app.plugins.builtins.unified_search.provider_registry import (
get_search_registry,
)
+86
View File
@@ -232,6 +232,31 @@ class PluginManifest(BaseModel):
custom_fields: list[CustomFieldDefinition] = Field(
default_factory=list, description="Custom field definitions contributed by this plugin"
)
# ── Versioning (Phase 4) ──
min_app_version: str = Field(
default="0.0.0",
description="Minimum LeoCRM version required (SemVer)"
)
# ── Marketplace (Phase 5) ──
author: str = Field(default="", max_length=200, description="Plugin author name")
author_email: str = Field(default="", max_length=200, description="Author contact email")
homepage: str = Field(default="", max_length=500, description="Plugin homepage URL")
license: str = Field(default="MIT", max_length=50, description="License identifier")
icon: str = Field(default="", description="Icon URL or emoji")
screenshots: list[str] = Field(default_factory=list, description="Screenshot URLs for marketplace")
changelog: str = Field(default="", description="Changelog URL or inline text")
marketplace_tags: list[str] = Field(default_factory=list, description="Marketplace category tags")
price: float = Field(default=0.0, ge=0.0, description="Price (0 = free)")
# ── Hooks (Phase 2) ──
hooks: list[str] = Field(
default_factory=list,
description="Hook names this plugin registers (e.g. 'contact.before_create')"
)
# ── Contracts (Phase 1) ──
contract_version: str = Field(
default="1.0.0",
description="Contract API version this plugin exposes"
)
@field_validator("name")
@@ -241,6 +266,27 @@ class PluginManifest(BaseModel):
raise ValueError("Plugin name must be alphanumeric with underscores only")
return v.lower()
@field_validator("min_app_version")
@classmethod
def validate_min_app_version(cls, v: str) -> str:
"""Validate min_app_version is valid SemVer."""
if v and v != "0.0.0":
from app.plugins.semver import SemVer
SemVer.parse(v) # Raises ValueError if invalid
return v
@field_validator("hooks")
@classmethod
def validate_hooks(cls, v: list[str]) -> list[str]:
"""Validate hook names follow namespace.action pattern."""
import re
for hook in v:
if not re.match(r"^[a-z_]+\.[a-z_]+$", hook):
raise ValueError(
f"Invalid hook name '{hook}': must be 'namespace.action' (lowercase, underscores only)"
)
return v
model_config = {"extra": "forbid"}
@@ -323,6 +369,46 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
"required": "false",
"description": "Dashboard widgets (id, label_key, component, col_span, permission)",
},
"min_app_version": {
"type": "str",
"required": "false",
"description": "Minimum LeoCRM version required (SemVer, default: 0.0.0)",
},
"author": {
"type": "str",
"required": "false",
"description": "Plugin author name",
},
"license": {
"type": "str",
"required": "false",
"description": "License identifier (default: MIT)",
},
"homepage": {
"type": "str",
"required": "false",
"description": "Plugin homepage URL",
},
"hooks": {
"type": "list[str]",
"required": "false",
"description": "Hook names this plugin registers (e.g. 'contact.before_create')",
},
"contract_version": {
"type": "str",
"required": "false",
"description": "Contract API version this plugin exposes (default: 1.0.0)",
},
"marketplace_tags": {
"type": "list[str]",
"required": "false",
"description": "Marketplace category tags",
},
"price": {
"type": "float",
"required": "false",
"description": "Price (0 = free, default: 0.0)",
},
},
example=PluginManifest(
name="example_plugin",
+172
View File
@@ -190,6 +190,178 @@ class MigrationRunner:
return dropped_tables
async def get_applied_migrations(
self,
db: AsyncSession,
plugin_name: str,
) -> list[str]:
"""Get all applied migration filenames for a plugin, sorted by application order.
Args:
db: Async database session.
plugin_name: Name of the plugin.
Returns:
List of migration filenames sorted by application order (oldest first).
"""
from sqlalchemy import select
result = await db.execute(
select(PluginMigration)
.where(
PluginMigration.plugin_name == plugin_name,
PluginMigration.status == "applied",
)
.order_by(PluginMigration.id)
)
return [row.migration_file for row in result.scalars().all()]
async def _find_down_sql(
self,
migration_filename: str,
plugin_name: str | None = None,
) -> str | None:
"""Find rollback SQL for a migration.
Search order:
1. A dedicated down file: <migration_filename>_down.sql
2. A `-- DOWN:` block inside the original migration file
Returns the rollback SQL string, or None if no rollback is found.
"""
# 1. Try dedicated down file
base, ext = os.path.splitext(migration_filename)
down_filename = f"{base}_down{ext}"
try:
down_path = self._resolve_migration_path(down_filename, plugin_name)
return down_path.read_text(encoding="utf-8")
except FileNotFoundError:
pass
# 2. Try parsing -- DOWN: block from the original migration file
try:
up_path = self._resolve_migration_path(migration_filename, plugin_name)
content = up_path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
down_marker = "-- DOWN:"
if down_marker in content:
parts = content.split(down_marker, 1)
if len(parts) == 2:
down_sql = parts[1].strip()
return down_sql if down_sql else None
return None
async def run_migration_down(
self,
db: AsyncSession,
plugin_name: str,
migration_filename: str,
) -> None:
"""Roll back a single migration.
Searches for rollback SQL (dedicated _down.sql file or -- DOWN: block
in the original migration), executes it, and removes the migration
record from plugin_migrations.
Args:
db: Async database session.
plugin_name: Name of the plugin.
migration_filename: Filename of the migration to roll back.
Raises:
FileNotFoundError: If no rollback SQL can be found.
"""
down_sql = await self._find_down_sql(migration_filename, plugin_name)
if down_sql is None:
raise FileNotFoundError(
f"No rollback SQL found for migration '{migration_filename}'. "
f"Create a '{migration_filename.replace('.sql', '_down.sql')}' file "
f"or add a '-- DOWN:' section to the migration file."
)
# Execute the rollback SQL
statements = self._split_sql(down_sql)
for stmt in statements:
stmt = stmt.strip()
if stmt:
await db.execute(text(stmt))
await db.flush()
# Remove the migration record
from sqlalchemy import select
result = await db.execute(
select(PluginMigration).where(
PluginMigration.plugin_name == plugin_name,
PluginMigration.migration_file == migration_filename,
PluginMigration.status == "applied",
)
)
record = result.scalar_one_or_none()
if record:
await db.delete(record)
await db.flush()
async def rollback_to_version(
self,
db: AsyncSession,
plugin_name: str,
target_version: str,
) -> list[str]:
"""Roll back all applied migrations after a target version.
Migrations are rolled back in reverse order (newest first) until
the target version is reached. The plugin version in the database
is updated to the target version.
Args:
db: Async database session.
plugin_name: Name of the plugin.
target_version: Target version string (e.g. '0002'). Migrations
with filenames greater than this will be rolled back.
Returns:
List of migration filenames that were rolled back.
"""
applied = await self.get_applied_migrations(db, plugin_name)
# Filter migrations after target_version (by filename sort order)
migrations_to_rollback = [
m for m in applied if m > target_version
]
if not migrations_to_rollback:
return []
# Roll back in reverse order (newest first)
rolled_back: list[str] = []
for migration_filename in reversed(migrations_to_rollback):
await self.run_migration_down(db, plugin_name, migration_filename)
rolled_back.append(migration_filename)
# Update plugin version in DB (if a plugin_versions table exists)
try:
from sqlalchemy import select, update as sa_update
result = await db.execute(
text("SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'plugin_versions'")
)
if result.fetchone():
await db.execute(
text("UPDATE plugin_versions SET version = :version "
"WHERE plugin_name = :plugin_name"),
{"version": target_version, "plugin_name": plugin_name},
)
await db.flush()
except Exception:
pass # plugin_versions table may not exist — that's ok
return rolled_back
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
"""Get current table names using the session's own connection.
+215
View File
@@ -0,0 +1,215 @@
"""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
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
manifest_info = _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():
shutil.rmtree(temp_dir, ignore_errors=True)
+80
View File
@@ -104,6 +104,67 @@ class PluginRegistry:
return discovered
def discover_external(self) -> list[str]:
"""Discover plugins from an external plugins/ directory.
Scans the directory specified by EXTERNAL_PLUGINS_PATH env var
(default: 'plugins/') for plugin packages.
Returns list of discovered plugin names.
"""
import os
import sys
discovered: list[str] = []
external_dir = Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
if not external_dir.exists():
return discovered
for plugin_dir in sorted(external_dir.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
continue
# Look for plugin.py or __init__.py
plugin_file = plugin_dir / "plugin.py"
init_file = plugin_dir / "__init__.py"
if not plugin_file.exists() and not init_file.exists():
continue
try:
# Add to sys.path temporarily
str_dir = str(external_dir)
if str_dir not in sys.path:
sys.path.insert(0, str_dir)
module_name = f"{plugin_dir.name}.plugin" if plugin_file.exists() else plugin_dir.name
module = importlib.import_module(module_name)
# Look for BasePlugin subclass
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
instance = attr()
if instance.name not in self._plugins:
self._plugins[instance.name] = instance
discovered.append(instance.name)
logger.info(f"Discovered external plugin: {instance.name} v{instance.version}")
except Exception as exc:
logger.warning(f"Failed to load external plugin {plugin_dir.name}: {exc}")
return discovered
def discover_all(self) -> list[str]:
"""Discover built-in AND external plugins."""
discovered = self.discover_builtins()
discovered.extend(self.discover_external())
return discovered
def register_plugin(self, plugin: BasePlugin) -> None:
"""Manually register a plugin instance."""
self._plugins[plugin.name] = plugin
@@ -465,6 +526,25 @@ class PluginRegistry:
await self._check_and_run_version_migrations(db, name, existing)
return existing
# Check app version compatibility
from app.plugins.semver import SemVer
from app.config import get_settings
settings = get_settings()
app_version = getattr(settings, "app_version", "0.0.0")
min_version = plugin.manifest.min_app_version
if min_version and min_version != "0.0.0":
try:
if not SemVer.parse(app_version).is_compatible_with(SemVer.parse(min_version)):
raise ValueError(
f"Plugin '{name}' requires LeoCRM >= {min_version}, "
f"but current version is {app_version}"
)
except ValueError as e:
if "Invalid semver" in str(e):
pass # Skip check if version is not valid SemVer
else:
raise
# Check dependencies are installed
await self._check_dependencies_installed(db, name)
+215
View File
@@ -0,0 +1,215 @@
"""Semantic version comparison for plugin versions.
Supports parsing, comparison, and compatibility checks for SemVer strings.
Handles pre-release versions (e.g. 1.0.0-alpha.1) per SemVer spec.
Usage::
from app.plugins.semver import SemVer
v1 = SemVer.parse("1.2.3")
v2 = SemVer.parse("1.3.0")
if v1 < v2:
print(f"{v1} is older than {v2}")
if v1.is_breaking_change(v2):
print("Major version changed — breaking!")
if v2.is_compatible_with(v1):
print("v2 is compatible with v1")
"""
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class SemVer:
"""A semantic version following semver.org spec.
Attributes:
major: Major version (breaking changes).
minor: Minor version (new features, backward compatible).
patch: Patch version (bug fixes, backward compatible).
prerelease: Optional pre-release string (e.g. "alpha.1", "beta.2").
"""
major: int
minor: int
patch: int
prerelease: str = ""
@classmethod
def parse(cls, version: str) -> SemVer:
"""Parse a SemVer string into a SemVer instance.
Args:
version: Version string like "1.2.3" or "1.2.3-alpha.1".
Returns:
SemVer instance.
Raises:
ValueError: If the version string is not valid SemVer.
"""
if not version:
raise ValueError("Version string is empty")
# Strip leading 'v' if present
version = version.strip().lstrip("v")
match = re.match(
r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$",
version,
)
if not match:
raise ValueError(
f"Invalid semver '{version}': expected MAJOR.MINOR.PATCH[-prerelease]"
)
return cls(
major=int(match.group(1)),
minor=int(match.group(2)),
patch=int(match.group(3)),
prerelease=match.group(4) or "",
)
def __str__(self) -> str:
base = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
return f"{base}-{self.prerelease}"
return base
def __repr__(self) -> str:
return f"SemVer({self!s})"
def __lt__(self, other: SemVer) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
# Compare major.minor.patch
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
# Pre-release versions are lower than release versions
if not self.prerelease and other.prerelease:
return False
if self.prerelease and not other.prerelease:
return True
# Both have pre-release — compare lexically
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
def __eq__(self, other: object) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
return (
self.major == other.major
and self.minor == other.minor
and self.patch == other.patch
and self.prerelease == other.prerelease
)
def __le__(self, other: SemVer) -> bool:
return self == other or self < other
def __gt__(self, other: SemVer) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
return not self <= other
def __ge__(self, other: SemVer) -> bool:
return not self < other
def __hash__(self) -> int:
return hash((self.major, self.minor, self.patch, self.prerelease))
# ─── Compatibility checks ───
def is_breaking_change(self, other: SemVer) -> bool:
"""Return True if the major version differs (breaking change)."""
return self.major != other.major
def is_compatible_with(self, min_version: SemVer) -> bool:
"""Return True if this version satisfies the minimum version requirement.
A version is compatible if:
- Same major version and >= min_version, OR
- Higher major version (forward compatible)
"""
if self.major > min_version.major:
return True
if self.major < min_version.major:
return False
# Same major — compare minor.patch
return self >= min_version
def is_upgrade_from(self, old_version: SemVer) -> bool:
"""Return True if this version is newer than old_version."""
return self > old_version
def is_downgrade_from(self, old_version: SemVer) -> bool:
"""Return True if this version is older than old_version."""
return self < old_version
# ─── Internal helpers ───
@staticmethod
def _compare_prerelease(a: str, b: str) -> int:
"""Compare two pre-release strings per SemVer spec.
Numeric identifiers are compared numerically, alphanumeric lexically.
"""
a_parts = a.split(".")
b_parts = b.split(".")
for i in range(min(len(a_parts), len(b_parts))):
ap, bp = a_parts[i], b_parts[i]
a_is_num = ap.isdigit()
b_is_num = bp.isdigit()
if a_is_num and b_is_num:
ai, bi = int(ap), int(bp)
if ai < bi:
return -1
if ai > bi:
return 1
elif a_is_num and not b_is_num:
return -1 # Numeric < alphanumeric
elif not a_is_num and b_is_num:
return 1 # Alphanumeric > numeric
else:
if ap < bp:
return -1
if ap > bp:
return 1
# All compared parts are equal — shorter pre-release is lower
return len(a_parts) - len(b_parts)
def compare_versions(v1: str, v2: str) -> int:
"""Compare two version strings.
Returns:
-1 if v1 < v2
0 if v1 == v2
1 if v1 > v2
"""
sv1 = SemVer.parse(v1)
sv2 = SemVer.parse(v2)
if sv1 < sv2:
return -1
if sv1 > sv2:
return 1
return 0
def is_breaking_change(old: str, new: str) -> bool:
"""Check if upgrading from old to new is a breaking change."""
return SemVer.parse(old).is_breaking_change(SemVer.parse(new))
def is_compatible(current: str, min_required: str) -> bool:
"""Check if current version satisfies the minimum required version."""
return SemVer.parse(current).is_compatible_with(SemVer.parse(min_required))
+128
View File
@@ -0,0 +1,128 @@
"""Plugin signature verification for external plugins.
Uses Ed25519 signatures to verify that a plugin ZIP package
has not been tampered with and comes from a trusted source.
Usage::
from app.plugins.signature import PluginSignature
# Verify a downloaded plugin
is_valid = PluginSignature.verify_signature(
zip_path=Path("plugin.zip"),
signature=b"...",
public_key=b"...",
)
# Compute hash for allowlist
file_hash = PluginSignature.compute_hash(Path("plugin.zip"))
# Sign a plugin (for plugin authors)
signature = PluginSignature.sign_plugin(
zip_path=Path("plugin.zip"),
private_key=b"...",
)
"""
from __future__ import annotations
import hashlib
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class PluginSignature:
"""Verify plugin package signatures using Ed25519."""
@staticmethod
def compute_hash(file_path: Path) -> str:
"""Compute SHA-256 hash of a file.
Args:
file_path: Path to the file to hash.
Returns:
Hex-encoded SHA-256 hash string.
"""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
@staticmethod
def verify_signature(
zip_path: Path,
signature: bytes,
public_key: bytes,
) -> bool:
"""Verify Ed25519 signature of a plugin ZIP.
Args:
zip_path: Path to the plugin ZIP file.
signature: The Ed25519 signature bytes.
public_key: The Ed25519 public key bytes.
Returns:
True if the signature is valid, False otherwise.
"""
try:
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
file_hash = PluginSignature.compute_hash(zip_path)
verify_key = VerifyKey(public_key)
verify_key.verify(file_hash.encode(), signature)
logger.info("Signature verified for %s", zip_path.name)
return True
except ImportError:
logger.warning(
"PyNaCl not installed — signature verification disabled. "
"Install with: pip install pynacl"
)
return False
except BadSignatureError:
logger.warning("Invalid signature for %s", zip_path.name)
return False
except Exception:
logger.exception("Error verifying signature for %s", zip_path.name)
return False
@staticmethod
def sign_plugin(
zip_path: Path,
private_key: bytes,
) -> bytes:
"""Sign a plugin ZIP with an Ed25519 private key.
Args:
zip_path: Path to the plugin ZIP file.
private_key: The Ed25519 private key bytes.
Returns:
The Ed25519 signature bytes.
Raises:
ImportError: If PyNaCl is not installed.
"""
from nacl.signing import SigningKey
file_hash = PluginSignature.compute_hash(zip_path)
signing_key = SigningKey(private_key)
return signing_key.sign(file_hash.encode()).signature
@staticmethod
def generate_keypair() -> tuple[bytes, bytes]:
"""Generate a new Ed25519 key pair.
Returns:
Tuple of (private_key, public_key) bytes.
"""
from nacl.signing import SigningKey
signing_key = SigningKey.generate()
private_key = bytes(signing_key)
public_key = bytes(signing_key.verify_key)
return private_key, public_key