feat(plugins): add managed runtime SDK and release 2.4.5

This commit is contained in:
chenmingxuan committed 2026-10-30 14:39:21 +08:00
1 parent 506e3bf1c5
commit 2a3bbaa3fb
45 files changed
+3348 -98

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
"""Evidence-bound market materials. Declarations still need source/rights review."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
import tempfile
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from artemis.config.settings import settings
from artemis.plugins.installer import _extract
from artemis.plugins.models import PluginManifest
REPORT_NAME = "MARKET.json"
CHECKS = (
"installation",
"tool_contract",
"failure_partial",
"timeout_cancel",
"process_tree",
"file_binding",
"resource_conflicts",
"evidence_export",
"history_uninstall",
"secret_redaction",
"real_task",
"source_review",
)
FORBIDDEN_DIRECTORIES = {".git", ".ruff_cache", ".pytest_cache", "__pycache__", ".venv"}
class SubmissionModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class Component(SubmissionModel):
name: str = Field(min_length=1)
paths: list[str] = Field(min_length=1)
license: str = Field(min_length=1)
permission: Literal["permitted", "local_only", "unknown"]
license_file: str
source_file: str
class CheckRecord(SubmissionModel):
status: Literal["passed", "failed", "unverified", "not_applicable"]
evidence_file: str = ""
evidence_sha256: str = ""
notes: str = ""
class Traits(SubmissionModel):
uses_subprocesses: bool | None
file_inputs_for_writes: bool | None
class MarketSubmission(SubmissionModel):
schema_version: Literal[1]
plugin_id: str
plugin_version: str
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
traits: Traits
components: list[Component] = Field(min_length=1)
checks: dict[str, CheckRecord]
@contextmanager
def package_directory(path: Path) -> Iterator[tuple[Path, str | None]]:
"""Inspect a directory or safely unpack a ZIP without installing or hooks."""
path = path.resolve(strict=True)
if path.is_dir():
yield path, None
return
if not path.is_file():
raise ValueError("检查输入必须是普通插件目录或 ZIP 文件")
with path.open("rb") as stream:
data = stream.read(settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES + 1)
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件 ZIP 超过下载大小限制")
with tempfile.TemporaryDirectory(prefix="artemis-plugin-check-") as temporary:
directory = Path(temporary)
_extract(data, directory)
archive_sha256 = hashlib.sha256(data).hexdigest()
yield directory, archive_sha256
# Re-read only to reject changes; never relabel the inspected snapshot.
current_digest = hashlib.sha256()
current_size = 0
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
current_size += len(chunk)
if current_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("检查期间 ZIP 发生变化或超过大小限制")
current_digest.update(chunk)
if current_digest.hexdigest() != archive_sha256:
raise ValueError("检查期间 ZIP 发生变化,检查记录失效")
def _relative(value: str, *, root_allowed: bool = False) -> str:
if root_allowed and value == ".":
return value
path = PurePosixPath(value)
if (
not value
or "\\" in value
or ":" in value
or path.is_absolute()
or any(p in {".", ".."} for p in value.split("/"))
or any(ord(c) < 32 for c in value)
or str(path) != value
):
raise ValueError("市场材料必须使用包内普通相对路径")
return value
def package_files(directory: Path) -> dict[str, str]:
"""Hash every distributable file, excluding only the self-referential report."""
directory = directory.resolve(strict=True)
files = {}
seen = set()
size = 0
for path in sorted(directory.rglob("*")):
relative = path.relative_to(directory)
name = relative.as_posix()
if path.is_symlink() or path.is_junction() or not path.resolve().is_relative_to(directory):
raise ValueError("市场包不能包含链接")
if name.casefold() in seen:
raise ValueError("市场包存在大小写冲突的路径")
seen.add(name.casefold())
if len(seen) > 20000:
raise ValueError("市场包超过文件数量限制")
if any(p.casefold() in FORBIDDEN_DIRECTORIES for p in relative.parts):
raise ValueError(f"市场包包含缓存、依赖环境或版本库目录:{name}")
if path.is_dir():
continue
info = path.stat()
if not stat.S_ISREG(info.st_mode):
raise ValueError("市场包只能包含普通文件")
if name == REPORT_NAME:
continue
lower = path.name.casefold()
if (
lower.endswith((".pyc", ".pyo"))
or lower == ".env"
or (
lower.startswith(".env.")
and not lower.endswith((".example", ".sample", ".template"))
)
):
raise ValueError(f"市场包不能包含缓存或个人环境配置:{name}")
_relative(name)
digest = hashlib.sha256()
read = 0
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
read += len(chunk)
size += len(chunk)
if size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("市场包超过展开大小或文件数量限制")
digest.update(chunk)
if read != info.st_size:
raise ValueError("文件校验期间大小发生变化")
files[name] = digest.hexdigest()
return files
def content_fingerprint(files: dict[str, str]) -> str:
encoded = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def draft_submission(manifest: PluginManifest, files: dict[str, str]) -> dict:
"""Drafts never fabricate passed results or distribution permission."""
return {
"schema_version": 1,
"plugin_id": manifest.id,
"plugin_version": manifest.version,
"content_sha256": content_fingerprint(files),
"traits": {"uses_subprocesses": None, "file_inputs_for_writes": None},
"components": [
{
"name": "请拆分原始代码、适配层与第三方资料",
"paths": ["."],
"license": manifest.license or "待补充",
"permission": "unknown",
"license_file": "LICENSE",
"source_file": "SOURCE.md",
}
],
"checks": {
name: {"status": "unverified", "evidence_file": "", "evidence_sha256": "", "notes": ""}
for name in CHECKS
},
}
def _document(directory: Path, files: dict[str, str], value: str) -> str:
name = _relative(value)
if name not in files or not 0 < (directory / name).stat().st_size <= 1024 * 1024:
raise ValueError("许可、来源与验证记录必须是非空包内文档,且不超过 1 MiB")
return name
def validate_submission(
directory: Path, manifest: PluginManifest, files: dict[str, str]
) -> MarketSubmission:
path = directory / REPORT_NAME
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ValueError("市场检查需要包内 MARKET.json;先生成草稿并补充真实记录")
report = MarketSubmission.model_validate_json(path.read_text("utf-8"))
if (report.plugin_id, report.plugin_version) != (manifest.id, manifest.version):
raise ValueError("市场材料的插件 ID/版本与清单不一致")
if report.content_sha256 != content_fingerprint(files):
raise ValueError("包内容已变化,旧的市场验证记录失效")
if not manifest.license.strip():
raise ValueError("市场清单必须声明许可证或具体的 LicenseRef")
if None in (report.traits.uses_subprocesses, report.traits.file_inputs_for_writes):
raise ValueError("必须明确是否使用子进程、是否将文件用于写操作")
covered = set()
for component in report.components:
if component.permission != "permitted" or component.license.strip().casefold() in {
"",
"unknown",
"todo",
"待补充",
}:
raise ValueError(f"组件未明确允许市场分发或未提供许可证:{component.name}")
_document(directory, files, component.license_file)
_document(directory, files, component.source_file)
for scope in component.paths:
scope = _relative(scope, root_allowed=True)
selected = {
name
for name in files
if scope == "." or name == scope or name.startswith(scope + "/")
}
if not selected:
raise ValueError("来源组件声明了不存在的文件范围")
covered.update(selected)
if set(files) - covered:
raise ValueError("市场包有文件未记录来源与分发许可")
if set(report.checks) != set(CHECKS):
raise ValueError("市场验证项目缺失或包含未知项目")
optional = {
"tool_contract": manifest.server is None,
"timeout_cancel": manifest.server is None,
"process_tree": not report.traits.uses_subprocesses,
"file_binding": not report.traits.file_inputs_for_writes,
"resource_conflicts": not manifest.resources,
}
for name, record in report.checks.items():
if not record.notes.strip():
raise ValueError(f"验证项目必须说明实际范围或不适用原因:{name}")
if record.status == "not_applicable" and optional.get(name, False):
continue
if record.status != "passed":
raise ValueError(f"市场验证项目未通过:{name}")
evidence = _document(directory, files, record.evidence_file)
if files[evidence] != record.evidence_sha256:
raise ValueError(f"验证记录 SHA256 不一致:{name}")
return report