"""Plugin review workflow. Manages the approval queue for newly submitted plugins and enforces a security checklist before any plugin is made visible in the marketplace. Module-level singleton:: from app.marketplace.plugin_review import review_queue """ from __future__ import annotations import re import time from typing import Any, Literal from app.marketplace.plugin_registry import registry from app.schemas import PluginManifest # ── Security policy ─────────────────────────────────────────────────── ALLOWED_PERMISSIONS: frozenset[str] = frozenset( { "read:tasks", "write:tasks", "read:projects", "write:projects", "read:notes", "write:notes", "read:checkpoints", "write:checkpoints", "read:calendar", "write:calendar", } ) _PLUGIN_ID_RE = re.compile(r"^[a-z0-9-]+$") def validate_manifest(manifest: PluginManifest) -> None: """Enforce the plugin security checklist. Raises: ``ValueError`` on the first violation found. Callers should catch this and return HTTP 422 / reject the submission. Checks: 1. Plugin id matches ``^[a-z0-9-]+$`` 2. All declared permissions are in ``ALLOWED_PERMISSIONS`` 3. No manifest field contains raw binary data """ if not _PLUGIN_ID_RE.match(manifest.id): raise ValueError( f"Invalid plugin id format: '{manifest.id}'. " "Only lowercase letters, digits, and hyphens are allowed." ) for perm in manifest.permissions: if perm not in ALLOWED_PERMISSIONS: raise ValueError( f"Unknown permission: '{perm}'. " f"Allowed permissions: {sorted(ALLOWED_PERMISSIONS)}" ) for field_name, value in manifest.model_dump().items(): if isinstance(value, (bytes, bytearray)): raise ValueError( f"Binary content is not allowed in manifest field '{field_name}'." ) class ReviewQueue: """Approval queue for pending plugin submissions. Delegates status changes to the shared ``PluginRegistry`` singleton so there is a single source of truth for plugin state. """ def __init__(self) -> None: # Completed reviews — Step 12 stores in plugin_reviews table self._reviews: list[dict[str, Any]] = [] async def get_pending(self) -> list[dict[str, Any]]: """Return all plugins currently awaiting review. Each item is ``{plugin_id, manifest, submitted_at}``. """ entries = registry._get_pending_entries() return [ { "plugin_id": e["manifest"].id, "manifest": e["manifest"], "submitted_at": e["submitted_at"], } for e in entries ] async def submit_review( self, plugin_id: str, reviewer_id: str, decision: Literal["approved", "rejected"], notes: str = "", ) -> None: """Record a review decision and update the plugin's status. Raises: ``KeyError`` if *plugin_id* is not found in the registry. """ if decision == "approved": await registry.approve_plugin(plugin_id) else: await registry.reject_plugin(plugin_id, reason=notes) self._reviews.append( { "plugin_id": plugin_id, "reviewer_id": reviewer_id, "decision": decision, "notes": notes, "reviewed_at": int(time.time()), } ) # Module-level singleton review_queue = ReviewQueue()