From 7e098d8f66b027d5d272af36cafa617dbb920ba3 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 11:01:40 +0800 Subject: [PATCH] feat(mcp): add isolated protocol replay verifier --- .gitea/workflows/mcp-external-replay.yaml | 328 +++++ .../test_gitea_workflow_runner_health_api.py | 12 + config/mcp/playwright-mcp-replay-policy.json | 135 ++ docs/LOGBOOK.md | 23 + ...tea_workflow_runner_health_2026-06-05.json | 44 +- .../external_mcp_replay_controller.py | 1245 +++++++++++++++++ .../test_external_mcp_replay_controller.py | 372 +++++ ...test_verify_external_mcp_replay_receipt.py | 325 +++++ .../verify_external_mcp_replay_receipt.py | 898 ++++++++++++ 9 files changed, 3376 insertions(+), 6 deletions(-) create mode 100644 .gitea/workflows/mcp-external-replay.yaml create mode 100644 config/mcp/playwright-mcp-replay-policy.json create mode 100644 scripts/security/external_mcp_replay_controller.py create mode 100644 scripts/security/tests/test_external_mcp_replay_controller.py create mode 100644 scripts/security/tests/test_verify_external_mcp_replay_receipt.py create mode 100644 scripts/security/verify_external_mcp_replay_receipt.py diff --git a/.gitea/workflows/mcp-external-replay.yaml b/.gitea/workflows/mcp-external-replay.yaml new file mode 100644 index 000000000..6328f0e84 --- /dev/null +++ b/.gitea/workflows/mcp-external-replay.yaml @@ -0,0 +1,328 @@ +# AWOOOI external MCP offline protocol/schema replay. +# +# This Gitea-only high-risk controlled-apply lane pins the verified artifact +# receipt commit, artifact digest, and runtime digest. It starts only the MCP +# stdio process with network none, calls initialize and tools/list, runs a fresh +# standalone verifier, then writes receipts to a dedicated Gitea audit branch. +# Browser start, MCP tool calls, Gateway registration, RAG writes, production +# writes, shadow, canary, and promotion remain disabled. + +name: MCP External Protocol Replay + +on: + workflow_dispatch: + schedule: + - cron: "43 2 * * 3" # Wednesday 10:43 Asia/Taipei, after artifact mirror + push: + branches: + - main + paths: + - config/mcp/playwright-mcp-replay-policy.json + - scripts/security/external_mcp_replay_controller.py + - scripts/security/verify_external_mcp_replay_receipt.py + - scripts/security/tests/test_external_mcp_replay_controller.py + - scripts/security/tests/test_verify_external_mcp_replay_receipt.py + - .gitea/workflows/mcp-external-replay.yaml + +concurrency: + group: awoooi-mcp-external-protocol-replay + cancel-in-progress: true + +env: + GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git + HARBOR_REGISTRY: registry.wooo.work + POLICY_PATH: config/mcp/playwright-mcp-replay-policy.json + ARTIFACT_POLICY_PATH: config/mcp/playwright-mcp-artifact-policy.json + ARTIFACT_RECEIPT_BRANCH: mcp-artifact-receipts + ARTIFACT_RECEIPT_COMMIT: 9c08ada5c0f15bb6924304af29cd13c8376182ff + ARTIFACT_CONTROLLER_SOURCE_PATH: docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-controller.snapshot.json + ARTIFACT_VERIFIER_SOURCE_PATH: docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-verifier.snapshot.json + ARTIFACT_CONTROLLER_RECEIPT_PATH: /tmp/awoooi-mcp-replay/artifact-controller.json + ARTIFACT_VERIFIER_RECEIPT_PATH: /tmp/awoooi-mcp-replay/artifact-verifier.json + REPLAY_RECEIPT_BRANCH: mcp-replay-receipts + REPLAY_CONTROLLER_RECEIPT_PATH: docs/operations/external-mcp-replays/playwright-mcp-0.0.78-controller.snapshot.json + REPLAY_VERIFIER_RECEIPT_PATH: docs/operations/external-mcp-replays/playwright-mcp-0.0.78-verifier.snapshot.json + +jobs: + replay-and-verify: + runs-on: awoooi-non110-host + timeout-minutes: 25 + steps: + - name: Bootstrap bounded runner tools + run: | + set -euo pipefail + if command -v apk >/dev/null 2>&1; then + apk add --no-cache bash coreutils curl docker-cli git python3 + fi + command -v bash >/dev/null + command -v curl >/dev/null + command -v docker >/dev/null + command -v git >/dev/null + command -v python3 >/dev/null + + - name: Checkout exact source from Gitea + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin "${GITEA_SOURCE_URL}" + git fetch --no-tags --prune --depth=1 origin "${GITHUB_SHA}" + git checkout --force -B main FETCH_HEAD + + - name: Validate policy controller verifier and freeze boundaries + run: | + set -euo pipefail + python3 -m py_compile \ + scripts/security/external_mcp_replay_controller.py \ + scripts/security/verify_external_mcp_replay_receipt.py \ + scripts/security/tests/test_external_mcp_replay_controller.py \ + scripts/security/tests/test_verify_external_mcp_replay_receipt.py + python3 scripts/security/tests/test_external_mcp_replay_controller.py + python3 scripts/security/tests/test_verify_external_mcp_replay_receipt.py + python3 - <<'PY' + import json + from pathlib import Path + + paths = [ + Path("config/mcp/playwright-mcp-replay-policy.json"), + Path(".gitea/workflows/mcp-external-replay.yaml"), + ] + text = "\n".join(path.read_text(encoding="utf-8").lower() for path in paths) + forbidden = ( + "uses" + ":", + "actions" + "/checkout", + "github" + ".com", + "api" + ".github" + ".com", + "raw" + ".githubusercontent" + ".com", + "codeload" + ".github" + ".com", + "ghcr" + ".io", + "npx" + " -y", + "@" + "latest", + ":" + "latest", + ) + hits = [value for value in forbidden if value in text] + if hits: + raise SystemExit(f"freeze_or_floating_reference_violation:{hits}") + policy = json.loads(paths[0].read_text(encoding="utf-8")) + if policy.get("risk_class") != "high": + raise SystemExit("replay_policy_risk_class_invalid") + if any( + value is not False + for value in (policy.get("execution_boundaries") or {}).values() + ): + raise SystemExit("runtime_boundary_must_remain_disabled") + runtime_ref = (policy.get("runtime") or {}).get("image_ref", "") + artifact_ref = (policy.get("artifact_source") or {}).get("artifact_ref", "") + if "@sha256:" not in runtime_ref or "@sha256:" not in artifact_ref: + raise SystemExit("immutable_digest_pin_missing") + if (policy.get("artifact_source") or {}).get("audit_commit") != ( + "9c08ada5c0f15bb6924304af29cd13c8376182ff" + ): + raise SystemExit("artifact_audit_commit_drift") + print("freeze_boundary_verified=true") + print("runtime_promotion_enabled=false") + print("browser_or_tool_execution_enabled=false") + PY + git diff --check + + - name: Wait for bounded host capacity + env: + HOST_WEB_BUILD_PRESSURE_ATTEMPTS: "18" + HOST_WEB_BUILD_PRESSURE_SLEEP_SECONDS: "10" + run: bash scripts/ci/wait-host-web-build-pressure.sh + + - name: Read pinned artifact receipts from Gitea audit commit + run: | + set -euo pipefail + mkdir -p "$(dirname "${ARTIFACT_CONTROLLER_RECEIPT_PATH}")" + git fetch --no-tags --prune --depth=100 origin \ + "refs/heads/${ARTIFACT_RECEIPT_BRANCH}:refs/remotes/origin/${ARTIFACT_RECEIPT_BRANCH}" + git cat-file -e "${ARTIFACT_RECEIPT_COMMIT}^{commit}" + git merge-base --is-ancestor \ + "${ARTIFACT_RECEIPT_COMMIT}" \ + "refs/remotes/origin/${ARTIFACT_RECEIPT_BRANCH}" + git show \ + "${ARTIFACT_RECEIPT_COMMIT}:${ARTIFACT_CONTROLLER_SOURCE_PATH}" \ + > "${ARTIFACT_CONTROLLER_RECEIPT_PATH}" + git show \ + "${ARTIFACT_RECEIPT_COMMIT}:${ARTIFACT_VERIFIER_SOURCE_PATH}" \ + > "${ARTIFACT_VERIFIER_RECEIPT_PATH}" + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + policy = json.loads(Path(os.environ["POLICY_PATH"]).read_text()) + artifact = policy["artifact_source"] + rows = ( + ("controller_receipt_sha256", "ARTIFACT_CONTROLLER_RECEIPT_PATH"), + ("verifier_receipt_sha256", "ARTIFACT_VERIFIER_RECEIPT_PATH"), + ) + for policy_field, env_field in rows: + digest = hashlib.sha256(Path(os.environ[env_field]).read_bytes()).hexdigest() + if digest != artifact[policy_field]: + raise SystemExit(f"pinned_artifact_receipt_checksum_mismatch:{policy_field}") + print("artifact_receipt_commit_pinned=true") + print("artifact_receipt_checksums_verified=true") + PY + + - name: Controlled offline replay and independent verifier + env: + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + run: | + set -euo pipefail + set +x + cleanup_registry_auth() { + docker logout "${HARBOR_REGISTRY}" >/dev/null 2>&1 || true + } + trap cleanup_registry_auth EXIT + + registry_status="$( + curl --silent --show-error --output /dev/null \ + --write-out '%{http_code}' --max-time 10 \ + "https://${HARBOR_REGISTRY}/v2/" || true + )" + if [ "${registry_status}" != "200" ] && [ "${registry_status}" != "401" ]; then + echo "BLOCKER harbor_registry_readiness_status_${registry_status:-000}" + exit 1 + fi + printf '%s\n' "${HARBOR_PASSWORD}" | \ + docker login "${HARBOR_REGISTRY}" \ + --username "${HARBOR_USERNAME}" \ + --password-stdin >/dev/null + + RUN_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')" + TRACE_ID="mcp-replay-${RUN_ID}" + python3 scripts/security/external_mcp_replay_controller.py \ + --policy "${POLICY_PATH}" \ + --artifact-policy "${ARTIFACT_POLICY_PATH}" \ + --artifact-controller-receipt "${ARTIFACT_CONTROLLER_RECEIPT_PATH}" \ + --artifact-verifier-receipt "${ARTIFACT_VERIFIER_RECEIPT_PATH}" \ + --run-id "${RUN_ID}" \ + --trace-id "${TRACE_ID}" \ + --receipt "${REPLAY_CONTROLLER_RECEIPT_PATH}" \ + replay --apply + python3 scripts/security/verify_external_mcp_replay_receipt.py \ + --policy "${POLICY_PATH}" \ + --artifact-controller-receipt "${ARTIFACT_CONTROLLER_RECEIPT_PATH}" \ + --artifact-verifier-receipt "${ARTIFACT_VERIFIER_RECEIPT_PATH}" \ + --controller-receipt "${REPLAY_CONTROLLER_RECEIPT_PATH}" \ + --verifier-receipt "${REPLAY_VERIFIER_RECEIPT_PATH}" + + python3 - <<'PY' + import json + import os + from pathlib import Path + + controller = json.loads( + Path(os.environ["REPLAY_CONTROLLER_RECEIPT_PATH"]).read_text() + ) + verifier = json.loads( + Path(os.environ["REPLAY_VERIFIER_RECEIPT_PATH"]).read_text() + ) + if controller.get("status") != ( + "completed_protocol_schema_replay_pending_independent_verifier" + ): + raise SystemExit("controller_terminal_invalid") + if verifier.get("status") != "completed_protocol_schema_replay_verified": + raise SystemExit("independent_verifier_terminal_invalid") + if controller.get("run_id") != verifier.get("run_id"): + raise SystemExit("controller_verifier_run_id_mismatch") + if controller.get("trace_id") != verifier.get("trace_id"): + raise SystemExit("controller_verifier_trace_id_mismatch") + for payload in (controller, verifier): + if any( + payload.get(field) is not False + for field in ( + "browser_started", + "tool_call_performed", + "external_rag_write_performed", + "production_write_performed", + "promotion_allowed", + ) + ): + raise SystemExit("replay_boundary_invalid") + post = verifier["independent_post_verifier"] + if ( + post.get("status") != "passed" + or post.get("fresh_protocol_replay") is not True + or post.get("ephemeral_container_removed") is not True + ): + raise SystemExit("independent_replay_verifier_invalid") + print(f"controlled_apply_run_id={verifier['run_id']}") + print(f"controlled_apply_trace_id={verifier['trace_id']}") + print(f"mcp_tool_count={post['tool_count']}") + print("mcp_process_started_and_terminated=true") + print("browser_or_tool_call_performed=false") + print("production_route_changed=false") + PY + + - name: Commit verified replay receipts to Gitea audit branch + env: + CD_PUSH_TOKEN: ${{ secrets.CD_PUSH_TOKEN }} + run: | + set -euo pipefail + set +x + test -s "${REPLAY_CONTROLLER_RECEIPT_PATH}" + test -s "${REPLAY_VERIFIER_RECEIPT_PATH}" + git config user.email "mcp-replay-controller@awoooi.internal" + git config user.name "AWOOOI MCP Replay Controller" + git add \ + "${REPLAY_CONTROLLER_RECEIPT_PATH}" \ + "${REPLAY_VERIFIER_RECEIPT_PATH}" + git diff --cached --quiet && { + echo "receipt_writeback=no_change" + exit 0 + } + git commit -m "chore(mcp): record verified Playwright protocol replay [skip ci] [metadata-only]" + + ASKPASS_PATH="$(mktemp)" + export ASKPASS_PATH + python3 - <<'PY' + import os + from pathlib import Path + + path = Path(os.environ["ASKPASS_PATH"]) + path.write_text( + "#!/bin/sh\n" + "case \"$1\" in\n" + " *Username*) printf '%s\\n' wooo ;;\n" + " *Password*) printf '%s\\n' \"$CD_PUSH_TOKEN\" ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + path.chmod(0o700) + PY + cleanup_push_auth() { + rm -f "${ASKPASS_PATH}" + } + trap cleanup_push_auth EXIT + export GIT_ASKPASS="${ASKPASS_PATH}" + export GIT_TERMINAL_PROMPT=0 + git remote remove gitea 2>/dev/null || true + git remote add gitea "${GITEA_SOURCE_URL}" + + for attempt in 1 2 3; do + if git ls-remote --exit-code --heads gitea \ + "refs/heads/${REPLAY_RECEIPT_BRANCH}" >/dev/null 2>&1; then + git fetch --no-tags --depth=100 gitea "${REPLAY_RECEIPT_BRANCH}" + if ! git merge --no-edit FETCH_HEAD; then + git merge --abort || true + echo "BLOCKER receipt_writeback_merge_conflict" + exit 1 + fi + fi + if git push gitea "HEAD:refs/heads/${REPLAY_RECEIPT_BRANCH}"; then + echo "receipt_writeback=verified_audit_branch_normal_push" + echo "receipt_branch=${REPLAY_RECEIPT_BRANCH}" + echo "receipt_commit_sha=$(git rev-parse HEAD)" + exit 0 + fi + echo "receipt_writeback_retry=${attempt}" + sleep 5 + done + echo "BLOCKER receipt_writeback_non_fast_forward_after_bounded_retry" + exit 1 diff --git a/apps/api/tests/test_gitea_workflow_runner_health_api.py b/apps/api/tests/test_gitea_workflow_runner_health_api.py index a65eb96fe..c1dc604b9 100644 --- a/apps/api/tests/test_gitea_workflow_runner_health_api.py +++ b/apps/api/tests/test_gitea_workflow_runner_health_api.py @@ -80,5 +80,17 @@ def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): ) assert mcp_artifact_mirror["notification_policy"] == "read_only_no_notify" assert mcp_artifact_mirror["notify_bridge_calls"] == 0 + mcp_protocol_replay = next( + row + for row in workflow_records + if row["workflow_id"] == "mcp_external_protocol_replay" + ) + assert mcp_protocol_replay["runner_labels"] == ["awoooi-non110-host"] + assert mcp_protocol_replay["runner_evidence_status"] == ( + "non110_host_runner_mapped" + ) + assert mcp_protocol_replay["notification_policy"] == "read_only_no_notify" + assert mcp_protocol_replay["notify_bridge_calls"] == 0 + assert "schedule" in mcp_protocol_replay["triggers"] assert "workflow 修改批准" in data["operator_contract"]["must_not_interpret_as"] assert "Secret 已讀取或可輸出" in data["operator_contract"]["must_not_interpret_as"] diff --git a/config/mcp/playwright-mcp-replay-policy.json b/config/mcp/playwright-mcp-replay-policy.json new file mode 100644 index 000000000..72e5f262a --- /dev/null +++ b/config/mcp/playwright-mcp-replay-policy.json @@ -0,0 +1,135 @@ +{ + "schema_version": "awoooi_external_mcp_replay_policy_v1", + "policy_version": "1.0.0", + "work_item_id": "MCP-REPLAY-PLAYWRIGHT-001", + "candidate_id": "external.playwright-verifier", + "adapter_id": "playwright_public_accessibility_verifier_v1", + "risk_class": "high", + "scope": "offline_protocol_and_tool_schema_compatibility_only", + "owner_lane": "awoooi_mcp_supply_chain", + "expires_at": "2026-10-15T00:00:00+08:00", + "exit_condition": "replace_with_verified_public_origin_shadow_canary_and_rollback_receipts_or_disable_candidate", + "replacement_action": "publish_a_new_exact_policy_and_replay_receipt_before_any_shadow_or_canary", + "rollback_ref": "disable_external.playwright-verifier_and_retain_immutable_artifact", + "post_verifier": "verify_external_mcp_replay_receipt.py", + "artifact_source": { + "policy_path": "config/mcp/playwright-mcp-artifact-policy.json", + "policy_checksum": "sha256:44c9f79c2af59f1d2703cbd2181b43f4c4dd4ee9f70164f606bdda70d823e78b", + "audit_branch": "mcp-artifact-receipts", + "audit_commit": "9c08ada5c0f15bb6924304af29cd13c8376182ff", + "controller_receipt_path": "docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-controller.snapshot.json", + "controller_receipt_sha256": "d59214363d9b380591b51157088f63d4ca115354d383070ba7cd4a958408641a", + "verifier_receipt_path": "docs/operations/external-mcp-artifacts/playwright-mcp-0.0.78-verifier.snapshot.json", + "verifier_receipt_sha256": "c993ba77c771b01144c962335dfd79490356b5d6797eed2bafb8c0261173f43c", + "artifact_ref": "registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp@sha256:de002c418bbb94fb4609539c4259c39ccb7f8b353f8bac8fbcba9e5742ad79ed", + "artifact_runtime_ref": "192.168.0.110:5000/awoooi/mcp-artifacts/playwright-mcp@sha256:de002c418bbb94fb4609539c4259c39ccb7f8b353f8bac8fbcba9e5742ad79ed", + "bundle_manifest_sha256": "54b1d7ff24fc6f8ca89d4d36adcdb43e1cf79ed6018a4cd4f0d630c857bffb2c", + "cyclonedx_sbom_sha256": "092500242530e40f361f52cc662edda9e6c83f76edf81dfe9574235546c22be7", + "package_integrities": { + "@playwright/mcp@0.0.78": "5cb4d4780ea610df6c43e849e1d7c6f042240dbc52d0add3adcd91064509b9fd364e013615044d4ccb6afda25f87244c20db1197f61b738b317162ed167e3f4d", + "playwright@1.62.0-alpha-1783623505000": "e8a57d8783cfde1aaee0d686771c5c8a359f621f4b25c148fd1dac3f84d30b8239705a37a116b0374113956e4c904ddc40480a861a23d0a50dcf8b1fa424f44e", + "playwright-core@1.62.0-alpha-1783623505000": "08f25976c03f2864f641095e92257a5adf90950ad91d5499ea888db4e23f6d860e2152ccf237ca1964cce33422c9de1437ee340aae1c03a9719991d96ed3ef25" + } + }, + "runtime": { + "image_ref": "registry.wooo.work/awoooi/ci-runner@sha256:04de9ba58445af167ef0a9f5a8d21ef20980ed82aa7818fd2cd0b64ed0aa3866", + "platform": "linux/amd64", + "user": "65534:65534", + "entrypoint": [ + "node", + "node_modules/@playwright/mcp/cli.js" + ], + "arguments": [ + "--headless", + "--isolated", + "--allowed-origins", + "https://awoooi.wooo.work", + "--output-dir", + "/tmp/mcp-output", + "--executable-path", + "/nonexistent/browser" + ], + "network_mode": "none", + "read_only_root": true, + "no_new_privileges": true, + "cap_drop": [ + "ALL" + ], + "pids_limit": 64, + "memory_limit": "384m", + "cpu_limit": "0.5", + "tmpfs": "/tmp:rw,noexec,nosuid,size=32m", + "startup_timeout_seconds": 20, + "shutdown_timeout_seconds": 5 + }, + "protocol_contract": { + "protocol_version": "2025-06-18", + "server_name": "Playwright", + "server_version": "1.62.0-alpha-1783623505000", + "tool_count": 24, + "tool_name_set_sha256": "12d23fea9d8d1a1de3f44863dc00cf393f0a2165323838cf93fed30a6a4cc237", + "tool_schema_manifest_sha256": "c3737ced21147d0512b0400df5885c95a545bc915892b5c8f928cee78428ac99", + "adapter_allowed_tools": [ + "browser_close", + "browser_navigate", + "browser_snapshot", + "browser_take_screenshot" + ], + "adapter_denied_tools": [ + "browser_click", + "browser_console_messages", + "browser_drag", + "browser_drop", + "browser_evaluate", + "browser_file_upload", + "browser_fill_form", + "browser_find", + "browser_handle_dialog", + "browser_hover", + "browser_navigate_back", + "browser_network_request", + "browser_network_requests", + "browser_press_key", + "browser_resize", + "browser_run_code_unsafe", + "browser_select_option", + "browser_tabs", + "browser_type", + "browser_wait_for" + ] + }, + "execution_boundaries": { + "direct_gateway_registration_allowed": false, + "adapter_registration_allowed": false, + "browser_start_allowed": false, + "tool_call_allowed": false, + "navigation_allowed": false, + "authenticated_session_allowed": false, + "raw_request_or_response_body_storage_allowed": false, + "external_rag_write_allowed": false, + "production_write_allowed": false, + "shadow_allowed": false, + "canary_allowed": false, + "promotion_allowed": false + }, + "promotion_blockers_after_replay": [ + "immutable_browser_runtime_supply_chain_missing", + "public_origin_egress_proxy_and_private_network_denial_missing", + "public_origin_shadow_replay_not_verified", + "canary_verifier_and_rollback_execution_pending" + ], + "prohibited": [ + "github_api_or_actions", + "actions_checkout", + "github_source_or_container_download", + "npx_y", + "at_latest", + "floating_container_tag", + "direct_external_mcp_registration", + "browser_or_tool_execution_in_protocol_replay", + "secret_read_or_log", + "request_or_response_body_storage", + "external_rag_write", + "production_mutation" + ] +} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index bd7166202..b90f03160 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,26 @@ +## 2026-07-15 — P0 MCP Playwright offline protocol/schema compatibility replay + +**完成內容**: +- 新增 high-risk replay policy,固定 artifact policy checksum、`mcp-artifact-receipts` commit `9c08ada5c0f15bb6924304af29cd13c8376182ff`、controller/verifier receipt checksum、Harbor artifact digest與 internal runtime image digest;policy 有 owner、expiry、exit condition、replacement、rollback 與獨立 post-verifier。 +- replay controller 重新驗證 artifact receipt chain、manifest、CycloneDX SBOM、3 個 tarball SHA-512 與 package identity,再以 non-root、network none、read-only root、no-new-privileges、cap-drop ALL、bounded CPU/memory/PID/tmpfs/timeout 啟動 MCP。只送 `initialize`、`notifications/initialized`、`tools/list`,不啟動 browser、不呼叫 tool。 +- adapter contract 只允許 `browser_navigate`、`browser_snapshot`、`browser_take_screenshot`、`browser_close` 4 個未來 public verifier 能力;`browser_run_code_unsafe`、file upload、form/click/type/evaluate/network 等其餘 20 個工具明確 denied。Direct Gateway registration、adapter registration、shadow、canary、promotion、RAG write與 production write仍全部停用。 +- 獨立 verifier 完全不 import controller,重新 pull/readback exact artifact/runtime digest、重新解包並再次執行 protocol replay;controller/verifier 都要求 MCP stdin EOF clean exit與 ephemeral container removal terminal。 +- 新增 Gitea-only `mcp-external-replay.yaml`:每週三 10:43 Asia/Taipei、manual/push、non-110 host runner、host pressure guard、Harbor password-stdin/logout、pinned artifact audit commit、同 run/trace/work-item receipts,成功後只 normal push `mcp-replay-receipts` audit branch。 +- Gitea workflow runner-health snapshot納入第 15 條 workflow;scheduled workflow `6 -> 7`,non110 host mapped `4 -> 5`,沒有新增 runner attestation gap。 + +**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` 通過。 +- global security mirror guard另抓到 latest main 既有 `apps/web/src/app/[locale]/iwooos/page.tsx` 的 `raw_blocked_waiting_state` 敏感字樣;本 work item未修改該檔,保留為獨立 drift blocker,不以 replay source 綠燈覆蓋。 + +**尚未完成 / 不誤報**: +- 目前是 source + 本機真實 replay證據;Gitea replay workflow、audit branch receipt、main CD/deploy marker與 production runtime/UI readback尚未產生,因此 production compatibility仍不得標 completed。 +- Browser binary/runtime immutable supply chain、public-origin egress proxy與 private-network denial、prompt-injection replay、accessibility/latency shadow、canary、Gateway adapter registration、rollback execution與正式 KM/RAG learning acknowledgement仍是 promotion blocker。 +- 未讀 secret value、`.env`、raw session、SQLite/auth;未連線或下載 GitHub/Actions/hosted artifact,未使用 `npx -y`、`@latest`、`:latest`、force push、外部 RAG write或 production mutation。 + +**下一步**: +- normal push Gitea main並取得 replay workflow controller + standalone verifier audit receipts;再更新 MCP production read model/UI為 compatibility verified但 promotion disabled,之後才建立 immutable browser runtime與隔離 public-origin shadow/canary/rollback lane。 + ## 2026-07-15 — P0 MCP Playwright immutable mirror 與獨立供應鏈 verifier **完成內容**: diff --git a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json index fe7b0fd49..9656d8c3c 100644 --- a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json +++ b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json @@ -1,6 +1,6 @@ { "schema_version": "gitea_workflow_runner_health_v1", - "generated_at": "2026-07-15T09:57:22+08:00", + "generated_at": "2026-07-15T11:01:19+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", @@ -22,9 +22,11 @@ ".gitea/workflows/e2e-health.yaml", ".gitea/workflows/harbor-110-local-repair.yaml", ".gitea/workflows/mcp-external-artifact-mirror.yaml", + ".gitea/workflows/mcp-external-replay.yaml", ".gitea/workflows/run-migration.yml", ".gitea/workflows/type-sync-check.yaml", "config/mcp/playwright-mcp-artifact-policy.json", + "config/mcp/playwright-mcp-replay-policy.json", "docs/evaluations/ai_agent_version_lifecycle_update_proposal_2026-07-10.json", "scripts/ci/check-gitea-step-env-secrets.js", "scripts/ci/cleanup-host-runner-workspace.sh", @@ -34,16 +36,16 @@ "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], "rollups": { - "total_workflows": 14, + "total_workflows": 15, "by_workflow_status": { - "manifest_mapped": 14 + "manifest_mapped": 15 }, "by_runner_evidence_status": { "non110_runner_mapped": 10, - "non110_host_runner_mapped": 4 + "non110_host_runner_mapped": 5 }, - "workflows_with_schedule": 6, - "workflows_with_workflow_dispatch": 14, + "workflows_with_schedule": 7, + "workflows_with_workflow_dispatch": 15, "workflows_with_notify_bridge": 6, "workflows_with_actionable_or_failure_quiet_policy": 3, "workflow_ids_requiring_runner_attestation": [], @@ -369,6 +371,36 @@ ], "next_action": "維持 bounded capacity wait、獨立 verifier 與 mcp-artifact-receipts audit branch;不得啟動 artifact、寫 RAG、切 production route 或推回 deploy main。" }, + { + "workflow_id": "mcp_external_protocol_replay", + "file_ref": ".gitea/workflows/mcp-external-replay.yaml", + "display_name": "MCP External Protocol Replay", + "scope": "讀取 pinned artifact receipt commit,以 immutable artifact/runtime digest 在 network none、non-root、read-only container 只執行 MCP initialize 與 tools/list;獨立 verifier 重跑後只寫專用 audit branch。", + "status": "manifest_mapped", + "risk_level": "high", + "triggers": [ + "push:main", + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "每週三 10:43 Asia/Taipei;cron=43 2 * * 3 UTC;排在 artifact mirror 後。", + "runner_labels": [ + "awoooi-non110-host" + ], + "runner_evidence_status": "non110_host_runner_mapped", + "job_count": 1, + "notification_policy": "read_only_no_notify", + "notify_bridge_calls": 0, + "secrets_policy_status": "只使用 workflow secret references 登入 Harbor 與 normal-push Gitea audit branch;controller/verifier 不讀取、保存或顯示 secret value。", + "evidence_refs": [ + ".gitea/workflows/mcp-external-replay.yaml", + "config/mcp/playwright-mcp-replay-policy.json", + "scripts/security/external_mcp_replay_controller.py", + "scripts/security/verify_external_mcp_replay_receipt.py", + "scripts/ci/wait-host-web-build-pressure.sh" + ], + "next_action": "先取得 mcp-replay-receipts 獨立 verifier receipt;browser runtime、public-origin egress proxy、shadow、canary、Gateway adapter registration、RAG 與 production route 仍保持停用。" + }, { "workflow_id": "run_migration", "file_ref": ".gitea/workflows/run-migration.yml", diff --git a/scripts/security/external_mcp_replay_controller.py b/scripts/security/external_mcp_replay_controller.py new file mode 100644 index 000000000..319a32222 --- /dev/null +++ b/scripts/security/external_mcp_replay_controller.py @@ -0,0 +1,1245 @@ +#!/usr/bin/env python3 +"""Run a bounded, offline MCP protocol/schema compatibility replay. + +The replay consumes only an independently verified immutable artifact bundle and +an immutable internal runtime image. It starts the MCP process with no network, +performs initialize and tools/list, then terminates it. It never starts a +browser, invokes an MCP tool, registers a provider, writes RAG, or mutates +production. +""" + +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 dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any + + +POLICY_SCHEMA_VERSION = "awoooi_external_mcp_replay_policy_v1" +RECEIPT_SCHEMA_VERSION = "awoooi_external_mcp_replay_receipt_v1" +ARTIFACT_POLICY_SCHEMA_VERSION = "awoooi_external_mcp_artifact_policy_v1" +ARTIFACT_CONTROLLER_SCHEMA_VERSION = "awoooi_external_mcp_artifact_receipt_v1" +ARTIFACT_VERIFIER_SCHEMA_VERSION = ( + "awoooi_external_mcp_artifact_verifier_receipt_v1" +) +ARTIFACT_BUNDLE_SCHEMA_VERSION = "awoooi_external_mcp_artifact_bundle_v1" +TRACE_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{3,160}$") +SHA256_HEX_PATTERN = re.compile(r"^[0-9a-f]{64}$") +SHA512_HEX_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}$" +) +GIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") +MAX_JSON_BYTES = 4 * 1024 * 1024 +MAX_PROTOCOL_LINE_BYTES = 2 * 1024 * 1024 +MAX_PACKAGE_UNPACKED_BYTES = 40 * 1024 * 1024 +MAX_PACKAGE_FILES = 4_096 +EXPECTED_PACKAGE_KEYS = { + "@playwright/mcp@0.0.78", + "playwright@1.62.0-alpha-1783623505000", + "playwright-core@1.62.0-alpha-1783623505000", +} + + +class ReplayError(RuntimeError): + """Fail-closed public-safe replay error class.""" + + +@dataclass(frozen=True) +class ReplayPolicy: + work_item_id: str + candidate_id: str + adapter_id: str + risk_class: str + owner_lane: str + expires_at: str + artifact_policy_checksum: str + audit_branch: str + audit_commit: str + controller_receipt_sha256: str + verifier_receipt_sha256: str + artifact_ref: str + artifact_runtime_ref: str + bundle_manifest_sha256: str + cyclonedx_sbom_sha256: str + package_integrities: dict[str, str] + runtime_image_ref: str + runtime_platform: str + runtime_user: str + runtime_entrypoint: tuple[str, ...] + runtime_arguments: tuple[str, ...] + pids_limit: int + memory_limit: str + cpu_limit: str + tmpfs: str + startup_timeout_seconds: int + shutdown_timeout_seconds: int + protocol_version: str + server_name: str + server_version: str + tool_count: int + tool_name_set_sha256: str + tool_schema_manifest_sha256: str + adapter_allowed_tools: tuple[str, ...] + adapter_denied_tools: tuple[str, ...] + promotion_blockers_after_replay: tuple[str, ...] + checksum: str + + +@dataclass(frozen=True) +class SourceEvidence: + artifact_run_id: str + artifact_trace_id: str + artifact_controller_receipt_sha256: str + artifact_verifier_receipt_sha256: str + + +@dataclass(frozen=True) +class ReplayObservation: + server_name: str + server_version: str + protocol_version: str + tool_count: int + tool_name_set_sha256: str + tool_schema_manifest_sha256: str + process_exit_code: int + shutdown_mode: str + ephemeral_container_removed: bool + + +def _canonical_json_bytes(payload: Any) -> bytes: + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _read_json_bytes(path: Path, error_class: str) -> tuple[bytes, dict[str, Any]]: + try: + raw = path.read_bytes() + except OSError as exc: + raise ReplayError(error_class) from exc + if not raw or len(raw) > MAX_JSON_BYTES: + raise ReplayError(error_class) + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ReplayError(error_class) from exc + if not isinstance(payload, dict): + raise ReplayError(error_class) + return raw, payload + + +def _require_dict(value: Any, error_class: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ReplayError(error_class) + return value + + +def _require_text(value: Any, error_class: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ReplayError(error_class) + return value.strip() + + +def _require_text_list(value: Any, error_class: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise ReplayError(error_class) + rows = tuple(_require_text(item, error_class) for item in value) + if len(set(rows)) != len(rows): + raise ReplayError(error_class) + return rows + + +def _require_positive_int(value: Any, error_class: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ReplayError(error_class) + return value + + +def _validate_sha256(value: Any, error_class: str, *, prefix: bool = False) -> str: + text = _require_text(value, error_class) + digest = text.removeprefix("sha256:") if prefix else text + if not SHA256_HEX_PATTERN.fullmatch(digest): + raise ReplayError(error_class) + if prefix and not text.startswith("sha256:"): + raise ReplayError(error_class) + return text + + +def _validate_oci_ref(value: Any, error_class: str) -> str: + ref = _require_text(value, error_class) + if not OCI_REF_PATTERN.fullmatch(ref): + raise ReplayError(error_class) + return ref + + +def load_policy(path: Path) -> ReplayPolicy: + raw, payload = _read_json_bytes(path, "replay_policy_unreadable") + if payload.get("schema_version") != POLICY_SCHEMA_VERSION: + raise ReplayError("replay_policy_schema_invalid") + if payload.get("policy_version") != "1.0.0": + raise ReplayError("replay_policy_version_invalid") + if payload.get("scope") != "offline_protocol_and_tool_schema_compatibility_only": + raise ReplayError("replay_scope_invalid") + if payload.get("risk_class") != "high": + raise ReplayError("replay_risk_class_invalid") + + expiry_text = _require_text(payload.get("expires_at"), "replay_expiry_invalid") + try: + expiry = datetime.fromisoformat(expiry_text) + except ValueError as exc: + raise ReplayError("replay_expiry_invalid") from exc + if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc): + raise ReplayError("replay_policy_expired") + for field in ( + "exit_condition", + "replacement_action", + "rollback_ref", + "post_verifier", + ): + _require_text(payload.get(field), f"replay_{field}_invalid") + + artifact = _require_dict(payload.get("artifact_source"), "artifact_source_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") + package_integrities = _require_dict( + artifact.get("package_integrities"), "package_integrities_invalid" + ) + if set(package_integrities) != EXPECTED_PACKAGE_KEYS: + raise ReplayError("package_integrities_invalid") + normalized_integrities: dict[str, str] = {} + for key, value in package_integrities.items(): + digest = _require_text(value, "package_integrity_invalid") + if not SHA512_HEX_PATTERN.fullmatch(digest): + raise ReplayError("package_integrity_invalid") + normalized_integrities[key] = digest + + runtime = _require_dict(payload.get("runtime"), "runtime_invalid") + 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"] + ): + 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: + 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" + ) + allowed = _require_text_list( + protocol.get("adapter_allowed_tools"), "adapter_allowed_tools_invalid" + ) + denied = _require_text_list( + protocol.get("adapter_denied_tools"), "adapter_denied_tools_invalid" + ) + if set(allowed) & set(denied): + raise ReplayError("adapter_tool_partition_invalid") + if set(allowed) != { + "browser_close", + "browser_navigate", + "browser_snapshot", + "browser_take_screenshot", + }: + raise ReplayError("adapter_allowed_tools_not_minimal") + if "browser_run_code_unsafe" not in denied or "browser_file_upload" not in denied: + raise ReplayError("adapter_dangerous_tools_not_denied") + + boundaries = _require_dict( + payload.get("execution_boundaries"), "execution_boundaries_invalid" + ) + required_false = { + "direct_gateway_registration_allowed", + "adapter_registration_allowed", + "browser_start_allowed", + "tool_call_allowed", + "navigation_allowed", + "authenticated_session_allowed", + "raw_request_or_response_body_storage_allowed", + "external_rag_write_allowed", + "production_write_allowed", + "shadow_allowed", + "canary_allowed", + "promotion_allowed", + } + if any(boundaries.get(field) is not False for field in required_false): + raise ReplayError("execution_boundary_invalid") + + blockers = _require_text_list( + payload.get("promotion_blockers_after_replay"), + "promotion_blockers_after_replay_invalid", + ) + if len(blockers) < 4: + raise ReplayError("promotion_blockers_after_replay_invalid") + + return ReplayPolicy( + work_item_id=_require_text(payload.get("work_item_id"), "work_item_id_invalid"), + candidate_id=_require_text(payload.get("candidate_id"), "candidate_id_invalid"), + adapter_id=_require_text(payload.get("adapter_id"), "adapter_id_invalid"), + risk_class="high", + owner_lane=_require_text(payload.get("owner_lane"), "owner_lane_invalid"), + expires_at=expiry_text, + artifact_policy_checksum=_validate_sha256( + artifact.get("policy_checksum"), + "artifact_policy_checksum_invalid", + prefix=True, + ), + audit_branch=_require_text(artifact.get("audit_branch"), "audit_branch_invalid"), + audit_commit=audit_commit, + controller_receipt_sha256=_validate_sha256( + artifact.get("controller_receipt_sha256"), + "controller_receipt_sha256_invalid", + ), + verifier_receipt_sha256=_validate_sha256( + 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" + ), + bundle_manifest_sha256=_validate_sha256( + artifact.get("bundle_manifest_sha256"), "bundle_manifest_sha256_invalid" + ), + cyclonedx_sbom_sha256=_validate_sha256( + 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_platform=_require_text( + runtime.get("platform"), "runtime_platform_invalid" + ), + runtime_user=runtime["user"], + runtime_entrypoint=entrypoint, + runtime_arguments=arguments, + pids_limit=_require_positive_int(runtime.get("pids_limit"), "pids_limit_invalid"), + memory_limit=_require_text(runtime.get("memory_limit"), "memory_limit_invalid"), + cpu_limit=_require_text(runtime.get("cpu_limit"), "cpu_limit_invalid"), + tmpfs=_require_text(runtime.get("tmpfs"), "tmpfs_invalid"), + startup_timeout_seconds=_require_positive_int( + runtime.get("startup_timeout_seconds"), "startup_timeout_invalid" + ), + shutdown_timeout_seconds=_require_positive_int( + runtime.get("shutdown_timeout_seconds"), "shutdown_timeout_invalid" + ), + protocol_version=_require_text( + protocol.get("protocol_version"), "protocol_version_invalid" + ), + server_name=_require_text(protocol.get("server_name"), "server_name_invalid"), + server_version=_require_text( + protocol.get("server_version"), "server_version_invalid" + ), + tool_count=_require_positive_int(protocol.get("tool_count"), "tool_count_invalid"), + tool_name_set_sha256=_validate_sha256( + protocol.get("tool_name_set_sha256"), "tool_name_set_sha256_invalid" + ), + tool_schema_manifest_sha256=_validate_sha256( + protocol.get("tool_schema_manifest_sha256"), + "tool_schema_manifest_sha256_invalid", + ), + adapter_allowed_tools=tuple(sorted(allowed)), + adapter_denied_tools=tuple(sorted(denied)), + promotion_blockers_after_replay=blockers, + checksum=f"sha256:{_sha256_hex(_canonical_json_bytes(payload))}", + ) + + +def validate_source_evidence( + policy: ReplayPolicy, + *, + artifact_policy_path: Path, + controller_receipt_path: Path, + verifier_receipt_path: Path, +) -> SourceEvidence: + _, artifact_policy = _read_json_bytes( + artifact_policy_path, "artifact_policy_unreadable" + ) + if artifact_policy.get("schema_version") != ARTIFACT_POLICY_SCHEMA_VERSION: + raise ReplayError("artifact_policy_schema_invalid") + checksum = f"sha256:{_sha256_hex(_canonical_json_bytes(artifact_policy))}" + if checksum != policy.artifact_policy_checksum: + raise ReplayError("artifact_policy_checksum_mismatch") + + controller_raw, controller = _read_json_bytes( + controller_receipt_path, "artifact_controller_receipt_unreadable" + ) + verifier_raw, verifier = _read_json_bytes( + verifier_receipt_path, "artifact_verifier_receipt_unreadable" + ) + controller_sha = _sha256_hex(controller_raw) + verifier_sha = _sha256_hex(verifier_raw) + if controller_sha != policy.controller_receipt_sha256: + raise ReplayError("artifact_controller_receipt_checksum_mismatch") + if verifier_sha != policy.verifier_receipt_sha256: + raise ReplayError("artifact_verifier_receipt_checksum_mismatch") + if ( + controller.get("schema_version") != ARTIFACT_CONTROLLER_SCHEMA_VERSION + or controller.get("status") + != "completed_internal_mirror_pending_independent_verifier" + ): + raise ReplayError("artifact_controller_receipt_terminal_invalid") + if ( + verifier.get("schema_version") != ARTIFACT_VERIFIER_SCHEMA_VERSION + or verifier.get("status") != "completed_internal_mirror_verified" + or _require_dict( + verifier.get("independent_post_verifier"), + "artifact_post_verifier_invalid", + ).get("status") + != "passed" + ): + raise ReplayError("artifact_verifier_receipt_terminal_invalid") + for payload in (controller, verifier): + if any( + payload.get(field) is not False + for field in ( + "promotion_allowed", + "replay_allowed", + "shadow_allowed", + "canary_allowed", + ) + ): + raise ReplayError("artifact_receipt_boundary_invalid") + if payload.get("internal_mirror_ref") != policy.artifact_ref: + raise ReplayError("artifact_receipt_ref_mismatch") + if payload.get("policy_checksum") != policy.artifact_policy_checksum: + raise ReplayError("artifact_receipt_policy_checksum_mismatch") + if controller.get("runtime_mirror_ref") != policy.artifact_runtime_ref: + raise ReplayError("artifact_runtime_ref_mismatch") + if verifier.get("runtime_mirror_ref") != policy.artifact_runtime_ref: + raise ReplayError("artifact_runtime_ref_mismatch") + if controller.get("bundle_manifest_sha256") != policy.bundle_manifest_sha256: + raise ReplayError("artifact_manifest_receipt_mismatch") + if controller.get("cyclonedx_sbom_sha256") != policy.cyclonedx_sbom_sha256: + raise ReplayError("artifact_sbom_receipt_mismatch") + if verifier.get("controller_receipt_sha256") != controller_sha: + raise ReplayError("artifact_receipt_chain_invalid") + if ( + controller.get("run_id") != verifier.get("run_id") + or controller.get("trace_id") != verifier.get("trace_id") + ): + raise ReplayError("artifact_receipt_identity_mismatch") + + expected_packages = policy.package_integrities + observed_packages: dict[str, str] = {} + rows = controller.get("packages") + if not isinstance(rows, list): + raise ReplayError("artifact_package_receipts_invalid") + for row in rows: + if not isinstance(row, dict): + raise ReplayError("artifact_package_receipts_invalid") + key = f"{row.get('name')}@{row.get('version')}" + digest = row.get("tarball_sha512_hex") + if not isinstance(digest, str): + raise ReplayError("artifact_package_receipts_invalid") + observed_packages[key] = digest + if observed_packages != expected_packages: + raise ReplayError("artifact_package_receipts_mismatch") + return SourceEvidence( + artifact_run_id=_require_text(controller.get("run_id"), "artifact_run_id_invalid"), + artifact_trace_id=_require_text( + controller.get("trace_id"), "artifact_trace_id_invalid" + ), + artifact_controller_receipt_sha256=controller_sha, + artifact_verifier_receipt_sha256=verifier_sha, + ) + + +def _run_command(command: list[str], *, timeout: int, error_class: str) -> str: + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ReplayError(error_class) from exc + if completed.returncode != 0: + raise ReplayError(error_class) + return completed.stdout + + +def _docker_cli() -> str: + docker = shutil.which("docker") + if not docker: + raise ReplayError("docker_cli_unavailable") + return docker + + +def _pull_exact_image(docker: str, ref: str, platform: str) -> None: + _run_command( + [docker, "pull", "--platform", platform, ref], + timeout=300, + error_class="immutable_image_pull_failed", + ) + repo_digests_raw = _run_command( + [docker, "image", "inspect", "--format", "{{json .RepoDigests}}", ref], + timeout=30, + error_class="immutable_image_inspect_failed", + ).strip() + try: + repo_digests = json.loads(repo_digests_raw) + except json.JSONDecodeError as exc: + raise ReplayError("immutable_image_inspect_invalid") from exc + if not isinstance(repo_digests, list) or ref not in repo_digests: + raise ReplayError("immutable_image_digest_readback_mismatch") + + +def _safe_relative_member(name: str) -> Path | None: + pure = PurePosixPath(name) + if pure.is_absolute() or ".." in pure.parts or not pure.parts: + raise ReplayError("artifact_archive_path_invalid") + if pure.parts[0] != "package": + raise ReplayError("artifact_archive_root_invalid") + if len(pure.parts) == 1: + return None + return Path(*pure.parts[1:]) + + +def _extract_package(tarball: Path, destination: Path) -> tuple[int, int]: + file_count = 0 + unpacked_bytes = 0 + try: + archive = tarfile.open(tarball, mode="r:gz") + except (OSError, tarfile.TarError) as exc: + raise ReplayError("artifact_tarball_invalid") from exc + with archive: + for member in archive.getmembers(): + relative = _safe_relative_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 ReplayError("artifact_archive_member_type_invalid") + file_count += 1 + unpacked_bytes += member.size + if ( + file_count > MAX_PACKAGE_FILES + or unpacked_bytes > MAX_PACKAGE_UNPACKED_BYTES + ): + raise ReplayError("artifact_archive_limits_exceeded") + source = archive.extractfile(member) + if source is None: + raise ReplayError("artifact_archive_member_unreadable") + content = source.read(member.size + 1) + if len(content) != member.size: + raise ReplayError("artifact_archive_member_size_mismatch") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return file_count, unpacked_bytes + + +def _validate_package_json( + directory: Path, *, expected_name: str, expected_version: str +) -> None: + _, package = _read_json_bytes(directory / "package.json", "package_json_invalid") + if package.get("name") != expected_name or package.get("version") != expected_version: + raise ReplayError("package_identity_mismatch") + dependencies = package.get("dependencies", {}) + if not isinstance(dependencies, dict): + raise ReplayError("package_dependencies_invalid") + for dependency, version in dependencies.items(): + if dependency in {"playwright", "playwright-core"} and ( + version != "1.62.0-alpha-1783623505000" + ): + raise ReplayError("package_dependency_version_mismatch") + + +def materialize_verified_node_modules( + policy: ReplayPolicy, *, directory: Path +) -> tuple[Path, int, int]: + docker = _docker_cli() + _pull_exact_image(docker, policy.artifact_ref, policy.runtime_platform) + bundle = directory / "bundle" + node_modules = directory / "node_modules" + bundle.mkdir(parents=True, exist_ok=False) + node_modules.mkdir(parents=True, exist_ok=False) + container_name = f"awoooi-mcp-artifact-{uuid.uuid4().hex[:20]}" + try: + _run_command( + [ + docker, + "create", + "--platform", + policy.runtime_platform, + "--name", + container_name, + policy.artifact_ref, + "/nonexistent/no-start", + ], + timeout=60, + error_class="artifact_container_create_failed", + ) + _run_command( + [docker, "cp", f"{container_name}:/artifact/.", str(bundle)], + timeout=60, + error_class="artifact_bundle_copy_failed", + ) + finally: + subprocess.run( + [docker, "rm", "-f", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + + manifest_raw, manifest = _read_json_bytes( + bundle / "bundle-manifest.json", "artifact_bundle_manifest_invalid" + ) + sbom_raw, sbom = _read_json_bytes( + bundle / "sbom.cdx.json", "artifact_bundle_sbom_invalid" + ) + if _sha256_hex(manifest_raw) != policy.bundle_manifest_sha256: + raise ReplayError("artifact_bundle_manifest_checksum_mismatch") + if _sha256_hex(sbom_raw) != policy.cyclonedx_sbom_sha256: + raise ReplayError("artifact_bundle_sbom_checksum_mismatch") + if ( + manifest.get("schema_version") != ARTIFACT_BUNDLE_SCHEMA_VERSION + or manifest.get("candidate_id") != policy.candidate_id + or manifest.get("policy_checksum") != policy.artifact_policy_checksum + or manifest.get("package_count") != 3 + ): + raise ReplayError("artifact_bundle_manifest_identity_mismatch") + boundaries = _require_dict( + manifest.get("operation_boundaries"), "artifact_bundle_boundaries_invalid" + ) + if any( + 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 ReplayError("artifact_bundle_boundary_invalid") + if ( + sbom.get("bomFormat") != "CycloneDX" + or sbom.get("specVersion") != "1.5" + or not isinstance(sbom.get("components"), list) + or len(sbom["components"]) != 3 + ): + raise ReplayError("artifact_bundle_sbom_contract_invalid") + + rows = manifest.get("packages") + if not isinstance(rows, list) or len(rows) != 3: + raise ReplayError("artifact_bundle_package_set_invalid") + seen: set[str] = set() + total_files = 0 + total_bytes = 0 + for row in rows: + if not isinstance(row, dict): + raise ReplayError("artifact_bundle_package_set_invalid") + name = _require_text(row.get("name"), "artifact_package_name_invalid") + version = _require_text( + row.get("version"), "artifact_package_version_invalid" + ) + key = f"{name}@{version}" + if key in seen or policy.package_integrities.get(key) != row.get( + "tarball_sha512_hex" + ): + raise ReplayError("artifact_package_integrity_contract_invalid") + seen.add(key) + relative_tarball = _require_text( + row.get("tarball_path"), "artifact_tarball_path_invalid" + ) + tarball_path = bundle / relative_tarball + try: + tarball_raw = tarball_path.read_bytes() + except OSError as exc: + raise ReplayError("artifact_tarball_unreadable") from exc + if hashlib.sha512(tarball_raw).hexdigest() != policy.package_integrities[key]: + raise ReplayError("artifact_tarball_checksum_mismatch") + package_dir = ( + node_modules / "@playwright" / "mcp" + if name == "@playwright/mcp" + else node_modules / name + ) + package_dir.mkdir(parents=True, exist_ok=False) + files, size = _extract_package(tarball_path, package_dir) + total_files += files + total_bytes += size + _validate_package_json( + package_dir, expected_name=name, expected_version=version + ) + if seen != set(policy.package_integrities): + raise ReplayError("artifact_bundle_package_set_mismatch") + for path in node_modules.rglob("*"): + if path.is_dir(): + path.chmod(0o755) + elif path.is_file(): + path.chmod(0o444) + node_modules.chmod(0o755) + directory.chmod(0o755) + return node_modules, total_files, total_bytes + + +def _write_rpc(process: subprocess.Popen[str], payload: dict[str, Any]) -> None: + if process.stdin is None: + raise ReplayError("mcp_stdin_unavailable") + try: + process.stdin.write(_canonical_json_bytes(payload).decode("utf-8") + "\n") + process.stdin.flush() + except (BrokenPipeError, OSError) as exc: + raise ReplayError("mcp_protocol_write_failed") from exc + + +def _read_rpc( + process: subprocess.Popen[str], *, timeout_seconds: int, expected_id: int +) -> dict[str, Any]: + if process.stdout is None: + raise ReplayError("mcp_stdout_unavailable") + ready, _, _ = select.select([process.stdout], [], [], timeout_seconds) + if not ready: + raise ReplayError("mcp_protocol_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 ReplayError("mcp_protocol_response_invalid") + try: + payload = json.loads(line) + except json.JSONDecodeError as exc: + raise ReplayError("mcp_protocol_response_invalid") from exc + if ( + not isinstance(payload, dict) + or payload.get("jsonrpc") != "2.0" + or payload.get("id") != expected_id + or "error" in payload + or not isinstance(payload.get("result"), dict) + ): + raise ReplayError("mcp_protocol_response_invalid") + return payload["result"] + + +def _tool_hashes(tools: Any) -> tuple[int, str, str, tuple[str, ...]]: + if not isinstance(tools, list) or not tools: + raise ReplayError("mcp_tool_list_invalid") + normalized: list[dict[str, Any]] = [] + names: list[str] = [] + for tool in tools: + if not isinstance(tool, dict): + raise ReplayError("mcp_tool_schema_invalid") + name = _require_text(tool.get("name"), "mcp_tool_name_invalid") + input_schema = tool.get("inputSchema") + if not isinstance(input_schema, dict): + raise ReplayError("mcp_tool_schema_invalid") + row: dict[str, Any] = {"name": name, "inputSchema": input_schema} + if "annotations" in tool: + if not isinstance(tool["annotations"], dict): + raise ReplayError("mcp_tool_annotations_invalid") + row["annotations"] = tool["annotations"] + names.append(name) + normalized.append(row) + if len(set(names)) != len(names): + raise ReplayError("mcp_tool_names_not_unique") + sorted_names = tuple(sorted(names)) + normalized.sort(key=lambda row: row["name"]) + return ( + len(tools), + _sha256_hex(_canonical_json_bytes(list(sorted_names))), + _sha256_hex(_canonical_json_bytes(normalized)), + sorted_names, + ) + + +def run_protocol_replay( + policy: ReplayPolicy, *, node_modules: Path, client_name: str +) -> ReplayObservation: + docker = _docker_cli() + _pull_exact_image(docker, policy.runtime_image_ref, policy.runtime_platform) + container_name = f"awoooi-mcp-replay-{uuid.uuid4().hex[:20]}" + command = [ + docker, + "run", + "--rm", + "--platform", + policy.runtime_platform, + "--name", + container_name, + "--network", + "none", + "--read-only", + "--security-opt", + "no-new-privileges", + "--cap-drop", + "ALL", + "--pids-limit", + str(policy.pids_limit), + "--memory", + policy.memory_limit, + "--cpus", + policy.cpu_limit, + "--user", + policy.runtime_user, + "--env", + "HOME=/tmp", + "--env", + "CI=1", + "--tmpfs", + policy.tmpfs, + "--stop-timeout", + str(policy.shutdown_timeout_seconds), + "-i", + "-v", + f"{node_modules.resolve()}:/work/node_modules:ro", + "-w", + "/work", + policy.runtime_image_ref, + *policy.runtime_entrypoint, + *policy.runtime_arguments, + ] + process: subprocess.Popen[str] | None = None + stderr_file = tempfile.TemporaryFile(mode="w+t", encoding="utf-8") + shutdown_mode = "not_started" + 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 ReplayError("mcp_process_start_failed") from exc + _write_rpc( + process, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": policy.protocol_version, + "capabilities": {}, + "clientInfo": {"name": client_name, "version": "1"}, + }, + }, + ) + initialize = _read_rpc( + process, + timeout_seconds=policy.startup_timeout_seconds, + expected_id=1, + ) + server_info = _require_dict( + initialize.get("serverInfo"), "mcp_server_info_invalid" + ) + if ( + initialize.get("protocolVersion") != policy.protocol_version + or server_info.get("name") != policy.server_name + or server_info.get("version") != policy.server_version + ): + raise ReplayError("mcp_initialize_contract_mismatch") + _write_rpc( + process, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + _write_rpc( + process, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ) + tool_result = _read_rpc( + process, + timeout_seconds=policy.startup_timeout_seconds, + expected_id=2, + ) + tool_count, names_hash, schemas_hash, names = _tool_hashes( + tool_result.get("tools") + ) + if ( + tool_count != policy.tool_count + or names_hash != policy.tool_name_set_sha256 + or schemas_hash != policy.tool_schema_manifest_sha256 + ): + raise ReplayError("mcp_tool_contract_mismatch") + if set(names) != set(policy.adapter_allowed_tools) | set( + policy.adapter_denied_tools + ): + raise ReplayError("mcp_adapter_tool_partition_mismatch") + if process.stdin is None: + raise ReplayError("mcp_stdin_unavailable") + process.stdin.close() + try: + exit_code = process.wait(timeout=policy.shutdown_timeout_seconds) + shutdown_mode = "stdin_eof_clean_exit" + except subprocess.TimeoutExpired as exc: + raise ReplayError("mcp_process_shutdown_timeout") from exc + if exit_code != 0: + raise ReplayError("mcp_process_exit_invalid") + finally: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=policy.shutdown_timeout_seconds) + shutdown_mode = "terminated_after_replay_error" + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=policy.shutdown_timeout_seconds) + shutdown_mode = "killed_after_replay_error" + subprocess.run( + [docker, "rm", "-f", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + stderr_file.close() + inspect = subprocess.run( + [docker, "container", "inspect", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + if inspect.returncode == 0: + raise ReplayError("ephemeral_container_cleanup_failed") + return 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=shutdown_mode, + ephemeral_container_removed=True, + ) + + +def build_receipt( + *, + policy: ReplayPolicy, + source: SourceEvidence, + run_id: str, + trace_id: str, + mode: str, + observation: ReplayObservation | None, + package_file_count: int, + package_unpacked_bytes: int, +) -> dict[str, Any]: + applied = observation is not None + status = ( + "completed_protocol_schema_replay_pending_independent_verifier" + if applied + else "completed_check_mode_no_runtime_execution" + ) + return { + "schema_version": RECEIPT_SCHEMA_VERSION, + "run_id": run_id, + "trace_id": trace_id, + "work_item_id": policy.work_item_id, + "candidate_id": policy.candidate_id, + "adapter_id": policy.adapter_id, + "risk_class": policy.risk_class, + "mode": mode, + "status": status, + "observed_at": datetime.now(timezone.utc).isoformat(), + "policy_checksum": policy.checksum, + "policy_expires_at": policy.expires_at, + "artifact_audit_branch": policy.audit_branch, + "artifact_audit_commit": policy.audit_commit, + "artifact_ref": policy.artifact_ref, + "runtime_image_ref": policy.runtime_image_ref, + "artifact_run_id": source.artifact_run_id, + "artifact_trace_id": source.artifact_trace_id, + "protocol_observation": ( + { + "protocol_version": observation.protocol_version, + "server_name": observation.server_name, + "server_version": observation.server_version, + "tool_count": observation.tool_count, + "tool_name_set_sha256": observation.tool_name_set_sha256, + "tool_schema_manifest_sha256": observation.tool_schema_manifest_sha256, + "adapter_allowed_tools": list(policy.adapter_allowed_tools), + "adapter_denied_tool_count": len(policy.adapter_denied_tools), + "process_exit_code": observation.process_exit_code, + "shutdown_mode": observation.shutdown_mode, + } + if observation + else None + ), + "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": list(policy.promotion_blockers_after_replay), + "stage_receipts": { + "sensor_source_receipt": { + "artifact_controller_receipt_sha256": ( + source.artifact_controller_receipt_sha256 + ), + "artifact_verifier_receipt_sha256": ( + source.artifact_verifier_receipt_sha256 + ), + "artifact_audit_commit": policy.audit_commit, + }, + "normalized_asset_identity": { + "candidate_id": policy.candidate_id, + "adapter_id": policy.adapter_id, + "artifact_ref": policy.artifact_ref, + "runtime_image_ref": policy.runtime_image_ref, + }, + "source_of_truth_diff": { + "change_state": ( + "protocol_schema_replay_verified_by_controller" + if applied + else "protocol_schema_replay_ready_for_bounded_execution" + ), + "direct_registration_state": "disabled", + }, + "decision": { + "candidate_action": ( + "run_independent_protocol_schema_replay_verifier" + if applied + else "run_bounded_protocol_schema_replay" + ), + "browser_shadow_or_canary_allowed": False, + }, + "risk_policy": { + "risk_class": "high", + "network_mode": "none", + "read_only_root": True, + "no_new_privileges": True, + "cap_drop_all": True, + "non_root_user": policy.runtime_user, + "tool_call_allowed": False, + }, + "check_mode": { + "status": "passed", + "artifact_receipt_chain_verified": True, + "exact_artifact_and_runtime_refs_verified": True, + "adapter_tool_partition_verified": True, + }, + "bounded_execution": { + "status": ( + "mcp_initialize_and_tools_list_completed" + if applied + else "not_executed" + ), + "package_file_count": package_file_count, + "package_unpacked_bytes": package_unpacked_bytes, + "mcp_process_started": applied, + "browser_started": False, + "mcp_tool_call_performed": False, + "network_enabled": False, + }, + "independent_post_verifier": { + "status": "pending_external_verifier" if applied else "not_requested", + "implementation": "standalone_no_controller_import", + }, + "rollback_or_no_write_terminal": { + "status": ( + "mcp_process_terminated_and_ephemeral_container_removed" + if applied + else "no_runtime_execution" + ), + "ephemeral_container_removed": ( + observation.ephemeral_container_removed if observation else True + ), + "candidate_registration_state": "disabled", + "production_route_changed": False, + }, + "learning_writeback": { + "status": "receipt_ready_for_gitea_audit_branch_writeback", + "external_rag_write_performed": False, + "km_write_performed": False, + }, + }, + } + + +def _failure_receipt( + *, policy: ReplayPolicy, run_id: str, trace_id: str, error_class: str +) -> dict[str, Any]: + return { + "schema_version": RECEIPT_SCHEMA_VERSION, + "run_id": run_id, + "trace_id": trace_id, + "work_item_id": policy.work_item_id, + "candidate_id": policy.candidate_id, + "risk_class": policy.risk_class, + "status": "blocked_with_safe_next_action", + "error_class": error_class, + "observed_at": datetime.now(timezone.utc).isoformat(), + "policy_checksum": policy.checksum, + "browser_started": False, + "tool_call_performed": False, + "external_rag_write_performed": False, + "production_write_performed": False, + "promotion_allowed": False, + "stage_receipts": { + "rollback_or_no_write_terminal": { + "status": "candidate_disabled_ephemeral_process_cleanup_attempted", + "candidate_registration_state": "disabled", + "production_route_changed": False, + }, + "learning_writeback": { + "status": "blocked_receipt_ready_for_gitea_audit_branch_writeback", + "external_rag_write_performed": False, + }, + }, + } + + +def _write_receipt(path: Path, receipt: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(_canonical_json_bytes(receipt) + b"\n") + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--policy", type=Path, required=True) + parser.add_argument("--artifact-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("--run-id", required=True) + parser.add_argument("--trace-id", required=True) + parser.add_argument("--receipt", type=Path) + parser.add_argument("command", choices=("check", "replay")) + parser.add_argument("--apply", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + normalized_run_id = str(uuid.UUID(args.run_id)) + except (ValueError, AttributeError) as exc: + raise ReplayError("run_id_invalid") from exc + if normalized_run_id != args.run_id: + raise ReplayError("run_id_not_canonical") + if ( + not TRACE_PATTERN.fullmatch(args.trace_id) + or args.trace_id != f"mcp-replay-{normalized_run_id}" + ): + raise ReplayError("run_trace_identity_mismatch") + if args.command == "check" and args.apply: + raise ReplayError("check_mode_cannot_apply") + if args.command == "replay" and not args.apply: + raise ReplayError("replay_requires_apply") + if args.command == "replay" and args.receipt is None: + raise ReplayError("apply_receipt_path_required") + + policy = load_policy(args.policy) + try: + source = validate_source_evidence( + policy, + artifact_policy_path=args.artifact_policy, + controller_receipt_path=args.artifact_controller_receipt, + verifier_receipt_path=args.artifact_verifier_receipt, + ) + observation = None + package_file_count = 0 + package_unpacked_bytes = 0 + if args.command == "replay" and args.apply: + with tempfile.TemporaryDirectory(prefix="awoooi-mcp-replay-") as temp: + node_modules, package_file_count, package_unpacked_bytes = ( + materialize_verified_node_modules(policy, directory=Path(temp)) + ) + observation = run_protocol_replay( + policy, + node_modules=node_modules, + client_name="awoooi-external-mcp-replay-controller", + ) + receipt = build_receipt( + policy=policy, + source=source, + run_id=normalized_run_id, + trace_id=args.trace_id, + mode="apply" if observation else "check", + observation=observation, + package_file_count=package_file_count, + package_unpacked_bytes=package_unpacked_bytes, + ) + except ReplayError as exc: + if args.receipt is not None: + _write_receipt( + args.receipt, + _failure_receipt( + policy=policy, + run_id=normalized_run_id, + trace_id=args.trace_id, + error_class=str(exc), + ), + ) + raise + if args.receipt is not None: + _write_receipt(args.receipt, receipt) + print(json.dumps(receipt, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ReplayError as exc: + print( + json.dumps( + { + "schema_version": RECEIPT_SCHEMA_VERSION, + "status": "blocked_with_safe_next_action", + "error_class": str(exc), + }, + sort_keys=True, + ), + file=sys.stderr, + ) + raise SystemExit(1) from None diff --git a/scripts/security/tests/test_external_mcp_replay_controller.py b/scripts/security/tests/test_external_mcp_replay_controller.py new file mode 100644 index 000000000..0a9063d2a --- /dev/null +++ b/scripts/security/tests/test_external_mcp_replay_controller.py @@ -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() diff --git a/scripts/security/tests/test_verify_external_mcp_replay_receipt.py b/scripts/security/tests/test_verify_external_mcp_replay_receipt.py new file mode 100644 index 000000000..27c4ae9b3 --- /dev/null +++ b/scripts/security/tests/test_verify_external_mcp_replay_receipt.py @@ -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() diff --git a/scripts/security/verify_external_mcp_replay_receipt.py b/scripts/security/verify_external_mcp_replay_receipt.py new file mode 100644 index 000000000..5733edc65 --- /dev/null +++ b/scripts/security/verify_external_mcp_replay_receipt.py @@ -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