feat(mcp): add isolated protocol replay verifier
This commit is contained in:
1245
scripts/security/external_mcp_replay_controller.py
Normal file
1245
scripts/security/external_mcp_replay_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
372
scripts/security/tests/test_external_mcp_replay_controller.py
Normal file
372
scripts/security/tests/test_external_mcp_replay_controller.py
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the bounded external MCP replay controller without mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_replay_controller.py"
|
||||
POLICY_PATH = ROOT / "config/mcp/playwright-mcp-replay-policy.json"
|
||||
ARTIFACT_POLICY_PATH = ROOT / "config/mcp/playwright-mcp-artifact-policy.json"
|
||||
WORKFLOW_PATH = ROOT / ".gitea/workflows/mcp-external-replay.yaml"
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("external_mcp_replay_controller", CONTROLLER_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
controller = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = controller
|
||||
SPEC.loader.exec_module(controller)
|
||||
|
||||
|
||||
def _canonical(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: object) -> bytes:
|
||||
raw = _canonical(payload) + b"\n"
|
||||
path.write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _policy_payload() -> dict:
|
||||
return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _artifact_receipts(policy_payload: dict) -> tuple[dict, dict]:
|
||||
artifact = policy_payload["artifact_source"]
|
||||
artifact_run_id = "11111111-2222-4333-8444-555555555555"
|
||||
artifact_trace_id = f"mcp-artifact-{artifact_run_id}"
|
||||
packages = []
|
||||
for key, digest in artifact["package_integrities"].items():
|
||||
name, version = key.rsplit("@", 1)
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"tarball_sha512_hex": digest,
|
||||
}
|
||||
)
|
||||
artifact_controller = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_receipt_v1",
|
||||
"status": "completed_internal_mirror_pending_independent_verifier",
|
||||
"run_id": artifact_run_id,
|
||||
"trace_id": artifact_trace_id,
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"runtime_mirror_ref": artifact["artifact_runtime_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"bundle_manifest_sha256": artifact["bundle_manifest_sha256"],
|
||||
"cyclonedx_sbom_sha256": artifact["cyclonedx_sbom_sha256"],
|
||||
"packages": packages,
|
||||
}
|
||||
artifact_verifier = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_verifier_receipt_v1",
|
||||
"status": "completed_internal_mirror_verified",
|
||||
"run_id": artifact_run_id,
|
||||
"trace_id": artifact_trace_id,
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"runtime_mirror_ref": artifact["artifact_runtime_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"independent_post_verifier": {"status": "passed"},
|
||||
}
|
||||
return artifact_controller, artifact_verifier
|
||||
|
||||
|
||||
def _source_fixture(directory: Path) -> tuple[Path, Path, Path]:
|
||||
payload = _policy_payload()
|
||||
artifact_controller, artifact_verifier = _artifact_receipts(payload)
|
||||
controller_path = directory / "artifact-controller.json"
|
||||
verifier_path = directory / "artifact-verifier.json"
|
||||
controller_raw = _write_json(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write_json(verifier_path, artifact_verifier)
|
||||
payload["artifact_source"]["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
payload["artifact_source"]["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
policy_path = directory / "replay-policy.json"
|
||||
_write_json(policy_path, payload)
|
||||
return policy_path, controller_path, verifier_path
|
||||
|
||||
|
||||
class ReplayPolicyTests(unittest.TestCase):
|
||||
def _mutated_policy(self, mutate) -> Path:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
path = Path(self.temp.name) / "policy.json"
|
||||
payload = _policy_payload()
|
||||
mutate(payload)
|
||||
_write_json(path, payload)
|
||||
return path
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if hasattr(self, "temp"):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_committed_policy_is_exact_and_minimal(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
self.assertEqual(policy.work_item_id, "MCP-REPLAY-PLAYWRIGHT-001")
|
||||
self.assertEqual(policy.risk_class, "high")
|
||||
self.assertEqual(policy.tool_count, 24)
|
||||
self.assertEqual(len(policy.adapter_allowed_tools), 4)
|
||||
self.assertEqual(len(policy.adapter_denied_tools), 20)
|
||||
self.assertRegex(policy.artifact_ref, r"@sha256:[0-9a-f]{64}$")
|
||||
self.assertRegex(policy.runtime_image_ref, r"@sha256:[0-9a-f]{64}$")
|
||||
|
||||
def test_expired_policy_fails_closed(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload.__setitem__(
|
||||
"expires_at", "2026-07-01T00:00:00+08:00"
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "replay_policy_expired"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_network_must_remain_none(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__("network_mode", "bridge")
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_isolation_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_runtime_must_remain_non_root(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__("user", "0:0")
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_user_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_floating_runtime_reference_is_rejected(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__(
|
||||
"image_ref", "registry.wooo.work/awoooi/ci-runner:current"
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_image_ref_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_direct_registration_cannot_be_enabled(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["execution_boundaries"].__setitem__(
|
||||
"direct_gateway_registration_allowed", True
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "execution_boundary_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_dangerous_tool_cannot_leave_denied_partition(self) -> None:
|
||||
def mutate(payload: dict) -> None:
|
||||
payload["protocol_contract"]["adapter_denied_tools"].remove(
|
||||
"browser_run_code_unsafe"
|
||||
)
|
||||
|
||||
path = self._mutated_policy(mutate)
|
||||
with self.assertRaises(controller.ReplayError):
|
||||
controller.load_policy(path)
|
||||
|
||||
|
||||
class SourceEvidenceTests(unittest.TestCase):
|
||||
def test_verified_source_receipt_chain_passes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
policy_path, controller_path, verifier_path = _source_fixture(Path(temp))
|
||||
policy = controller.load_policy(policy_path)
|
||||
evidence = controller.validate_source_evidence(
|
||||
policy,
|
||||
artifact_policy_path=ARTIFACT_POLICY_PATH,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
self.assertEqual(
|
||||
evidence.artifact_run_id, "11111111-2222-4333-8444-555555555555"
|
||||
)
|
||||
self.assertRegex(evidence.artifact_verifier_receipt_sha256, r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_tampered_controller_receipt_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
policy_path, controller_path, verifier_path = _source_fixture(Path(temp))
|
||||
policy = controller.load_policy(policy_path)
|
||||
controller_path.write_bytes(controller_path.read_bytes() + b" ")
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError,
|
||||
"artifact_controller_receipt_checksum_mismatch",
|
||||
):
|
||||
controller.validate_source_evidence(
|
||||
policy,
|
||||
artifact_policy_path=ARTIFACT_POLICY_PATH,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
|
||||
def test_artifact_policy_checksum_drift_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
policy_path, controller_path, verifier_path = _source_fixture(root)
|
||||
artifact_policy = json.loads(ARTIFACT_POLICY_PATH.read_text())
|
||||
artifact_policy["risk_level"] = "medium"
|
||||
drift_path = root / "artifact-policy.json"
|
||||
_write_json(drift_path, artifact_policy)
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError, "artifact_policy_checksum_mismatch"
|
||||
):
|
||||
controller.validate_source_evidence(
|
||||
controller.load_policy(policy_path),
|
||||
artifact_policy_path=drift_path,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
|
||||
|
||||
class ArchiveAndProtocolTests(unittest.TestCase):
|
||||
def test_archive_traversal_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(controller.ReplayError, "artifact_archive_path_invalid"):
|
||||
controller._safe_relative_member("package/../../escape")
|
||||
|
||||
def test_archive_symlink_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
tarball = root / "bad.tgz"
|
||||
with tarfile.open(tarball, "w:gz") as archive:
|
||||
member = tarfile.TarInfo("package/link")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = "/etc/passwd"
|
||||
archive.addfile(member)
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError, "artifact_archive_member_type_invalid"
|
||||
):
|
||||
controller._extract_package(tarball, root / "out")
|
||||
|
||||
def test_regular_archive_extracts_with_exact_size(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
tarball = root / "good.tgz"
|
||||
content = b'{"name":"fixture"}'
|
||||
with tarfile.open(tarball, "w:gz") as archive:
|
||||
member = tarfile.TarInfo("package/package.json")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
files, size = controller._extract_package(tarball, root / "out")
|
||||
self.assertEqual((files, size), (1, len(content)))
|
||||
self.assertEqual((root / "out/package.json").read_bytes(), content)
|
||||
|
||||
def test_tool_hashes_are_order_independent(self) -> None:
|
||||
tools = [
|
||||
{
|
||||
"name": "beta",
|
||||
"inputSchema": {"type": "object"},
|
||||
"annotations": {"readOnlyHint": True},
|
||||
},
|
||||
{"name": "alpha", "inputSchema": {"type": "object"}},
|
||||
]
|
||||
first = controller._tool_hashes(tools)
|
||||
second = controller._tool_hashes(list(reversed(tools)))
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first[0], 2)
|
||||
self.assertEqual(first[3], ("alpha", "beta"))
|
||||
|
||||
def test_duplicate_tool_names_are_rejected(self) -> None:
|
||||
tools = [
|
||||
{"name": "same", "inputSchema": {}},
|
||||
{"name": "same", "inputSchema": {}},
|
||||
]
|
||||
with self.assertRaisesRegex(controller.ReplayError, "mcp_tool_names_not_unique"):
|
||||
controller._tool_hashes(tools)
|
||||
|
||||
|
||||
class ReceiptAndWorkflowTests(unittest.TestCase):
|
||||
def test_success_receipt_preserves_no_browser_no_write_boundary(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
source = controller.SourceEvidence("a", "b", "c", "d")
|
||||
observation = controller.ReplayObservation(
|
||||
policy.server_name,
|
||||
policy.server_version,
|
||||
policy.protocol_version,
|
||||
policy.tool_count,
|
||||
policy.tool_name_set_sha256,
|
||||
policy.tool_schema_manifest_sha256,
|
||||
0,
|
||||
"stdin_eof_clean_exit",
|
||||
True,
|
||||
)
|
||||
receipt = controller.build_receipt(
|
||||
policy=policy,
|
||||
source=source,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
mode="apply",
|
||||
observation=observation,
|
||||
package_file_count=10,
|
||||
package_unpacked_bytes=20,
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt["status"],
|
||||
"completed_protocol_schema_replay_pending_independent_verifier",
|
||||
)
|
||||
for field in (
|
||||
"browser_started",
|
||||
"tool_call_performed",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"promotion_allowed",
|
||||
):
|
||||
self.assertIs(receipt[field], False)
|
||||
|
||||
def test_failure_receipt_keeps_candidate_disabled(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
receipt = controller._failure_receipt(
|
||||
policy=policy,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
error_class="fixture_failure",
|
||||
)
|
||||
self.assertEqual(receipt["status"], "blocked_with_safe_next_action")
|
||||
terminal = receipt["stage_receipts"]["rollback_or_no_write_terminal"]
|
||||
self.assertEqual(terminal["candidate_registration_state"], "disabled")
|
||||
self.assertIs(receipt["promotion_allowed"], False)
|
||||
|
||||
def test_gitea_workflow_has_no_floating_or_external_source_controls(self) -> None:
|
||||
raw = WORKFLOW_PATH.read_text(encoding="utf-8").lower()
|
||||
forbidden = (
|
||||
"uses" + ":",
|
||||
"actions" + "/checkout",
|
||||
"github" + ".com",
|
||||
"api" + ".github" + ".com",
|
||||
"raw" + ".githubusercontent" + ".com",
|
||||
"codeload" + ".github" + ".com",
|
||||
"ghcr" + ".io",
|
||||
"npx" + " -y",
|
||||
"@" + "latest",
|
||||
":" + "latest",
|
||||
)
|
||||
self.assertFalse([value for value in forbidden if value in raw])
|
||||
self.assertIn("runs-on: awoooi-non110-host", raw)
|
||||
self.assertIn("wait-host-web-build-pressure.sh", raw)
|
||||
self.assertIn("mcp-replay-receipts", raw)
|
||||
self.assertIn("verify_external_mcp_replay_receipt.py", raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the standalone external MCP replay verifier without mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
VERIFIER_PATH = ROOT / "scripts/security/verify_external_mcp_replay_receipt.py"
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_replay_controller.py"
|
||||
POLICY_PATH = ROOT / "config/mcp/playwright-mcp-replay-policy.json"
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verifier = _load("verify_external_mcp_replay_receipt", VERIFIER_PATH)
|
||||
controller = _load("external_mcp_replay_controller_for_verifier_test", CONTROLLER_PATH)
|
||||
|
||||
|
||||
def _canonical(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write(path: Path, payload: object) -> bytes:
|
||||
raw = _canonical(payload) + b"\n"
|
||||
path.write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _policy_payload() -> dict:
|
||||
return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _valid_controller_receipt() -> tuple[dict, dict, str]:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
source = controller.SourceEvidence(
|
||||
artifact_run_id="aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
artifact_trace_id="mcp-artifact-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
artifact_controller_receipt_sha256="a" * 64,
|
||||
artifact_verifier_receipt_sha256="b" * 64,
|
||||
)
|
||||
observation = controller.ReplayObservation(
|
||||
server_name=policy.server_name,
|
||||
server_version=policy.server_version,
|
||||
protocol_version=policy.protocol_version,
|
||||
tool_count=policy.tool_count,
|
||||
tool_name_set_sha256=policy.tool_name_set_sha256,
|
||||
tool_schema_manifest_sha256=policy.tool_schema_manifest_sha256,
|
||||
process_exit_code=0,
|
||||
shutdown_mode="stdin_eof_clean_exit",
|
||||
ephemeral_container_removed=True,
|
||||
)
|
||||
receipt = controller.build_receipt(
|
||||
policy=policy,
|
||||
source=source,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
mode="apply",
|
||||
observation=observation,
|
||||
package_file_count=173,
|
||||
package_unpacked_bytes=1_000,
|
||||
)
|
||||
payload, checksum = verifier.load_and_validate_policy(POLICY_PATH)
|
||||
return payload, receipt, checksum
|
||||
|
||||
|
||||
def _artifact_chain(policy: dict) -> tuple[dict, dict]:
|
||||
artifact = policy["artifact_source"]
|
||||
run_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
controller_receipt = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_receipt_v1",
|
||||
"status": "completed_internal_mirror_pending_independent_verifier",
|
||||
"run_id": run_id,
|
||||
"trace_id": f"mcp-artifact-{run_id}",
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
}
|
||||
verifier_receipt = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_verifier_receipt_v1",
|
||||
"status": "completed_internal_mirror_verified",
|
||||
"run_id": run_id,
|
||||
"trace_id": f"mcp-artifact-{run_id}",
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"independent_post_verifier": {
|
||||
"status": "passed",
|
||||
"container_or_mcp_started": False,
|
||||
},
|
||||
}
|
||||
return controller_receipt, verifier_receipt
|
||||
|
||||
|
||||
class StandalonePolicyTests(unittest.TestCase):
|
||||
def test_committed_policy_passes_independent_validation(self) -> None:
|
||||
policy, checksum = verifier.load_and_validate_policy(POLICY_PATH)
|
||||
self.assertEqual(policy["work_item_id"], "MCP-REPLAY-PLAYWRIGHT-001")
|
||||
self.assertRegex(checksum, r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
def test_any_enabled_execution_boundary_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["execution_boundaries"]["browser_start_allowed"] = True
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "boundary_must_remain_false"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
def test_adapter_tool_partition_overlap_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["protocol_contract"]["adapter_denied_tools"].append(
|
||||
"browser_snapshot"
|
||||
)
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "adapter_tool_partition_invalid"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
def test_tagged_artifact_reference_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["artifact_source"]["artifact_ref"] = (
|
||||
"registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp:0.0.78"
|
||||
)
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "artifact_ref_invalid"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
|
||||
class ControllerReceiptTests(unittest.TestCase):
|
||||
def test_valid_controller_receipt_passes_standalone_validation(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
raw = _write(path, receipt)
|
||||
observed, observed_sha = verifier.validate_controller_receipt(
|
||||
policy, checksum, path
|
||||
)
|
||||
self.assertEqual(observed["run_id"], receipt["run_id"])
|
||||
self.assertEqual(observed_sha, hashlib.sha256(raw).hexdigest())
|
||||
|
||||
def test_controller_browser_start_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["browser_started"] = True
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "controller_boundary_invalid"):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
def test_controller_missing_cleanup_terminal_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["stage_receipts"]["rollback_or_no_write_terminal"][
|
||||
"ephemeral_container_removed"
|
||||
] = False
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "controller_rollback_invalid"):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
def test_controller_protocol_hash_drift_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["protocol_observation"]["tool_name_set_sha256"] = "0" * 64
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError, "controller_protocol_observation_mismatch"
|
||||
):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
|
||||
class ArtifactChainAndToolTests(unittest.TestCase):
|
||||
def test_valid_artifact_chain_passes(self) -> None:
|
||||
policy = _policy_payload()
|
||||
artifact = policy["artifact_source"]
|
||||
artifact_controller, artifact_verifier = _artifact_chain(policy)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
controller_path = root / "artifact-controller.json"
|
||||
verifier_path = root / "artifact-verifier.json"
|
||||
controller_raw = _write(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write(verifier_path, artifact_verifier)
|
||||
artifact["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
artifact["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
observed = verifier.validate_artifact_receipt_chain(
|
||||
policy, controller_path, verifier_path
|
||||
)
|
||||
self.assertEqual(observed[0], artifact["controller_receipt_sha256"])
|
||||
self.assertEqual(observed[1], artifact["verifier_receipt_sha256"])
|
||||
|
||||
def test_artifact_chain_tamper_is_rejected(self) -> None:
|
||||
policy = _policy_payload()
|
||||
artifact = policy["artifact_source"]
|
||||
artifact_controller, artifact_verifier = _artifact_chain(policy)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
controller_path = root / "artifact-controller.json"
|
||||
verifier_path = root / "artifact-verifier.json"
|
||||
controller_raw = _write(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write(verifier_path, artifact_verifier)
|
||||
artifact["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
artifact["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
verifier_path.write_bytes(verifier_raw + b" ")
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "artifact_receipt_chain_invalid"):
|
||||
verifier.validate_artifact_receipt_chain(
|
||||
policy, controller_path, verifier_path
|
||||
)
|
||||
|
||||
def test_independent_tool_hashes_are_order_independent(self) -> None:
|
||||
tools = [
|
||||
{"name": "zeta", "inputSchema": {"type": "object"}},
|
||||
{
|
||||
"name": "alpha",
|
||||
"inputSchema": {"type": "object"},
|
||||
"annotations": {"readOnlyHint": True},
|
||||
},
|
||||
]
|
||||
first = verifier._independent_tool_hashes(tools)
|
||||
second = verifier._independent_tool_hashes(list(reversed(tools)))
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first[3], {"alpha", "zeta"})
|
||||
|
||||
def test_duplicate_tool_names_are_rejected(self) -> None:
|
||||
tools = [
|
||||
{"name": "duplicate", "inputSchema": {}},
|
||||
{"name": "duplicate", "inputSchema": {}},
|
||||
]
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "tool_names_not_unique"):
|
||||
verifier._independent_tool_hashes(tools)
|
||||
|
||||
|
||||
class VerifierReceiptTests(unittest.TestCase):
|
||||
def test_completed_verifier_receipt_keeps_promotion_disabled(self) -> None:
|
||||
policy, controller_receipt, policy_checksum = _valid_controller_receipt()
|
||||
receipt = verifier.build_verifier_receipt(
|
||||
policy=policy,
|
||||
policy_checksum=policy_checksum,
|
||||
controller=controller_receipt,
|
||||
controller_sha256="c" * 64,
|
||||
artifact_controller_sha256="a" * 64,
|
||||
artifact_verifier_sha256="b" * 64,
|
||||
observation={
|
||||
"protocol_version": "2025-06-18",
|
||||
"server_name": "Playwright",
|
||||
"server_version": "1.62.0-alpha-1783623505000",
|
||||
"tool_count": 24,
|
||||
"tool_name_set_sha256": "d" * 64,
|
||||
"tool_schema_manifest_sha256": "e" * 64,
|
||||
"process_exit_code": 0,
|
||||
"shutdown_mode": "stdin_eof_clean_exit",
|
||||
"ephemeral_container_removed": True,
|
||||
},
|
||||
package_file_count=173,
|
||||
package_unpacked_bytes=17_000_000,
|
||||
)
|
||||
self.assertEqual(receipt["status"], "completed_protocol_schema_replay_verified")
|
||||
self.assertEqual(
|
||||
receipt["independent_post_verifier"]["implementation"],
|
||||
"standalone_no_controller_import",
|
||||
)
|
||||
for field in (
|
||||
"browser_started",
|
||||
"tool_call_performed",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"promotion_allowed",
|
||||
):
|
||||
self.assertIs(receipt[field], False)
|
||||
|
||||
def test_failure_receipt_has_rollback_terminal(self) -> None:
|
||||
policy = _policy_payload()
|
||||
receipt = verifier._failure_receipt(policy, {}, "fixture_failure")
|
||||
self.assertEqual(receipt["status"], "blocked_with_safe_next_action")
|
||||
self.assertEqual(
|
||||
receipt["rollback_terminal"]["candidate_registration_state"], "disabled"
|
||||
)
|
||||
self.assertIs(receipt["promotion_allowed"], False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
898
scripts/security/verify_external_mcp_replay_receipt.py
Normal file
898
scripts/security/verify_external_mcp_replay_receipt.py
Normal file
@@ -0,0 +1,898 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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"
|
||||
):
|
||||
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"
|
||||
):
|
||||
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")
|
||||
if artifact_ref == runtime_ref:
|
||||
raise VerifierError("artifact_runtime_separation_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
|
||||
Reference in New Issue
Block a user