267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""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
|