From dd32cbf9cdc7e699f19cf91dcd7782945c4ed475 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 11:27:36 +0800 Subject: [PATCH] fix(ci): make MCP audit writeback repeatable --- .../mcp-external-artifact-mirror.yaml | 48 +-- .gitea/workflows/mcp-external-replay.yaml | 50 +-- docs/LOGBOOK.md | 9 +- ...tea_workflow_runner_health_2026-06-05.json | 3 + .../tests/test_write_gitea_audit_receipts.py | 290 ++++++++++++++++++ scripts/ci/write-gitea-audit-receipts.sh | 188 ++++++++++++ .../test_external_mcp_artifact_controller.py | 11 +- .../test_external_mcp_replay_controller.py | 9 + 8 files changed, 539 insertions(+), 69 deletions(-) create mode 100644 scripts/ci/tests/test_write_gitea_audit_receipts.py create mode 100755 scripts/ci/write-gitea-audit-receipts.sh diff --git a/.gitea/workflows/mcp-external-artifact-mirror.yaml b/.gitea/workflows/mcp-external-artifact-mirror.yaml index 1fa6be8da..607db7544 100644 --- a/.gitea/workflows/mcp-external-artifact-mirror.yaml +++ b/.gitea/workflows/mcp-external-artifact-mirror.yaml @@ -22,6 +22,8 @@ on: - scripts/security/verify_external_mcp_artifact_receipt.py - scripts/security/tests/test_external_mcp_artifact_controller.py - scripts/security/tests/test_verify_external_mcp_artifact_receipt.py + - scripts/ci/write-gitea-audit-receipts.sh + - scripts/ci/tests/test_write_gitea_audit_receipts.py - .gitea/workflows/mcp-external-artifact-mirror.yaml concurrency: @@ -68,9 +70,11 @@ jobs: scripts/security/external_mcp_artifact_controller.py \ scripts/security/verify_external_mcp_artifact_receipt.py \ scripts/security/tests/test_external_mcp_artifact_controller.py \ - scripts/security/tests/test_verify_external_mcp_artifact_receipt.py + scripts/security/tests/test_verify_external_mcp_artifact_receipt.py \ + scripts/ci/tests/test_write_gitea_audit_receipts.py python3 scripts/security/tests/test_external_mcp_artifact_controller.py python3 scripts/security/tests/test_verify_external_mcp_artifact_receipt.py + python3 scripts/ci/tests/test_write_gitea_audit_receipts.py python3 - <<'PY' import json from pathlib import Path @@ -194,14 +198,11 @@ jobs: set +x test -s "${CONTROLLER_RECEIPT_PATH}" test -s "${VERIFIER_RECEIPT_PATH}" + WRITEBACK_DIR="$(mktemp -d)" + cp "${CONTROLLER_RECEIPT_PATH}" "${WRITEBACK_DIR}/controller.json" + cp "${VERIFIER_RECEIPT_PATH}" "${WRITEBACK_DIR}/verifier.json" git config user.email "mcp-artifact-controller@awoooi.internal" git config user.name "AWOOOI MCP Artifact Controller" - git add "${CONTROLLER_RECEIPT_PATH}" "${VERIFIER_RECEIPT_PATH}" - git diff --cached --quiet && { - echo "receipt_writeback=no_change" - exit 0 - } - git commit -m "chore(mcp): record verified Playwright artifact mirror [skip ci] [metadata-only]" ASKPASS_PATH="$(mktemp)" export ASKPASS_PATH @@ -223,31 +224,16 @@ jobs: PY cleanup_push_auth() { rm -f "${ASKPASS_PATH}" + rm -rf "${WRITEBACK_DIR}" } 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/${RECEIPT_BRANCH}" >/dev/null 2>&1; then - git fetch --no-tags --depth=100 gitea "${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/${RECEIPT_BRANCH}"; then - echo "receipt_writeback=verified_audit_branch_normal_push" - echo "receipt_branch=${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 + export GITEA_AUDIT_REMOTE_URL="${GITEA_SOURCE_URL}" + export GITEA_AUDIT_BRANCH="${RECEIPT_BRANCH}" + export GITEA_AUDIT_CONTROLLER_SOURCE="${WRITEBACK_DIR}/controller.json" + export GITEA_AUDIT_VERIFIER_SOURCE="${WRITEBACK_DIR}/verifier.json" + export GITEA_AUDIT_CONTROLLER_TARGET="${CONTROLLER_RECEIPT_PATH}" + export GITEA_AUDIT_VERIFIER_TARGET="${VERIFIER_RECEIPT_PATH}" + export GITEA_AUDIT_COMMIT_MESSAGE="chore(mcp): record verified Playwright artifact mirror [skip ci] [metadata-only]" + bash scripts/ci/write-gitea-audit-receipts.sh diff --git a/.gitea/workflows/mcp-external-replay.yaml b/.gitea/workflows/mcp-external-replay.yaml index 6328f0e84..bf1eff5a7 100644 --- a/.gitea/workflows/mcp-external-replay.yaml +++ b/.gitea/workflows/mcp-external-replay.yaml @@ -22,6 +22,8 @@ on: - 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 + - scripts/ci/write-gitea-audit-receipts.sh + - scripts/ci/tests/test_write_gitea_audit_receipts.py - .gitea/workflows/mcp-external-replay.yaml concurrency: @@ -76,9 +78,11 @@ jobs: 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 + scripts/security/tests/test_verify_external_mcp_replay_receipt.py \ + scripts/ci/tests/test_write_gitea_audit_receipts.py python3 scripts/security/tests/test_external_mcp_replay_controller.py python3 scripts/security/tests/test_verify_external_mcp_replay_receipt.py + python3 scripts/ci/tests/test_write_gitea_audit_receipts.py python3 - <<'PY' import json from pathlib import Path @@ -267,16 +271,11 @@ jobs: set +x test -s "${REPLAY_CONTROLLER_RECEIPT_PATH}" test -s "${REPLAY_VERIFIER_RECEIPT_PATH}" + WRITEBACK_DIR="$(mktemp -d)" + cp "${REPLAY_CONTROLLER_RECEIPT_PATH}" "${WRITEBACK_DIR}/controller.json" + cp "${REPLAY_VERIFIER_RECEIPT_PATH}" "${WRITEBACK_DIR}/verifier.json" 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 @@ -298,31 +297,16 @@ jobs: PY cleanup_push_auth() { rm -f "${ASKPASS_PATH}" + rm -rf "${WRITEBACK_DIR}" } 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 + export GITEA_AUDIT_REMOTE_URL="${GITEA_SOURCE_URL}" + export GITEA_AUDIT_BRANCH="${REPLAY_RECEIPT_BRANCH}" + export GITEA_AUDIT_CONTROLLER_SOURCE="${WRITEBACK_DIR}/controller.json" + export GITEA_AUDIT_VERIFIER_SOURCE="${WRITEBACK_DIR}/verifier.json" + export GITEA_AUDIT_CONTROLLER_TARGET="${REPLAY_CONTROLLER_RECEIPT_PATH}" + export GITEA_AUDIT_VERIFIER_TARGET="${REPLAY_VERIFIER_RECEIPT_PATH}" + export GITEA_AUDIT_COMMIT_MESSAGE="chore(mcp): record verified Playwright protocol replay [skip ci] [metadata-only]" + bash scripts/ci/write-gitea-audit-receipts.sh diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 98bd573b1..04276b79f 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -10,16 +10,19 @@ **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 `37 passed`;artifact + replay supply-chain tests與 MCP control-plane/federation/version lifecycle/runner-health 聚焦回歸合計 `89 passed`。Ruff、py_compile、JSON/YAML parse、source-control owner guard與 `git diff --check` 通過。 +- artifact/replay controller + independent verifier `61 passed`、MCP control-plane/federation/version lifecycle/runner-health API `27 passed`、shared audit writeback `4 passed`,本輪聚焦回歸合計 `92 passed`。Ruff、py_compile、JSON/YAML parse、source-control owner guard與 `git diff --check` 通過。 +- Gitea replay `#5183` 已在 source `387f841...` 完成 controller + independent verifier,run `d3b8d15e-e5c6-43c0-b657-f9f23089677c`,audit commit `281d6e0058721dfbf84235d85c2f894a004ceea1`;24 tools、offline clean exit、container removal與所有 runtime/write boundary false 均有 durable receipt。 +- 後續 replay `#5186` 在 source `5eb46f9...` 的 controller/verifier 仍成功,但 final writeback 因 shallow main 與 audit branch `unrelated histories` 失敗;scheduled artifact `#5178` 有相同 terminal。根因不是 MCP replay 或 artifact verification,而是舊 workflow 把 audit branch merge 回 depth-1 main checkout。 +- 新增共用 fail-closed audit writeback:直接 checkout audit branch tip、只 stage兩個 allowlisted receipt、同 run identity與 branch/path/schema 綁定、最多 3 次 normal fast-forward push,無 merge、無 force。真實 temporary bare-Git functional tests涵蓋連續第二/第三次線性 push與拒絕 branch/path/identity mismatch,`4 passed`。 - 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。 +- `#5183` 證明 protocol/schema replay本身與 durable receipt可成立,但最新 source `5eb46f9...` 的 repeatability writeback修正尚未 normal push / Gitea workflow terminal;artifact/replay recurring lanes仍要各取得一筆新的 success terminal,production catalog也尚未讀取該 receipt,因此不得把 compatibility或週期升級 loop標 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。 +- normal push shared writeback修正到 Gitea main,要求 artifact與 replay recurring workflow各自取得線性 audit commit;再更新 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 9656d8c3c..a3f3b4225 100644 --- a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json +++ b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json @@ -30,6 +30,7 @@ "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", + "scripts/ci/write-gitea-audit-receipts.sh", "scripts/ci/wait-host-web-build-pressure.sh", "scripts/ci/notify-awoooi-cicd.sh", "scripts/setup-runner-watchdog.sh", @@ -367,6 +368,7 @@ "config/mcp/playwright-mcp-artifact-policy.json", "scripts/security/external_mcp_artifact_controller.py", "scripts/security/verify_external_mcp_artifact_receipt.py", + "scripts/ci/write-gitea-audit-receipts.sh", "scripts/ci/wait-host-web-build-pressure.sh" ], "next_action": "維持 bounded capacity wait、獨立 verifier 與 mcp-artifact-receipts audit branch;不得啟動 artifact、寫 RAG、切 production route 或推回 deploy main。" @@ -397,6 +399,7 @@ "config/mcp/playwright-mcp-replay-policy.json", "scripts/security/external_mcp_replay_controller.py", "scripts/security/verify_external_mcp_replay_receipt.py", + "scripts/ci/write-gitea-audit-receipts.sh", "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 仍保持停用。" diff --git a/scripts/ci/tests/test_write_gitea_audit_receipts.py b/scripts/ci/tests/test_write_gitea_audit_receipts.py new file mode 100644 index 000000000..2299b7a8b --- /dev/null +++ b/scripts/ci/tests/test_write_gitea_audit_receipts.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Functional tests for linear Gitea audit-branch receipt writeback.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts/ci/write-gitea-audit-receipts.sh" +CONTROLLER_TARGET = ( + "docs/operations/external-mcp-replays/fixture-controller.snapshot.json" +) +VERIFIER_TARGET = ( + "docs/operations/external-mcp-replays/fixture-verifier.snapshot.json" +) + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> str: + completed = subprocess.run( + command, + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + raise AssertionError( + f"command failed: {command}\nstdout={completed.stdout}\nstderr={completed.stderr}" + ) + return completed.stdout.strip() + + +def _receipt(schema: str, run_id: str) -> dict: + return { + "schema_version": schema, + "run_id": run_id, + "trace_id": f"mcp-replay-{run_id}", + "work_item_id": "MCP-REPLAY-PLAYWRIGHT-001", + "status": "fixture_verified", + } + + +def _write_receipts(directory: Path, run_id: str) -> tuple[Path, Path]: + controller = directory / "controller.json" + verifier = directory / "verifier.json" + controller.write_text( + json.dumps( + _receipt("awoooi_external_mcp_replay_receipt_v1", run_id), + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + verifier.write_text( + json.dumps( + _receipt("awoooi_external_mcp_replay_verifier_receipt_v1", run_id), + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return controller, verifier + + +class AuditReceiptWritebackTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.remote = self.root / "remote.git" + self.seed = self.root / "seed" + _run(["git", "init", "--bare", str(self.remote)], cwd=self.root) + self.seed.mkdir() + _run(["git", "init"], cwd=self.seed) + _run(["git", "config", "user.email", "fixture@awoooi.internal"], cwd=self.seed) + _run(["git", "config", "user.name", "Fixture"], cwd=self.seed) + (self.seed / "base.txt").write_text("base\n", encoding="utf-8") + _run(["git", "add", "base.txt"], cwd=self.seed) + _run(["git", "commit", "-m", "base"], cwd=self.seed) + _run(["git", "branch", "-M", "main"], cwd=self.seed) + _run(["git", "remote", "add", "origin", f"file://{self.remote}"], cwd=self.seed) + _run(["git", "push", "origin", "main"], cwd=self.seed) + + _run(["git", "checkout", "-b", "mcp-replay-receipts"], cwd=self.seed) + initial_controller, initial_verifier = _write_receipts( + self.seed, "00000000-0000-4000-8000-000000000000" + ) + (self.seed / CONTROLLER_TARGET).parent.mkdir(parents=True) + (self.seed / CONTROLLER_TARGET).write_bytes(initial_controller.read_bytes()) + (self.seed / VERIFIER_TARGET).write_bytes(initial_verifier.read_bytes()) + _run(["git", "add", "docs"], cwd=self.seed) + _run(["git", "commit", "-m", "initial audit receipt"], cwd=self.seed) + _run(["git", "push", "origin", "mcp-replay-receipts"], cwd=self.seed) + self.initial_audit_sha = _run(["git", "rev-parse", "HEAD"], cwd=self.seed) + + _run(["git", "checkout", "main"], cwd=self.seed) + (self.seed / "main-only.txt").write_text("new main\n", encoding="utf-8") + _run(["git", "add", "main-only.txt"], cwd=self.seed) + _run(["git", "commit", "-m", "advance main"], cwd=self.seed) + _run(["git", "push", "origin", "main"], cwd=self.seed) + + def tearDown(self) -> None: + self.temp.cleanup() + + def _fresh_main_clone(self, name: str) -> Path: + clone = self.root / name + _run( + [ + "git", + "clone", + "--depth=1", + "--branch", + "main", + f"file://{self.remote}", + str(clone), + ], + cwd=self.root, + ) + _run(["git", "config", "user.email", "fixture@awoooi.internal"], cwd=clone) + _run(["git", "config", "user.name", "Fixture"], cwd=clone) + return clone + + def _apply(self, clone: Path, run_id: str) -> str: + source_dir = self.root / f"source-{run_id}" + source_dir.mkdir() + controller, verifier = _write_receipts(source_dir, run_id) + env = { + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + "GITEA_AUDIT_TEST_FILE_REMOTE": "1", + "GITEA_AUDIT_REMOTE_URL": f"file://{self.remote}", + "GITEA_AUDIT_BRANCH": "mcp-replay-receipts", + "GITEA_AUDIT_CONTROLLER_SOURCE": str(controller), + "GITEA_AUDIT_VERIFIER_SOURCE": str(verifier), + "GITEA_AUDIT_CONTROLLER_TARGET": CONTROLLER_TARGET, + "GITEA_AUDIT_VERIFIER_TARGET": VERIFIER_TARGET, + "GITEA_AUDIT_COMMIT_MESSAGE": "fixture audit receipt", + } + return _run(["bash", str(SCRIPT)], cwd=clone, env=env) + + def test_second_and_third_writebacks_are_linear_normal_pushes(self) -> None: + first_clone = self._fresh_main_clone("clone-one") + first_output = self._apply( + first_clone, "11111111-1111-4111-8111-111111111111" + ) + self.assertIn("receipt_writeback=verified_audit_branch_normal_push", first_output) + first_sha = _run( + ["git", "--git-dir", str(self.remote), "rev-parse", "mcp-replay-receipts"], + cwd=self.root, + ) + first_parent = _run( + ["git", "--git-dir", str(self.remote), "rev-parse", f"{first_sha}^"], + cwd=self.root, + ) + self.assertEqual(first_parent, self.initial_audit_sha) + + second_clone = self._fresh_main_clone("clone-two") + second_output = self._apply( + second_clone, "22222222-2222-4222-8222-222222222222" + ) + self.assertIn("receipt_writeback=verified_audit_branch_normal_push", second_output) + second_sha = _run( + ["git", "--git-dir", str(self.remote), "rev-parse", "mcp-replay-receipts"], + cwd=self.root, + ) + second_parent = _run( + ["git", "--git-dir", str(self.remote), "rev-parse", f"{second_sha}^"], + cwd=self.root, + ) + self.assertEqual(second_parent, first_sha) + latest = _run( + [ + "git", + "--git-dir", + str(self.remote), + "show", + f"mcp-replay-receipts:{CONTROLLER_TARGET}", + ], + cwd=self.root, + ) + self.assertEqual( + json.loads(latest)["run_id"], + "22222222-2222-4222-8222-222222222222", + ) + + def test_non_allowlisted_branch_is_rejected_without_push(self) -> None: + clone = self._fresh_main_clone("clone-rejected") + source_dir = self.root / "source-rejected" + source_dir.mkdir() + controller, verifier = _write_receipts( + source_dir, "33333333-3333-4333-8333-333333333333" + ) + env = { + **os.environ, + "GITEA_AUDIT_TEST_FILE_REMOTE": "1", + "GITEA_AUDIT_REMOTE_URL": f"file://{self.remote}", + "GITEA_AUDIT_BRANCH": "main", + "GITEA_AUDIT_CONTROLLER_SOURCE": str(controller), + "GITEA_AUDIT_VERIFIER_SOURCE": str(verifier), + "GITEA_AUDIT_CONTROLLER_TARGET": CONTROLLER_TARGET, + "GITEA_AUDIT_VERIFIER_TARGET": VERIFIER_TARGET, + "GITEA_AUDIT_COMMIT_MESSAGE": "must not commit", + } + completed = subprocess.run( + ["bash", str(SCRIPT)], + cwd=clone, + env=env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(completed.returncode, 2) + self.assertIn("audit_writeback_branch_not_allowlisted", completed.stdout) + + def test_branch_and_receipt_directory_must_match(self) -> None: + clone = self._fresh_main_clone("clone-mismatched-target") + source_dir = self.root / "source-mismatched-target" + source_dir.mkdir() + controller, verifier = _write_receipts( + source_dir, "44444444-4444-4444-8444-444444444444" + ) + env = { + **os.environ, + "GITEA_AUDIT_TEST_FILE_REMOTE": "1", + "GITEA_AUDIT_REMOTE_URL": f"file://{self.remote}", + "GITEA_AUDIT_BRANCH": "mcp-artifact-receipts", + "GITEA_AUDIT_CONTROLLER_SOURCE": str(controller), + "GITEA_AUDIT_VERIFIER_SOURCE": str(verifier), + "GITEA_AUDIT_CONTROLLER_TARGET": CONTROLLER_TARGET, + "GITEA_AUDIT_VERIFIER_TARGET": VERIFIER_TARGET, + "GITEA_AUDIT_COMMIT_MESSAGE": "must not commit", + } + completed = subprocess.run( + ["bash", str(SCRIPT)], + cwd=clone, + env=env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(completed.returncode, 2) + self.assertIn("audit_writeback_branch_target_mismatch", completed.stdout) + + def test_controller_and_verifier_identity_must_match(self) -> None: + clone = self._fresh_main_clone("clone-mismatched-identity") + source_dir = self.root / "source-mismatched-identity" + source_dir.mkdir() + controller, verifier = _write_receipts( + source_dir, "55555555-5555-4555-8555-555555555555" + ) + verifier_payload = json.loads(verifier.read_text(encoding="utf-8")) + verifier_payload["run_id"] = "66666666-6666-4666-8666-666666666666" + verifier.write_text( + json.dumps(verifier_payload, sort_keys=True) + "\n", encoding="utf-8" + ) + env = { + **os.environ, + "GITEA_AUDIT_TEST_FILE_REMOTE": "1", + "GITEA_AUDIT_REMOTE_URL": f"file://{self.remote}", + "GITEA_AUDIT_BRANCH": "mcp-replay-receipts", + "GITEA_AUDIT_CONTROLLER_SOURCE": str(controller), + "GITEA_AUDIT_VERIFIER_SOURCE": str(verifier), + "GITEA_AUDIT_CONTROLLER_TARGET": CONTROLLER_TARGET, + "GITEA_AUDIT_VERIFIER_TARGET": VERIFIER_TARGET, + "GITEA_AUDIT_COMMIT_MESSAGE": "must not commit", + } + completed = subprocess.run( + ["bash", str(SCRIPT)], + cwd=clone, + env=env, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(completed.returncode, 2) + self.assertIn("audit_writeback_receipt_identity_mismatch", completed.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/write-gitea-audit-receipts.sh b/scripts/ci/write-gitea-audit-receipts.sh new file mode 100755 index 000000000..08c29fd26 --- /dev/null +++ b/scripts/ci/write-gitea-audit-receipts.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +# Append two verified JSON receipts to a dedicated Gitea audit branch without +# merging the shallow workflow checkout. Authentication is supplied by the +# caller through GIT_ASKPASS; this script never accepts a token argument. + +required_env=( + GITEA_AUDIT_REMOTE_URL + GITEA_AUDIT_BRANCH + GITEA_AUDIT_CONTROLLER_SOURCE + GITEA_AUDIT_VERIFIER_SOURCE + GITEA_AUDIT_CONTROLLER_TARGET + GITEA_AUDIT_VERIFIER_TARGET + GITEA_AUDIT_COMMIT_MESSAGE +) +for name in "${required_env[@]}"; do + if [ -z "${!name:-}" ]; then + echo "BLOCKER audit_writeback_missing_${name}" + exit 2 + fi +done + +if [ "${GITEA_AUDIT_REMOTE_URL}" = "http://192.168.0.110:3001/wooo/awoooi.git" ]; then + if [ -z "${GIT_ASKPASS:-}" ] || [ ! -x "${GIT_ASKPASS}" ]; then + echo "BLOCKER audit_writeback_askpass_missing_or_not_executable" + exit 2 + fi +elif [[ "${GITEA_AUDIT_REMOTE_URL}" == file://* ]] && \ + [ "${GITEA_AUDIT_TEST_FILE_REMOTE:-0}" = "1" ]; then + : +else + echo "BLOCKER audit_writeback_remote_not_allowlisted" + exit 2 +fi +if ! [[ "${GITEA_AUDIT_BRANCH}" =~ ^mcp-(artifact|replay)-receipts$ ]]; then + echo "BLOCKER audit_writeback_branch_not_allowlisted" + exit 2 +fi +if ! [[ "${GITEA_AUDIT_CONTROLLER_TARGET}" =~ ^docs/operations/external-mcp-(artifacts|replays)/[A-Za-z0-9._-]+-controller\.snapshot\.json$ ]]; then + echo "BLOCKER audit_writeback_controller_target_not_allowlisted" + exit 2 +fi +if ! [[ "${GITEA_AUDIT_VERIFIER_TARGET}" =~ ^docs/operations/external-mcp-(artifacts|replays)/[A-Za-z0-9._-]+-verifier\.snapshot\.json$ ]]; then + echo "BLOCKER audit_writeback_verifier_target_not_allowlisted" + exit 2 +fi +if [ "$(dirname "${GITEA_AUDIT_CONTROLLER_TARGET}")" != "$(dirname "${GITEA_AUDIT_VERIFIER_TARGET}")" ]; then + echo "BLOCKER audit_writeback_target_directory_mismatch" + exit 2 +fi +case "${GITEA_AUDIT_BRANCH}" in + mcp-artifact-receipts) + expected_target_directory="docs/operations/external-mcp-artifacts" + expected_controller_schema="awoooi_external_mcp_artifact_receipt_v1" + expected_verifier_schema="awoooi_external_mcp_artifact_verifier_receipt_v1" + ;; + mcp-replay-receipts) + expected_target_directory="docs/operations/external-mcp-replays" + expected_controller_schema="awoooi_external_mcp_replay_receipt_v1" + expected_verifier_schema="awoooi_external_mcp_replay_verifier_receipt_v1" + ;; +esac +if [ "$(dirname "${GITEA_AUDIT_CONTROLLER_TARGET}")" != "${expected_target_directory}" ]; then + echo "BLOCKER audit_writeback_branch_target_mismatch" + exit 2 +fi +if [ "${#GITEA_AUDIT_COMMIT_MESSAGE}" -gt 200 ] || \ + [[ "${GITEA_AUDIT_COMMIT_MESSAGE}" == *$'\n'* ]] || \ + [[ "${GITEA_AUDIT_COMMIT_MESSAGE}" == *$'\r'* ]]; then + echo "BLOCKER audit_writeback_commit_message_invalid" + exit 2 +fi + +for source_path in \ + "${GITEA_AUDIT_CONTROLLER_SOURCE}" \ + "${GITEA_AUDIT_VERIFIER_SOURCE}"; do + if [ ! -f "${source_path}" ] || [ -L "${source_path}" ]; then + echo "BLOCKER audit_writeback_source_invalid" + exit 2 + fi + source_size="$(wc -c < "${source_path}" | tr -d ' ')" + if [ "${source_size}" -le 0 ] || [ "${source_size}" -gt 4194304 ]; then + echo "BLOCKER audit_writeback_source_size_invalid" + exit 2 + fi +done + +python3 - \ + "${GITEA_AUDIT_CONTROLLER_SOURCE}" \ + "${GITEA_AUDIT_VERIFIER_SOURCE}" \ + "${expected_controller_schema}" \ + "${expected_verifier_schema}" <<'PY' +import json +import sys +from pathlib import Path + +def fail(message: str) -> None: + print(message) + raise SystemExit(2) + + +try: + receipts = [ + json.loads(Path(value).read_text(encoding="utf-8")) for value in sys.argv[1:3] + ] +except (OSError, UnicodeError, json.JSONDecodeError): + fail("BLOCKER audit_writeback_receipt_json_invalid") + +expected_schemas = sys.argv[3:5] +identity_fields = ("run_id", "trace_id", "work_item_id") +for payload, expected_schema in zip(receipts, expected_schemas): + if not isinstance(payload, dict) or payload.get("schema_version") != expected_schema: + fail("BLOCKER audit_writeback_receipt_schema_invalid") + if any(not str(payload.get(field) or "") for field in identity_fields): + fail("BLOCKER audit_writeback_receipt_identity_missing") +if any(receipts[0][field] != receipts[1][field] for field in identity_fields): + fail("BLOCKER audit_writeback_receipt_identity_mismatch") +PY + +remote_name="gitea-audit" +git remote remove "${remote_name}" >/dev/null 2>&1 || true +git remote add "${remote_name}" "${GITEA_AUDIT_REMOTE_URL}" +rm -f \ + "${GITEA_AUDIT_CONTROLLER_TARGET}" \ + "${GITEA_AUDIT_VERIFIER_TARGET}" + +for attempt in 1 2 3; do + if git ls-remote --exit-code --heads "${remote_name}" \ + "refs/heads/${GITEA_AUDIT_BRANCH}" >/dev/null 2>&1; then + git fetch --no-tags --depth=1 "${remote_name}" "${GITEA_AUDIT_BRANCH}" + git checkout --force --detach FETCH_HEAD + fi + + target_directory="$(dirname "${GITEA_AUDIT_CONTROLLER_TARGET}")" + for ancestor in docs docs/operations "${target_directory}"; do + if [ -L "${ancestor}" ]; then + echo "BLOCKER audit_writeback_target_symlink_rejected" + exit 2 + fi + done + mkdir -p "${target_directory}" + for target_path in \ + "${GITEA_AUDIT_CONTROLLER_TARGET}" \ + "${GITEA_AUDIT_VERIFIER_TARGET}"; do + if [ -L "${target_path}" ]; then + echo "BLOCKER audit_writeback_target_symlink_rejected" + exit 2 + fi + done + install -m 0644 \ + "${GITEA_AUDIT_CONTROLLER_SOURCE}" \ + "${GITEA_AUDIT_CONTROLLER_TARGET}" + install -m 0644 \ + "${GITEA_AUDIT_VERIFIER_SOURCE}" \ + "${GITEA_AUDIT_VERIFIER_TARGET}" + git add \ + "${GITEA_AUDIT_CONTROLLER_TARGET}" \ + "${GITEA_AUDIT_VERIFIER_TARGET}" + + while IFS= read -r staged_path; do + if [ "${staged_path}" != "${GITEA_AUDIT_CONTROLLER_TARGET}" ] && \ + [ "${staged_path}" != "${GITEA_AUDIT_VERIFIER_TARGET}" ]; then + echo "BLOCKER audit_writeback_unexpected_staged_path" + exit 2 + fi + done < <(git diff --cached --name-only) + + if git diff --cached --quiet; then + echo "receipt_writeback=no_change" + echo "receipt_branch=${GITEA_AUDIT_BRANCH}" + echo "receipt_commit_sha=$(git rev-parse HEAD)" + exit 0 + fi + git commit -m "${GITEA_AUDIT_COMMIT_MESSAGE}" + if git push "${remote_name}" "HEAD:refs/heads/${GITEA_AUDIT_BRANCH}"; then + echo "receipt_writeback=verified_audit_branch_normal_push" + echo "receipt_branch=${GITEA_AUDIT_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/scripts/security/tests/test_external_mcp_artifact_controller.py b/scripts/security/tests/test_external_mcp_artifact_controller.py index 273b68ef6..121f8ac37 100644 --- a/scripts/security/tests/test_external_mcp_artifact_controller.py +++ b/scripts/security/tests/test_external_mcp_artifact_controller.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[3] CONTROLLER_PATH = ROOT / "scripts/security/external_mcp_artifact_controller.py" POLICY_PATH = ROOT / "config/mcp/playwright-mcp-artifact-policy.json" WORKFLOW_PATH = ROOT / ".gitea/workflows/mcp-external-artifact-mirror.yaml" +AUDIT_WRITEBACK_PATH = ROOT / "scripts/ci/write-gitea-audit-receipts.sh" RUN_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" TRACE_ID = f"mcp-artifact-{RUN_ID}" @@ -130,8 +131,14 @@ class ExternalMcpArtifactControllerTest(unittest.TestCase): self.assertIn("docker logout", raw) self.assertIn("verify_external_mcp_artifact_receipt.py", raw) self.assertIn("RECEIPT_BRANCH: mcp-artifact-receipts", raw) - self.assertIn('git merge --no-edit FETCH_HEAD', raw) - self.assertIn('HEAD:refs/heads/${RECEIPT_BRANCH}', raw) + self.assertIn("bash scripts/ci/write-gitea-audit-receipts.sh", raw) + writeback = AUDIT_WRITEBACK_PATH.read_text(encoding="utf-8") + self.assertIn("git checkout --force --detach FETCH_HEAD", writeback) + self.assertNotIn("git merge --no-edit FETCH_HEAD", writeback) + self.assertIn( + 'git push "${remote_name}" "HEAD:refs/heads/${GITEA_AUDIT_BRANCH}"', + writeback, + ) self.assertNotIn("git push gitea HEAD:main", raw) self.assertIn('cron: "13 2 * * 3"', raw) diff --git a/scripts/security/tests/test_external_mcp_replay_controller.py b/scripts/security/tests/test_external_mcp_replay_controller.py index c267a0550..c4d765a88 100644 --- a/scripts/security/tests/test_external_mcp_replay_controller.py +++ b/scripts/security/tests/test_external_mcp_replay_controller.py @@ -19,6 +19,7 @@ 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" +AUDIT_WRITEBACK_PATH = ROOT / "scripts/ci/write-gitea-audit-receipts.sh" SPEC = importlib.util.spec_from_file_location("external_mcp_replay_controller", CONTROLLER_PATH) assert SPEC and SPEC.loader @@ -389,6 +390,14 @@ class ReceiptAndWorkflowTests(unittest.TestCase): self.assertIn("wait-host-web-build-pressure.sh", raw) self.assertIn("mcp-replay-receipts", raw) self.assertIn("verify_external_mcp_replay_receipt.py", raw) + self.assertIn("bash scripts/ci/write-gitea-audit-receipts.sh", raw) + writeback = AUDIT_WRITEBACK_PATH.read_text(encoding="utf-8").lower() + self.assertIn("git checkout --force --detach fetch_head", writeback) + self.assertNotIn("git merge --no-edit fetch_head", writeback) + self.assertIn( + 'git push "${remote_name}" "head:refs/heads/${gitea_audit_branch}"', + writeback, + ) if __name__ == "__main__":