From b7838b382f7fcc8e1beae82e3ccf29c47385afef Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 31 May 2026 16:47:08 +0800 Subject: [PATCH] =?UTF-8?q?V10.502=20=E4=BF=AE=E6=AD=A3=20AiderHeal=20?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E4=BF=AE=E5=BE=A9=E8=A8=BA=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- .../current_execution_queue_20260524.md | 1 + docs/memory/history_logs.md | 1 + docs/runbooks/aider-heal-110-setup-sop.md | 4 +- services/aider_heal_executor.py | 65 +++++++++++++------ services/code_review_pipeline_service.py | 38 +++++++++-- tests/test_aider_heal_executor.py | 45 +++++++++++++ tests/test_code_review_pipeline_security.py | 40 ++++++++++++ 9 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 tests/test_aider_heal_executor.py diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index acf72f9..acf5460 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.502 修正 AiderHeal 自動修復診斷鏈:先做 ADR-020 檔案白名單再打 110 preflight,`tests/` finding 會明確略過而不誤報 repo preflight;Code Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal;白名單放行 `services/routes/database` 子目錄 Python 檔,preflight 通知帶 stderr/stdout 細節,健康檢查同時接受 `/health` 回 `ok` 與 `healthy`。 - V10.501 新增市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review 安全預覽 gate:只審核 closeout review 後由操作員 shell 完成的 live inventory read-only 摘要,確認 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmation;API 不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler。 - V10.500 新增市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review 安全預覽 gate:只審核 receipt review 通過後的 operator closeout 摘要,確認 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmation;API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler。 - V10.499 新增市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review 安全預覽 gate:只審核操作員 shell writer run 後貼回的 receipt 摘要,確認 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmation;API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler。 diff --git a/config.py b/config.py index 3eef01c..9a48799 100644 --- a/config.py +++ b/config.py @@ -350,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.501" +SYSTEM_VERSION = "V10.502" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index b359ea5..621f2ec 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -97,6 +97,7 @@ - 2026-05-31 起,`V10.499` 新增市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review gate:在 run readiness 通過後只審核操作員 shell writer run 的 receipt 摘要,要求 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmation 對齊;仍不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler,只放行到 closeout review。 - 2026-05-31 起,`V10.500` 新增市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review gate:在 receipt review 通過後只審核 operator closeout 摘要,要求 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmation 對齊;仍不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler,只放行到 read-only post-closeout inventory review。 - 2026-05-31 起,`V10.501` 新增市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review gate:在 closeout review 通過後只審核 operator live inventory read-only 摘要,要求 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmation 對齊;仍不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler,只放行到 candidate queue review handoff。 +- 2026-05-31 起,`V10.502` 修正 AiderHeal 自動修復診斷鏈:先檢查 ADR-020 檔案白名單再執行 110 preflight,`tests/` finding 會明確略過而不誤報 repo preflight;Code Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal;白名單放行 `services/routes/database` 子目錄 Python 檔,preflight 通知帶遠端錯誤細節,健康檢查接受 `/health` 回 `healthy`。 ## 3. 12 Agent 決策信封整合 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 4555f9c..a99d704 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.502 AiderHeal 自動修復診斷鏈修正**: `execute_code_fix()` 改為先檢查 ADR-020 檔案白名單再執行 110 preflight,避免 `tests/...` 這類不得自動修的 finding 被誤報成 `/home/wooo/ewoooc` preflight 失敗;Code Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal;白名單同步放行 `services/routes/database` 子目錄 Python 檔,preflight 通知帶 stderr/stdout 細節,健康檢查接受正式 `/health` 回傳的 `healthy`。 - **V10.501 市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_post_closeout_inventory_review` 與 UI preview,只審核 closeout review 通過後的 operator live inventory read-only 摘要;要求 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmation 對齊,且 API 不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler,只放行到 candidate queue review handoff。 - **V10.500 市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_run_closeout_review` 與 UI preview,只審核 receipt review 通過後的 operator closeout 摘要;要求 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmation 對齊,且 API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler,只放行到 read-only post-closeout inventory review。 - **V10.499 市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_run_receipt_review` 與 UI preview,只審核操作員 shell writer run 後貼回的 receipt 摘要;要求 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmation 對齊,且 API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler,只放行到 closeout review。 diff --git a/docs/runbooks/aider-heal-110-setup-sop.md b/docs/runbooks/aider-heal-110-setup-sop.md index c5bfb2d..b61fab6 100644 --- a/docs/runbooks/aider-heal-110-setup-sop.md +++ b/docs/runbooks/aider-heal-110-setup-sop.md @@ -182,8 +182,8 @@ ssh ollama@192.168.0.188 'docker logs momo-pro-system --since 10m 2>&1 | grep -E | L | 機制 | 觸發點 | |---|------|-------| -| L0 | preflight 路徑檢查 | `aider_heal_executor.py:execute_code_fix` 開頭 | -| L1 | 檔案白名單 `^(services\|routes\|database)/[a-zA-Z0-9_]+\.py$` | `ALLOWED_FILE_PATTERN` | +| L0 | preflight 路徑檢查 | `aider_heal_executor.py:execute_code_fix` 白名單通過後 | +| L1 | 檔案白名單 `^(services\|routes\|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$`,允許子目錄但不允許 `tests/` | `ALLOWED_FILE_PATTERN` | | L2 | diff > 50 行拒絕 push | `AIDER_MAX_DIFF_LINES` | | L3 | 每小時最多 5 次 CODE_FIX | `_enforce_rate_limit` | | L4 | health check 失敗自動 git revert | `_revert_last_commit` | diff --git a/services/aider_heal_executor.py b/services/aider_heal_executor.py index 6571345..ca2f283 100644 --- a/services/aider_heal_executor.py +++ b/services/aider_heal_executor.py @@ -22,10 +22,10 @@ import re import time import threading import shlex +import html import requests -from datetime import datetime, timedelta +from datetime import datetime from typing import Optional, Dict, Any, List -from pathlib import Path from services.logger_manager import SystemLogger from utils.ssh_helper import run_ssh_command @@ -79,8 +79,10 @@ except Exception: TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") # 允許 Aider 修改的路徑(正規表示式) +# ADR-020 白名單允許 services/routes/database 底下的 Python 模組,含子目錄; +# tests/docs/config 等檔案仍需人工處理,避免 Aider 以「修測試」掩蓋產品問題。 ALLOWED_FILE_PATTERN = re.compile( - r"^(services|routes|database)/[a-zA-Z0-9_]+\.py$" + r"^(services|routes|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$" ) # ── 速率控制(執行緒安全) ──────────────────────────────────────────────────── @@ -159,7 +161,7 @@ def _wait_for_health( deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: data = _http_get_json(url) - if data and data.get("status") == "ok": + if data and str(data.get("status", "")).lower() in {"ok", "healthy"}: return True time.sleep(interval_seconds) return False @@ -225,24 +227,57 @@ def execute_code_fix( """ ts = datetime.now().strftime("%Y/%m/%d %H:%M:%S") ctx: Dict[str, Any] = context or {} - repo = Path(REPO_PATH_110).expanduser() + + # L1:檔案白名單。必須先於 110 preflight 執行,否則 tests/docs 等 + # 本來就不能自動修的 finding 會被誤報成「110 repo 不存在」。 + if not ALLOWED_FILE_PATTERN.match(target_file): + reason = f"[AiderHeal] 檔案不在 ADR-020 自動修復白名單:{target_file}" + logger.warning("event=heal_reject reason=path_not_allowed file=%s", target_file) + _notify_telegram( + f"⚠️ AiderHeal 已略過自動修復\n" + f"├ 檔案:{html.escape(target_file[:200])}\n" + f"├ 原因:不在 ADR-020 自動修復白名單(僅允許 services/routes/database 內 Python 檔案)\n" + f"└ 動作:請人工確認 finding,或調整白名單後重跑 Code Review" + ) + return { + "success": False, + "action": "CODE_FIX", + "message": reason, + "commit_sha": None, + "reverted": False, + } # L0:preflight — 確認 110 上的 repo 路徑真的存在且是 git repo # 沒有這個檢查時,後續 cd $REPO_PATH 失敗會被 shell `|| true` 吞掉, # 導致整條 pipeline 走完卻 0 次 push,靜默 100% no-op(2026-05-03 實測) - rc_pre, _, _ = _ssh_exec( - f"test -d {shlex.quote(REPO_PATH_110)}/.git", timeout=10 + preflight_cmd = ( + f"test -d {shlex.quote(REPO_PATH_110)} && " + f"test -d {shlex.quote(REPO_PATH_110)}/.git && " + f"cd {shlex.quote(REPO_PATH_110)} && " + f"git rev-parse --is-inside-work-tree 2>&1" ) + rc_pre, out_pre, err_pre = _ssh_exec(preflight_cmd, timeout=10) if rc_pre != 0: + preflight_detail = (err_pre or out_pre or "").strip() + if not preflight_detail: + preflight_detail = "SSH 逾時、repo 路徑不存在,或目標不是 git repo" msg = ( f"[AiderHeal] preflight 失敗:110 主機上 {REPO_PATH_110} 不存在或不是 git repo。" - f"請檢查 AIDER_REPO_PATH env / 在 110 上 git clone repo(見 ADR-020 SOP)" + f"請檢查 AIDER_REPO_PATH env / 在 110 上 git clone repo(見 ADR-020 SOP)。" + f"detail={preflight_detail[:300]}" + ) + logger.error( + "event=preflight_failed path=%s rc=%s stderr=%s stdout=%s", + REPO_PATH_110, + rc_pre, + err_pre, + out_pre, ) - logger.error("event=preflight_failed path=%s", REPO_PATH_110) _notify_telegram( f"🚨 AiderHeal preflight 失敗\n" f"├ 路徑:{REPO_PATH_110}\n" f"├ 主機:{HEAL_SSH_HOST}\n" + f"├ 細節:{html.escape(preflight_detail[:240])}\n" f"└ 動作:請依 ADR-020 SOP 在 110 上 clone repo 並設好 push 權限" ) return { @@ -253,18 +288,6 @@ def execute_code_fix( "reverted": False, } - # L1:檔案白名單 - if not ALLOWED_FILE_PATTERN.match(target_file): - reason = f"[AiderHeal] 檔案不在白名單:{target_file}" - logger.warning("event=heal_reject reason=%s file=%s", reason, target_file) - return { - "success": False, - "action": "CODE_FIX", - "message": reason, - "commit_sha": None, - "reverted": False, - } - # L3:速率限制 if not _enforce_rate_limit(): reason = f"[AiderHeal] 每小時上限 {MAX_HOURLY_FIX} 次,跳過" diff --git a/services/code_review_pipeline_service.py b/services/code_review_pipeline_service.py index 5d2e33d..de6abfd 100644 --- a/services/code_review_pipeline_service.py +++ b/services/code_review_pipeline_service.py @@ -97,12 +97,20 @@ CODE_REVIEW_HERMES_LLM_SCAN_ENABLED = ( INTERNAL_TOKEN = os.getenv("INTERNAL_WEBHOOK_TOKEN", "") AUTO_FIX_ENABLED = os.getenv("CODE_REVIEW_AUTO_FIX_ENABLED", "true").lower() == "true" ALLOW_INSECURE_WEBHOOK = os.getenv("MOMO_ALLOW_INSECURE_INTERNAL_WEBHOOK_FOR_DEV", "").lower() == "true" +AIDER_AUTO_FIX_FILE_PATTERN = re.compile( + r"^(services|routes|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$" +) # Phase 7 Frontier 升級 feature flag — 預設 OFF;啟用後只作 Ollama 失敗後的雲端備援。 CODE_REVIEW_USE_CLAUDE = os.getenv("CODE_REVIEW_USE_CLAUDE", "false").lower() == "true" CLAUDE_REVIEW_MODEL = os.getenv("CLAUDE_MODEL", "claude-opus-4-7") +def _aider_allowed_fix_files(files: List[str]) -> List[str]: + """回傳 ADR-020 允許交給 AiderHeal 自動修復的檔案。""" + return [f for f in files if AIDER_AUTO_FIX_FILE_PATTERN.match(f or "")] + + # ═══════════════════════════════════════════════════════════════════════════════ # Pipeline Class # ═══════════════════════════════════════════════════════════════════════════════ @@ -821,6 +829,7 @@ class CodeReviewPipeline: def _nemotron_dispatch(self, ea: Dict, findings: List[Dict]) -> Dict: auto_fix = ea.get("auto_fix", False) fix_files = ea.get("fix_files", []) + allowed_fix_files = _aider_allowed_fix_files(fix_files) priority_map = {"critical": 1, "high": 2, "medium": 3, "low": 4} priority_num = priority_map.get(ea.get("priority", "low"), 4) @@ -841,13 +850,20 @@ class CodeReviewPipeline: ('code_review_fix', :desc, :status, :priority, :meta, NOW()) """), { "desc": desc[:500], - "status": "auto_pending" if auto_fix else "auto_disabled", + "status": ( + "auto_pending" + if auto_fix and fpath in allowed_fix_files + else "auto_skipped_whitelist" + if auto_fix + else "auto_disabled" + ), "priority": priority_num, "meta": json.dumps({ "pipeline_id": self.pipeline_id, "commit_sha": self.commit_sha, "file": fpath, "auto_fix": auto_fix, + "aider_auto_fix_allowed": fpath in allowed_fix_files, "ea_priority": ea.get("priority"), "findings": related, }, ensure_ascii=False), @@ -861,12 +877,20 @@ class CodeReviewPipeline: session.close() # 觸發 AiderHeal(非阻塞) - if auto_fix and fix_files: + if auto_fix and allowed_fix_files: self.state["auto_fix_triggered"] = True self._sync_global() - self._trigger_aider_heal(findings, fix_files) + self._trigger_aider_heal(findings, allowed_fix_files) + elif auto_fix and fix_files: + self.state["auto_fix_skipped_whitelist"] = True + self._sync_global() + logger.info("[CodeReview] AiderHeal skipped: no files matched ADR-020 whitelist files=%s", fix_files) - return {"actions": actions_created, "auto_fix": auto_fix} + return { + "actions": actions_created, + "auto_fix": auto_fix, + "aider_fix_files": allowed_fix_files, + } def _trigger_aider_heal(self, findings: List[Dict], fix_files: List[str]): """非阻塞觸發 AiderHeal 自動修復""" @@ -965,6 +989,8 @@ class CodeReviewPipeline: sev = self.state["severity_summary"] priority = ea.get("priority", "medium") auto_fix = ea.get("auto_fix", False) + fix_files = ea.get("fix_files", []) + allowed_fix_files = _aider_allowed_fix_files(fix_files) icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🟢"}.get(priority, "🟡") top_issues = [f for f in findings if f.get("severity") in ("CRITICAL", "HIGH")][:3] @@ -986,8 +1012,10 @@ class CodeReviewPipeline: if openclaw_report: msg += f"\n{openclaw_report[:400]}\n" - if auto_fix: + if auto_fix and allowed_fix_files: fix_status = "🔧 已觸發自動修復(AiderHeal)" + elif auto_fix and fix_files: + fix_status = "⚠️ 不在自動修復白名單,需人工處理" elif sev['critical'] + sev['high'] + sev['medium'] + sev['low'] == 0: fix_status = "✅ 無需修復動作" else: diff --git a/tests/test_aider_heal_executor.py b/tests/test_aider_heal_executor.py new file mode 100644 index 0000000..ee4835a --- /dev/null +++ b/tests/test_aider_heal_executor.py @@ -0,0 +1,45 @@ +def test_aider_heal_allowed_file_pattern_accepts_nested_service_modules(): + from services import aider_heal_executor as svc + + assert svc.ALLOWED_FILE_PATTERN.match("services/market_intel/phase.py") + assert svc.ALLOWED_FILE_PATTERN.match("routes/market_intel_mcp_run_routes.py") + assert svc.ALLOWED_FILE_PATTERN.match("database/ai_models.py") + assert not svc.ALLOWED_FILE_PATTERN.match("tests/test_market_intel_skeleton.py") + assert not svc.ALLOWED_FILE_PATTERN.match("config.py") + + +def test_aider_heal_rejects_disallowed_file_before_ssh(monkeypatch): + from services import aider_heal_executor as svc + + messages = [] + + def fail_if_called(*_args, **_kwargs): + raise AssertionError("SSH preflight should not run for disallowed files") + + monkeypatch.setattr(svc, "_ssh_exec", fail_if_called) + monkeypatch.setattr(svc, "_notify_telegram", messages.append) + + result = svc.execute_code_fix( + error_type="code_review_security", + error_message="疑似硬編碼敏感字串", + target_file="tests/test_market_intel_skeleton.py", + ) + + assert result["success"] is False + assert result["commit_sha"] is None + assert result["reverted"] is False + assert "不在 ADR-020 自動修復白名單" in result["message"] + assert messages + assert "已略過自動修復" in messages[0] + + +def test_aider_heal_health_accepts_current_healthy_status(monkeypatch): + from services import aider_heal_executor as svc + + monkeypatch.setattr(svc, "_http_get_json", lambda _url: {"status": "healthy"}) + + assert svc._wait_for_health( + "https://mo.wooo.work/health", + timeout_seconds=1, + interval_seconds=0, + ) is True diff --git a/tests/test_code_review_pipeline_security.py b/tests/test_code_review_pipeline_security.py index 926d7ac..7118a0f 100644 --- a/tests/test_code_review_pipeline_security.py +++ b/tests/test_code_review_pipeline_security.py @@ -130,3 +130,43 @@ def test_guard_upgrades_llm_human_review_true_to_false(monkeypatch): assert guarded["auto_fix"] is True assert guarded["human_review_needed"] is False + + +def test_complete_notification_marks_non_whitelisted_aider_files(monkeypatch): + """tests/docs/config 等 finding 不應在完成通知中宣稱 AiderHeal 會自動修復。""" + import services.telegram_templates as telegram_templates + import services.code_review_pipeline_service as module + + messages = [] + monkeypatch.setattr(telegram_templates, "_send_telegram_raw", messages.append) + + pipeline = module.CodeReviewPipeline( + "abcdef123456", + ["tests/test_market_intel_skeleton.py"], + ) + pipeline.state["severity_summary"] = { + "critical": 0, + "high": 1, + "medium": 0, + "low": 0, + } + + pipeline._notify_complete( + [ + { + "severity": "HIGH", + "description": "疑似硬編碼敏感字串", + "file": "tests/test_market_intel_skeleton.py", + } + ], + "", + { + "priority": "high", + "auto_fix": True, + "fix_files": ["tests/test_market_intel_skeleton.py"], + }, + ) + + assert messages + assert "不在自動修復白名單" in messages[0] + assert "已觸發自動修復" not in messages[0]