"""插件域:Registry、安装三态与调用门(WP-07 / ADR-001/008)。""" import hashlib import json from typing import Any from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models import AuditEvent, Plugin, PluginInstallation, PluginVersion def _hash_manifest(manifest: dict[str, Any]) -> str: canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False) return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" def _manifest_permissions(manifest: dict[str, Any]) -> set[str]: """权限标识统一为 group:item(如 network:api.example.com)。""" perms = manifest.get("permissions", {}) result: set[str] = set() if isinstance(perms, dict): for group, items in perms.items(): if isinstance(items, list): result.update(f"{group}:{i}" for i in items) return result async def publish_version( db: AsyncSession, publisher_domain_id: str, body: dict[str, Any], trace_id: str | None, ) -> tuple[Plugin, PluginVersion]: """发布插件版本:Manifest 声明权限与行为;内容签名=规范化哈希。 安装展示数据、网络、设备、费用、外部目的地与现实后果(ADR/契约)。 """ name = str(body.get("name", "")).strip() if not name or "." not in name: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 须为点分稳定名") version = str(body.get("version", "")).strip() if not version: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "version 不能为空") manifest = body.get("manifest", {}) if not isinstance(manifest, dict) or "permissions" not in manifest: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "manifest 必须声明 permissions") plugin = await db.get(Plugin, name) if plugin is None: plugin = Plugin( name=name, publisher_domain_id=publisher_domain_id, kind=str(body.get("kind", "declarative")), ) db.add(plugin) await db.flush() elif plugin.publisher_domain_id != publisher_domain_id: raise HTTPException(status.HTTP_409_CONFLICT, "插件名已被其他发布者占用") existing = await db.execute( select(PluginVersion).where( PluginVersion.plugin_name == name, PluginVersion.version == version ) ) if existing.scalar_one_or_none() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "版本已存在") pv = PluginVersion( plugin_name=name, version=version, manifest=manifest, content_hash=_hash_manifest(manifest), ) db.add(pv) await db.flush() db.add( AuditEvent( actor_type="user", actor_id=publisher_domain_id, action="plugin.published", aggregate_type="plugin", aggregate_id=name, payload={"version": version, "hash": pv.content_hash}, trace_id=trace_id, ) ) await db.flush() return plugin, pv async def withdraw( db: AsyncSession, publisher_domain_id: str, name: str, trace_id: str | None ) -> Plugin: """安全撤架:阻断新调用,保留历史审计与安装记录(不静默删除)。""" plugin = await db.get(Plugin, name) if plugin is None or plugin.publisher_domain_id != publisher_domain_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在") plugin.status = "removed" await db.execute( select(PluginVersion).where(PluginVersion.plugin_name == name) # 触发加载 ) for pv in ( await db.scalars(select(PluginVersion).where(PluginVersion.plugin_name == name)) ).all(): pv.status = "removed" db.add( AuditEvent( actor_type="user", actor_id=publisher_domain_id, action="plugin.withdrawn", aggregate_type="plugin", aggregate_id=name, trace_id=trace_id, ) ) await db.flush() return plugin async def install( db: AsyncSession, domain_id: str, name: str, version: str, trace_id: str | None, ) -> PluginInstallation: """安装:默认 disabled、零授权——购买≠安装≠启用≠授权(WP-07 验证项)。""" plugin = await db.get(Plugin, name) if plugin is None or plugin.status != "published": raise HTTPException(status.HTTP_404_NOT_FOUND, "插件不存在或已撤架") existing_install = await db.execute( select(PluginInstallation).where( PluginInstallation.domain_id == domain_id, PluginInstallation.plugin_name == name, ) ) if existing_install.scalar_one_or_none() is not None: raise HTTPException(status.HTTP_409_CONFLICT, "已安装(升级请走升级端点)") pv = ( await db.execute( select(PluginVersion).where( PluginVersion.plugin_name == name, PluginVersion.version == version, PluginVersion.status == "published", ) ) ).scalar_one_or_none() if pv is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在或不可用") installation = PluginInstallation( domain_id=domain_id, plugin_name=name, version_id=pv.id, enabled=False, granted_permissions=[], ) db.add(installation) await db.flush() db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.installed", aggregate_type="plugin", aggregate_id=name, payload={"version": version, "enabled": False}, trace_id=trace_id, ) ) await db.flush() return installation async def require_installation( db: AsyncSession, domain_id: str, installation_id: str ) -> PluginInstallation: installation = await db.get(PluginInstallation, installation_id) if installation is None or installation.domain_id != domain_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "安装不存在") return installation async def enable( db: AsyncSession, domain_id: str, installation_id: str, granted_permissions: list[str], trace_id: str | None, ) -> PluginInstallation: """启用:显式确认权限(授予集必须是 Manifest 声明的子集)。""" installation = await require_installation(db, domain_id, installation_id) pv = await db.get(PluginVersion, installation.version_id) assert pv is not None declared = _manifest_permissions(pv.manifest) granted = set(granted_permissions) if not granted.issubset(declared): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, f"授权超出 Manifest 声明:{sorted(granted - declared)}", ) installation.enabled = True installation.granted_permissions = sorted(granted) db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.enabled", aggregate_type="plugin", aggregate_id=installation.plugin_name, payload={"granted": sorted(granted)}, trace_id=trace_id, ) ) await db.flush() return installation async def upgrade( db: AsyncSession, domain_id: str, installation_id: str, version: str, granted_permissions: list[str] | None, trace_id: str | None, ) -> PluginInstallation: """升级:Manifest 新增权限必须重新确认(granted 覆盖新增项,否则 422)。""" installation = await require_installation(db, domain_id, installation_id) current = await db.get(PluginVersion, installation.version_id) assert current is not None target = ( await db.execute( select(PluginVersion).where( PluginVersion.plugin_name == installation.plugin_name, PluginVersion.version == version, PluginVersion.status == "published", ) ) ).scalar_one_or_none() if target is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "目标版本不存在") old_declared = _manifest_permissions(current.manifest) new_declared = _manifest_permissions(target.manifest) newly_added = new_declared - old_declared granted = ( set(granted_permissions) if granted_permissions is not None else set(installation.granted_permissions) ) if newly_added and not newly_added.issubset(granted): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, f"新版本新增权限需要重新确认:{sorted(newly_added - granted)}", ) if not granted.issubset(new_declared): raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "授权超出 Manifest 声明") installation.version_id = target.id installation.granted_permissions = sorted(granted) db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.upgraded", aggregate_type="plugin", aggregate_id=installation.plugin_name, payload={"to_version": version, "newly_added": sorted(newly_added)}, trace_id=trace_id, ) ) await db.flush() return installation async def invoke( db: AsyncSession, domain_id: str, name: str, input_: dict[str, Any], run_id: str | None, trace_id: str | None, ) -> dict[str, Any]: """调用门:撤架/未启用/未授权逐一校验;按 Manifest 声明真实执行。 声明式插件执行 response_template 回填(确定性、无副作用); remote/isolated 形态的传输执行随 WP-14 沙箱交付,当前未安装即拒绝。 """ plugin = await db.get(Plugin, name) if plugin is None or plugin.status != "published": raise HTTPException(status.HTTP_403_FORBIDDEN, "插件不可用(已撤架或不存在)") installation = ( await db.execute( select(PluginInstallation).where( PluginInstallation.domain_id == domain_id, PluginInstallation.plugin_name == name, ) ) ).scalar_one_or_none() if installation is None: raise HTTPException(status.HTTP_403_FORBIDDEN, "未安装") if not installation.enabled: raise HTTPException(status.HTTP_403_FORBIDDEN, "未启用") pv = await db.get(PluginVersion, installation.version_id) assert pv is not None if pv.status != "published": raise HTTPException(status.HTTP_403_FORBIDDEN, "已安装版本已撤架") if plugin.kind != "declarative": raise HTTPException( status.HTTP_501_NOT_IMPLEMENTED, "该形态执行随沙箱 Runtime 交付(WP-14)" ) template = str(pv.manifest.get("response_template", "")) output = {"executed": True, "runtime": "declarative", "response": template} db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.invoked", aggregate_type="plugin", aggregate_id=name, payload={ "version": pv.version, "run_id": run_id, "version_hash": pv.content_hash, # 快照语义:审计可追溯当时版本 }, trace_id=trace_id, ) ) await db.flush() return output async def execute_isolated( db: AsyncSession, domain_id: str, installation_id: str, input_: dict[str, Any], sandbox_enabled: bool, trace_id: str | None, ) -> tuple[Any, dict[str, Any]]: """隔离执行:isolated 形态插件经沙箱子进程运行;未启用沙箱一律拒绝。 返回(执行记录, 结果)。代码体取自 Manifest 的 code 字段 (部署者明确信任的本地代码插件,仍受资源限制约束)。 """ from app.models import PluginExecution from app.sandbox.executor import run_sandboxed installation = await require_installation(db, domain_id, installation_id) plugin = await db.get(Plugin, installation.plugin_name) assert plugin is not None if plugin.kind != "isolated_process": raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "仅 isolated_process 形态走沙箱执行" ) if not installation.enabled: raise HTTPException(status.HTTP_403_FORBIDDEN, "插件未启用") if not sandbox_enabled: db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.execution_denied", aggregate_type="plugin", aggregate_id=plugin.name, payload={"reason": "sandbox_not_enabled"}, trace_id=trace_id, ) ) await db.flush() raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "沙箱未启用:拒绝执行(fail-closed),启用请设置 HUNJING_SANDBOX_ENABLED", ) pv = await db.get(PluginVersion, installation.version_id) assert pv is not None code = str(pv.manifest.get("code", "")) execution = PluginExecution(installation_id=installation.id, status="running") db.add(execution) await db.flush() result = await run_sandboxed(code, input_) execution.status = result["status"] execution.exit_code = result.get("exit_code") execution.duration_ms = result.get("duration_ms") execution.stdout_excerpt = result.get("stdout_excerpt") execution.error = result.get("error") from datetime import UTC from datetime import datetime as _dt execution.completed_at = _dt.now(UTC) db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="plugin.executed", aggregate_type="plugin", aggregate_id=plugin.name, payload={"execution_id": execution.id, "status": execution.status, "duration_ms": execution.duration_ms}, trace_id=trace_id, ) ) await db.flush() return execution, result