- 迁移 17:plugin_execution(五态、退出码、时长、stdout 摘要、错误留痕) - 沙箱执行器:独立子进程(代码经 stdin JSON 安全传输,不进命令行)、CPU rlimit + RLIMIT_AS(平台不支持时尽力而为并注明)、墙钟超时强杀、输出限额;runner 仅暴露 input/output 作用域 - 执行端点:isolated_process 形态、已安装且启用后可执行;**未启用沙箱一律 503 fail-closed**(HUNJING_SANDBOX_ENABLED 显式开启,审计记录拒绝原因)——AGENTS 第 6 节"无符合策略的 Sandbox 时拒绝执行"落地 - 每次执行落 plugin_execution + 审计(状态/时长);进程级隔离的边界如实注明(网络/文件系统最小化在 Linux 容器档交付) - 测试 96 项全绿(+2):echo 真实执行回填、死循环 5s 超时强杀、异常 failed 含 stderr、无沙箱 503
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""插件域: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
|