147 lines
6.3 KiB
Python
147 lines
6.3 KiB
Python
"""Task-local receipts and verified attachments; traces remain the execution ledger."""
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
import time
|
||
from typing import Any
|
||
|
||
from artemis.plugins.models import EvidenceSource, PluginCallResult
|
||
|
||
MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024
|
||
|
||
|
||
def source_identity(plugin_id: str, source: EvidenceSource | None) -> str:
|
||
if source is None:
|
||
return f"plugin:{plugin_id}"
|
||
identity = source.model_dump(exclude={"label", "captured_at"})
|
||
digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()[:16]
|
||
return f"plugin:{plugin_id}:{digest}"
|
||
|
||
|
||
def validate_tools(manifest, tools) -> None:
|
||
"""V2 capability declarations must describe the actual MCP surface."""
|
||
capabilities = manifest.capabilities
|
||
if capabilities is None:
|
||
return
|
||
actual = {tool.name: tool for tool in tools}
|
||
missing = (set(capabilities.tools) | set(capabilities.required_tools)) - actual.keys()
|
||
undeclared = actual.keys() - capabilities.tools.keys()
|
||
if missing or undeclared:
|
||
raise ValueError(
|
||
f"插件工具与清单不一致;缺少:{', '.join(sorted(missing)) or '无'};"
|
||
f"未声明:{', '.join(sorted(undeclared)) or '无'}"
|
||
)
|
||
for name, spec in capabilities.tools.items():
|
||
tool = actual[name]
|
||
if spec.target_argument and spec.target_argument not in tool.inputSchema.get(
|
||
"properties", {}
|
||
):
|
||
raise ValueError(f"工具 {name} 缺少目标参数 {spec.target_argument}")
|
||
if spec.observation and not (tool.annotations and tool.annotations.readOnlyHint):
|
||
raise ValueError(f"观察工具 {name} 的 MCP 只读声明与清单不一致")
|
||
|
||
|
||
def decode_receipt(outcome: PluginCallResult) -> dict[str, Any]:
|
||
raw = (outcome.structured_content or {}).get("artemis", {})
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("插件证据回执必须是对象")
|
||
if not raw:
|
||
return {}
|
||
if raw.get("version") != 1:
|
||
raise ValueError("不支持的插件证据回执版本")
|
||
if raw.get("source"):
|
||
outcome.source = EvidenceSource.model_validate(raw["source"])
|
||
if raw.get("execution") in {"not_started", "completed", "partial", "unknown"}:
|
||
outcome.execution = raw["execution"]
|
||
if raw.get("side_effect") in {"none", "possible", "confirmed"}:
|
||
outcome.side_effect = raw["side_effect"]
|
||
if outcome.status != "success":
|
||
outcome.error_code = str(raw.get("error_code") or "tool_error")[:100]
|
||
# A plugin cannot authorize replay of a possibly completed write.
|
||
outcome.retryable = bool(raw.get("retryable")) and outcome.execution == "not_started"
|
||
outcome.requires_observation = outcome.execution in {"partial", "unknown"}
|
||
return raw
|
||
|
||
|
||
def persist_attachments(root: Path, plugin_id: str, receipts: list) -> list[dict]:
|
||
directory = root / "artifacts" / "plugins" / plugin_id
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
if directory.resolve() != directory.absolute() or not directory.resolve().is_relative_to(
|
||
root.resolve()
|
||
):
|
||
raise ValueError("附件目录不能通过链接指向任务目录之外")
|
||
verified = []
|
||
for item in receipts:
|
||
if not isinstance(item, dict):
|
||
raise ValueError("附件回执必须是对象")
|
||
name = str(item.get("name") or "")
|
||
if (
|
||
not name
|
||
or len(name) > 180
|
||
or name != Path(name).name
|
||
or any(c in name for c in '/\\:*?"<>|')
|
||
or name in {".", ".."}
|
||
or name.rstrip(" .") != name
|
||
or name.split(".")[0].upper()
|
||
in {
|
||
"CON",
|
||
"PRN",
|
||
"AUX",
|
||
"NUL",
|
||
*(f"COM{i}" for i in range(10)),
|
||
*(f"LPT{i}" for i in range(10)),
|
||
}
|
||
or any(ord(c) < 32 for c in name)
|
||
):
|
||
raise ValueError("附件必须使用普通文件名")
|
||
path = directory / name
|
||
if path.is_symlink() or path.resolve().parent != directory.resolve():
|
||
raise ValueError("附件不能指向任务目录之外")
|
||
if "data" in item:
|
||
encoded = item["data"]
|
||
if not isinstance(encoded, str) or len(encoded) > MAX_ATTACHMENT_BYTES * 4 // 3 + 4:
|
||
raise ValueError("附件超过大小限制")
|
||
data = base64.b64decode(encoded, validate=True)
|
||
if len(data) > MAX_ATTACHMENT_BYTES:
|
||
raise ValueError("附件超过大小限制")
|
||
if hashlib.sha256(data).hexdigest() != item.get("sha256"):
|
||
raise ValueError("插件附件读回校验失败")
|
||
if path.exists():
|
||
if path.read_bytes() != data:
|
||
raise ValueError("附件重名,不能覆盖已有证据")
|
||
else:
|
||
with path.open("xb") as handle:
|
||
handle.write(data)
|
||
if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
||
raise ValueError("插件附件不存在或超过大小限制")
|
||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||
if item.get("sha256") != digest:
|
||
raise ValueError("插件附件读回校验失败")
|
||
verified.append(
|
||
{
|
||
"path": path.relative_to(root).as_posix(),
|
||
"name": name,
|
||
"bytes": path.stat().st_size,
|
||
"sha256": digest,
|
||
"media_type": str(item.get("media_type") or "application/octet-stream"),
|
||
}
|
||
)
|
||
return verified
|
||
|
||
|
||
def persist_receipt(ctx, outcome: PluginCallResult, arguments: dict, started_at: float) -> None:
|
||
engine = getattr(ctx, "data_engine", None)
|
||
if not engine or not isinstance(getattr(engine, "base_dir", None), (str, Path)):
|
||
return
|
||
root = Path(engine.base_dir)
|
||
directory = root / "evidence"
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
receipt = outcome.model_dump(exclude={"content"})
|
||
# The complete redacted result is already in the tool trace. Keep the index small.
|
||
receipt.pop("structured_content", None)
|
||
receipt.update({"arguments": arguments, "started_at": started_at, "ended_at": time.time()})
|
||
with (directory / "calls.jsonl").open("a", encoding="utf-8") as handle:
|
||
handle.write(json.dumps(receipt, ensure_ascii=False) + "\n")
|