From e2cd70501d86b981600b12fd0ede718c865736b0 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 11:07:00 +0800 Subject: [PATCH] fix(mcp): enforce exact replay sandbox contract --- docs/LOGBOOK.md | 2 +- .../external_mcp_replay_controller.py | 76 ++++++++++++++----- .../test_external_mcp_replay_controller.py | 25 +++++- ...test_verify_external_mcp_replay_receipt.py | 20 +++++ .../verify_external_mcp_replay_receipt.py | 41 +++++++++- 5 files changed, 140 insertions(+), 24 deletions(-) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index b90f03160..98bd573b1 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -10,7 +10,7 @@ **source/test evidence**: - controller/verifier 本機真實雙 replay 皆通過:MCP `Playwright@1.62.0-alpha-1783623505000`、protocol `2025-06-18`、24 tools、tool-name hash `12d23fea9d8d1a1de3f44863dc00cf393f0a2165323838cf93fed30a6a4cc237`、schema hash `c3737ced21147d0512b0400df5885c95a545bc915892b5c8f928cee78428ac99`,兩次均 clean exit/container removed。 -- 新 replay tests `32 passed`;artifact + replay supply-chain tests與 MCP control-plane/federation/version lifecycle/runner-health 聚焦回歸合計 `84 passed`。Ruff、py_compile、JSON/YAML parse、source-control owner guard與 `git diff --check` 通過。 +- 新 replay tests `37 passed`;artifact + replay supply-chain tests與 MCP control-plane/federation/version lifecycle/runner-health 聚焦回歸合計 `89 passed`。Ruff、py_compile、JSON/YAML parse、source-control owner guard與 `git diff --check` 通過。 - global security mirror guard另抓到 latest main 既有 `apps/web/src/app/[locale]/iwooos/page.tsx` 的 `raw_blocked_waiting_state` 敏感字樣;本 work item未修改該檔,保留為獨立 drift blocker,不以 replay source 綠燈覆蓋。 **尚未完成 / 不誤報**: diff --git a/scripts/security/external_mcp_replay_controller.py b/scripts/security/external_mcp_replay_controller.py index 319a32222..7b76e7f0f 100644 --- a/scripts/security/external_mcp_replay_controller.py +++ b/scripts/security/external_mcp_replay_controller.py @@ -52,6 +52,27 @@ EXPECTED_PACKAGE_KEYS = { "playwright@1.62.0-alpha-1783623505000", "playwright-core@1.62.0-alpha-1783623505000", } +EXPECTED_WORK_ITEM_ID = "MCP-REPLAY-PLAYWRIGHT-001" +EXPECTED_CANDIDATE_ID = "external.playwright-verifier" +EXPECTED_ADAPTER_ID = "playwright_public_accessibility_verifier_v1" +EXPECTED_OWNER_LANE = "awoooi_mcp_supply_chain" +EXPECTED_ARTIFACT_REPOSITORY = ( + "registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp@" +) +EXPECTED_ARTIFACT_RUNTIME_REPOSITORY = ( + "192.168.0.110:5000/awoooi/mcp-artifacts/playwright-mcp@" +) +EXPECTED_RUNTIME_REPOSITORY = "registry.wooo.work/awoooi/ci-runner@" +EXPECTED_RUNTIME_ARGUMENTS = ( + "--headless", + "--isolated", + "--allowed-origins", + "https://awoooi.wooo.work", + "--output-dir", + "/tmp/mcp-output", + "--executable-path", + "/nonexistent/browser", +) class ReplayError(RuntimeError): @@ -203,6 +224,13 @@ def load_policy(path: Path) -> ReplayPolicy: raise ReplayError("replay_scope_invalid") if payload.get("risk_class") != "high": raise ReplayError("replay_risk_class_invalid") + if ( + payload.get("work_item_id") != EXPECTED_WORK_ITEM_ID + or payload.get("candidate_id") != EXPECTED_CANDIDATE_ID + or payload.get("adapter_id") != EXPECTED_ADAPTER_ID + or payload.get("owner_lane") != EXPECTED_OWNER_LANE + ): + raise ReplayError("replay_asset_identity_invalid") expiry_text = _require_text(payload.get("expires_at"), "replay_expiry_invalid") try: @@ -220,6 +248,8 @@ def load_policy(path: Path) -> ReplayPolicy: _require_text(payload.get(field), f"replay_{field}_invalid") artifact = _require_dict(payload.get("artifact_source"), "artifact_source_invalid") + if artifact.get("audit_branch") != "mcp-artifact-receipts": + raise ReplayError("audit_branch_invalid") audit_commit = _require_text(artifact.get("audit_commit"), "audit_commit_invalid") if not GIT_SHA_PATTERN.fullmatch(audit_commit): raise ReplayError("audit_commit_invalid") @@ -235,32 +265,42 @@ def load_policy(path: Path) -> ReplayPolicy: raise ReplayError("package_integrity_invalid") normalized_integrities[key] = digest + artifact_ref = _validate_oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid") + artifact_runtime_ref = _validate_oci_ref( + artifact.get("artifact_runtime_ref"), "artifact_runtime_ref_invalid" + ) + if not artifact_ref.startswith(EXPECTED_ARTIFACT_REPOSITORY): + raise ReplayError("artifact_repository_not_allowlisted") + if not artifact_runtime_ref.startswith(EXPECTED_ARTIFACT_RUNTIME_REPOSITORY): + raise ReplayError("artifact_runtime_repository_not_allowlisted") + runtime = _require_dict(payload.get("runtime"), "runtime_invalid") + runtime_image_ref = _validate_oci_ref( + runtime.get("image_ref"), "runtime_image_ref_invalid" + ) + if not runtime_image_ref.startswith(EXPECTED_RUNTIME_REPOSITORY): + raise ReplayError("runtime_repository_not_allowlisted") 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("platform") != "linux/amd64" + or runtime.get("user") != "65534:65534" + 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 ): raise ReplayError("runtime_isolation_invalid") entrypoint = _require_text_list(runtime.get("entrypoint"), "entrypoint_invalid") arguments = _require_text_list(runtime.get("arguments"), "arguments_invalid") if entrypoint != ("node", "node_modules/@playwright/mcp/cli.js"): raise ReplayError("entrypoint_not_allowlisted") - required_arguments = { - "--headless", - "--isolated", - "--allowed-origins", - "https://awoooi.wooo.work", - "--output-dir", - "/tmp/mcp-output", - "--executable-path", - "/nonexistent/browser", - } - if set(arguments) != required_arguments or len(arguments) != 8: + if arguments != EXPECTED_RUNTIME_ARGUMENTS: raise ReplayError("arguments_not_allowlisted") - if runtime.get("user") != "65534:65534": - raise ReplayError("runtime_user_invalid") protocol = _require_dict( payload.get("protocol_contract"), "protocol_contract_invalid" @@ -332,10 +372,8 @@ def load_policy(path: Path) -> ReplayPolicy: artifact.get("verifier_receipt_sha256"), "verifier_receipt_sha256_invalid", ), - artifact_ref=_validate_oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid"), - artifact_runtime_ref=_validate_oci_ref( - artifact.get("artifact_runtime_ref"), "artifact_runtime_ref_invalid" - ), + artifact_ref=artifact_ref, + artifact_runtime_ref=artifact_runtime_ref, bundle_manifest_sha256=_validate_sha256( artifact.get("bundle_manifest_sha256"), "bundle_manifest_sha256_invalid" ), @@ -343,9 +381,7 @@ def load_policy(path: Path) -> ReplayPolicy: artifact.get("cyclonedx_sbom_sha256"), "cyclonedx_sbom_sha256_invalid" ), package_integrities=normalized_integrities, - runtime_image_ref=_validate_oci_ref( - runtime.get("image_ref"), "runtime_image_ref_invalid" - ), + runtime_image_ref=runtime_image_ref, runtime_platform=_require_text( runtime.get("platform"), "runtime_platform_invalid" ), diff --git a/scripts/security/tests/test_external_mcp_replay_controller.py b/scripts/security/tests/test_external_mcp_replay_controller.py index 0a9063d2a..c267a0550 100644 --- a/scripts/security/tests/test_external_mcp_replay_controller.py +++ b/scripts/security/tests/test_external_mcp_replay_controller.py @@ -157,7 +157,23 @@ class ReplayPolicyTests(unittest.TestCase): path = self._mutated_policy( lambda payload: payload["runtime"].__setitem__("user", "0:0") ) - with self.assertRaisesRegex(controller.ReplayError, "runtime_user_invalid"): + with self.assertRaisesRegex(controller.ReplayError, "runtime_isolation_invalid"): + controller.load_policy(path) + + def test_runtime_memory_limit_cannot_be_weakened(self) -> None: + path = self._mutated_policy( + lambda payload: payload["runtime"].__setitem__("memory_limit", "2g") + ) + with self.assertRaisesRegex(controller.ReplayError, "runtime_isolation_invalid"): + controller.load_policy(path) + + def test_runtime_argument_order_is_part_of_allowlist(self) -> None: + def mutate(payload: dict) -> None: + arguments = payload["runtime"]["arguments"] + arguments[2], arguments[4] = arguments[4], arguments[2] + + path = self._mutated_policy(mutate) + with self.assertRaisesRegex(controller.ReplayError, "arguments_not_allowlisted"): controller.load_policy(path) def test_floating_runtime_reference_is_rejected(self) -> None: @@ -178,6 +194,13 @@ class ReplayPolicyTests(unittest.TestCase): with self.assertRaisesRegex(controller.ReplayError, "execution_boundary_invalid"): controller.load_policy(path) + def test_candidate_identity_cannot_be_retargeted(self) -> None: + path = self._mutated_policy( + lambda payload: payload.__setitem__("candidate_id", "external.other") + ) + with self.assertRaisesRegex(controller.ReplayError, "replay_asset_identity_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( diff --git a/scripts/security/tests/test_verify_external_mcp_replay_receipt.py b/scripts/security/tests/test_verify_external_mcp_replay_receipt.py index 27c4ae9b3..633eebce0 100644 --- a/scripts/security/tests/test_verify_external_mcp_replay_receipt.py +++ b/scripts/security/tests/test_verify_external_mcp_replay_receipt.py @@ -154,6 +154,26 @@ class StandalonePolicyTests(unittest.TestCase): with self.assertRaisesRegex(verifier.VerifierError, "artifact_ref_invalid"): verifier.load_and_validate_policy(path) + def test_runtime_resource_limits_cannot_be_weakened(self) -> None: + with tempfile.TemporaryDirectory() as temp: + payload = _policy_payload() + payload["runtime"]["pids_limit"] = 512 + path = Path(temp) / "policy.json" + _write(path, payload) + with self.assertRaisesRegex(verifier.VerifierError, "runtime_isolation_invalid"): + verifier.load_and_validate_policy(path) + + def test_runtime_argument_order_drift_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temp: + payload = _policy_payload() + payload["runtime"]["arguments"] = list( + reversed(payload["runtime"]["arguments"]) + ) + path = Path(temp) / "policy.json" + _write(path, payload) + with self.assertRaisesRegex(verifier.VerifierError, "runtime_isolation_invalid"): + verifier.load_and_validate_policy(path) + class ControllerReceiptTests(unittest.TestCase): def test_valid_controller_receipt_passes_standalone_validation(self) -> None: diff --git a/scripts/security/verify_external_mcp_replay_receipt.py b/scripts/security/verify_external_mcp_replay_receipt.py index 5733edc65..b643d82df 100644 --- a/scripts/security/verify_external_mcp_replay_receipt.py +++ b/scripts/security/verify_external_mcp_replay_receipt.py @@ -41,6 +41,16 @@ 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): @@ -113,6 +123,11 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any], str]: 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: @@ -134,12 +149,34 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any], str]: 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") - if artifact_ref == runtime_ref: - raise VerifierError("artifact_runtime_separation_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",