feat(mcp): add isolated protocol replay verifier
This commit is contained in:
328
.gitea/workflows/mcp-external-replay.yaml
Normal file
328
.gitea/workflows/mcp-external-replay.yaml
Normal file
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
135
config/mcp/playwright-mcp-replay-policy.json
Normal file
135
config/mcp/playwright-mcp-replay-policy.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
**完成內容**:
|
||||
|
||||
@@ -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",
|
||||
|
||||
1245
scripts/security/external_mcp_replay_controller.py
Normal file
1245
scripts/security/external_mcp_replay_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
372
scripts/security/tests/test_external_mcp_replay_controller.py
Normal file
372
scripts/security/tests/test_external_mcp_replay_controller.py
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the bounded external MCP replay controller without mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_replay_controller.py"
|
||||
POLICY_PATH = ROOT / "config/mcp/playwright-mcp-replay-policy.json"
|
||||
ARTIFACT_POLICY_PATH = ROOT / "config/mcp/playwright-mcp-artifact-policy.json"
|
||||
WORKFLOW_PATH = ROOT / ".gitea/workflows/mcp-external-replay.yaml"
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("external_mcp_replay_controller", CONTROLLER_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
controller = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = controller
|
||||
SPEC.loader.exec_module(controller)
|
||||
|
||||
|
||||
def _canonical(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: object) -> bytes:
|
||||
raw = _canonical(payload) + b"\n"
|
||||
path.write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _policy_payload() -> dict:
|
||||
return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _artifact_receipts(policy_payload: dict) -> tuple[dict, dict]:
|
||||
artifact = policy_payload["artifact_source"]
|
||||
artifact_run_id = "11111111-2222-4333-8444-555555555555"
|
||||
artifact_trace_id = f"mcp-artifact-{artifact_run_id}"
|
||||
packages = []
|
||||
for key, digest in artifact["package_integrities"].items():
|
||||
name, version = key.rsplit("@", 1)
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"tarball_sha512_hex": digest,
|
||||
}
|
||||
)
|
||||
artifact_controller = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_receipt_v1",
|
||||
"status": "completed_internal_mirror_pending_independent_verifier",
|
||||
"run_id": artifact_run_id,
|
||||
"trace_id": artifact_trace_id,
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"runtime_mirror_ref": artifact["artifact_runtime_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"bundle_manifest_sha256": artifact["bundle_manifest_sha256"],
|
||||
"cyclonedx_sbom_sha256": artifact["cyclonedx_sbom_sha256"],
|
||||
"packages": packages,
|
||||
}
|
||||
artifact_verifier = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_verifier_receipt_v1",
|
||||
"status": "completed_internal_mirror_verified",
|
||||
"run_id": artifact_run_id,
|
||||
"trace_id": artifact_trace_id,
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"runtime_mirror_ref": artifact["artifact_runtime_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"independent_post_verifier": {"status": "passed"},
|
||||
}
|
||||
return artifact_controller, artifact_verifier
|
||||
|
||||
|
||||
def _source_fixture(directory: Path) -> tuple[Path, Path, Path]:
|
||||
payload = _policy_payload()
|
||||
artifact_controller, artifact_verifier = _artifact_receipts(payload)
|
||||
controller_path = directory / "artifact-controller.json"
|
||||
verifier_path = directory / "artifact-verifier.json"
|
||||
controller_raw = _write_json(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write_json(verifier_path, artifact_verifier)
|
||||
payload["artifact_source"]["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
payload["artifact_source"]["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
policy_path = directory / "replay-policy.json"
|
||||
_write_json(policy_path, payload)
|
||||
return policy_path, controller_path, verifier_path
|
||||
|
||||
|
||||
class ReplayPolicyTests(unittest.TestCase):
|
||||
def _mutated_policy(self, mutate) -> Path:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
path = Path(self.temp.name) / "policy.json"
|
||||
payload = _policy_payload()
|
||||
mutate(payload)
|
||||
_write_json(path, payload)
|
||||
return path
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if hasattr(self, "temp"):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_committed_policy_is_exact_and_minimal(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
self.assertEqual(policy.work_item_id, "MCP-REPLAY-PLAYWRIGHT-001")
|
||||
self.assertEqual(policy.risk_class, "high")
|
||||
self.assertEqual(policy.tool_count, 24)
|
||||
self.assertEqual(len(policy.adapter_allowed_tools), 4)
|
||||
self.assertEqual(len(policy.adapter_denied_tools), 20)
|
||||
self.assertRegex(policy.artifact_ref, r"@sha256:[0-9a-f]{64}$")
|
||||
self.assertRegex(policy.runtime_image_ref, r"@sha256:[0-9a-f]{64}$")
|
||||
|
||||
def test_expired_policy_fails_closed(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload.__setitem__(
|
||||
"expires_at", "2026-07-01T00:00:00+08:00"
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "replay_policy_expired"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_network_must_remain_none(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__("network_mode", "bridge")
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_isolation_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_runtime_must_remain_non_root(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__("user", "0:0")
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_user_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_floating_runtime_reference_is_rejected(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["runtime"].__setitem__(
|
||||
"image_ref", "registry.wooo.work/awoooi/ci-runner:current"
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "runtime_image_ref_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_direct_registration_cannot_be_enabled(self) -> None:
|
||||
path = self._mutated_policy(
|
||||
lambda payload: payload["execution_boundaries"].__setitem__(
|
||||
"direct_gateway_registration_allowed", True
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(controller.ReplayError, "execution_boundary_invalid"):
|
||||
controller.load_policy(path)
|
||||
|
||||
def test_dangerous_tool_cannot_leave_denied_partition(self) -> None:
|
||||
def mutate(payload: dict) -> None:
|
||||
payload["protocol_contract"]["adapter_denied_tools"].remove(
|
||||
"browser_run_code_unsafe"
|
||||
)
|
||||
|
||||
path = self._mutated_policy(mutate)
|
||||
with self.assertRaises(controller.ReplayError):
|
||||
controller.load_policy(path)
|
||||
|
||||
|
||||
class SourceEvidenceTests(unittest.TestCase):
|
||||
def test_verified_source_receipt_chain_passes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
policy_path, controller_path, verifier_path = _source_fixture(Path(temp))
|
||||
policy = controller.load_policy(policy_path)
|
||||
evidence = controller.validate_source_evidence(
|
||||
policy,
|
||||
artifact_policy_path=ARTIFACT_POLICY_PATH,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
self.assertEqual(
|
||||
evidence.artifact_run_id, "11111111-2222-4333-8444-555555555555"
|
||||
)
|
||||
self.assertRegex(evidence.artifact_verifier_receipt_sha256, r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_tampered_controller_receipt_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
policy_path, controller_path, verifier_path = _source_fixture(Path(temp))
|
||||
policy = controller.load_policy(policy_path)
|
||||
controller_path.write_bytes(controller_path.read_bytes() + b" ")
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError,
|
||||
"artifact_controller_receipt_checksum_mismatch",
|
||||
):
|
||||
controller.validate_source_evidence(
|
||||
policy,
|
||||
artifact_policy_path=ARTIFACT_POLICY_PATH,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
|
||||
def test_artifact_policy_checksum_drift_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
policy_path, controller_path, verifier_path = _source_fixture(root)
|
||||
artifact_policy = json.loads(ARTIFACT_POLICY_PATH.read_text())
|
||||
artifact_policy["risk_level"] = "medium"
|
||||
drift_path = root / "artifact-policy.json"
|
||||
_write_json(drift_path, artifact_policy)
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError, "artifact_policy_checksum_mismatch"
|
||||
):
|
||||
controller.validate_source_evidence(
|
||||
controller.load_policy(policy_path),
|
||||
artifact_policy_path=drift_path,
|
||||
controller_receipt_path=controller_path,
|
||||
verifier_receipt_path=verifier_path,
|
||||
)
|
||||
|
||||
|
||||
class ArchiveAndProtocolTests(unittest.TestCase):
|
||||
def test_archive_traversal_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(controller.ReplayError, "artifact_archive_path_invalid"):
|
||||
controller._safe_relative_member("package/../../escape")
|
||||
|
||||
def test_archive_symlink_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
tarball = root / "bad.tgz"
|
||||
with tarfile.open(tarball, "w:gz") as archive:
|
||||
member = tarfile.TarInfo("package/link")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = "/etc/passwd"
|
||||
archive.addfile(member)
|
||||
with self.assertRaisesRegex(
|
||||
controller.ReplayError, "artifact_archive_member_type_invalid"
|
||||
):
|
||||
controller._extract_package(tarball, root / "out")
|
||||
|
||||
def test_regular_archive_extracts_with_exact_size(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
tarball = root / "good.tgz"
|
||||
content = b'{"name":"fixture"}'
|
||||
with tarfile.open(tarball, "w:gz") as archive:
|
||||
member = tarfile.TarInfo("package/package.json")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
files, size = controller._extract_package(tarball, root / "out")
|
||||
self.assertEqual((files, size), (1, len(content)))
|
||||
self.assertEqual((root / "out/package.json").read_bytes(), content)
|
||||
|
||||
def test_tool_hashes_are_order_independent(self) -> None:
|
||||
tools = [
|
||||
{
|
||||
"name": "beta",
|
||||
"inputSchema": {"type": "object"},
|
||||
"annotations": {"readOnlyHint": True},
|
||||
},
|
||||
{"name": "alpha", "inputSchema": {"type": "object"}},
|
||||
]
|
||||
first = controller._tool_hashes(tools)
|
||||
second = controller._tool_hashes(list(reversed(tools)))
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first[0], 2)
|
||||
self.assertEqual(first[3], ("alpha", "beta"))
|
||||
|
||||
def test_duplicate_tool_names_are_rejected(self) -> None:
|
||||
tools = [
|
||||
{"name": "same", "inputSchema": {}},
|
||||
{"name": "same", "inputSchema": {}},
|
||||
]
|
||||
with self.assertRaisesRegex(controller.ReplayError, "mcp_tool_names_not_unique"):
|
||||
controller._tool_hashes(tools)
|
||||
|
||||
|
||||
class ReceiptAndWorkflowTests(unittest.TestCase):
|
||||
def test_success_receipt_preserves_no_browser_no_write_boundary(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
source = controller.SourceEvidence("a", "b", "c", "d")
|
||||
observation = controller.ReplayObservation(
|
||||
policy.server_name,
|
||||
policy.server_version,
|
||||
policy.protocol_version,
|
||||
policy.tool_count,
|
||||
policy.tool_name_set_sha256,
|
||||
policy.tool_schema_manifest_sha256,
|
||||
0,
|
||||
"stdin_eof_clean_exit",
|
||||
True,
|
||||
)
|
||||
receipt = controller.build_receipt(
|
||||
policy=policy,
|
||||
source=source,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
mode="apply",
|
||||
observation=observation,
|
||||
package_file_count=10,
|
||||
package_unpacked_bytes=20,
|
||||
)
|
||||
self.assertEqual(
|
||||
receipt["status"],
|
||||
"completed_protocol_schema_replay_pending_independent_verifier",
|
||||
)
|
||||
for field in (
|
||||
"browser_started",
|
||||
"tool_call_performed",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"promotion_allowed",
|
||||
):
|
||||
self.assertIs(receipt[field], False)
|
||||
|
||||
def test_failure_receipt_keeps_candidate_disabled(self) -> None:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
receipt = controller._failure_receipt(
|
||||
policy=policy,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
error_class="fixture_failure",
|
||||
)
|
||||
self.assertEqual(receipt["status"], "blocked_with_safe_next_action")
|
||||
terminal = receipt["stage_receipts"]["rollback_or_no_write_terminal"]
|
||||
self.assertEqual(terminal["candidate_registration_state"], "disabled")
|
||||
self.assertIs(receipt["promotion_allowed"], False)
|
||||
|
||||
def test_gitea_workflow_has_no_floating_or_external_source_controls(self) -> None:
|
||||
raw = WORKFLOW_PATH.read_text(encoding="utf-8").lower()
|
||||
forbidden = (
|
||||
"uses" + ":",
|
||||
"actions" + "/checkout",
|
||||
"github" + ".com",
|
||||
"api" + ".github" + ".com",
|
||||
"raw" + ".githubusercontent" + ".com",
|
||||
"codeload" + ".github" + ".com",
|
||||
"ghcr" + ".io",
|
||||
"npx" + " -y",
|
||||
"@" + "latest",
|
||||
":" + "latest",
|
||||
)
|
||||
self.assertFalse([value for value in forbidden if value in raw])
|
||||
self.assertIn("runs-on: awoooi-non110-host", raw)
|
||||
self.assertIn("wait-host-web-build-pressure.sh", raw)
|
||||
self.assertIn("mcp-replay-receipts", raw)
|
||||
self.assertIn("verify_external_mcp_replay_receipt.py", raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the standalone external MCP replay verifier without mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
VERIFIER_PATH = ROOT / "scripts/security/verify_external_mcp_replay_receipt.py"
|
||||
CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_replay_controller.py"
|
||||
POLICY_PATH = ROOT / "config/mcp/playwright-mcp-replay-policy.json"
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verifier = _load("verify_external_mcp_replay_receipt", VERIFIER_PATH)
|
||||
controller = _load("external_mcp_replay_controller_for_verifier_test", CONTROLLER_PATH)
|
||||
|
||||
|
||||
def _canonical(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _write(path: Path, payload: object) -> bytes:
|
||||
raw = _canonical(payload) + b"\n"
|
||||
path.write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _policy_payload() -> dict:
|
||||
return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _valid_controller_receipt() -> tuple[dict, dict, str]:
|
||||
policy = controller.load_policy(POLICY_PATH)
|
||||
source = controller.SourceEvidence(
|
||||
artifact_run_id="aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
artifact_trace_id="mcp-artifact-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
artifact_controller_receipt_sha256="a" * 64,
|
||||
artifact_verifier_receipt_sha256="b" * 64,
|
||||
)
|
||||
observation = controller.ReplayObservation(
|
||||
server_name=policy.server_name,
|
||||
server_version=policy.server_version,
|
||||
protocol_version=policy.protocol_version,
|
||||
tool_count=policy.tool_count,
|
||||
tool_name_set_sha256=policy.tool_name_set_sha256,
|
||||
tool_schema_manifest_sha256=policy.tool_schema_manifest_sha256,
|
||||
process_exit_code=0,
|
||||
shutdown_mode="stdin_eof_clean_exit",
|
||||
ephemeral_container_removed=True,
|
||||
)
|
||||
receipt = controller.build_receipt(
|
||||
policy=policy,
|
||||
source=source,
|
||||
run_id="11111111-2222-4333-8444-555555555555",
|
||||
trace_id="mcp-replay-11111111-2222-4333-8444-555555555555",
|
||||
mode="apply",
|
||||
observation=observation,
|
||||
package_file_count=173,
|
||||
package_unpacked_bytes=1_000,
|
||||
)
|
||||
payload, checksum = verifier.load_and_validate_policy(POLICY_PATH)
|
||||
return payload, receipt, checksum
|
||||
|
||||
|
||||
def _artifact_chain(policy: dict) -> tuple[dict, dict]:
|
||||
artifact = policy["artifact_source"]
|
||||
run_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
controller_receipt = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_receipt_v1",
|
||||
"status": "completed_internal_mirror_pending_independent_verifier",
|
||||
"run_id": run_id,
|
||||
"trace_id": f"mcp-artifact-{run_id}",
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
}
|
||||
verifier_receipt = {
|
||||
"schema_version": "awoooi_external_mcp_artifact_verifier_receipt_v1",
|
||||
"status": "completed_internal_mirror_verified",
|
||||
"run_id": run_id,
|
||||
"trace_id": f"mcp-artifact-{run_id}",
|
||||
"internal_mirror_ref": artifact["artifact_ref"],
|
||||
"policy_checksum": artifact["policy_checksum"],
|
||||
"promotion_allowed": False,
|
||||
"replay_allowed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"independent_post_verifier": {
|
||||
"status": "passed",
|
||||
"container_or_mcp_started": False,
|
||||
},
|
||||
}
|
||||
return controller_receipt, verifier_receipt
|
||||
|
||||
|
||||
class StandalonePolicyTests(unittest.TestCase):
|
||||
def test_committed_policy_passes_independent_validation(self) -> None:
|
||||
policy, checksum = verifier.load_and_validate_policy(POLICY_PATH)
|
||||
self.assertEqual(policy["work_item_id"], "MCP-REPLAY-PLAYWRIGHT-001")
|
||||
self.assertRegex(checksum, r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
def test_any_enabled_execution_boundary_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["execution_boundaries"]["browser_start_allowed"] = True
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "boundary_must_remain_false"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
def test_adapter_tool_partition_overlap_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["protocol_contract"]["adapter_denied_tools"].append(
|
||||
"browser_snapshot"
|
||||
)
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "adapter_tool_partition_invalid"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
def test_tagged_artifact_reference_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
payload = _policy_payload()
|
||||
payload["artifact_source"]["artifact_ref"] = (
|
||||
"registry.wooo.work/awoooi/mcp-artifacts/playwright-mcp:0.0.78"
|
||||
)
|
||||
path = Path(temp) / "policy.json"
|
||||
_write(path, payload)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "artifact_ref_invalid"):
|
||||
verifier.load_and_validate_policy(path)
|
||||
|
||||
|
||||
class ControllerReceiptTests(unittest.TestCase):
|
||||
def test_valid_controller_receipt_passes_standalone_validation(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
raw = _write(path, receipt)
|
||||
observed, observed_sha = verifier.validate_controller_receipt(
|
||||
policy, checksum, path
|
||||
)
|
||||
self.assertEqual(observed["run_id"], receipt["run_id"])
|
||||
self.assertEqual(observed_sha, hashlib.sha256(raw).hexdigest())
|
||||
|
||||
def test_controller_browser_start_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["browser_started"] = True
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "controller_boundary_invalid"):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
def test_controller_missing_cleanup_terminal_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["stage_receipts"]["rollback_or_no_write_terminal"][
|
||||
"ephemeral_container_removed"
|
||||
] = False
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "controller_rollback_invalid"):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
def test_controller_protocol_hash_drift_is_rejected(self) -> None:
|
||||
policy, receipt, checksum = _valid_controller_receipt()
|
||||
receipt["protocol_observation"]["tool_name_set_sha256"] = "0" * 64
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "controller.json"
|
||||
_write(path, receipt)
|
||||
with self.assertRaisesRegex(
|
||||
verifier.VerifierError, "controller_protocol_observation_mismatch"
|
||||
):
|
||||
verifier.validate_controller_receipt(policy, checksum, path)
|
||||
|
||||
|
||||
class ArtifactChainAndToolTests(unittest.TestCase):
|
||||
def test_valid_artifact_chain_passes(self) -> None:
|
||||
policy = _policy_payload()
|
||||
artifact = policy["artifact_source"]
|
||||
artifact_controller, artifact_verifier = _artifact_chain(policy)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
controller_path = root / "artifact-controller.json"
|
||||
verifier_path = root / "artifact-verifier.json"
|
||||
controller_raw = _write(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write(verifier_path, artifact_verifier)
|
||||
artifact["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
artifact["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
observed = verifier.validate_artifact_receipt_chain(
|
||||
policy, controller_path, verifier_path
|
||||
)
|
||||
self.assertEqual(observed[0], artifact["controller_receipt_sha256"])
|
||||
self.assertEqual(observed[1], artifact["verifier_receipt_sha256"])
|
||||
|
||||
def test_artifact_chain_tamper_is_rejected(self) -> None:
|
||||
policy = _policy_payload()
|
||||
artifact = policy["artifact_source"]
|
||||
artifact_controller, artifact_verifier = _artifact_chain(policy)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
controller_path = root / "artifact-controller.json"
|
||||
verifier_path = root / "artifact-verifier.json"
|
||||
controller_raw = _write(controller_path, artifact_controller)
|
||||
artifact_verifier["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
verifier_raw = _write(verifier_path, artifact_verifier)
|
||||
artifact["controller_receipt_sha256"] = hashlib.sha256(
|
||||
controller_raw
|
||||
).hexdigest()
|
||||
artifact["verifier_receipt_sha256"] = hashlib.sha256(
|
||||
verifier_raw
|
||||
).hexdigest()
|
||||
verifier_path.write_bytes(verifier_raw + b" ")
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "artifact_receipt_chain_invalid"):
|
||||
verifier.validate_artifact_receipt_chain(
|
||||
policy, controller_path, verifier_path
|
||||
)
|
||||
|
||||
def test_independent_tool_hashes_are_order_independent(self) -> None:
|
||||
tools = [
|
||||
{"name": "zeta", "inputSchema": {"type": "object"}},
|
||||
{
|
||||
"name": "alpha",
|
||||
"inputSchema": {"type": "object"},
|
||||
"annotations": {"readOnlyHint": True},
|
||||
},
|
||||
]
|
||||
first = verifier._independent_tool_hashes(tools)
|
||||
second = verifier._independent_tool_hashes(list(reversed(tools)))
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first[3], {"alpha", "zeta"})
|
||||
|
||||
def test_duplicate_tool_names_are_rejected(self) -> None:
|
||||
tools = [
|
||||
{"name": "duplicate", "inputSchema": {}},
|
||||
{"name": "duplicate", "inputSchema": {}},
|
||||
]
|
||||
with self.assertRaisesRegex(verifier.VerifierError, "tool_names_not_unique"):
|
||||
verifier._independent_tool_hashes(tools)
|
||||
|
||||
|
||||
class VerifierReceiptTests(unittest.TestCase):
|
||||
def test_completed_verifier_receipt_keeps_promotion_disabled(self) -> None:
|
||||
policy, controller_receipt, policy_checksum = _valid_controller_receipt()
|
||||
receipt = verifier.build_verifier_receipt(
|
||||
policy=policy,
|
||||
policy_checksum=policy_checksum,
|
||||
controller=controller_receipt,
|
||||
controller_sha256="c" * 64,
|
||||
artifact_controller_sha256="a" * 64,
|
||||
artifact_verifier_sha256="b" * 64,
|
||||
observation={
|
||||
"protocol_version": "2025-06-18",
|
||||
"server_name": "Playwright",
|
||||
"server_version": "1.62.0-alpha-1783623505000",
|
||||
"tool_count": 24,
|
||||
"tool_name_set_sha256": "d" * 64,
|
||||
"tool_schema_manifest_sha256": "e" * 64,
|
||||
"process_exit_code": 0,
|
||||
"shutdown_mode": "stdin_eof_clean_exit",
|
||||
"ephemeral_container_removed": True,
|
||||
},
|
||||
package_file_count=173,
|
||||
package_unpacked_bytes=17_000_000,
|
||||
)
|
||||
self.assertEqual(receipt["status"], "completed_protocol_schema_replay_verified")
|
||||
self.assertEqual(
|
||||
receipt["independent_post_verifier"]["implementation"],
|
||||
"standalone_no_controller_import",
|
||||
)
|
||||
for field in (
|
||||
"browser_started",
|
||||
"tool_call_performed",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"promotion_allowed",
|
||||
):
|
||||
self.assertIs(receipt[field], False)
|
||||
|
||||
def test_failure_receipt_has_rollback_terminal(self) -> None:
|
||||
policy = _policy_payload()
|
||||
receipt = verifier._failure_receipt(policy, {}, "fixture_failure")
|
||||
self.assertEqual(receipt["status"], "blocked_with_safe_next_action")
|
||||
self.assertEqual(
|
||||
receipt["rollback_terminal"]["candidate_registration_state"], "disabled"
|
||||
)
|
||||
self.assertIs(receipt["promotion_allowed"], False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
898
scripts/security/verify_external_mcp_replay_receipt.py
Normal file
898
scripts/security/verify_external_mcp_replay_receipt.py
Normal file
@@ -0,0 +1,898 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently verify an external MCP protocol/schema replay receipt.
|
||||
|
||||
This standalone verifier intentionally does not import the replay controller.
|
||||
It revalidates the committed policy and artifact receipt chain, rematerializes
|
||||
the immutable bundle, and repeats initialize plus tools/list in a fresh offline
|
||||
container. No browser or MCP tool is invoked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
POLICY_SCHEMA = "awoooi_external_mcp_replay_policy_v1"
|
||||
CONTROLLER_SCHEMA = "awoooi_external_mcp_replay_receipt_v1"
|
||||
VERIFIER_SCHEMA = "awoooi_external_mcp_replay_verifier_receipt_v1"
|
||||
ARTIFACT_CONTROLLER_SCHEMA = "awoooi_external_mcp_artifact_receipt_v1"
|
||||
ARTIFACT_VERIFIER_SCHEMA = "awoooi_external_mcp_artifact_verifier_receipt_v1"
|
||||
ARTIFACT_BUNDLE_SCHEMA = "awoooi_external_mcp_artifact_bundle_v1"
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
SHA512_PATTERN = re.compile(r"^[0-9a-f]{128}$")
|
||||
OCI_REF_PATTERN = re.compile(
|
||||
r"^[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$"
|
||||
)
|
||||
MAX_JSON_BYTES = 4 * 1024 * 1024
|
||||
MAX_PROTOCOL_LINE_BYTES = 2 * 1024 * 1024
|
||||
MAX_FILES = 4_096
|
||||
MAX_UNPACKED_BYTES = 40 * 1024 * 1024
|
||||
|
||||
|
||||
class VerifierError(RuntimeError):
|
||||
"""Fail-closed public-safe verifier error class."""
|
||||
|
||||
|
||||
def _canonical(payload: Any) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, error: str) -> tuple[bytes, dict[str, Any]]:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise VerifierError(error) from exc
|
||||
if not raw or len(raw) > MAX_JSON_BYTES:
|
||||
raise VerifierError(error)
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError(error) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise VerifierError(error)
|
||||
return raw, payload
|
||||
|
||||
|
||||
def _dict(value: Any, error: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise VerifierError(error)
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: Any, error: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise VerifierError(error)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _exact_digest(value: Any, error: str, *, prefixed: bool = False) -> str:
|
||||
text = _text(value, error)
|
||||
digest = text.removeprefix("sha256:") if prefixed else text
|
||||
if not SHA256_PATTERN.fullmatch(digest):
|
||||
raise VerifierError(error)
|
||||
if prefixed and not text.startswith("sha256:"):
|
||||
raise VerifierError(error)
|
||||
return text
|
||||
|
||||
|
||||
def _oci_ref(value: Any, error: str) -> str:
|
||||
ref = _text(value, error)
|
||||
if not OCI_REF_PATTERN.fullmatch(ref):
|
||||
raise VerifierError(error)
|
||||
return ref
|
||||
|
||||
|
||||
def load_and_validate_policy(path: Path) -> tuple[dict[str, Any], str]:
|
||||
_, policy = _read_json(path, "policy_unreadable")
|
||||
if (
|
||||
policy.get("schema_version") != POLICY_SCHEMA
|
||||
or policy.get("policy_version") != "1.0.0"
|
||||
or policy.get("risk_class") != "high"
|
||||
or policy.get("scope")
|
||||
!= "offline_protocol_and_tool_schema_compatibility_only"
|
||||
):
|
||||
raise VerifierError("policy_contract_invalid")
|
||||
try:
|
||||
expires = datetime.fromisoformat(_text(policy.get("expires_at"), "policy_expiry_invalid"))
|
||||
except ValueError as exc:
|
||||
raise VerifierError("policy_expiry_invalid") from exc
|
||||
if expires.tzinfo is None or expires <= datetime.now(timezone.utc):
|
||||
raise VerifierError("policy_expired")
|
||||
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
||||
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
||||
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
||||
boundaries = _dict(policy.get("execution_boundaries"), "boundaries_invalid")
|
||||
if any(value is not False for value in boundaries.values()):
|
||||
raise VerifierError("boundary_must_remain_false")
|
||||
if (
|
||||
runtime.get("network_mode") != "none"
|
||||
or runtime.get("read_only_root") is not True
|
||||
or runtime.get("no_new_privileges") is not True
|
||||
or runtime.get("cap_drop") != ["ALL"]
|
||||
or runtime.get("user") != "65534:65534"
|
||||
or runtime.get("platform") != "linux/amd64"
|
||||
):
|
||||
raise VerifierError("runtime_isolation_invalid")
|
||||
artifact_ref = _oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid")
|
||||
runtime_ref = _oci_ref(runtime.get("image_ref"), "runtime_ref_invalid")
|
||||
if artifact_ref == runtime_ref:
|
||||
raise VerifierError("artifact_runtime_separation_invalid")
|
||||
for field in (
|
||||
"controller_receipt_sha256",
|
||||
"verifier_receipt_sha256",
|
||||
"bundle_manifest_sha256",
|
||||
"cyclonedx_sbom_sha256",
|
||||
"tool_name_set_sha256",
|
||||
"tool_schema_manifest_sha256",
|
||||
):
|
||||
source = artifact if field in artifact else protocol
|
||||
_exact_digest(source.get(field), f"{field}_invalid")
|
||||
_exact_digest(artifact.get("policy_checksum"), "artifact_policy_checksum_invalid", prefixed=True)
|
||||
integrities = _dict(artifact.get("package_integrities"), "integrities_invalid")
|
||||
if len(integrities) != 3:
|
||||
raise VerifierError("integrities_invalid")
|
||||
for digest in integrities.values():
|
||||
if not isinstance(digest, str) or not SHA512_PATTERN.fullmatch(digest):
|
||||
raise VerifierError("integrities_invalid")
|
||||
allowed = protocol.get("adapter_allowed_tools")
|
||||
denied = protocol.get("adapter_denied_tools")
|
||||
if (
|
||||
not isinstance(allowed, list)
|
||||
or not isinstance(denied, list)
|
||||
or len(allowed) != 4
|
||||
or len(denied) != 20
|
||||
or set(allowed) & set(denied)
|
||||
or set(allowed)
|
||||
!= {
|
||||
"browser_close",
|
||||
"browser_navigate",
|
||||
"browser_snapshot",
|
||||
"browser_take_screenshot",
|
||||
}
|
||||
or "browser_run_code_unsafe" not in denied
|
||||
or "browser_file_upload" not in denied
|
||||
):
|
||||
raise VerifierError("adapter_tool_partition_invalid")
|
||||
return policy, f"sha256:{_sha256(_canonical(policy))}"
|
||||
|
||||
|
||||
def validate_controller_receipt(
|
||||
policy: dict[str, Any], policy_checksum: str, path: Path
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
raw, receipt = _read_json(path, "controller_receipt_unreadable")
|
||||
if (
|
||||
receipt.get("schema_version") != CONTROLLER_SCHEMA
|
||||
or receipt.get("status")
|
||||
!= "completed_protocol_schema_replay_pending_independent_verifier"
|
||||
or receipt.get("mode") != "apply"
|
||||
or receipt.get("policy_checksum") != policy_checksum
|
||||
or receipt.get("work_item_id") != policy.get("work_item_id")
|
||||
or receipt.get("candidate_id") != policy.get("candidate_id")
|
||||
or receipt.get("adapter_id") != policy.get("adapter_id")
|
||||
):
|
||||
raise VerifierError("controller_receipt_terminal_invalid")
|
||||
try:
|
||||
normalized_run_id = str(uuid.UUID(_text(receipt.get("run_id"), "run_id_invalid")))
|
||||
except ValueError as exc:
|
||||
raise VerifierError("run_id_invalid") from exc
|
||||
if (
|
||||
receipt.get("run_id") != normalized_run_id
|
||||
or receipt.get("trace_id") != f"mcp-replay-{normalized_run_id}"
|
||||
):
|
||||
raise VerifierError("run_trace_identity_invalid")
|
||||
for field in (
|
||||
"direct_gateway_registration_allowed",
|
||||
"adapter_registration_allowed",
|
||||
"browser_started",
|
||||
"tool_call_performed",
|
||||
"navigation_performed",
|
||||
"raw_request_or_response_body_stored",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"shadow_allowed",
|
||||
"canary_allowed",
|
||||
"promotion_allowed",
|
||||
):
|
||||
if receipt.get(field) is not False:
|
||||
raise VerifierError("controller_boundary_invalid")
|
||||
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
||||
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
||||
if (
|
||||
receipt.get("artifact_ref") != artifact.get("artifact_ref")
|
||||
or receipt.get("runtime_image_ref") != runtime.get("image_ref")
|
||||
or receipt.get("artifact_audit_commit") != artifact.get("audit_commit")
|
||||
):
|
||||
raise VerifierError("controller_source_identity_invalid")
|
||||
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
||||
observed = _dict(receipt.get("protocol_observation"), "protocol_observation_invalid")
|
||||
expected = {
|
||||
"protocol_version": protocol.get("protocol_version"),
|
||||
"server_name": protocol.get("server_name"),
|
||||
"server_version": protocol.get("server_version"),
|
||||
"tool_count": protocol.get("tool_count"),
|
||||
"tool_name_set_sha256": protocol.get("tool_name_set_sha256"),
|
||||
"tool_schema_manifest_sha256": protocol.get("tool_schema_manifest_sha256"),
|
||||
}
|
||||
if any(observed.get(field) != value for field, value in expected.items()):
|
||||
raise VerifierError("controller_protocol_observation_mismatch")
|
||||
if observed.get("shutdown_mode") != "stdin_eof_clean_exit":
|
||||
raise VerifierError("controller_shutdown_terminal_invalid")
|
||||
stages = _dict(receipt.get("stage_receipts"), "controller_stages_invalid")
|
||||
rollback = _dict(
|
||||
stages.get("rollback_or_no_write_terminal"), "controller_rollback_invalid"
|
||||
)
|
||||
if (
|
||||
rollback.get("status")
|
||||
!= "mcp_process_terminated_and_ephemeral_container_removed"
|
||||
or rollback.get("ephemeral_container_removed") is not True
|
||||
or rollback.get("production_route_changed") is not False
|
||||
):
|
||||
raise VerifierError("controller_rollback_invalid")
|
||||
return receipt, _sha256(raw)
|
||||
|
||||
|
||||
def validate_artifact_receipt_chain(
|
||||
policy: dict[str, Any], controller_path: Path, verifier_path: Path
|
||||
) -> tuple[str, str]:
|
||||
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
||||
controller_raw, controller = _read_json(
|
||||
controller_path, "artifact_controller_receipt_unreadable"
|
||||
)
|
||||
verifier_raw, verifier = _read_json(
|
||||
verifier_path, "artifact_verifier_receipt_unreadable"
|
||||
)
|
||||
controller_sha = _sha256(controller_raw)
|
||||
verifier_sha = _sha256(verifier_raw)
|
||||
if (
|
||||
controller_sha != artifact.get("controller_receipt_sha256")
|
||||
or verifier_sha != artifact.get("verifier_receipt_sha256")
|
||||
or controller.get("schema_version") != ARTIFACT_CONTROLLER_SCHEMA
|
||||
or controller.get("status")
|
||||
!= "completed_internal_mirror_pending_independent_verifier"
|
||||
or verifier.get("schema_version") != ARTIFACT_VERIFIER_SCHEMA
|
||||
or verifier.get("status") != "completed_internal_mirror_verified"
|
||||
or verifier.get("controller_receipt_sha256") != controller_sha
|
||||
):
|
||||
raise VerifierError("artifact_receipt_chain_invalid")
|
||||
post = _dict(verifier.get("independent_post_verifier"), "artifact_verifier_invalid")
|
||||
if (
|
||||
post.get("status") != "passed"
|
||||
or post.get("container_or_mcp_started") is not False
|
||||
or controller.get("run_id") != verifier.get("run_id")
|
||||
or controller.get("trace_id") != verifier.get("trace_id")
|
||||
):
|
||||
raise VerifierError("artifact_independent_verifier_invalid")
|
||||
for receipt in (controller, verifier):
|
||||
if (
|
||||
receipt.get("internal_mirror_ref") != artifact.get("artifact_ref")
|
||||
or receipt.get("policy_checksum") != artifact.get("policy_checksum")
|
||||
or any(
|
||||
receipt.get(field) is not False
|
||||
for field in (
|
||||
"promotion_allowed",
|
||||
"replay_allowed",
|
||||
"shadow_allowed",
|
||||
"canary_allowed",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise VerifierError("artifact_receipt_boundary_invalid")
|
||||
return controller_sha, verifier_sha
|
||||
|
||||
|
||||
def _docker() -> str:
|
||||
executable = shutil.which("docker")
|
||||
if not executable:
|
||||
raise VerifierError("docker_cli_unavailable")
|
||||
return executable
|
||||
|
||||
|
||||
def _run(command: list[str], *, timeout: int, error: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise VerifierError(error) from exc
|
||||
if result.returncode != 0:
|
||||
raise VerifierError(error)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _pull_and_readback(docker: str, ref: str, platform: str) -> None:
|
||||
_run(
|
||||
[docker, "pull", "--platform", platform, ref],
|
||||
timeout=300,
|
||||
error="image_pull_failed",
|
||||
)
|
||||
output = _run(
|
||||
[docker, "image", "inspect", "--format", "{{json .RepoDigests}}", ref],
|
||||
timeout=30,
|
||||
error="image_readback_failed",
|
||||
)
|
||||
try:
|
||||
digests = json.loads(output.strip())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError("image_readback_invalid") from exc
|
||||
if not isinstance(digests, list) or ref not in digests:
|
||||
raise VerifierError("image_digest_readback_mismatch")
|
||||
|
||||
|
||||
def _safe_member(name: str) -> Path | None:
|
||||
pure = PurePosixPath(name)
|
||||
if pure.is_absolute() or not pure.parts or ".." in pure.parts:
|
||||
raise VerifierError("archive_path_invalid")
|
||||
if pure.parts[0] != "package":
|
||||
raise VerifierError("archive_root_invalid")
|
||||
if len(pure.parts) == 1:
|
||||
return None
|
||||
return Path(*pure.parts[1:])
|
||||
|
||||
|
||||
def _unpack(tarball: Path, destination: Path) -> tuple[int, int]:
|
||||
files = 0
|
||||
size = 0
|
||||
try:
|
||||
archive = tarfile.open(tarball, "r:gz")
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise VerifierError("tarball_invalid") from exc
|
||||
with archive:
|
||||
for member in archive.getmembers():
|
||||
relative = _safe_member(member.name)
|
||||
if relative is None:
|
||||
continue
|
||||
target = destination / relative
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not member.isfile() or member.issym() or member.islnk():
|
||||
raise VerifierError("archive_member_type_invalid")
|
||||
files += 1
|
||||
size += member.size
|
||||
if files > MAX_FILES or size > MAX_UNPACKED_BYTES:
|
||||
raise VerifierError("archive_limits_exceeded")
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise VerifierError("archive_member_unreadable")
|
||||
content = source.read(member.size + 1)
|
||||
if len(content) != member.size:
|
||||
raise VerifierError("archive_member_size_mismatch")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return files, size
|
||||
|
||||
|
||||
def independently_materialize(
|
||||
policy: dict[str, Any], directory: Path
|
||||
) -> tuple[Path, int, int]:
|
||||
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
||||
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
||||
docker = _docker()
|
||||
artifact_ref = _oci_ref(artifact.get("artifact_ref"), "artifact_ref_invalid")
|
||||
platform = _text(runtime.get("platform"), "platform_invalid")
|
||||
_pull_and_readback(docker, artifact_ref, platform)
|
||||
bundle = directory / "bundle"
|
||||
modules = directory / "node_modules"
|
||||
bundle.mkdir()
|
||||
modules.mkdir()
|
||||
container = f"awoooi-mcp-verifier-artifact-{uuid.uuid4().hex[:16]}"
|
||||
try:
|
||||
_run(
|
||||
[
|
||||
docker,
|
||||
"create",
|
||||
"--platform",
|
||||
platform,
|
||||
"--name",
|
||||
container,
|
||||
artifact_ref,
|
||||
"/nonexistent/no-start",
|
||||
],
|
||||
timeout=60,
|
||||
error="artifact_container_create_failed",
|
||||
)
|
||||
_run(
|
||||
[docker, "cp", f"{container}:/artifact/.", str(bundle)],
|
||||
timeout=60,
|
||||
error="artifact_copy_failed",
|
||||
)
|
||||
finally:
|
||||
subprocess.run(
|
||||
[docker, "rm", "-f", container],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
manifest_raw, manifest = _read_json(
|
||||
bundle / "bundle-manifest.json", "bundle_manifest_invalid"
|
||||
)
|
||||
sbom_raw, sbom = _read_json(bundle / "sbom.cdx.json", "bundle_sbom_invalid")
|
||||
if (
|
||||
_sha256(manifest_raw) != artifact.get("bundle_manifest_sha256")
|
||||
or _sha256(sbom_raw) != artifact.get("cyclonedx_sbom_sha256")
|
||||
or manifest.get("schema_version") != ARTIFACT_BUNDLE_SCHEMA
|
||||
or manifest.get("candidate_id") != policy.get("candidate_id")
|
||||
or manifest.get("policy_checksum") != artifact.get("policy_checksum")
|
||||
or sbom.get("bomFormat") != "CycloneDX"
|
||||
or sbom.get("specVersion") != "1.5"
|
||||
):
|
||||
raise VerifierError("bundle_identity_invalid")
|
||||
bundle_boundaries = _dict(
|
||||
manifest.get("operation_boundaries"), "bundle_boundaries_invalid"
|
||||
)
|
||||
if any(
|
||||
bundle_boundaries.get(field) is not False
|
||||
for field in (
|
||||
"mcp_server_started",
|
||||
"browser_started",
|
||||
"external_tool_call_performed",
|
||||
"external_rag_write_performed",
|
||||
"production_write_performed",
|
||||
"runtime_request_or_response_body_stored",
|
||||
)
|
||||
):
|
||||
raise VerifierError("bundle_boundary_invalid")
|
||||
integrities = _dict(artifact.get("package_integrities"), "integrities_invalid")
|
||||
rows = manifest.get("packages")
|
||||
if not isinstance(rows, list) or len(rows) != 3:
|
||||
raise VerifierError("bundle_package_set_invalid")
|
||||
observed: set[str] = set()
|
||||
total_files = 0
|
||||
total_size = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise VerifierError("bundle_package_set_invalid")
|
||||
name = _text(row.get("name"), "package_name_invalid")
|
||||
version = _text(row.get("version"), "package_version_invalid")
|
||||
key = f"{name}@{version}"
|
||||
tarball = bundle / _text(row.get("tarball_path"), "tarball_path_invalid")
|
||||
try:
|
||||
raw = tarball.read_bytes()
|
||||
except OSError as exc:
|
||||
raise VerifierError("tarball_unreadable") from exc
|
||||
if (
|
||||
key in observed
|
||||
or integrities.get(key) != row.get("tarball_sha512_hex")
|
||||
or hashlib.sha512(raw).hexdigest() != integrities.get(key)
|
||||
):
|
||||
raise VerifierError("tarball_integrity_mismatch")
|
||||
observed.add(key)
|
||||
destination = (
|
||||
modules / "@playwright" / "mcp"
|
||||
if name == "@playwright/mcp"
|
||||
else modules / name
|
||||
)
|
||||
destination.mkdir(parents=True)
|
||||
files, size = _unpack(tarball, destination)
|
||||
total_files += files
|
||||
total_size += size
|
||||
_, package_json = _read_json(destination / "package.json", "package_json_invalid")
|
||||
if package_json.get("name") != name or package_json.get("version") != version:
|
||||
raise VerifierError("package_json_identity_mismatch")
|
||||
if observed != set(integrities):
|
||||
raise VerifierError("bundle_package_set_mismatch")
|
||||
for path in modules.rglob("*"):
|
||||
path.chmod(0o755 if path.is_dir() else 0o444)
|
||||
modules.chmod(0o755)
|
||||
directory.chmod(0o755)
|
||||
return modules, total_files, total_size
|
||||
|
||||
|
||||
def _send(process: subprocess.Popen[str], payload: dict[str, Any]) -> None:
|
||||
if process.stdin is None:
|
||||
raise VerifierError("mcp_stdin_unavailable")
|
||||
try:
|
||||
process.stdin.write(_canonical(payload).decode("utf-8") + "\n")
|
||||
process.stdin.flush()
|
||||
except (BrokenPipeError, OSError) as exc:
|
||||
raise VerifierError("mcp_write_failed") from exc
|
||||
|
||||
|
||||
def _receive(
|
||||
process: subprocess.Popen[str], timeout_seconds: int, expected_id: int
|
||||
) -> dict[str, Any]:
|
||||
if process.stdout is None:
|
||||
raise VerifierError("mcp_stdout_unavailable")
|
||||
ready, _, _ = select.select([process.stdout], [], [], timeout_seconds)
|
||||
if not ready:
|
||||
raise VerifierError("mcp_response_timeout")
|
||||
line = process.stdout.readline(MAX_PROTOCOL_LINE_BYTES + 1)
|
||||
if not line or len(line.encode("utf-8")) > MAX_PROTOCOL_LINE_BYTES:
|
||||
raise VerifierError("mcp_response_invalid")
|
||||
try:
|
||||
response = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VerifierError("mcp_response_invalid") from exc
|
||||
if (
|
||||
not isinstance(response, dict)
|
||||
or response.get("jsonrpc") != "2.0"
|
||||
or response.get("id") != expected_id
|
||||
or "error" in response
|
||||
or not isinstance(response.get("result"), dict)
|
||||
):
|
||||
raise VerifierError("mcp_response_invalid")
|
||||
return response["result"]
|
||||
|
||||
|
||||
def _independent_tool_hashes(tools: Any) -> tuple[int, str, str, set[str]]:
|
||||
if not isinstance(tools, list):
|
||||
raise VerifierError("tool_list_invalid")
|
||||
names: list[str] = []
|
||||
schema_rows: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
raise VerifierError("tool_schema_invalid")
|
||||
name = _text(tool.get("name"), "tool_name_invalid")
|
||||
schema = tool.get("inputSchema")
|
||||
if not isinstance(schema, dict):
|
||||
raise VerifierError("tool_schema_invalid")
|
||||
row: dict[str, Any] = {"name": name, "inputSchema": schema}
|
||||
if "annotations" in tool:
|
||||
annotations = tool["annotations"]
|
||||
if not isinstance(annotations, dict):
|
||||
raise VerifierError("tool_annotations_invalid")
|
||||
row["annotations"] = annotations
|
||||
names.append(name)
|
||||
schema_rows.append(row)
|
||||
if len(names) != len(set(names)):
|
||||
raise VerifierError("tool_names_not_unique")
|
||||
schema_rows.sort(key=lambda row: row["name"])
|
||||
return (
|
||||
len(tools),
|
||||
_sha256(_canonical(sorted(names))),
|
||||
_sha256(_canonical(schema_rows)),
|
||||
set(names),
|
||||
)
|
||||
|
||||
|
||||
def independently_replay(
|
||||
policy: dict[str, Any], modules: Path
|
||||
) -> dict[str, Any]:
|
||||
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
||||
protocol = _dict(policy.get("protocol_contract"), "protocol_invalid")
|
||||
docker = _docker()
|
||||
runtime_ref = _oci_ref(runtime.get("image_ref"), "runtime_ref_invalid")
|
||||
platform = _text(runtime.get("platform"), "platform_invalid")
|
||||
_pull_and_readback(docker, runtime_ref, platform)
|
||||
container = f"awoooi-mcp-replay-verifier-{uuid.uuid4().hex[:16]}"
|
||||
command = [
|
||||
docker,
|
||||
"run",
|
||||
"--rm",
|
||||
"--platform",
|
||||
platform,
|
||||
"--name",
|
||||
container,
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--pids-limit",
|
||||
str(runtime.get("pids_limit")),
|
||||
"--memory",
|
||||
_text(runtime.get("memory_limit"), "memory_limit_invalid"),
|
||||
"--cpus",
|
||||
_text(runtime.get("cpu_limit"), "cpu_limit_invalid"),
|
||||
"--user",
|
||||
_text(runtime.get("user"), "runtime_user_invalid"),
|
||||
"--env",
|
||||
"HOME=/tmp",
|
||||
"--env",
|
||||
"CI=1",
|
||||
"--tmpfs",
|
||||
_text(runtime.get("tmpfs"), "tmpfs_invalid"),
|
||||
"--stop-timeout",
|
||||
str(runtime.get("shutdown_timeout_seconds")),
|
||||
"-i",
|
||||
"-v",
|
||||
f"{modules.resolve()}:/work/node_modules:ro",
|
||||
"-w",
|
||||
"/work",
|
||||
runtime_ref,
|
||||
*runtime.get("entrypoint", []),
|
||||
*runtime.get("arguments", []),
|
||||
]
|
||||
stderr_file = tempfile.TemporaryFile(mode="w+t", encoding="utf-8")
|
||||
process: subprocess.Popen[str] | None = None
|
||||
try:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=stderr_file,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env={**os.environ, "DOCKER_CLI_HINTS": "false"},
|
||||
)
|
||||
except OSError as exc:
|
||||
raise VerifierError("mcp_process_start_failed") from exc
|
||||
_send(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 41,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": protocol.get("protocol_version"),
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "awoooi-external-mcp-replay-verifier",
|
||||
"version": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
timeout = int(runtime.get("startup_timeout_seconds"))
|
||||
initialized = _receive(process, timeout, 41)
|
||||
server = _dict(initialized.get("serverInfo"), "server_info_invalid")
|
||||
if (
|
||||
initialized.get("protocolVersion") != protocol.get("protocol_version")
|
||||
or server.get("name") != protocol.get("server_name")
|
||||
or server.get("version") != protocol.get("server_version")
|
||||
):
|
||||
raise VerifierError("initialize_contract_mismatch")
|
||||
_send(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
_send(
|
||||
process,
|
||||
{"jsonrpc": "2.0", "id": 42, "method": "tools/list", "params": {}},
|
||||
)
|
||||
listed = _receive(process, timeout, 42)
|
||||
count, names_hash, schemas_hash, names = _independent_tool_hashes(
|
||||
listed.get("tools")
|
||||
)
|
||||
if (
|
||||
count != protocol.get("tool_count")
|
||||
or names_hash != protocol.get("tool_name_set_sha256")
|
||||
or schemas_hash != protocol.get("tool_schema_manifest_sha256")
|
||||
or names
|
||||
!= set(protocol.get("adapter_allowed_tools", []))
|
||||
| set(protocol.get("adapter_denied_tools", []))
|
||||
):
|
||||
raise VerifierError("tool_contract_mismatch")
|
||||
if process.stdin is None:
|
||||
raise VerifierError("mcp_stdin_unavailable")
|
||||
process.stdin.close()
|
||||
try:
|
||||
exit_code = process.wait(timeout=int(runtime.get("shutdown_timeout_seconds")))
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise VerifierError("mcp_shutdown_timeout") from exc
|
||||
if exit_code != 0:
|
||||
raise VerifierError("mcp_exit_invalid")
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=int(runtime.get("shutdown_timeout_seconds", 5)))
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
subprocess.run(
|
||||
[docker, "rm", "-f", container],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
stderr_file.close()
|
||||
inspect = subprocess.run(
|
||||
[docker, "container", "inspect", container],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
if inspect.returncode == 0:
|
||||
raise VerifierError("container_cleanup_failed")
|
||||
return {
|
||||
"protocol_version": protocol.get("protocol_version"),
|
||||
"server_name": protocol.get("server_name"),
|
||||
"server_version": protocol.get("server_version"),
|
||||
"tool_count": count,
|
||||
"tool_name_set_sha256": names_hash,
|
||||
"tool_schema_manifest_sha256": schemas_hash,
|
||||
"process_exit_code": 0,
|
||||
"shutdown_mode": "stdin_eof_clean_exit",
|
||||
"ephemeral_container_removed": True,
|
||||
}
|
||||
|
||||
|
||||
def build_verifier_receipt(
|
||||
*,
|
||||
policy: dict[str, Any],
|
||||
policy_checksum: str,
|
||||
controller: dict[str, Any],
|
||||
controller_sha256: str,
|
||||
artifact_controller_sha256: str,
|
||||
artifact_verifier_sha256: str,
|
||||
observation: dict[str, Any],
|
||||
package_file_count: int,
|
||||
package_unpacked_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
artifact = _dict(policy.get("artifact_source"), "artifact_source_invalid")
|
||||
runtime = _dict(policy.get("runtime"), "runtime_invalid")
|
||||
return {
|
||||
"schema_version": VERIFIER_SCHEMA,
|
||||
"run_id": controller["run_id"],
|
||||
"trace_id": controller["trace_id"],
|
||||
"work_item_id": controller["work_item_id"],
|
||||
"candidate_id": controller["candidate_id"],
|
||||
"adapter_id": controller["adapter_id"],
|
||||
"risk_class": "high",
|
||||
"status": "completed_protocol_schema_replay_verified",
|
||||
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"policy_checksum": policy_checksum,
|
||||
"controller_receipt_sha256": controller_sha256,
|
||||
"artifact_controller_receipt_sha256": artifact_controller_sha256,
|
||||
"artifact_verifier_receipt_sha256": artifact_verifier_sha256,
|
||||
"artifact_ref": artifact.get("artifact_ref"),
|
||||
"runtime_image_ref": runtime.get("image_ref"),
|
||||
"independent_post_verifier": {
|
||||
"status": "passed",
|
||||
"implementation": "standalone_no_controller_import",
|
||||
"fresh_artifact_digest_readback": True,
|
||||
"fresh_runtime_digest_readback": True,
|
||||
"fresh_protocol_replay": True,
|
||||
"package_file_count": package_file_count,
|
||||
"package_unpacked_bytes": package_unpacked_bytes,
|
||||
**observation,
|
||||
},
|
||||
"direct_gateway_registration_allowed": False,
|
||||
"adapter_registration_allowed": False,
|
||||
"browser_started": False,
|
||||
"tool_call_performed": False,
|
||||
"navigation_performed": False,
|
||||
"raw_request_or_response_body_stored": False,
|
||||
"external_rag_write_performed": False,
|
||||
"production_write_performed": False,
|
||||
"shadow_allowed": False,
|
||||
"canary_allowed": False,
|
||||
"promotion_allowed": False,
|
||||
"promotion_blockers": policy.get("promotion_blockers_after_replay"),
|
||||
"rollback_terminal": {
|
||||
"status": "mcp_process_terminated_and_ephemeral_container_removed",
|
||||
"candidate_registration_state": "disabled",
|
||||
"ephemeral_container_removed": True,
|
||||
"production_route_changed": False,
|
||||
},
|
||||
"learning_writeback": {
|
||||
"status": "verified_receipt_ready_for_gitea_audit_branch_writeback",
|
||||
"external_rag_write_performed": False,
|
||||
"km_write_performed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(_canonical(payload) + b"\n")
|
||||
|
||||
|
||||
def _failure_receipt(
|
||||
policy: dict[str, Any], controller: dict[str, Any], error: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": VERIFIER_SCHEMA,
|
||||
"run_id": controller.get("run_id"),
|
||||
"trace_id": controller.get("trace_id"),
|
||||
"work_item_id": policy.get("work_item_id"),
|
||||
"candidate_id": policy.get("candidate_id"),
|
||||
"status": "blocked_with_safe_next_action",
|
||||
"error_class": error,
|
||||
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"browser_started": False,
|
||||
"tool_call_performed": False,
|
||||
"external_rag_write_performed": False,
|
||||
"production_write_performed": False,
|
||||
"promotion_allowed": False,
|
||||
"rollback_terminal": {
|
||||
"status": "candidate_disabled_ephemeral_process_cleanup_attempted",
|
||||
"candidate_registration_state": "disabled",
|
||||
"production_route_changed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--policy", type=Path, required=True)
|
||||
parser.add_argument("--artifact-controller-receipt", type=Path, required=True)
|
||||
parser.add_argument("--artifact-verifier-receipt", type=Path, required=True)
|
||||
parser.add_argument("--controller-receipt", type=Path, required=True)
|
||||
parser.add_argument("--verifier-receipt", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
policy, policy_checksum = load_and_validate_policy(args.policy)
|
||||
controller: dict[str, Any] = {}
|
||||
try:
|
||||
controller, controller_sha256 = validate_controller_receipt(
|
||||
policy, policy_checksum, args.controller_receipt
|
||||
)
|
||||
artifact_controller_sha256, artifact_verifier_sha256 = (
|
||||
validate_artifact_receipt_chain(
|
||||
policy,
|
||||
args.artifact_controller_receipt,
|
||||
args.artifact_verifier_receipt,
|
||||
)
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="awoooi-mcp-replay-verifier-") as temp:
|
||||
modules, file_count, unpacked_bytes = independently_materialize(
|
||||
policy, Path(temp)
|
||||
)
|
||||
observation = independently_replay(policy, modules)
|
||||
receipt = build_verifier_receipt(
|
||||
policy=policy,
|
||||
policy_checksum=policy_checksum,
|
||||
controller=controller,
|
||||
controller_sha256=controller_sha256,
|
||||
artifact_controller_sha256=artifact_controller_sha256,
|
||||
artifact_verifier_sha256=artifact_verifier_sha256,
|
||||
observation=observation,
|
||||
package_file_count=file_count,
|
||||
package_unpacked_bytes=unpacked_bytes,
|
||||
)
|
||||
except VerifierError as exc:
|
||||
_write(args.verifier_receipt, _failure_receipt(policy, controller, str(exc)))
|
||||
raise
|
||||
_write(args.verifier_receipt, receipt)
|
||||
print(json.dumps(receipt, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except VerifierError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": VERIFIER_SCHEMA,
|
||||
"status": "blocked_with_safe_next_action",
|
||||
"error_class": str(exc),
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
Reference in New Issue
Block a user