From 8745bda8e44cbe4c31ae50edbe0e3db42543dcd2 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:07:54 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20verdict=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=A0=87=E8=AE=B0=20+=20=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=99=8D=E7=BA=A7'=E9=9C=80=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9'=20(issue=20#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表头严重度分布之前靠正则猜报告正文, 格式稍变即误读 (把表头/枚举句数字 当计数, 真实案例正文 P0=0 却渲染 P0×2) 或读不到 (fallback 解析失败)。 mcp-server 侧: - prompt 要求报告以严格单行 'VERDICT: P0=n P1=n P2=n MERGE=yes|no' 收尾 - _append_verdict_marker(): 自尾向头找该行, 转写成文末 HTML 注释标记 zob-verdict (GitHub 评论不渲染); 模型没按格式输出则原样返回不编造 review-gate 侧: - parse_severity_counts 优先读 zob-verdict 标记 (全文搜索), 无标记退正文 正则兜底 - verdict_from_counts: 解析失败 → unresolved ('❓ 需人工核对') 而非误标 concerns — 假红灯曾致下游指挥 agent 停工等人工 - 评论表头三态渲染; README/模块注释同步 测试: review-gate +4 例 (标记优先/残缺回退/仅标记/unresolved 渲染) + mcp-server +6 例 (转写/大小写/自尾向头/幂等/空安全/无行不编造) --- packages/mcp-server/zcode-mcp-server | 44 ++++++++++++++++- packages/review-gate/README.md | 8 +-- packages/review-gate/zcode-review-gate | 43 +++++++++++----- tests/test_review_gate.py | 41 +++++++++++++--- tests/test_security_review.py | 68 ++++++++++++++++++++++++-- 5 files changed, 179 insertions(+), 25 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 5b315f7..8e682bb 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1320,7 +1320,9 @@ def tool_zcode_pr_review(args): "规则: 绝对不要修改、创建或删除任何文件 (写/执行工具已被物理禁用);" "不要提出\"帮你修复\"的提议; 只输出审查报告。\n" "输出格式: 先给汇总 (P0/P1/P2 各几条 + findings 确认/误报/存疑各几条" - " + 能否合并的结论), 再逐条详述。用中文。" + " + 能否合并的结论), 再逐条详述。用中文。\n" + "报告最后一行必须是严格的单行结论 (供机器解析, 字段不得增删改名):\n" + "VERDICT: P0=<非负整数> P1=<非负整数> P2=<非负整数> MERGE=" ) if focus: prompt += f"\n额外审查重点: {focus}。" @@ -1330,7 +1332,12 @@ def tool_zcode_pr_review(args): log(f"调用 zcode PR 复核 (yolo+只读黑名单, {len(changed)} 个改动文件)") env = _merge_env_with_creds(load_zcode_credentials()) - return _run_zcode_headless(cmd, env, _review_timeout()) + result = _run_zcode_headless(cmd, env, _review_timeout()) + # issue #16: 报告尾附机器可读 verdict 标记, 下游 (review-gate) 直读不猜 + if not result.get("isError"): + result["content"][0]["text"] = _append_verdict_marker( + result["content"][0]["text"]) + return result finally: if tmp_path: try: @@ -1339,6 +1346,39 @@ def tool_zcode_pr_review(args): pass +_RE_FINAL_VERDICT = re.compile( + r"^\s*VERDICT:\s*P0\s*=\s*(\d+)\s+P1\s*=\s*(\d+)\s+" + r"P2\s*=\s*(\d+)\s+MERGE\s*=\s*(yes|no)\s*$", + re.IGNORECASE, +) + + +def _append_verdict_marker(text): + """PR 复核报告尾附机器可读 verdict 标记 (issue #16)。 + + review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即 + 误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。 + prompt 已要求报告以严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" + 收尾; 这里自尾向头找该行, 命中则在文末追加 HTML 注释标记 (GitHub + 评论里不渲染, 人读无感): + + 找不到该行 (模型没按格式输出) 原样返回 — 下游走旧正则兜底 + 人工核对 + 降级, 不编造。已带标记 (重入) 不重复追加。 + """ + if not text or "zob-verdict:" in text: + return text + for line in reversed(text.splitlines()): + m = _RE_FINAL_VERDICT.match(line) + if m: + p0, p1, p2 = int(m.group(1)), int(m.group(2)), int(m.group(3)) + merge = "true" if m.group(4).lower() == "yes" else "false" + marker = (f'') + log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge}") + return text.rstrip("\n") + "\n" + marker + return text + + def _env_int(name, default, maximum=None): """从环境变量读整数, 失败用默认值; maximum 给上界 (复审 R2 P2-5: 之前只靠调用点 max() 兜下限, env 误设天文数字没有防线)。""" diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index 37f9799..a4753fe 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -162,9 +162,11 @@ zcode。 - **评论 at-least-once**:评论请求发出后响应丢失(超时/连接断开)会按失败 重试,而 GitHub issue comments 没有幂等键——极端情况下同一 head 可能 出现重复评论,属已知限制(方向仍是宁多勿漏)。 -- **verdict 依赖报告文本解析**:从报告开头解析 `P0/P1/P2` 条数得出 - pass/concerns;解析失败时 fail-safe 为 **concerns**(宁错拦不错放), - 评论里会标注"严重度分布解析失败,请人工核对"。 +- **verdict 依赖报告文本解析**:优先读报告尾的 `zob-verdict` 结构化标记 + (mcp-server 要求 zcode 以严格单行 `VERDICT: P0=n P1=n P2=n MERGE=yes|no` + 收尾并转写成 HTML 注释,issue #16);标记缺失时退回报告开头的正则解析。 + 两者都失败时 verdict 为 **需人工核对**(❓ unresolved)——不再误标 + concerns:假红灯曾让下游把"可以合并"误读成"闸门卡死"。 - **单线程串行**:逐仓逐 PR 串行审查;并发安全靠 bridge mcp-server 侧的 跨进程文件锁兜底(多实例同时跑也不会并发打爆 zcode 限流)。 - **fork PR**:走 `refs/pull/{n}/head` 拉取,无需加 fork 远端; diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index 21d0f58..ab9ed55 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -15,7 +15,9 @@ zcode-review-gate — PR 自动审查闸门守护进程 (zcode-open-bridge 第 4 - token 不落盘: 只经 git≥2.31 的 GIT_CONFIG_COUNT/KEY/VALUE 环境变量 逐命令进程内注入 http.extraHeader (env 只对本用户可见, 优于 argv); clone URL / .git/config / state / config 文件里都没有 token。 - - verdict 解析是确定性 fail-safe: 严重度分布解析不出来一律按 concerns。 + - verdict 解析: 优先读报告尾 zob-verdict 结构化标记 (mcp-server 侧从 + 严格单行 VERDICT 结论转写), 无标记退正文正则; 都失败标 unresolved + ("需人工核对") 而非误标 concerns (issue #16: 假红灯曾致下游停工)。 依赖: 仅 Python3 标准库 (零第三方依赖) 日志: 全部走 stderr (带时间戳前缀) @@ -56,12 +58,18 @@ PULLS_MAX_PAGES = 10 # 分页兜底: 单仓 open PR 超过 1000 个极罕 VERDICT_SCAN_HEAD = 3000 # verdict 只扫报告前 N 字符 (汇总段在开头, 防正文干扰) MIN_COMMENT_BODY = 2000 # comment.max_body 下限 (小于模板开销的极端配置钳到这) -# 严重度分布解析: 在报告开头找 "P0 ... <数字>" 三段。确定性 fail-safe: -# 宁可解析失败按 concerns, 也不猜。P 必须大写 (不用 IGNORECASE), +# 严重度分布解析 (issue #16): 优先读报告尾的 zob-verdict 结构化标记 — +# 由 mcp-server 侧从严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" 结论 +# 转写, 解析端直读不猜。无标记再退回正文正则 (下方三段)。 +# 正文正则: 在报告开头找 "P0 ... <数字>" 三段。确定性 fail-safe: 宁可 +# 解析失败标"需人工核对", 也不猜。P 必须大写 (不用 IGNORECASE), # 防正文里 "p0" 之类的偶然命中。 # [^\dP] 排除 P 是关键: "P0/P1/P2 各 0/0/2" 枚举格式里 P0 后面紧跟 # "/P1", 若不排除 P 会把 P1 的数字算到 P0 头上 — 枚举格式统一解析失败 # 回 None (评论标注"解析失败请人工核对"), 也不拿错数字。 +_RE_ZOB_VERDICT = re.compile( + r"zob-verdict:\{\"P0\":(\d+),\"P1\":(\d+),\"P2\":(\d+)," + r"\"merge\":(?:true|false)\}") _RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d+)(?!\d)") _RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d+)(?!\d)") _RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d+)(?!\d)") @@ -498,7 +506,15 @@ def run_review(cfg, clone, base_ref, head_sha): # verdict 解析 (确定性, fail-safe) # ============================================================ def parse_severity_counts(report): - """从报告前 VERDICT_SCAN_HEAD 字符解析 (P0, P1, P2) 条数; 任一缺失 → None。""" + """解析 (P0, P1, P2) 条数; 任一缺失 → None。 + + 优先读报告尾的 zob-verdict 结构化标记 (issue #16: mcp-server 侧从 + 严格单行 VERDICT 结论转写, 全文搜索); 无标记退回正文前 + VERDICT_SCAN_HEAD 字符的正则猜测 (自由 prose, 格式敏感, 仅兜底)。 + """ + m = _RE_ZOB_VERDICT.search(report or "") + if m: + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) head = (report or "")[:VERDICT_SCAN_HEAD] counts = [] for pattern in (_RE_P0, _RE_P1, _RE_P2): @@ -510,9 +526,11 @@ def parse_severity_counts(report): def verdict_from_counts(counts): - """counts=None (解析失败) → concerns (fail-safe); P0/P1>0 → concerns; 否则 pass。""" + """counts=None (解析失败) → unresolved (issue #16: 表头标"需人工核对", + 不再误标 concerns — 假红灯曾致下游指挥 agent 停工); P0/P1>0 → + concerns; 否则 pass。""" if counts is None: - return "concerns" + return "unresolved" return "concerns" if (counts[0] > 0 or counts[1] > 0) else "pass" @@ -526,13 +544,16 @@ def build_comment_body(verdict, counts, head_sha, report, max_body): max_body = max(_as_int(max_body, 60000), MIN_COMMENT_BODY) def assemble(body_text): - icon = "✅ pass" if verdict == "pass" else "⚠️ concerns" + icon = {"pass": "✅ pass", "concerns": "⚠️ concerns"}.get( + verdict, "❓ 需人工核对") if counts is not None: sev = f"P0 × {counts[0]} · P1 × {counts[1]} · P2 × {counts[2]}" else: - sev = "解析失败(严重度分布解析失败,请人工核对)" - conclusion = ("未发现阻断问题,可以合并" if verdict == "pass" - else "存在需关注的问题,合并前请处理") + sev = "解析失败(结构化标记与正文汇总均未解析出,请人工核对)" + conclusion = { + "pass": "未发现阻断问题,可以合并", + "concerns": "存在需关注的问题,合并前请处理", + }.get(verdict, "严重度分布未解析出,请人工核对报告正文") return ( f"## ZCode Review Gate:{icon}\n\n" "| 项 | 值 |\n" @@ -736,7 +757,7 @@ def process_pr(cfg, state, token, owner, repo, clone, info): counts = parse_severity_counts(report) verdict = verdict_from_counts(counts) if counts is None: - log(f"{key}: verdict=concerns (严重度分布解析失败, fail-safe 请人工核对)") + log(f"{key}: verdict=unresolved (解析失败, 请人工核对)") else: log(f"{key}: verdict={verdict} " f"(P0={counts[0]} P1={counts[1]} P2={counts[2]})") diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 766f05b..8537b1f 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -12,8 +12,9 @@ - 去重状态机 (同 sha 跳过 / 新 sha 重审 / failed 退避 / comment_failed 退避 / gave_up 跳过 / 新 sha 复活) - 退避数学 min(base*2**(attempts-1), max) + 超限 gave_up + comment_failed 保 verdict - - verdict 解析 (三段齐 / 缺一段 None / 枚举格式 None / 无关数字 fail-safe 钉住 - / p0>0 concerns / 全 0 pass / None concerns) + - verdict 解析 (issue #16: zob-verdict 结构化标记优先 / 正文正则兜底; + 三段齐 / 缺一段 None / 枚举格式 None / 无关数字 fail-safe 钉住 + / p0>0 concerns / 全 0 pass / None unresolved"需人工核对") - 评论 body 超 max_body 截断 + max_body 钳下限 - state 原子写 (os.replace 被调) + 损坏恢复 + 往返 - 配置默认值 + env 覆盖 + 下限钳制 @@ -346,8 +347,8 @@ def test_parse_enumeration_format_returns_none(self): # 也不拿错数字 text = "汇总: P0/P1/P2 各 0/0/2 条, findings 确认 1 条" self.assertIsNone(self.mod.parse_severity_counts(text)) - # 对应的 verdict 走向: None → concerns (fail-safe) - self.assertEqual(self.mod.verdict_from_counts(None), "concerns") + # 对应的 verdict 走向: None → unresolved (issue #16: 不再误标 concerns) + self.assertEqual(self.mod.verdict_from_counts(None), "unresolved") def test_parse_unrelated_number_fail_safe(self): # "P0 级问题参见 2024 年报": 无关数字仍会被当作计数 (2024), @@ -373,8 +374,27 @@ def test_verdict_p1_concerns(self): def test_verdict_all_zero_pass(self): self.assertEqual(self.mod.verdict_from_counts((0, 0, 5)), "pass") - def test_verdict_none_concerns(self): - self.assertEqual(self.mod.verdict_from_counts(None), "concerns") + def test_verdict_none_unresolved(self): + # issue #16: 解析失败 → "需人工核对" 而非 concerns — 假红灯曾致 + # 下游指挥 agent 看到表头 P0×2 停工等人工, 实际正文判定可合并 + self.assertEqual(self.mod.verdict_from_counts(None), "unresolved") + + def test_marker_priority_over_prose(self): + # issue #16: zob-verdict 结构化标记优先 — 正文有误导性计数也不采信 + text = ("汇总: P0: 2 条, P1: 2 条, P2: 3 条\n详情...\n" + '') + self.assertEqual(self.mod.parse_severity_counts(text), (0, 0, 5)) + + def test_marker_malformed_falls_back_to_prose(self): + # 标记残缺 (缺 P1/P2/merge 字段) → 不匹配, 退回正文正则 + text = '汇总: P0: 1 条, P1: 2 条, P2: 3 条\nzob-verdict:{"P0":9}' + self.assertEqual(self.mod.parse_severity_counts(text), (1, 2, 3)) + + def test_marker_only_no_prose_summary(self): + # 正文无 prose 汇总, 仅靠标记也能解析 (对报告格式变化免疫) + text = ('逐条详述...\n' + '') + self.assertEqual(self.mod.parse_severity_counts(text), (1, 0, 2)) # ============================================================ @@ -401,6 +421,15 @@ def test_concerns_parse_failure_body(self): self.assertIn("解析失败", body) self.assertIn("合并前请处理", body) + def test_unresolved_parse_failure_body(self): + # issue #16: 解析失败 → ❓ 需人工核对, 不显示 concerns 假红灯 + body = self.mod.build_comment_body( + "unresolved", None, self.SHA, "r", 60000) + self.assertIn("❓ 需人工核对", body) + self.assertIn("解析失败", body) + self.assertIn("请人工核对报告正文", body) + self.assertNotIn("concerns", body) + def test_truncation_over_max_body(self): report = "报" * 100000 body = self.mod.build_comment_body( diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 01317b4..998abbd 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1028,9 +1028,10 @@ def setUpClass(cls): cls.mod = _load_mcp_module() def _patch(self, changed=None, diff_text="diff --git a/app.py b/app.py\n+new line\n", - rev_ok=True): + rev_ok=True, zcode_report="PR 报告"): """patch git/mimosa/zcode 三路。changed=None 表示非 git 仓库; - rev_ok=False 表示所有 rev 解析失败 (测 base 自动探测失败)。""" + rev_ok=False 表示所有 rev 解析失败 (测 base 自动探测失败); + zcode_report 控制假 zcode 返回的报告正文 (测 verdict 标记转写)。""" import tempfile mod = self.mod proj = tempfile.mkdtemp(prefix="zcode-pr-proj-") @@ -1061,7 +1062,8 @@ def fake_run(cmd, *a, **kw): return _FakeCompletedProcess(0, diff_text, "") captured["cmd"] = cmd # zcode 调用 return _FakeCompletedProcess( - 0, json.dumps({"response": "PR 报告"}, ensure_ascii=False), "") + 0, json.dumps({"response": zcode_report}, ensure_ascii=False), + "") def fake_find_root(): return "/fake/mimosa" @@ -1219,6 +1221,66 @@ def test_pr10_attachment_tmpfile_cleaned(self): self.assertIn("zcode-pr-review-", attach_path) self.assertFalse(os.path.exists(attach_path), "PR 附件临时文件应被清理") + def test_pr11_verdict_marker_appended(self): + """PR11: 报告以严格 VERDICT 行收尾 → 尾部转写 zob-verdict 标记 + (issue #16: 下游 review-gate 直读标记, 不再正则猜正文)""" + report = ("汇总: P0: 0 条, P1: 1 条, P2: 2 条\n详述...\n" + "VERDICT: P0=0 P1=1 P2=2 MERGE=no") + mod, saved, proj, _ = self._patch(changed=["a.py"], zcode_report=report) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + text = result["content"][0]["text"] + self.assertIn(report, text) # 原文保留 + self.assertIn('', text) # 标记转写正确 + self.assertTrue(text.rstrip().endswith("-->")) # 标记在最尾 + + def test_pr12_no_verdict_line_unchanged(self): + """PR12: 报告没按格式输出 VERDICT 行 → 原样返回, 不编造标记 + (下游走旧正则兜底 + 人工核对降级)""" + report = "汇总: P0: 0 条, P1: 0 条, P2: 2 条\n一切正常, 无 VERDICT 行" + mod, saved, proj, _ = self._patch(changed=["a.py"], zcode_report=report) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertEqual(result["content"][0]["text"], report) + + +class TestVerdictMarker(_EnvGuard): + """_append_verdict_marker 单元行为 (issue #16)""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def test_yes_maps_to_true(self): + out = self.mod._append_verdict_marker( + "VERDICT: P0=0 P1=0 P2=0 MERGE=YES") # 大小写不敏感 + self.assertIn('"merge":true', out) + + def test_verdict_line_found_from_tail(self): + # 正文里出现形似的 VERDICT 行 (带前缀文字不匹配行首), 只有真正的 + # 独立单行才算; 取自尾向头的最后一个 + report = "VERDICT: P0=9 P1=9 P2=9 MERGE=yes\n中间正文\n说明: VERDICT 不在此行\n" + out = self.mod._append_verdict_marker(report) + # 第一行是合法行, 但 reversed 先遇到的是 "说明: ..." (不匹配), + # 再往前 "中间正文" (不匹配), 最终命中第一行 + self.assertIn('"P0":9', out) + + def test_idempotent_no_duplicate(self): + once = self.mod._append_verdict_marker("VERDICT: P0=0 P1=0 P2=0 MERGE=yes") + twice = self.mod._append_verdict_marker(once) + self.assertEqual(once, twice) + self.assertEqual(twice.count("zob-verdict:"), 1) + + def test_empty_and_none_safe(self): + self.assertEqual(self.mod._append_verdict_marker(""), "") + self.assertIsNone(self.mod._append_verdict_marker(None)) + class TestGitTimeouts(_EnvGuard): """_git 超时分档 (整体 review P2-6): 元数据类 15s, diff 类 60s""" From 7dee8a839cd4a6a15eabb3aab98ef8bdf1ede685 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:22:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E7=8B=97=E9=A3=9F=20review=20?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=20=E2=80=94=20verdict=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E9=98=B2=E4=BC=AA=E9=80=A0=20+=20merge=20=E5=AD=97=E6=AE=B5=20?= =?UTF-8?q?+=20=E6=88=AA=E6=96=AD=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19 用 zcode_pr_review 自审, 报告自身即复现了 P1-1 (正文引用 zob-verdict 字样 → 幂等短路 → 真标记未追加), 据此修复: - P1-1 标记伪造链 (被审 PR 预埋标记骗表头): ① mcp-server 幂等检查改为「文末完整匹配 」, 裸串/正文引用不再抑制追加 ② 转写前对正文完整注释形态标记消毒 ([已消毒的 zob-verdict 引用]), 唯一可信来源 = 文末追加的真标记 ③ review-gate 解析只认完整注释形态 + 取最后一个匹配 (真标记恒在文末) - P2-1 标记 merge=no 时全 0 计数不给 pass (表头'可以合并'与报告结论 矛盾是 issue #16 误导残余) — verdict_from_counts 增 merge_from_marker - P2-2 超长截断日志提示尾部 VERDICT 行可能被切; README 记录评论截断 不含标记的限制 测试: review-gate +3 例 (末匹配胜出/裸串不认/merge 覆盖), mcp-server +2 例 (裸串不抑制/伪造注释被消毒); 全绿。 --- packages/mcp-server/zcode-mcp-server | 30 +++++++++++---- packages/review-gate/README.md | 8 ++++ packages/review-gate/zcode-review-gate | 52 ++++++++++++++++++++------ tests/test_review_gate.py | 27 +++++++++++++ tests/test_security_review.py | 21 +++++++++++ 5 files changed, 119 insertions(+), 19 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 8e682bb..3ebd792 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -974,7 +974,9 @@ def _run_zcode_headless(cmd, env, timeout): max_output = max(10_000, _env_int( "ZCODE_BRIDGE_MAX_OUTPUT", 10_000_000, maximum=100_000_000)) if len(text) > max_output: - log(f"⚠ zcode 输出超上限 ({len(text)} > {max_output} 字符), 截断") + log(f"⚠ zcode 输出超上限 ({len(text)} > {max_output} 字符), 截断" + " (报告尾部 VERDICT 行可能被切掉 → 下游无 zob-verdict 标记," + " 走正则兜底)") text = text[:max_output] + ( f"\n\n[... 输出超 {max_output} 字符已截断; " "超大审查建议拆分文件分批进行 ...]") @@ -1352,6 +1354,16 @@ _RE_FINAL_VERDICT = re.compile( re.IGNORECASE, ) +# 文末合法标记的完整形态 (狗食 review P1-1: 幂等只认"最后一行完整注释", +# 正文里被复述/预埋的标记串不抑制追加) +_RE_ZOB_MARK_TAIL = re.compile( + r"\s*\Z") +# 正文中出现的完整注释形态标记 (被审代码可预埋) → 转写前消毒, 保证唯一 +# 可信来源是文末追加的真标记 +_RE_ZOB_MARK_ANY = re.compile( + r"") + def _append_verdict_marker(text): """PR 复核报告尾附机器可读 verdict 标记 (issue #16)。 @@ -1359,13 +1371,15 @@ def _append_verdict_marker(text): review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即 误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。 prompt 已要求报告以严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" - 收尾; 这里自尾向头找该行, 命中则在文末追加 HTML 注释标记 (GitHub - 评论里不渲染, 人读无感): + 收尾; 这里自尾向头找该行, 命中则先给正文消毒 (把被审代码预埋/被复述 + 的完整注释形态标记改写掉, 狗食 review P1-1: 被审 PR 作者可在代码里 + 伪造标记骗过下游), 再在文末追加 HTML 注释标记 (GitHub 评论里不渲染, + 人读无感): 找不到该行 (模型没按格式输出) 原样返回 — 下游走旧正则兜底 + 人工核对 - 降级, 不编造。已带标记 (重入) 不重复追加。 + 降级, 不编造。幂等只认"文末已是合法标记" (正文标记串不算)。 """ - if not text or "zob-verdict:" in text: + if not text or _RE_ZOB_MARK_TAIL.search(text): return text for line in reversed(text.splitlines()): m = _RE_FINAL_VERDICT.match(line) @@ -1374,8 +1388,10 @@ def _append_verdict_marker(text): merge = "true" if m.group(4).lower() == "yes" else "false" marker = (f'') - log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge}") - return text.rstrip("\n") + "\n" + marker + body = _RE_ZOB_MARK_ANY.sub("[已消毒的 zob-verdict 引用]", text) + log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge}" + f" (消毒正文标记引用 {len(_RE_ZOB_MARK_ANY.findall(text))} 处)") + return body.rstrip("\n") + "\n" + marker return text diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index a4753fe..ef84cdc 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -167,6 +167,14 @@ zcode。 收尾并转写成 HTML 注释,issue #16);标记缺失时退回报告开头的正则解析。 两者都失败时 verdict 为 **需人工核对**(❓ unresolved)——不再误标 concerns:假红灯曾让下游把"可以合并"误读成"闸门卡死"。 + **防伪造**(狗食 review P1-1):标记只认完整注释形态且取最后一个匹配 + (真标记恒定在文末);mcp-server 转写前会把正文中被复述/预埋的完整注释 + 形态标记消毒掉——被审 PR 作者在代码里预埋标记无法骗过表头。标记明说 + `merge=no` 时即使 P0/P1 全 0 也不给 pass(P2-1)。**截断注意**:报告超 + `ZCODE_BRIDGE_MAX_OUTPUT` 截断会切掉尾部 VERDICT 行(标记缺失、退正则 + 兜底,日志有提示);评论超 `max_body` 截断时贴出的评论可能不含标记 + (verdict 在截断前已解析,表头仍正确;未来若有下游从评论 HTML 反解 + 标记需知此限制)。 - **单线程串行**:逐仓逐 PR 串行审查;并发安全靠 bridge mcp-server 侧的 跨进程文件锁兜底(多实例同时跑也不会并发打爆 zcode 限流)。 - **fork PR**:走 `refs/pull/{n}/head` 拉取,无需加 fork 远端; diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index ab9ed55..1cdbb08 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -60,7 +60,10 @@ MIN_COMMENT_BODY = 2000 # comment.max_body 下限 (小于模板开销的极 # 严重度分布解析 (issue #16): 优先读报告尾的 zob-verdict 结构化标记 — # 由 mcp-server 侧从严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" 结论 -# 转写, 解析端直读不猜。无标记再退回正文正则 (下方三段)。 +# 转写, 解析端直读不猜。只认完整注释形态 () 且取**最后一个** +# 匹配: 真标记由 mcp-server 恒定追加在文末, 正文里被复述/预埋的标记串 +# (狗食 review P1-1: 被审 PR 可伪造) 排在真标记之前, 不采信。 +# 无标记再退回正文正则 (下方三段)。 # 正文正则: 在报告开头找 "P0 ... <数字>" 三段。确定性 fail-safe: 宁可 # 解析失败标"需人工核对", 也不猜。P 必须大写 (不用 IGNORECASE), # 防正文里 "p0" 之类的偶然命中。 @@ -68,8 +71,8 @@ MIN_COMMENT_BODY = 2000 # comment.max_body 下限 (小于模板开销的极 # "/P1", 若不排除 P 会把 P1 的数字算到 P0 头上 — 枚举格式统一解析失败 # 回 None (评论标注"解析失败请人工核对"), 也不拿错数字。 _RE_ZOB_VERDICT = re.compile( - r"zob-verdict:\{\"P0\":(\d+),\"P1\":(\d+),\"P2\":(\d+)," - r"\"merge\":(?:true|false)\}") + r"") _RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d+)(?!\d)") _RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d+)(?!\d)") _RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d+)(?!\d)") @@ -505,16 +508,31 @@ def run_review(cfg, clone, base_ref, head_sha): # ============================================================ # verdict 解析 (确定性, fail-safe) # ============================================================ +def parse_verdict_marker(report): + """读 zob-verdict 标记, 返回 ((P0, P1, P2), merge) 或 None。 + + 只认完整注释形态且取最后一个匹配 — 真标记由 mcp-server 恒定追加在 + 文末 (且 mcp-server 侧已对正文标记引用消毒), 正文靠前的伪造标记 + 不采信 (狗食 review P1-1)。 + """ + matches = list(_RE_ZOB_VERDICT.finditer(report or "")) + if not matches: + return None + m = matches[-1] + return ((int(m.group(1)), int(m.group(2)), int(m.group(3))), + m.group(4) == "true") + + def parse_severity_counts(report): """解析 (P0, P1, P2) 条数; 任一缺失 → None。 优先读报告尾的 zob-verdict 结构化标记 (issue #16: mcp-server 侧从 - 严格单行 VERDICT 结论转写, 全文搜索); 无标记退回正文前 - VERDICT_SCAN_HEAD 字符的正则猜测 (自由 prose, 格式敏感, 仅兜底)。 + 严格单行 VERDICT 结论转写, 取最后一个完整注释形态匹配); 无标记退回 + 正文前 VERDICT_SCAN_HEAD 字符的正则猜测 (自由 prose, 格式敏感, 仅兜底)。 """ - m = _RE_ZOB_VERDICT.search(report or "") - if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + marker = parse_verdict_marker(report) + if marker: + return marker[0] head = (report or "")[:VERDICT_SCAN_HEAD] counts = [] for pattern in (_RE_P0, _RE_P1, _RE_P2): @@ -525,13 +543,19 @@ def parse_severity_counts(report): return tuple(counts) -def verdict_from_counts(counts): +def verdict_from_counts(counts, merge_from_marker=None): """counts=None (解析失败) → unresolved (issue #16: 表头标"需人工核对", 不再误标 concerns — 假红灯曾致下游指挥 agent 停工); P0/P1>0 → - concerns; 否则 pass。""" + concerns; 否则 pass。结构化标记明说 merge=no 时即使全 0 计数也不给 + pass (狗食 review P2-1: 表头"可以合并"与报告结论直接矛盾是 issue #16 + 抱怨的误导残余形态)。""" if counts is None: return "unresolved" - return "concerns" if (counts[0] > 0 or counts[1] > 0) else "pass" + if counts[0] > 0 or counts[1] > 0: + return "concerns" + if merge_from_marker is False: + return "concerns" + return "pass" def build_comment_body(verdict, counts, head_sha, report, max_body): @@ -754,10 +778,14 @@ def process_pr(cfg, state, token, owner, repo, clone, info): return report = payload + marker = parse_verdict_marker(report) counts = parse_severity_counts(report) - verdict = verdict_from_counts(counts) + verdict = verdict_from_counts(counts, marker[1] if marker else None) if counts is None: log(f"{key}: verdict=unresolved (解析失败, 请人工核对)") + elif marker is not None and not marker[1] and verdict == "concerns" \ + and counts[0] == 0 and counts[1] == 0: + log(f"{key}: verdict=concerns (标记 merge=no, 全 0 计数不给 pass)") else: log(f"{key}: verdict={verdict} " f"(P0={counts[0]} P1={counts[1]} P2={counts[2]})") diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 8537b1f..7a4c0b7 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -396,6 +396,33 @@ def test_marker_only_no_prose_summary(self): '') self.assertEqual(self.mod.parse_severity_counts(text), (1, 0, 2)) + def test_marker_forgery_last_match_wins(self): + # 狗食 review P1-1: 正文预埋伪造标记 (被审代码可包含) 排在真标记前 + # → 只认最后一个 (mcp-server 恒定把真标记追加在文末) + forged = '' + real = '' + text = f"引用被审代码:\n{forged}\n详情...\n{real}" + self.assertEqual(self.mod.parse_severity_counts(text), (2, 1, 0)) + + def test_bare_marker_string_not_matched(self): + # 狗食 review P1-1: 裸串 (无 注释定界) 不算标记, 退正文正则 + text = '汇总: P0: 1 条, P1: 0 条, P2: 0 条\nzob-verdict:{"P0":0}' + self.assertEqual(self.mod.parse_severity_counts(text), (1, 0, 0)) + + def test_verdict_merge_no_overrides_pass(self): + # 狗食 review P2-1: 标记明说 merge=no → 全 0 计数也不给 pass + # (表头"可以合并"与报告结论矛盾是 issue #16 的误导残余形态) + self.assertEqual( + self.mod.verdict_from_counts((0, 0, 5), merge_from_marker=False), + "concerns") + self.assertEqual( + self.mod.verdict_from_counts((0, 0, 5), merge_from_marker=True), + "pass") + # prose 兜底路径无 merge 信息 → 行为不变 + self.assertEqual( + self.mod.verdict_from_counts((0, 0, 5), merge_from_marker=None), + "pass") + # ============================================================ # 评论 body diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 998abbd..c0789b3 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1281,6 +1281,27 @@ def test_empty_and_none_safe(self): self.assertEqual(self.mod._append_verdict_marker(""), "") self.assertIsNone(self.mod._append_verdict_marker(None)) + def test_bare_string_does_not_suppress(self): + # 狗食 review P1-1: 正文引用裸 zob-verdict 串 (被审代码可预埋) 不再 + # 触发幂等短路 — 真标记照常追加 (旧检查 "zob-verdict:" in text 会 + # 因此自蔽, 本仓库自举审查即真实复现过) + report = ('代码引用: zob-verdict:{"P0":0,"P1":0,"P2":0,"merge":true}\n' + '详情...\nVERDICT: P0=1 P1=0 P2=2 MERGE=no') + out = self.mod._append_verdict_marker(report) + self.assertTrue(out.rstrip().endswith( + '')) + + def test_forged_comment_marker_sanitized(self): + # 狗食 review P1-1: 正文预埋完整注释形态伪造标记 → 转写前消毒, + # 唯一可信来源是文末追加的真标记 + forged = '' + report = f"引用被审代码:\n{forged}\nVERDICT: P0=2 P1=1 P2=0 MERGE=no" + out = self.mod._append_verdict_marker(report) + self.assertIn("[已消毒的 zob-verdict 引用]", out) + self.assertNotIn(forged, out) + self.assertTrue(out.rstrip().endswith( + '')) + class TestGitTimeouts(_EnvGuard): """_git 超时分档 (整体 review P2-6): 元数据类 15s, diff 类 60s""" From 465eb7fb78e7a42b215ed62c2de3ef7c6e531a73 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:28:59 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(mcp-server):=20=E6=B6=88=E6=AF=92?= =?UTF-8?q?=E6=97=A0=E6=9D=A1=E4=BB=B6=E6=89=A7=E8=A1=8C=20=E2=80=94=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E8=B7=AF=E5=BE=84=E4=B8=8D=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E4=BC=AA=E9=80=A0=E6=A0=87=E8=AE=B0=20(=E7=8B=97=E9=A3=9F?= =?UTF-8?q?=E4=BA=8C=E8=BD=AE=20review=20P1-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二轮自审发现: _RE_ZOB_MARK_ANY.sub 只在命中 VERDICT 行的分支里执行, 模型未按格式输出结论行时 (格式漂移真实存在, issue #16 起因即是) 兜底 return 原样透传 — 被审 PR 预埋的完整注释形态伪造标记直达 review-gate, '取最后一个匹配' 全信 → 伪造 pass。 - 消毒提到函数入口无条件执行 (review 已验证正则语言覆盖关系: gate 的 匹配串全部落在消毒模式内), 到达下游的完整形态标记必然出自文末追加 - 幂等改认「VERDICT 行 + 标记行」两行完整追加形态 (_ends_with_appended_marker), 正文孤立标记 (含文末裸标记) 不再抑制追加/透传 测试 +1 例 (文末裸标记无 VERDICT 行 → 消毒且不追加), 全绿。 --- packages/mcp-server/zcode-mcp-server | 49 ++++++++++++++++++---------- tests/test_security_review.py | 10 ++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 3ebd792..9a00e52 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1354,45 +1354,60 @@ _RE_FINAL_VERDICT = re.compile( re.IGNORECASE, ) -# 文末合法标记的完整形态 (狗食 review P1-1: 幂等只认"最后一行完整注释", -# 正文里被复述/预埋的标记串不抑制追加) -_RE_ZOB_MARK_TAIL = re.compile( - r"\s*\Z") -# 正文中出现的完整注释形态标记 (被审代码可预埋) → 转写前消毒, 保证唯一 -# 可信来源是文末追加的真标记 +# 文末「VERDICT 行 + 标记行」的完整追加形态 (幂等只认这个形状 — 是本函数 +# 自己写出来的; 正文里被复述/预埋的孤立标记不算, 狗食 review P1-1) +_RE_ZOB_MARK_LINE = re.compile( + r"^$") +# 正文中出现的完整注释形态标记 (被审代码可预埋) → 无条件消毒, 保证到达 +# 下游的任何完整形态标记必然出自本文末追加 (狗食二轮 review P1-1: 兜底 +# 路径同样不放行) _RE_ZOB_MARK_ANY = re.compile( r"") +def _ends_with_appended_marker(text): + """文末两行是「VERDICT 行 + 标记行」→ 已是本函数追加过的完整形态。""" + lines = [l for l in (text or "").rstrip().splitlines() if l.strip()] + if len(lines) < 2: + return False + return (_RE_FINAL_VERDICT.match(lines[-2]) is not None + and _RE_ZOB_MARK_LINE.match(lines[-1]) is not None) + + def _append_verdict_marker(text): """PR 复核报告尾附机器可读 verdict 标记 (issue #16)。 review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即 误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。 prompt 已要求报告以严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" - 收尾; 这里自尾向头找该行, 命中则先给正文消毒 (把被审代码预埋/被复述 - 的完整注释形态标记改写掉, 狗食 review P1-1: 被审 PR 作者可在代码里 - 伪造标记骗过下游), 再在文末追加 HTML 注释标记 (GitHub 评论里不渲染, - 人读无感): + 收尾; 这里自尾向头找该行, 命中则在文末追加 HTML 注释标记 (GitHub 评论 + 里不渲染, 人读无感): - 找不到该行 (模型没按格式输出) 原样返回 — 下游走旧正则兜底 + 人工核对 - 降级, 不编造。幂等只认"文末已是合法标记" (正文标记串不算)。 + + 防伪造 (狗食 review P1-1, 被审 PR 作者可在代码里预埋标记骗下游): + - 消毒**无条件**执行 — 找没找到 VERDICT 行都先把正文里的完整注释形态 + 标记改写掉, 兜底路径同样不放行 (二轮 review 补: 只在命中分支消毒 + 时, 无 VERDICT 行的回退会把伪造标记原样透传给 review-gate) + - 幂等只认「VERDICT 行 + 标记行」的完整追加形态 (本函数写出的形状) + + 找不到 VERDICT 行时返回消毒后的正文 (不加标记) — 下游走旧正则兜底 + + 人工核对降级, 不编造。 """ - if not text or _RE_ZOB_MARK_TAIL.search(text): + if not text or _ends_with_appended_marker(text): return text - for line in reversed(text.splitlines()): + body = _RE_ZOB_MARK_ANY.sub("[已消毒的 zob-verdict 引用]", text) + for line in reversed(body.splitlines()): m = _RE_FINAL_VERDICT.match(line) if m: p0, p1, p2 = int(m.group(1)), int(m.group(2)), int(m.group(3)) merge = "true" if m.group(4).lower() == "yes" else "false" marker = (f'') - body = _RE_ZOB_MARK_ANY.sub("[已消毒的 zob-verdict 引用]", text) log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge}" f" (消毒正文标记引用 {len(_RE_ZOB_MARK_ANY.findall(text))} 处)") return body.rstrip("\n") + "\n" + marker - return text + return body def _env_int(name, default, maximum=None): diff --git a/tests/test_security_review.py b/tests/test_security_review.py index c0789b3..8ed349e 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1302,6 +1302,16 @@ def test_forged_comment_marker_sanitized(self): self.assertTrue(out.rstrip().endswith( '')) + def test_forged_tail_marker_without_verdict_sanitized(self): + # 狗食二轮 review P1-1: 无 VERDICT 行的兜底路径同样消毒 — 伪造标记 + # 落在文末也原样透传的话, review-gate "取最后一个匹配" 会全信 + forged = '' + out = self.mod._append_verdict_marker(f"正文...\n{forged}") + self.assertNotIn(forged, out) + self.assertIn("[已消毒的 zob-verdict 引用]", out) + # 不编造: 无 VERDICT 行 → 不追加任何标记 + self.assertFalse(out.rstrip().endswith("-->")) + class TestGitTimeouts(_EnvGuard): """_git 超时分档 (整体 review P2-6): 元数据类 15s, diff 类 60s""" From 52fdbd7cbf93916f4abb875c9fcfaa62790f136f Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:32:56 +0800 Subject: [PATCH 4/6] =?UTF-8?q?style:=20ruff=20E741=20=E2=80=94=20?= =?UTF-8?q?=E6=AD=A7=E4=B9=89=E5=8F=98=E9=87=8F=E5=90=8D=20l=20=E6=94=B9?= =?UTF-8?q?=20ln=20(CI=20=E6=8A=93=E5=88=B0,=20=E6=9C=AC=E5=9C=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=9C=AA=E6=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mcp-server/zcode-mcp-server | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 9a00e52..2e9397e 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1368,7 +1368,8 @@ _RE_ZOB_MARK_ANY = re.compile( def _ends_with_appended_marker(text): """文末两行是「VERDICT 行 + 标记行」→ 已是本函数追加过的完整形态。""" - lines = [l for l in (text or "").rstrip().splitlines() if l.strip()] + lines = [ln for ln in (text or "").rstrip().splitlines() + if ln.strip()] if len(lines) < 2: return False return (_RE_FINAL_VERDICT.match(lines[-2]) is not None From c24dc3debde271f68255ebee2950f3c76918d3bf Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:39:23 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E7=8B=97=E9=A3=9F=E4=BA=8C=E8=BD=AE?= =?UTF-8?q?=20P2=20=E5=8A=A0=E5=9B=BA=20=E2=80=94=20=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=A5=91=E7=BA=A6/=E5=B9=82=E7=AD=89=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7/=E6=AD=A3=E5=88=99=E9=99=90=E7=95=8C/=E4=BD=8D?= =?UTF-8?q?=E6=95=B0=E9=92=B3=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二轮自审的 4 条纵深加固建议全部落地: - P2-2 位置契约: 结论行只认全文最后一个非空行 (prompt 规定结论收尾), 其余位置的 VERDICT 形态行一律按引用消毒 — 防'真结论之后的尾置引用 行'劫持自尾向头搜索, 也防引用行喂下游 prose 正则 - P2-1 幂等一致性: 尾置「VERDICT 行+标记行」形状对但数值不一致 → 丢弃伪造标记, 以结论行为准重写 (不再是纯形状短路) - P2-3 正则限界: _RE_ZOB_MARK_ANY 的无界惰性量词 [^>]*? 改 [^>]{0,200}, 消除最坏 O(n²) 扫描 (MB 级构造文本可停摆单线程审查) - P2-4 位数钳制: 全链路 (mcp 结论行/标记行 + gate 标记/prose) 统一 \d{1,9} — ≥4301 位整数串会让 Python ≥3.11 的 int() 抛 ValueError, 整次审查作废并烧满 gate 侧 5 次重审 测试: mcp 重写 1 例 (位置契约) + 新增 4 例 (尾置引用块/不一致重写/ 超长数字×2), gate 新增 2 例 (超长数字标记/prose); 71+81 全绿。 --- packages/mcp-server/zcode-mcp-server | 136 ++++++++++++++++++------- packages/review-gate/README.md | 17 ++-- packages/review-gate/zcode-review-gate | 11 +- tests/test_review_gate.py | 13 +++ tests/test_security_review.py | 41 ++++++-- 5 files changed, 163 insertions(+), 55 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 2e9397e..77ed4db 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1349,31 +1349,62 @@ def tool_zcode_pr_review(args): _RE_FINAL_VERDICT = re.compile( - r"^\s*VERDICT:\s*P0\s*=\s*(\d+)\s+P1\s*=\s*(\d+)\s+" - r"P2\s*=\s*(\d+)\s+MERGE\s*=\s*(yes|no)\s*$", + r"^\s*VERDICT:\s*P0\s*=\s*(\d{1,9})\s+P1\s*=\s*(\d{1,9})\s+" + r"P2\s*=\s*(\d{1,9})\s+MERGE\s*=\s*(yes|no)\s*$", re.IGNORECASE, ) # 文末「VERDICT 行 + 标记行」的完整追加形态 (幂等只认这个形状 — 是本函数 -# 自己写出来的; 正文里被复述/预埋的孤立标记不算, 狗食 review P1-1) +# 自己写出的; 正文里被复述/预埋的孤立标记不算, 狗食 review P1-1)。 +# 捕获组供一致性校验 (二轮 P2-1: 数值不一致的尾置伪造按 VERDICT 行重写)。 +# \d{1,9} 位数上限: 无界 \d+ 配 ≥4301 位数字会让 int() 抛 ValueError +# (Python ≥3.11 整数转换上限), 整次审查作废并烧满 gate 侧 5 次重审 (P2-4)。 _RE_ZOB_MARK_LINE = re.compile( - r"^$") + r"^$") # 正文中出现的完整注释形态标记 (被审代码可预埋) → 无条件消毒, 保证到达 # 下游的任何完整形态标记必然出自本文末追加 (狗食二轮 review P1-1: 兜底 -# 路径同样不放行) +# 路径同样不放行)。{0,200} 限界 (P2-3): 无界惰性量词最坏 O(n²), MB 级 +# 构造文本可让单线程审查停摆分钟~小时级。 _RE_ZOB_MARK_ANY = re.compile( - r"") + r"") +_SANITIZED_MARK = "[已消毒的 zob-verdict 引用]" +_SANITIZED_VERDICT = "[已消毒的 VERDICT 行引用]" -def _ends_with_appended_marker(text): - """文末两行是「VERDICT 行 + 标记行」→ 已是本函数追加过的完整形态。""" - lines = [ln for ln in (text or "").rstrip().splitlines() - if ln.strip()] - if len(lines) < 2: - return False - return (_RE_FINAL_VERDICT.match(lines[-2]) is not None - and _RE_ZOB_MARK_LINE.match(lines[-1]) is not None) + +def _tail_verdict_marker(text): + """拆出文末「VERDICT 行 + 标记行」紧邻形态: (verdict_m, marker_m, head)。 + + head 为 VERDICT 行之前的全部内容 (不含); 形状不符返回 None。只认绝对 + 最后一行是标记、其前最后一个非空行是结论行的形状 — 即本函数自己的 + 输出形状。 + """ + lines = (text or "").rstrip().splitlines() + if not lines: + return None + marker_m = _RE_ZOB_MARK_LINE.match(lines[-1]) + if not marker_m: + return None + prev_idx = None + for i in range(len(lines) - 2, -1, -1): + if lines[i].strip(): + prev_idx = i + break + if prev_idx is None: + return None + verdict_m = _RE_FINAL_VERDICT.match(lines[prev_idx]) + if not verdict_m: + return None + return verdict_m, marker_m, "\n".join(lines[:prev_idx]) + + +def _verdict_marker_consistent(vm, zm): + """尾置两行的标记数值与 VERDICT 行一致 (本函数自产形态的必然性质)。""" + return (int(vm.group(1)) == int(zm.group(1)) + and int(vm.group(2)) == int(zm.group(2)) + and int(vm.group(3)) == int(zm.group(3)) + and ((vm.group(4).lower() == "yes") == (zm.group(4) == "true"))) def _append_verdict_marker(text): @@ -1382,33 +1413,62 @@ def _append_verdict_marker(text): review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即 误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。 prompt 已要求报告以严格单行 "VERDICT: P0=n P1=n P2=n MERGE=yes|no" - 收尾; 这里自尾向头找该行, 命中则在文末追加 HTML 注释标记 (GitHub 评论 - 里不渲染, 人读无感): + 收尾; 结论行命中则在文末追加 HTML 注释标记 (GitHub 评论里不渲染, 人读 + 无感): - 防伪造 (狗食 review P1-1, 被审 PR 作者可在代码里预埋标记骗下游): - - 消毒**无条件**执行 — 找没找到 VERDICT 行都先把正文里的完整注释形态 - 标记改写掉, 兜底路径同样不放行 (二轮 review 补: 只在命中分支消毒 - 时, 无 VERDICT 行的回退会把伪造标记原样透传给 review-gate) - - 幂等只认「VERDICT 行 + 标记行」的完整追加形态 (本函数写出的形状) - - 找不到 VERDICT 行时返回消毒后的正文 (不加标记) — 下游走旧正则兜底 + - 人工核对降级, 不编造。 + 防伪造 (被审 PR 作者可预埋标记/结论行骗下游, 狗食两轮 review 攻防): + - **位置契约**: 只认全文最后一个非空行为结论行 (prompt 规定结论收尾)。 + 其余位置的 VERDICT 形态行一律按"引用"消毒改写 (P2-2: 防"真结论之后 + 的尾置引用行"劫持, 也防引用行喂下游 prose 正则) + - 消毒**无条件**执行: 找没找到结论行, 正文里的完整注释形态标记都先 + 改写掉 (P1-1: 兜底路径同样不放行) + - 幂等只认「VERDICT 行 + 标记行」且**数值一致**的自产形态 (P2-1: + 形状对但数值不一致的尾置伪造 → 丢弃伪造标记, 以 VERDICT 行重写) + - 位数上限 \d{1,9} (P2-4): 超长数字串不构成合法结论行/标记, 不进 + int() (Python ≥3.11 对 ≥4301 位整数串抛 ValueError) + + 结尾不是严格结论行 (格式漂移/尾置引用块) 时返回消毒后的正文, 不加 + 标记不编造 — 下游走旧正则兜底 + 人工核对降级。 """ - if not text or _ends_with_appended_marker(text): + if not text: return text - body = _RE_ZOB_MARK_ANY.sub("[已消毒的 zob-verdict 引用]", text) - for line in reversed(body.splitlines()): - m = _RE_FINAL_VERDICT.match(line) - if m: - p0, p1, p2 = int(m.group(1)), int(m.group(2)), int(m.group(3)) - merge = "true" if m.group(4).lower() == "yes" else "false" - marker = (f'') - log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge}" - f" (消毒正文标记引用 {len(_RE_ZOB_MARK_ANY.findall(text))} 处)") - return body.rstrip("\n") + "\n" + marker - return body + tail = _tail_verdict_marker(text) + if tail is not None: + verdict_m, marker_m, head = tail + if _verdict_marker_consistent(verdict_m, marker_m): + return text # 已是本函数追加过的可信形态 + # P2-1: 两行形状对但数值不一致 → 预埋伪造, 丢弃尾标记以结论行重写 + text = head.rstrip("\n") + "\n" + verdict_m.group(0).strip() + n_marks = len(_RE_ZOB_MARK_ANY.findall(text)) + body = _RE_ZOB_MARK_ANY.sub(_SANITIZED_MARK, text) if n_marks else text + lines = body.splitlines() + last_idx = -1 + for i, ln in enumerate(lines): + if ln.strip(): + last_idx = i + if last_idx < 0: + return body + verdict_m = _RE_FINAL_VERDICT.match(lines[last_idx]) + keep = last_idx if verdict_m is not None else None + if any(_RE_FINAL_VERDICT.match(ln) + for i, ln in enumerate(lines) if i != keep): + lines = [(_SANITIZED_VERDICT + if (i != keep and _RE_FINAL_VERDICT.match(ln)) else ln) + for i, ln in enumerate(lines)] + body = "\n".join(lines) + if verdict_m is None: + log(f"报告尾部无严格 VERDICT 结论行, 不附注标记 (消毒标记引用 " + f"{n_marks} 处, VERDICT 引用行已消毒)") + return body + p0, p1, p2 = int(verdict_m.group(1)), int(verdict_m.group(2)), \ + int(verdict_m.group(3)) + merge = "true" if verdict_m.group(4).lower() == "yes" else "false" + marker = (f'') + log(f"verdict 标记已附注: P0={p0} P1={p1} P2={p2} merge={merge} " + f"(消毒正文标记引用 {n_marks} 处)") + return body.rstrip("\n") + "\n" + marker def _env_int(name, default, maximum=None): diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index ef84cdc..9b7c51a 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -169,12 +169,17 @@ zcode。 concerns:假红灯曾让下游把"可以合并"误读成"闸门卡死"。 **防伪造**(狗食 review P1-1):标记只认完整注释形态且取最后一个匹配 (真标记恒定在文末);mcp-server 转写前会把正文中被复述/预埋的完整注释 - 形态标记消毒掉——被审 PR 作者在代码里预埋标记无法骗过表头。标记明说 - `merge=no` 时即使 P0/P1 全 0 也不给 pass(P2-1)。**截断注意**:报告超 - `ZCODE_BRIDGE_MAX_OUTPUT` 截断会切掉尾部 VERDICT 行(标记缺失、退正则 - 兜底,日志有提示);评论超 `max_body` 截断时贴出的评论可能不含标记 - (verdict 在截断前已解析,表头仍正确;未来若有下游从评论 HTML 反解 - 标记需知此限制)。 + 形态标记消毒掉——被审 PR 作者在代码里预埋标记无法骗过表头。结论行采用 + **位置契约**(二轮 P2-2):只认全文最后一个非空行的严格 `VERDICT:` 行, + 其余位置的 VERDICT 形态行一律按引用消毒(防"真结论之后的尾置引用行" + 劫持,也防引用行喂 prose 正则);幂等只认「VERDICT 行+标记行」且数值 + 一致的自产形态(P2-1:不一致的尾置伪造按结论行重写);数字位数钳制 + ≤9 位(P2-4:≥4301 位整数串会让 Python ≥3.11 的 `int()` 抛错、烧满 + 重审)。标记明说 `merge=no` 时即使 P0/P1 全 0 也不给 pass(P2-1)。 + **截断注意**:报告超 `ZCODE_BRIDGE_MAX_OUTPUT` 截断会切掉尾部 VERDICT 行 + (标记缺失、退正则兜底,日志有提示);评论超 `max_body` 截断时贴出的 + 评论可能不含标记(verdict 在截断前已解析,表头仍正确;未来若有下游从 + 评论 HTML 反解标记需知此限制)。 - **单线程串行**:逐仓逐 PR 串行审查;并发安全靠 bridge mcp-server 侧的 跨进程文件锁兜底(多实例同时跑也不会并发打爆 zcode 限流)。 - **fork PR**:走 `refs/pull/{n}/head` 拉取,无需加 fork 远端; diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index 1cdbb08..d590b06 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -70,12 +70,15 @@ MIN_COMMENT_BODY = 2000 # comment.max_body 下限 (小于模板开销的极 # [^\dP] 排除 P 是关键: "P0/P1/P2 各 0/0/2" 枚举格式里 P0 后面紧跟 # "/P1", 若不排除 P 会把 P1 的数字算到 P0 头上 — 枚举格式统一解析失败 # 回 None (评论标注"解析失败请人工核对"), 也不拿错数字。 +# \d{1,9} 位数上限 (狗食二轮 P2-4): 无界 \d+ 配 ≥4301 位数字会让 int() +# 抛 ValueError (Python ≥3.11 整数转换上限), 整次审查作废并烧满重审; +# 超长数字串一律不构成合法标记/计数 (finding 数到十亿不现实)。 _RE_ZOB_VERDICT = re.compile( - r"") -_RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d+)(?!\d)") -_RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d+)(?!\d)") -_RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d+)(?!\d)") +_RE_P0 = re.compile(r"P0[^\dP]{0,12}(\d{1,9})(?!\d)") +_RE_P1 = re.compile(r"P1[^\dP]{0,12}(\d{1,9})(?!\d)") +_RE_P2 = re.compile(r"P2[^\dP]{0,12}(\d{1,9})(?!\d)") _TRUNC_MARK = "\n\n[... 报告超长已截断 ...]" diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 7a4c0b7..ae2e32b 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -423,6 +423,19 @@ def test_verdict_merge_no_overrides_pass(self): self.mod.verdict_from_counts((0, 0, 5), merge_from_marker=None), "pass") + def test_huge_digit_marker_not_matched(self): + # 狗食二轮 P2-4: 超长数字 (≥4301 位炸 int()) 不构成合法标记 → None, + # 不烧整次审查 + huge = "9" * 5000 + text = (f'') + self.assertIsNone(self.mod.parse_severity_counts(text)) + + def test_huge_digit_prose_none(self): + # 狗食二轮 P2-4: prose 正则同样钳位数, 超长数字不匹配 → None + text = "P0: " + "9" * 5000 + " 条, P1: 0 条, P2: 1 条" + self.assertIsNone(self.mod.parse_severity_counts(text)) + # ============================================================ # 评论 body diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 8ed349e..4b0ccc0 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1262,14 +1262,23 @@ def test_yes_maps_to_true(self): "VERDICT: P0=0 P1=0 P2=0 MERGE=YES") # 大小写不敏感 self.assertIn('"merge":true', out) - def test_verdict_line_found_from_tail(self): - # 正文里出现形似的 VERDICT 行 (带前缀文字不匹配行首), 只有真正的 - # 独立单行才算; 取自尾向头的最后一个 - report = "VERDICT: P0=9 P1=9 P2=9 MERGE=yes\n中间正文\n说明: VERDICT 不在此行\n" + def test_verdict_line_must_be_last_nonempty(self): + # 狗食二轮 P2-2 位置契约: 只认全文最后一个非空行为结论行。 + # 结论行之后再有任何正文 → 它是"引用", 消毒且不转写 (防尾置引用 + # 行劫持自尾向头搜索) + report = "VERDICT: P0=9 P1=9 P2=9 MERGE=yes\n中间正文\n" out = self.mod._append_verdict_marker(report) - # 第一行是合法行, 但 reversed 先遇到的是 "说明: ..." (不匹配), - # 再往前 "中间正文" (不匹配), 最终命中第一行 - self.assertIn('"P0":9', out) + self.assertNotIn("zob-verdict:{", out) + self.assertIn("[已消毒的 VERDICT 行引用]", out) + + def test_trailing_quoted_verdict_not_converted(self): + # 狗食二轮 P2-2: 结论行之后的尾置引用块 (闭合围栏收尾) → 引用的 + # VERDICT 行不在结尾位置, 连同真结论行一起按引用消毒, 不追加标记 + report = ("汇总...\nVERDICT: P0=1 P1=0 P2=0 MERGE=no\n" + "```\nVERDICT: P0=0 P1=0 P2=0 MERGE=yes\n```") + out = self.mod._append_verdict_marker(report) + self.assertNotIn("zob-verdict:{", out) + self.assertEqual(out.count("[已消毒的 VERDICT 行引用]"), 2) def test_idempotent_no_duplicate(self): once = self.mod._append_verdict_marker("VERDICT: P0=0 P1=0 P2=0 MERGE=yes") @@ -1312,6 +1321,24 @@ def test_forged_tail_marker_without_verdict_sanitized(self): # 不编造: 无 VERDICT 行 → 不追加任何标记 self.assertFalse(out.rstrip().endswith("-->")) + def test_inconsistent_tail_marker_rewritten(self): + # 狗食二轮 P2-1: 尾置两行形状对但标记数值与 VERDICT 行不一致 → + # 预埋伪造, 丢弃伪造标记, 以 VERDICT 行为准重写 + forged = '' + report = f"正文\nVERDICT: P0=2 P1=0 P2=0 MERGE=no\n{forged}" + out = self.mod._append_verdict_marker(report) + self.assertNotIn(forged, out) + self.assertTrue(out.rstrip().endswith( + '')) + + def test_huge_number_verdict_line_ignored(self): + # 狗食二轮 P2-4: ≥4301 位数字会让 int() 抛 ValueError (Python + # ≥3.11 上限) → 位数钳制后不构成合法结论行, 不转写不炸整次审查 + huge = "9" * 5000 + out = self.mod._append_verdict_marker( + f"正文\nVERDICT: P0={huge} P1=0 P2=0 MERGE=yes") + self.assertNotIn("zob-verdict:{", out) + class TestGitTimeouts(_EnvGuard): """_git 超时分档 (整体 review P2-6): 元数据类 15s, diff 类 60s""" From b2810525d0b34743d62d80c8fe17ddb6c6463031 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:40:33 +0800 Subject: [PATCH 6/6] =?UTF-8?q?style:=20docstring=20=E6=94=B9=20raw=20stri?= =?UTF-8?q?ng=20=E2=80=94=20\d=20=E8=BD=AC=E4=B9=89=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=20(CI=20W605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mcp-server/zcode-mcp-server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 77ed4db..917b8af 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1408,7 +1408,7 @@ def _verdict_marker_consistent(vm, zm): def _append_verdict_marker(text): - """PR 复核报告尾附机器可读 verdict 标记 (issue #16)。 + r"""PR 复核报告尾附机器可读 verdict 标记 (issue #16)。 review-gate 之前靠正则猜报告正文的 P0/P1/P2 汇总计数, 报告格式稍变即 误读 (把表头/枚举句里的数字当计数) 或读不到 (fallback"解析失败")。