936 lines
35 KiB
Python
936 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Independently verify an external MCP protocol/schema replay receipt.
|
|
|
|
This standalone verifier intentionally does not import the replay controller.
|
|
It revalidates the committed policy and artifact receipt chain, rematerializes
|
|
the immutable bundle, and repeats initialize plus tools/list in a fresh offline
|
|
container. No browser or MCP tool is invoked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
|
|
POLICY_SCHEMA = "awoooi_external_mcp_replay_policy_v1"
|
|
CONTROLLER_SCHEMA = "awoooi_external_mcp_replay_receipt_v1"
|
|
VERIFIER_SCHEMA = "awoooi_external_mcp_replay_verifier_receipt_v1"
|
|
ARTIFACT_CONTROLLER_SCHEMA = "awoooi_external_mcp_artifact_receipt_v1"
|
|
ARTIFACT_VERIFIER_SCHEMA = "awoooi_external_mcp_artifact_verifier_receipt_v1"
|
|
ARTIFACT_BUNDLE_SCHEMA = "awoooi_external_mcp_artifact_bundle_v1"
|
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
SHA512_PATTERN = re.compile(r"^[0-9a-f]{128}$")
|
|
OCI_REF_PATTERN = re.compile(
|
|
r"^[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$"
|
|
)
|
|
MAX_JSON_BYTES = 4 * 1024 * 1024
|
|
MAX_PROTOCOL_LINE_BYTES = 2 * 1024 * 1024
|
|
MAX_FILES = 4_096
|
|
MAX_UNPACKED_BYTES = 40 * 1024 * 1024
|
|
EXPECTED_RUNTIME_ARGUMENTS = [
|
|
"--headless",
|
|
"--isolated",
|
|
"--allowed-origins",
|
|
"https://awoooi.wooo.work",
|
|
"--output-dir",
|
|
"/tmp/mcp-output",
|
|
"--executable-path",
|
|
"/nonexistent/browser",
|
|
]
|
|
|
|
|
|
class VerifierError(RuntimeError):
|
|
"""Fail-closed public-safe verifier error class."""
|
|
|
|
|
|
def _canonical(payload: Any) -> bytes:
|
|
return json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
|
|
|
|
def _sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _read_json(path: Path, error: str) -> tuple[bytes, dict[str, Any]]:
|
|
try:
|
|
raw = path.read_bytes()
|
|
except OSError as exc:
|
|
raise VerifierError(error) from exc
|
|
if not raw or len(raw) > MAX_JSON_BYTES:
|
|
raise VerifierError(error)
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise VerifierError(error) from exc
|
|
if not isinstance(payload, dict):
|
|
raise VerifierError(error)
|
|
return raw, payload
|
|
|
|
|
|
def _dict(value: Any, error: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise VerifierError(error)
|
|
return value
|
|
|
|
|
|
def _text(value: Any, error: str) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise VerifierError(error)
|
|
return value.strip()
|
|
|
|
|
|
def _exact_digest(value: Any, error: str, *, prefixed: bool = False) -> str:
|
|
text = _text(value, error)
|
|
digest = text.removeprefix("sha256:") if prefixed else text
|
|
if not SHA256_PATTERN.fullmatch(digest):
|
|
raise VerifierError(error)
|
|
if prefixed and not text.startswith("sha256:"):
|
|
raise VerifierError(error)
|
|
return text
|
|
|
|
|
|
def _oci_ref(value: Any, error: str) -> str:
|
|
ref = _text(value, error)
|
|
if not OCI_REF_PATTERN.fullmatch(ref):
|
|
raise VerifierError(error)
|
|
return ref
|
|
|
|
|
|
def load_and_validate_policy(path: Path) -> tuple[dict[str, Any], str]:
|
|
_, policy = _read_json(path, "policy_unreadable")
|
|
if (
|
|
policy.get("schema_version") != POLICY_SCHEMA
|
|
or policy.get("policy_version") != "1.0.0"
|
|
or policy.get("risk_class") != "high"
|
|
or policy.get("scope")
|
|
!= "offline_protocol_and_tool_schema_compatibility_only"
|
|
or policy.get("work_item_id") != "MCP-REPLAY-PLAYWRIGHT-001"
|
|
or policy.get("candidate_id") != "external.playwright-verifier"
|
|
or policy.get("adapter_id")
|
|
!= "playwright_public_accessibility_verifier_v1"
|
|
or policy.get("owner_lane") != "awoooi_mcp_supply_chain"
|
|
):
|
|
raise VerifierError("policy_contract_invalid")
|
|
try:
|
|
expires = datetime.fromisoformat(_text(policy.get("expires_at"), "policy_expiry_invalid"))
|
|
except ValueError as exc:
|
|
raise VerifierError("policy_expiry_invalid") from exc
|
|
if expires.tzinfo is None or expires <= datetime.now(timezone.utc):
|
|
raise VerifierError("policy_expired")
|
|
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
|
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
|
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
|
boundaries = _dict(policy.get("execution_boundaries"), "boundaries_invalid")
|
|
if any(value is not False for value in boundaries.values()):
|
|
raise VerifierError("boundary_must_remain_false")
|
|
if (
|
|
runtime.get("network_mode") != "none"
|
|
or runtime.get("read_only_root") is not True
|
|
or runtime.get("no_new_privileges") is not True
|
|
or runtime.get("cap_drop") != ["ALL"]
|
|
or runtime.get("user") != "65534:65534"
|
|
or runtime.get("platform") != "linux/amd64"
|
|
or runtime.get("pids_limit") != 64
|
|
or runtime.get("memory_limit") != "384m"
|
|
or runtime.get("cpu_limit") != "0.5"
|
|
or runtime.get("tmpfs") != "/tmp:rw,noexec,nosuid,size=32m"
|
|
or runtime.get("startup_timeout_seconds") != 20
|
|
or runtime.get("shutdown_timeout_seconds") != 5
|
|
or runtime.get("entrypoint")
|
|
!= ["node", "node_modules/@playwright/mcp/cli.js"]
|
|
or runtime.get("arguments") != EXPECTED_RUNTIME_ARGUMENTS
|
|
):
|
|
raise VerifierError("runtime_isolation_invalid")
|
|
artifact_ref = _oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid")
|
|
runtime_ref = _oci_ref(runtime.get("image_ref"), "runtime_ref_invalid")
|
|
artifact_runtime_ref = _oci_ref(
|
|
artifact.get("artifact_runtime_ref"), "artifact_runtime_ref_invalid"
|
|
)
|
|
if (
|
|
artifact_ref == runtime_ref
|
|
or not artifact_ref.startswith(
|
|
"registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp@"
|
|
)
|
|
or not artifact_runtime_ref.startswith(
|
|
"192.168.0.110:5000/awoooi/mcp-artifacts/playwright-mcp@"
|
|
)
|
|
or not runtime_ref.startswith("registry.wooo.work/awoooi/ci-runner@")
|
|
or artifact.get("audit_branch") != "mcp-artifact-receipts"
|
|
):
|
|
raise VerifierError("artifact_runtime_repository_contract_invalid")
|
|
for field in (
|
|
"controller_receipt_sha256",
|
|
"verifier_receipt_sha256",
|
|
"bundle_manifest_sha256",
|
|
"cyclonedx_sbom_sha256",
|
|
"tool_name_set_sha256",
|
|
"tool_schema_manifest_sha256",
|
|
):
|
|
source = artifact if field in artifact else protocol
|
|
_exact_digest(source.get(field), f"{field}_invalid")
|
|
_exact_digest(artifact.get("policy_checksum"), "artifact_policy_checksum_invalid", prefixed=True)
|
|
integrities = _dict(artifact.get("package_integrities"), "integrities_invalid")
|
|
if len(integrities) != 3:
|
|
raise VerifierError("integrities_invalid")
|
|
for digest in integrities.values():
|
|
if not isinstance(digest, str) or not SHA512_PATTERN.fullmatch(digest):
|
|
raise VerifierError("integrities_invalid")
|
|
allowed = protocol.get("adapter_allowed_tools")
|
|
denied = protocol.get("adapter_denied_tools")
|
|
if (
|
|
not isinstance(allowed, list)
|
|
or not isinstance(denied, list)
|
|
or len(allowed) != 4
|
|
or len(denied) != 20
|
|
or set(allowed) & set(denied)
|
|
or set(allowed)
|
|
!= {
|
|
"browser_close",
|
|
"browser_navigate",
|
|
"browser_snapshot",
|
|
"browser_take_screenshot",
|
|
}
|
|
or "browser_run_code_unsafe" not in denied
|
|
or "browser_file_upload" not in denied
|
|
):
|
|
raise VerifierError("adapter_tool_partition_invalid")
|
|
return policy, f"sha256:{_sha256(_canonical(policy))}"
|
|
|
|
|
|
def validate_controller_receipt(
|
|
policy: dict[str, Any], policy_checksum: str, path: Path
|
|
) -> tuple[dict[str, Any], str]:
|
|
raw, receipt = _read_json(path, "controller_receipt_unreadable")
|
|
if (
|
|
receipt.get("schema_version") != CONTROLLER_SCHEMA
|
|
or receipt.get("status")
|
|
!= "completed_protocol_schema_replay_pending_independent_verifier"
|
|
or receipt.get("mode") != "apply"
|
|
or receipt.get("policy_checksum") != policy_checksum
|
|
or receipt.get("work_item_id") != policy.get("work_item_id")
|
|
or receipt.get("candidate_id") != policy.get("candidate_id")
|
|
or receipt.get("adapter_id") != policy.get("adapter_id")
|
|
):
|
|
raise VerifierError("controller_receipt_terminal_invalid")
|
|
try:
|
|
normalized_run_id = str(uuid.UUID(_text(receipt.get("run_id"), "run_id_invalid")))
|
|
except ValueError as exc:
|
|
raise VerifierError("run_id_invalid") from exc
|
|
if (
|
|
receipt.get("run_id") != normalized_run_id
|
|
or receipt.get("trace_id") != f"mcp-replay-{normalized_run_id}"
|
|
):
|
|
raise VerifierError("run_trace_identity_invalid")
|
|
for field in (
|
|
"direct_gateway_registration_allowed",
|
|
"adapter_registration_allowed",
|
|
"browser_started",
|
|
"tool_call_performed",
|
|
"navigation_performed",
|
|
"raw_request_or_response_body_stored",
|
|
"external_rag_write_performed",
|
|
"production_write_performed",
|
|
"shadow_allowed",
|
|
"canary_allowed",
|
|
"promotion_allowed",
|
|
):
|
|
if receipt.get(field) is not False:
|
|
raise VerifierError("controller_boundary_invalid")
|
|
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
|
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
|
if (
|
|
receipt.get("artifact_ref") != artifact.get("artifact_ref")
|
|
or receipt.get("runtime_image_ref") != runtime.get("image_ref")
|
|
or receipt.get("artifact_audit_commit") != artifact.get("audit_commit")
|
|
):
|
|
raise VerifierError("controller_source_identity_invalid")
|
|
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
|
observed = _dict(receipt.get("protocol_observation"), "protocol_observation_invalid")
|
|
expected = {
|
|
"protocol_version": protocol.get("protocol_version"),
|
|
"server_name": protocol.get("server_name"),
|
|
"server_version": protocol.get("server_version"),
|
|
"tool_count": protocol.get("tool_count"),
|
|
"tool_name_set_sha256": protocol.get("tool_name_set_sha256"),
|
|
"tool_schema_manifest_sha256": protocol.get("tool_schema_manifest_sha256"),
|
|
}
|
|
if any(observed.get(field) != value for field, value in expected.items()):
|
|
raise VerifierError("controller_protocol_observation_mismatch")
|
|
if observed.get("shutdown_mode") != "stdin_eof_clean_exit":
|
|
raise VerifierError("controller_shutdown_terminal_invalid")
|
|
stages = _dict(receipt.get("stage_receipts"), "controller_stages_invalid")
|
|
rollback = _dict(
|
|
stages.get("rollback_or_no_write_terminal"), "controller_rollback_invalid"
|
|
)
|
|
if (
|
|
rollback.get("status")
|
|
!= "mcp_process_terminated_and_ephemeral_container_removed"
|
|
or rollback.get("ephemeral_container_removed") is not True
|
|
or rollback.get("production_route_changed") is not False
|
|
):
|
|
raise VerifierError("controller_rollback_invalid")
|
|
return receipt, _sha256(raw)
|
|
|
|
|
|
def validate_artifact_receipt_chain(
|
|
policy: dict[str, Any], controller_path: Path, verifier_path: Path
|
|
) -> tuple[str, str]:
|
|
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
|
controller_raw, controller = _read_json(
|
|
controller_path, "artifact_controller_receipt_unreadable"
|
|
)
|
|
verifier_raw, verifier = _read_json(
|
|
verifier_path, "artifact_verifier_receipt_unreadable"
|
|
)
|
|
controller_sha = _sha256(controller_raw)
|
|
verifier_sha = _sha256(verifier_raw)
|
|
if (
|
|
controller_sha != artifact.get("controller_receipt_sha256")
|
|
or verifier_sha != artifact.get("verifier_receipt_sha256")
|
|
or controller.get("schema_version") != ARTIFACT_CONTROLLER_SCHEMA
|
|
or controller.get("status")
|
|
!= "completed_internal_mirror_pending_independent_verifier"
|
|
or verifier.get("schema_version") != ARTIFACT_VERIFIER_SCHEMA
|
|
or verifier.get("status") != "completed_internal_mirror_verified"
|
|
or verifier.get("controller_receipt_sha256") != controller_sha
|
|
):
|
|
raise VerifierError("artifact_receipt_chain_invalid")
|
|
post = _dict(verifier.get("independent_post_verifier"), "artifact_verifier_invalid")
|
|
if (
|
|
post.get("status") != "passed"
|
|
or post.get("container_or_mcp_started") is not False
|
|
or controller.get("run_id") != verifier.get("run_id")
|
|
or controller.get("trace_id") != verifier.get("trace_id")
|
|
):
|
|
raise VerifierError("artifact_independent_verifier_invalid")
|
|
for receipt in (controller, verifier):
|
|
if (
|
|
receipt.get("internal_mirror_ref") != artifact.get("artifact_ref")
|
|
or receipt.get("policy_checksum") != artifact.get("policy_checksum")
|
|
or any(
|
|
receipt.get(field) is not False
|
|
for field in (
|
|
"promotion_allowed",
|
|
"replay_allowed",
|
|
"shadow_allowed",
|
|
"canary_allowed",
|
|
)
|
|
)
|
|
):
|
|
raise VerifierError("artifact_receipt_boundary_invalid")
|
|
return controller_sha, verifier_sha
|
|
|
|
|
|
def _docker() -> str:
|
|
executable = shutil.which("docker")
|
|
if not executable:
|
|
raise VerifierError("docker_cli_unavailable")
|
|
return executable
|
|
|
|
|
|
def _run(command: list[str], *, timeout: int, error: str) -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise VerifierError(error) from exc
|
|
if result.returncode != 0:
|
|
raise VerifierError(error)
|
|
return result.stdout
|
|
|
|
|
|
def _pull_and_readback(docker: str, ref: str, platform: str) -> None:
|
|
_run(
|
|
[docker, "pull", "--platform", platform, ref],
|
|
timeout=300,
|
|
error="image_pull_failed",
|
|
)
|
|
output = _run(
|
|
[docker, "image", "inspect", "--format", "{{json .RepoDigests}}", ref],
|
|
timeout=30,
|
|
error="image_readback_failed",
|
|
)
|
|
try:
|
|
digests = json.loads(output.strip())
|
|
except json.JSONDecodeError as exc:
|
|
raise VerifierError("image_readback_invalid") from exc
|
|
if not isinstance(digests, list) or ref not in digests:
|
|
raise VerifierError("image_digest_readback_mismatch")
|
|
|
|
|
|
def _safe_member(name: str) -> Path | None:
|
|
pure = PurePosixPath(name)
|
|
if pure.is_absolute() or not pure.parts or ".." in pure.parts:
|
|
raise VerifierError("archive_path_invalid")
|
|
if pure.parts[0] != "package":
|
|
raise VerifierError("archive_root_invalid")
|
|
if len(pure.parts) == 1:
|
|
return None
|
|
return Path(*pure.parts[1:])
|
|
|
|
|
|
def _unpack(tarball: Path, destination: Path) -> tuple[int, int]:
|
|
files = 0
|
|
size = 0
|
|
try:
|
|
archive = tarfile.open(tarball, "r:gz")
|
|
except (OSError, tarfile.TarError) as exc:
|
|
raise VerifierError("tarball_invalid") from exc
|
|
with archive:
|
|
for member in archive.getmembers():
|
|
relative = _safe_member(member.name)
|
|
if relative is None:
|
|
continue
|
|
target = destination / relative
|
|
if member.isdir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
if not member.isfile() or member.issym() or member.islnk():
|
|
raise VerifierError("archive_member_type_invalid")
|
|
files += 1
|
|
size += member.size
|
|
if files > MAX_FILES or size > MAX_UNPACKED_BYTES:
|
|
raise VerifierError("archive_limits_exceeded")
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
raise VerifierError("archive_member_unreadable")
|
|
content = source.read(member.size + 1)
|
|
if len(content) != member.size:
|
|
raise VerifierError("archive_member_size_mismatch")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(content)
|
|
return files, size
|
|
|
|
|
|
def independently_materialize(
|
|
policy: dict[str, Any], directory: Path
|
|
) -> tuple[Path, int, int]:
|
|
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
|
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
|
docker = _docker()
|
|
artifact_ref = _oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid")
|
|
platform = _text(runtime.get("platform"), "platform_invalid")
|
|
_pull_and_readback(docker, artifact_ref, platform)
|
|
bundle = directory / "bundle"
|
|
modules = directory / "node_modules"
|
|
bundle.mkdir()
|
|
modules.mkdir()
|
|
container = f"awoooi-mcp-verifier-artifact-{uuid.uuid4().hex[:16]}"
|
|
try:
|
|
_run(
|
|
[
|
|
docker,
|
|
"create",
|
|
"--platform",
|
|
platform,
|
|
"--name",
|
|
container,
|
|
artifact_ref,
|
|
"/nonexistent/no-start",
|
|
],
|
|
timeout=60,
|
|
error="artifact_container_create_failed",
|
|
)
|
|
_run(
|
|
[docker, "cp", f"{container}:/artifact/.", str(bundle)],
|
|
timeout=60,
|
|
error="artifact_copy_failed",
|
|
)
|
|
finally:
|
|
subprocess.run(
|
|
[docker, "rm", "-f", container],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=30,
|
|
)
|
|
manifest_raw, manifest = _read_json(
|
|
bundle / "bundle-manifest.json", "bundle_manifest_invalid"
|
|
)
|
|
sbom_raw, sbom = _read_json(bundle / "sbom.cdx.json", "bundle_sbom_invalid")
|
|
if (
|
|
_sha256(manifest_raw) != artifact.get("bundle_manifest_sha256")
|
|
or _sha256(sbom_raw) != artifact.get("cyclonedx_sbom_sha256")
|
|
or manifest.get("schema_version") != ARTIFACT_BUNDLE_SCHEMA
|
|
or manifest.get("candidate_id") != policy.get("candidate_id")
|
|
or manifest.get("policy_checksum") != artifact.get("policy_checksum")
|
|
or sbom.get("bomFormat") != "CycloneDX"
|
|
or sbom.get("specVersion") != "1.5"
|
|
):
|
|
raise VerifierError("bundle_identity_invalid")
|
|
bundle_boundaries = _dict(
|
|
manifest.get("operation_boundaries"), "bundle_boundaries_invalid"
|
|
)
|
|
if any(
|
|
bundle_boundaries.get(field) is not False
|
|
for field in (
|
|
"mcp_server_started",
|
|
"browser_started",
|
|
"external_tool_call_performed",
|
|
"external_rag_write_performed",
|
|
"production_write_performed",
|
|
"runtime_request_or_response_body_stored",
|
|
)
|
|
):
|
|
raise VerifierError("bundle_boundary_invalid")
|
|
integrities = _dict(artifact.get("package_integrities"), "integrities_invalid")
|
|
rows = manifest.get("packages")
|
|
if not isinstance(rows, list) or len(rows) != 3:
|
|
raise VerifierError("bundle_package_set_invalid")
|
|
observed: set[str] = set()
|
|
total_files = 0
|
|
total_size = 0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
raise VerifierError("bundle_package_set_invalid")
|
|
name = _text(row.get("name"), "package_name_invalid")
|
|
version = _text(row.get("version"), "package_version_invalid")
|
|
key = f"{name}@{version}"
|
|
tarball = bundle / _text(row.get("tarball_path"), "tarball_path_invalid")
|
|
try:
|
|
raw = tarball.read_bytes()
|
|
except OSError as exc:
|
|
raise VerifierError("tarball_unreadable") from exc
|
|
if (
|
|
key in observed
|
|
or integrities.get(key) != row.get("tarball_sha512_hex")
|
|
or hashlib.sha512(raw).hexdigest() != integrities.get(key)
|
|
):
|
|
raise VerifierError("tarball_integrity_mismatch")
|
|
observed.add(key)
|
|
destination = (
|
|
modules / "@playwright" / "mcp"
|
|
if name == "@playwright/mcp"
|
|
else modules / name
|
|
)
|
|
destination.mkdir(parents=True)
|
|
files, size = _unpack(tarball, destination)
|
|
total_files += files
|
|
total_size += size
|
|
_, package_json = _read_json(destination / "package.json", "package_json_invalid")
|
|
if package_json.get("name") != name or package_json.get("version") != version:
|
|
raise VerifierError("package_json_identity_mismatch")
|
|
if observed != set(integrities):
|
|
raise VerifierError("bundle_package_set_mismatch")
|
|
for path in modules.rglob("*"):
|
|
path.chmod(0o755 if path.is_dir() else 0o444)
|
|
modules.chmod(0o755)
|
|
directory.chmod(0o755)
|
|
return modules, total_files, total_size
|
|
|
|
|
|
def _send(process: subprocess.Popen[str], payload: dict[str, Any]) -> None:
|
|
if process.stdin is None:
|
|
raise VerifierError("mcp_stdin_unavailable")
|
|
try:
|
|
process.stdin.write(_canonical(payload).decode("utf-8") + "\n")
|
|
process.stdin.flush()
|
|
except (BrokenPipeError, OSError) as exc:
|
|
raise VerifierError("mcp_write_failed") from exc
|
|
|
|
|
|
def _receive(
|
|
process: subprocess.Popen[str], timeout_seconds: int, expected_id: int
|
|
) -> dict[str, Any]:
|
|
if process.stdout is None:
|
|
raise VerifierError("mcp_stdout_unavailable")
|
|
ready, _, _ = select.select([process.stdout], [], [], timeout_seconds)
|
|
if not ready:
|
|
raise VerifierError("mcp_response_timeout")
|
|
line = process.stdout.readline(MAX_PROTOCOL_LINE_BYTES + 1)
|
|
if not line or len(line.encode("utf-8")) > MAX_PROTOCOL_LINE_BYTES:
|
|
raise VerifierError("mcp_response_invalid")
|
|
try:
|
|
response = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise VerifierError("mcp_response_invalid") from exc
|
|
if (
|
|
not isinstance(response, dict)
|
|
or response.get("jsonrpc") != "2.0"
|
|
or response.get("id") != expected_id
|
|
or "error" in response
|
|
or not isinstance(response.get("result"), dict)
|
|
):
|
|
raise VerifierError("mcp_response_invalid")
|
|
return response["result"]
|
|
|
|
|
|
def _independent_tool_hashes(tools: Any) -> tuple[int, str, str, set[str]]:
|
|
if not isinstance(tools, list):
|
|
raise VerifierError("tool_list_invalid")
|
|
names: list[str] = []
|
|
schema_rows: list[dict[str, Any]] = []
|
|
for tool in tools:
|
|
if not isinstance(tool, dict):
|
|
raise VerifierError("tool_schema_invalid")
|
|
name = _text(tool.get("name"), "tool_name_invalid")
|
|
schema = tool.get("inputSchema")
|
|
if not isinstance(schema, dict):
|
|
raise VerifierError("tool_schema_invalid")
|
|
row: dict[str, Any] = {"name": name, "inputSchema": schema}
|
|
if "annotations" in tool:
|
|
annotations = tool["annotations"]
|
|
if not isinstance(annotations, dict):
|
|
raise VerifierError("tool_annotations_invalid")
|
|
row["annotations"] = annotations
|
|
names.append(name)
|
|
schema_rows.append(row)
|
|
if len(names) != len(set(names)):
|
|
raise VerifierError("tool_names_not_unique")
|
|
schema_rows.sort(key=lambda row: row["name"])
|
|
return (
|
|
len(tools),
|
|
_sha256(_canonical(sorted(names))),
|
|
_sha256(_canonical(schema_rows)),
|
|
set(names),
|
|
)
|
|
|
|
|
|
def independently_replay(
|
|
policy: dict[str, Any], modules: Path
|
|
) -> dict[str, Any]:
|
|
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
|
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
|
docker = _docker()
|
|
runtime_ref = _oci_ref(runtime.get("image_ref"), "runtime_ref_invalid")
|
|
platform = _text(runtime.get("platform"), "platform_invalid")
|
|
_pull_and_readback(docker, runtime_ref, platform)
|
|
container = f"awoooi-mcp-replay-verifier-{uuid.uuid4().hex[:16]}"
|
|
command = [
|
|
docker,
|
|
"run",
|
|
"--rm",
|
|
"--platform",
|
|
platform,
|
|
"--name",
|
|
container,
|
|
"--network",
|
|
"none",
|
|
"--read-only",
|
|
"--security-opt",
|
|
"no-new-privileges",
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--pids-limit",
|
|
str(runtime.get("pids_limit")),
|
|
"--memory",
|
|
_text(runtime.get("memory_limit"), "memory_limit_invalid"),
|
|
"--cpus",
|
|
_text(runtime.get("cpu_limit"), "cpu_limit_invalid"),
|
|
"--user",
|
|
_text(runtime.get("user"), "runtime_user_invalid"),
|
|
"--env",
|
|
"HOME=/tmp",
|
|
"--env",
|
|
"CI=1",
|
|
"--tmpfs",
|
|
_text(runtime.get("tmpfs"), "tmpfs_invalid"),
|
|
"--stop-timeout",
|
|
str(runtime.get("shutdown_timeout_seconds")),
|
|
"-i",
|
|
"-v",
|
|
f"{modules.resolve()}:/work/node_modules:ro",
|
|
"-w",
|
|
"/work",
|
|
runtime_ref,
|
|
*runtime.get("entrypoint", []),
|
|
*runtime.get("arguments", []),
|
|
]
|
|
stderr_file = tempfile.TemporaryFile(mode="w+t", encoding="utf-8")
|
|
process: subprocess.Popen[str] | None = None
|
|
try:
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=stderr_file,
|
|
text=True,
|
|
bufsize=1,
|
|
env={**os.environ, "DOCKER_CLI_HINTS": "false"},
|
|
)
|
|
except OSError as exc:
|
|
raise VerifierError("mcp_process_start_failed") from exc
|
|
_send(
|
|
process,
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 41,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": protocol.get("protocol_version"),
|
|
"capabilities": {},
|
|
"clientInfo": {
|
|
"name": "awoooi-external-mcp-replay-verifier",
|
|
"version": "1",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
timeout = int(runtime.get("startup_timeout_seconds"))
|
|
initialized = _receive(process, timeout, 41)
|
|
server = _dict(initialized.get("serverInfo"), "server_info_invalid")
|
|
if (
|
|
initialized.get("protocolVersion") != protocol.get("protocol_version")
|
|
or server.get("name") != protocol.get("server_name")
|
|
or server.get("version") != protocol.get("server_version")
|
|
):
|
|
raise VerifierError("initialize_contract_mismatch")
|
|
_send(
|
|
process,
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/initialized",
|
|
"params": {},
|
|
},
|
|
)
|
|
_send(
|
|
process,
|
|
{"jsonrpc": "2.0", "id": 42, "method": "tools/list", "params": {}},
|
|
)
|
|
listed = _receive(process, timeout, 42)
|
|
count, names_hash, schemas_hash, names = _independent_tool_hashes(
|
|
listed.get("tools")
|
|
)
|
|
if (
|
|
count != protocol.get("tool_count")
|
|
or names_hash != protocol.get("tool_name_set_sha256")
|
|
or schemas_hash != protocol.get("tool_schema_manifest_sha256")
|
|
or names
|
|
!= set(protocol.get("adapter_allowed_tools", []))
|
|
| set(protocol.get("adapter_denied_tools", []))
|
|
):
|
|
raise VerifierError("tool_contract_mismatch")
|
|
if process.stdin is None:
|
|
raise VerifierError("mcp_stdin_unavailable")
|
|
process.stdin.close()
|
|
try:
|
|
exit_code = process.wait(timeout=int(runtime.get("shutdown_timeout_seconds")))
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise VerifierError("mcp_shutdown_timeout") from exc
|
|
if exit_code != 0:
|
|
raise VerifierError("mcp_exit_invalid")
|
|
finally:
|
|
if process is not None and process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=int(runtime.get("shutdown_timeout_seconds", 5)))
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
subprocess.run(
|
|
[docker, "rm", "-f", container],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=30,
|
|
)
|
|
stderr_file.close()
|
|
inspect = subprocess.run(
|
|
[docker, "container", "inspect", container],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=30,
|
|
)
|
|
if inspect.returncode == 0:
|
|
raise VerifierError("container_cleanup_failed")
|
|
return {
|
|
"protocol_version": protocol.get("protocol_version"),
|
|
"server_name": protocol.get("server_name"),
|
|
"server_version": protocol.get("server_version"),
|
|
"tool_count": count,
|
|
"tool_name_set_sha256": names_hash,
|
|
"tool_schema_manifest_sha256": schemas_hash,
|
|
"process_exit_code": 0,
|
|
"shutdown_mode": "stdin_eof_clean_exit",
|
|
"ephemeral_container_removed": True,
|
|
}
|
|
|
|
|
|
def build_verifier_receipt(
|
|
*,
|
|
policy: dict[str, Any],
|
|
policy_checksum: str,
|
|
controller: dict[str, Any],
|
|
controller_sha256: str,
|
|
artifact_controller_sha256: str,
|
|
artifact_verifier_sha256: str,
|
|
observation: dict[str, Any],
|
|
package_file_count: int,
|
|
package_unpacked_bytes: int,
|
|
) -> dict[str, Any]:
|
|
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
|
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
|
return {
|
|
"schema_version": VERIFIER_SCHEMA,
|
|
"run_id": controller["run_id"],
|
|
"trace_id": controller["trace_id"],
|
|
"work_item_id": controller["work_item_id"],
|
|
"candidate_id": controller["candidate_id"],
|
|
"adapter_id": controller["adapter_id"],
|
|
"risk_class": "high",
|
|
"status": "completed_protocol_schema_replay_verified",
|
|
"observed_at": datetime.now(timezone.utc).isoformat(),
|
|
"policy_checksum": policy_checksum,
|
|
"controller_receipt_sha256": controller_sha256,
|
|
"artifact_controller_receipt_sha256": artifact_controller_sha256,
|
|
"artifact_verifier_receipt_sha256": artifact_verifier_sha256,
|
|
"artifact_ref": artifact.get("artifact_ref"),
|
|
"runtime_image_ref": runtime.get("image_ref"),
|
|
"independent_post_verifier": {
|
|
"status": "passed",
|
|
"implementation": "standalone_no_controller_import",
|
|
"fresh_artifact_digest_readback": True,
|
|
"fresh_runtime_digest_readback": True,
|
|
"fresh_protocol_replay": True,
|
|
"package_file_count": package_file_count,
|
|
"package_unpacked_bytes": package_unpacked_bytes,
|
|
**observation,
|
|
},
|
|
"direct_gateway_registration_allowed": False,
|
|
"adapter_registration_allowed": False,
|
|
"browser_started": False,
|
|
"tool_call_performed": False,
|
|
"navigation_performed": False,
|
|
"raw_request_or_response_body_stored": False,
|
|
"external_rag_write_performed": False,
|
|
"production_write_performed": False,
|
|
"shadow_allowed": False,
|
|
"canary_allowed": False,
|
|
"promotion_allowed": False,
|
|
"promotion_blockers": policy.get("promotion_blockers_after_replay"),
|
|
"rollback_terminal": {
|
|
"status": "mcp_process_terminated_and_ephemeral_container_removed",
|
|
"candidate_registration_state": "disabled",
|
|
"ephemeral_container_removed": True,
|
|
"production_route_changed": False,
|
|
},
|
|
"learning_writeback": {
|
|
"status": "verified_receipt_ready_for_gitea_audit_branch_writeback",
|
|
"external_rag_write_performed": False,
|
|
"km_write_performed": False,
|
|
},
|
|
}
|
|
|
|
|
|
def _write(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(_canonical(payload) + b"\n")
|
|
|
|
|
|
def _failure_receipt(
|
|
policy: dict[str, Any], controller: dict[str, Any], error: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": VERIFIER_SCHEMA,
|
|
"run_id": controller.get("run_id"),
|
|
"trace_id": controller.get("trace_id"),
|
|
"work_item_id": policy.get("work_item_id"),
|
|
"candidate_id": policy.get("candidate_id"),
|
|
"status": "blocked_with_safe_next_action",
|
|
"error_class": error,
|
|
"observed_at": datetime.now(timezone.utc).isoformat(),
|
|
"browser_started": False,
|
|
"tool_call_performed": False,
|
|
"external_rag_write_performed": False,
|
|
"production_write_performed": False,
|
|
"promotion_allowed": False,
|
|
"rollback_terminal": {
|
|
"status": "candidate_disabled_ephemeral_process_cleanup_attempted",
|
|
"candidate_registration_state": "disabled",
|
|
"production_route_changed": False,
|
|
},
|
|
}
|
|
|
|
|
|
def _parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--policy", type=Path, required=True)
|
|
parser.add_argument("--artifact-controller-receipt", type=Path, required=True)
|
|
parser.add_argument("--artifact-verifier-receipt", type=Path, required=True)
|
|
parser.add_argument("--controller-receipt", type=Path, required=True)
|
|
parser.add_argument("--verifier-receipt", type=Path, required=True)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parser().parse_args(argv)
|
|
policy, policy_checksum = load_and_validate_policy(args.policy)
|
|
controller: dict[str, Any] = {}
|
|
try:
|
|
controller, controller_sha256 = validate_controller_receipt(
|
|
policy, policy_checksum, args.controller_receipt
|
|
)
|
|
artifact_controller_sha256, artifact_verifier_sha256 = (
|
|
validate_artifact_receipt_chain(
|
|
policy,
|
|
args.artifact_controller_receipt,
|
|
args.artifact_verifier_receipt,
|
|
)
|
|
)
|
|
with tempfile.TemporaryDirectory(prefix="awoooi-mcp-replay-verifier-") as temp:
|
|
modules, file_count, unpacked_bytes = independently_materialize(
|
|
policy, Path(temp)
|
|
)
|
|
observation = independently_replay(policy, modules)
|
|
receipt = build_verifier_receipt(
|
|
policy=policy,
|
|
policy_checksum=policy_checksum,
|
|
controller=controller,
|
|
controller_sha256=controller_sha256,
|
|
artifact_controller_sha256=artifact_controller_sha256,
|
|
artifact_verifier_sha256=artifact_verifier_sha256,
|
|
observation=observation,
|
|
package_file_count=file_count,
|
|
package_unpacked_bytes=unpacked_bytes,
|
|
)
|
|
except VerifierError as exc:
|
|
_write(args.verifier_receipt, _failure_receipt(policy, controller, str(exc)))
|
|
raise
|
|
_write(args.verifier_receipt, receipt)
|
|
print(json.dumps(receipt, ensure_ascii=False, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except VerifierError as exc:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"schema_version": VERIFIER_SCHEMA,
|
|
"status": "blocked_with_safe_next_action",
|
|
"error_class": str(exc),
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1) from None
|