373 lines
15 KiB
Python
373 lines
15 KiB
Python
#!/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()
|