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]