feat(plugins): add managed runtime SDK and release 2.4.5
This commit is contained in:
1 parent
506e3bf1c5
commit
2a3bbaa3fb
45 files changed
+3348
-98
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user