From 525b60adc6251bb5bf58790cd74fb50f63e31c07 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:36:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20PR=20=E5=AE=A1=E6=9F=A5=20mimosa=20?= =?UTF-8?q?findings=20=E5=8F=8C=E7=BA=A7=E8=BF=87=E6=BB=A4=20=E2=80=94=20d?= =?UTF-8?q?iff=20=E6=96=87=E4=BB=B6=E8=BF=87=E6=BB=A4=20(#26)=20+=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=8C=87=E7=BA=B9=E5=8E=BB=E9=87=8D=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _filter_findings_by_files: findings 按 PR diff 触及文件过滤 (normpath 双侧匹配), 附件只含新增信号; scope 开关 ZCODE_BRIDGE_PR_FINDINGS_SCOPE=diff|all 可回滚 - 基线指纹库: sha256(path+anchor) (anchor 为 mimosa 内容哈希, 跨扫描稳定, 代码改动自动失效), 存仓外 ~/.local/state/zcode-mcp-server/baselines/ (gate 每轮 git clean -fdx 清仓内文件); 损坏降级空库; 原子写 - 写入时机守卫: 仅 zcode 复核成功 (非 isError) 才落库 — 失败重试不产生假「无新增」 - 报告头 known 汇总行 (纯中文, 不含 P0/P1/P2 token, gate 双解析路径钉子测试); verdict 计数只含新增 finding - zcode_security_review 保持全量扫语义; gate 脚本零改动; mcp-server 1.4.0→1.5.0 - tests: +16 用例 (pr13-pr23 + bf0-bf3 + gate 头行钉子) — 434 passed, 28 subtests --- README.md | 4 +- packages/mcp-server/zcode-mcp-server | 229 +++++++++++++++++- packages/review-gate/README.md | 41 +++- tests/test_review_gate.py | 19 ++ tests/test_security_review.py | 338 ++++++++++++++++++++++++++- 5 files changed, 609 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e3fa62e..a23b986 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ zcode --prompt "继续" --resume sess_xxxx | `get_zcode_capabilities` | 返回 ZCode 能力清单(调 agent-help) | | `zcode_review` | 调 ZCode 审查代码(yolo + 写/执行工具物理禁用,全程免授权但改不了文件,安全) | | `zcode_security_review` | 安全专项审查:mimosa 确定性规则引擎预扫 → ZCode 拿 findings 逐条核实(确认/误报/存疑 + 攻击路径 + 修复建议)。`depth=normal` 秒级快扫(默认),`depth=deep` 含业务逻辑投研(异步任务管线) | -| `zcode_pr_review` | PR 审查模式:自动算 `git diff base...HEAD`(merge-base 语义,base 可自动探测)→ mimosa 全仓扫描且业务逻辑复核聚焦改动文件(focus_files)→ ZCode 出 PR 复核报告(P0/P1/P2 分级 + findings 核实 + 能否合并结论)。默认 `depth=deep`;diff 超 `ZCODE_BRIDGE_PR_DIFF_MAX`(默认 500KB)截断保清单 | +| `zcode_pr_review` | PR 审查模式:自动算 `git diff base...HEAD`(merge-base 语义,base 可自动探测)→ mimosa 全仓扫描且业务逻辑复核聚焦改动文件(focus_files),findings 按本 PR 改动文件过滤 + 已知基线去重(只复核新增,`ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all` / `ZCODE_BRIDGE_PR_BASELINE=off` 可回滚)→ ZCode 出 PR 复核报告(P0/P1/P2 分级 + findings 核实 + 能否合并结论)。默认 `depth=deep`;diff 超 `ZCODE_BRIDGE_PR_DIFF_MAX`(默认 500KB)截断保清单 | > **只读原理(2026-08-08 重构,告别 `--mode plan`)**:review 体系不再用 plan 模式——plan 只禁「改文件」,读探索/子代理照样放行(限流超时主因),且 plan→build 的规划惯性容易让 review 变成「边审边修」。新方案用 `--mode yolo`(全程免授权)+ `--disallowed-tools` 把 `Write/Edit/MultiEdit/ApplyPatch/Bash` 连同 Node REPL 一族(`js` / `mcp__node_repl__js*`)一起禁掉:`--disallowed-tools` 是工具集级物理移除、先于权限层,yolo 也绕不过;Node REPL 一族必须同禁,否则可被 `execSync` 打穿 Bash 黑名单(0.16.1 实测复现)。读工具(Read/Grep/Glob)全开,不影响审查能力。prompt 层另有「只审不修」职责约束(不修改文件、不提议帮忙修复)作双保险。 @@ -205,7 +205,7 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用 ### Review gate(`zcode-review-gate`) -PR 自动审查闸门守护进程(第 4 组件,experimental):常驻轮询配置仓库的 open PR,对每个新 head sha 经 `zcode-mcp-server --call zcode_pr_review` 完成审查(锁/重试/只读护栏全部复用 bridge 同源路径),把带 verdict(✅ pass / ⚠️ concerns)的结果回贴为 PR 评论。同一 head sha 不重复审(state 文件去重),失败按指数退避重试,head 更新自动复活重审。token 不落盘(经 git≥2.31 的 `GIT_CONFIG_*` 环境变量进程内注入,不进 argv)。公开、通用,任何 GitHub 仓库可用。 +PR 自动审查闸门守护进程(第 4 组件,experimental):常驻轮询配置仓库的 open PR,对每个新 head sha 经 `zcode-mcp-server --call zcode_pr_review` 完成审查(锁/重试/只读护栏全部复用 bridge 同源路径),把带 verdict(✅ pass / ⚠️ concerns)的结果回贴为 PR 评论。同一 head sha 不重复审(state 文件去重),失败按指数退避重试,head 更新自动复活重审。token 不落盘(经 git≥2.31 的 `GIT_CONFIG_*` 环境变量进程内注入,不进 argv)。公开、通用,任何 GitHub 仓库可用。报告里的 mimosa findings 只含**新增**(按 diff 文件过滤 + 已知基线去重,issue #26/#27):已知的存量 finding 以报告头一行计数说明、不进 P0/P1/P2 计数与 verdict。 安装、配置参考、systemd 部署与运维详见 [packages/review-gate/README.md](packages/review-gate/README.md)。 diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index ef7b91b..cf10aa4 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -9,7 +9,8 @@ Claude Code、Cursor 等) 能标准化地发现并调用 headless zcode。 1. get_zcode_capabilities — 返回 headless zcode 完整能力清单 (JSON) 2. zcode_review — 调 zcode 审查代码 (yolo + 写工具物理禁用, 免授权且只读) 3. zcode_security_review — mimosa 规则引擎预扫 + zcode 只读逐条复核 (安全专项) - 4. zcode_pr_review — PR 审查: git diff + mimosa 聚焦深扫 + zcode 复核报告 + 4. zcode_pr_review — PR 审查: git diff + mimosa 聚焦深扫 (findings + 按 diff 文件过滤+基线去重) + zcode 复核报告 协议: MCP over stdio (JSON-RPC 2.0, 每行一条消息) 日志: 全部走 stderr (绝不污染 stdout 协议流) @@ -18,6 +19,7 @@ Claude Code、Cursor 等) 能标准化地发现并调用 headless zcode。 """ import fcntl +import hashlib import json import os import re @@ -26,6 +28,7 @@ import shutil import subprocess import sys import time +from datetime import datetime from pathlib import Path from urllib.parse import urlparse @@ -60,7 +63,7 @@ _META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" # 作为 MCP client 调 mimosa 时用的版本: mimosa 1.0.3 实测讲 2024-11-05, # 钉死该值 (它不回更高版; 我们不校验它的应答版本)。 MIMOSA_CLIENT_PROTOCOL_VERSION = "2024-11-05" -SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.4.0"} +SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.5.0"} def log(msg): @@ -612,6 +615,115 @@ def _compact_findings(findings): return out +def _filter_findings_by_files(findings, changed_files): + """按 PR 改动文件过滤 mimosa findings (issue #26)。返回 (kept, dropped_count)。 + + mimosa 静态引擎永远全仓扫 (focus_files 只是优先级提示, 见 + _mimosa_deep_scan docstring 实测结论), 历史仓的存量 findings 会淹没 + 本次 PR 真正新引入的问题 — 只有 location.path 落在 diff 触及文件内的 + 才值得进 zcode 逐条核实。双侧 os.path.normpath 归一后精确匹配: + findings 路径与 git diff --name-only 同为仓库相对路径, 但任一侧都可能 + 带 ./ 前缀 (normpath 消掉); 不做子串/后缀匹配, 防误吞同名文件。 + """ + changed_set = {os.path.normpath(f) for f in changed_files if f} + kept = [f for f in findings + if os.path.normpath((f.get("location") or {}).get("path") or "") + in changed_set] + dropped = len(findings) - len(kept) + if dropped: + log(f"findings 文件过滤: 保留 {len(kept)}, 丢弃 {dropped} " + "(未落在本次 diff 触及文件内)") + return kept, dropped + + +# ============================================================ +# finding 指纹与已知基线库 (issue #27) +# ============================================================ +# 基线 schema: {"schemaVersion": "zcode-pr-review-baseline/v1", "repo": "", +# "updatedAt": "", "entries": {"<指纹>": {path, anchor, +# findingId, publicClass, severity, title, firstSeen, lastSeen, +# count}}} +_BASELINE_SCHEMA = "zcode-pr-review-baseline/v1" + + +def _finding_fingerprint(finding): + """finding 指纹: sha256(normpath(path) + NUL + identity.anchor) 十六进制。 + + identity.anchor 是 mimosa 的内容锚定哈希 — 实测跨扫描稳定、不含路径 + 语义、代码内容变则变, 正适合做基线键: 同一 finding 重扫同指纹 (行号 + 漂移不炸指纹), 被审代码一行改动即换指纹 (作为新增重新进入复核)。 + path 参与哈希: anchor 只锚定代码内容, 同段代码复制到新文件是新的 + finding, 不能被旧文件的基线条目吞掉。缺 anchor 时退化为 + path+publicClass+title 的弱指纹并 log 警告 — 弱指纹的跨版本稳定性 + 没有 anchor 的实测背书, 仅兜底防炸。 + """ + path = os.path.normpath((finding.get("location") or {}).get("path") or "") + identity = finding.get("identity") or {} + anchor = identity.get("anchor") + if anchor: + material = f"{path}\0{anchor}" + else: + log(f"⚠ finding 缺 identity.anchor, 退化为弱指纹 (path+publicClass" + f"+title): {path} {identity.get('publicClass')}") + material = f"{path}\0{identity.get('publicClass') or ''}\0{finding.get('title') or ''}" + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def _baseline_path(repo_abs_path): + """基线文件路径: /.json。 + + 基线必须放仓外 — review-gate 每轮 git clean --force -d -x 会把仓内 + 未跟踪文件清掉, 放仓内等于每轮重置。键取 repo 绝对路径哈希前 16 hex: + 不同 clone 目录天然分仓隔离 (代价: 换机/改 clone root 会重置基线, + 一轮噪音回潮, 无害 — 见 review-gate README 限制节)。 + """ + base_dir = os.environ.get("ZCODE_BRIDGE_BASELINE_DIR") or os.path.expanduser( + "~/.local/state/zcode-mcp-server/baselines/") + key = hashlib.sha256(repo_abs_path.encode("utf-8")).hexdigest()[:16] + return os.path.join(base_dir, f"{key}.json") + + +def _empty_baseline(): + """空基线骨架 (缺失/损坏时的 fail-safe 返回值)。""" + return {"schemaVersion": _BASELINE_SCHEMA, "entries": {}} + + +def _load_baseline(path): + """读基线库; 缺失/损坏/形状非法 → 空库 + log WARNING, 绝不抛。 + + 基线是降噪优化不是正确性依赖: 读不到就当全量新 finding 重报, 方向 + 宁多报不漏报 (与 gate verdict 的 fail-safe 取舍一致)。 + """ + try: + with open(path) as f: + data = json.load(f) + except FileNotFoundError: + return _empty_baseline() # 首轮审查, 正常路径, 不刷日志 + except (json.JSONDecodeError, OSError) as e: + log(f"⚠ 基线文件 {path} 损坏, 按空基线处理: {e}") + return _empty_baseline() + if not isinstance(data.get("entries"), dict): + log(f"⚠ 基线文件 {path} 形状非法 (entries 非对象), 按空基线处理") + return _empty_baseline() + return data + + +def _save_baseline(path, baseline): + """基线库原子落盘: 同目录 tmp + os.replace (仿 gate StateStore.save)。 + + os.replace 同目录换名是原子的 — 中途断电最多丢本轮更新, 不会留半个 + JSON 让下轮 _load_baseline 整库作废。 + """ + baseline["updatedAt"] = datetime.now().isoformat(timespec="seconds") + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(baseline, f, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + # ============================================================ # PR 审查: git diff 计算 + base 自动探测 # ============================================================ @@ -819,6 +931,9 @@ TOOLS = [ "description": ( "PR 审查模式: 自动算 git diff (base...HEAD, merge-base 语义) 得到改动清单," "mimosa 全仓扫描 + 业务逻辑复核优先聚焦改动文件 (focus_files)," + "findings 按本 PR 改动文件过滤并按已知基线去重 (只复核新增;" + "ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all 恢复全量附件, " + "ZCODE_BRIDGE_PR_BASELINE=off 关闭基线)," "ZCode 拿着 diff+findings 出 PR 复核报告 (P0/P1/P2 分级 + 能否合并结论)。" "全程只读: yolo+写工具物理禁用, 免人工授权。需要本机装有 mimosa 插件。" ), @@ -1204,7 +1319,11 @@ def tool_zcode_pr_review(args): ① git diff base...head (merge-base 语义) 算改动清单与完整 diff; ② mimosa 全仓扫描 (focus_files=改动文件, deep 档业务逻辑投研聚焦改动); - ③ diff+findings 作附件喂 zcode 出 PR 复核报告 (P0/P1/P2 + 能否合并)。 + ③ findings 两级过滤 (issue #26/#27): 按 diff 触及文件 → 按已知基线 + (基线文件在仓外 ~/.local/state/zcode-mcp-server/baselines/, 指纹 = + path+内容锚 anchor, 代码不变则视为已知不重报); + ④ diff+过滤后 findings 作附件喂 zcode 出 PR 复核报告 (P0/P1/P2 + + 能否合并); 仅复核成功后才把本轮 finding 并入基线落盘。 全程只读: 写工具物理禁用; git/mimosa 都是只读调用。 """ scan_path = args.get("path") or args.get("cwd") or os.getcwd() @@ -1287,7 +1406,63 @@ def tool_zcode_pr_review(args): return {"content": [{"type": "text", "text": f"mimosa 扫描失败: {e}"}], "isError": True} - # ③ 附件 = PR 元信息 + diff + mimosa 摘要 + findings + # ③ findings 两级过滤 (issue #26/#27): 先按 diff 触及文件, 再按已知基线。 + # mimosa 静态引擎永远全仓扫, 不过滤时历史仓的存量 findings 会淹没本次 + # PR 真正新引入的问题; 两级开关均可 env 回滚 (见 README 运维节)。 + total = len(findings) + # 一级 (文件): ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all 恢复全量进附件的旧行为 + scope_all = os.environ.get("ZCODE_BRIDGE_PR_FINDINGS_SCOPE", "diff") == "all" + if findings and not scope_all: + findings, _dropped = _filter_findings_by_files(findings, changed) + in_scope = len(findings) + + # 二级 (基线): ZCODE_BRIDGE_PR_BASELINE=off 完全不读不写基线。 + # known 项只刷新 lastSeen/count, new 项先在内存里建 entry — 落盘统一 + # 押后到 zcode 复核成功之后 (见下方 pending_baseline 的安全说明)。 + known_count = 0 + pending_baseline = None # 非 None = 本轮启用了基线, 成功后需落盘 + baseline_file = None + if findings and os.environ.get("ZCODE_BRIDGE_PR_BASELINE", "on") != "off": + repo_abs = os.path.abspath(scan_path) + baseline_file = _baseline_path(repo_abs) + baseline = _load_baseline(baseline_file) + baseline["repo"] = repo_abs + entries = baseline["entries"] + now = datetime.now().isoformat(timespec="seconds") + new_findings = [] + staged = {} # 新 entry 先存这里, 循环后再并入 — 防同指纹的重复 + # finding 在循环内被刚 stage 的 entry 误判成 known + for f in findings: + fp = _finding_fingerprint(f) + entry = entries.get(fp) + if entry is not None: + # 已知 finding: 上一轮已详报过, 本轮只计数不进复核。 + # count 可能被手改成非数字 — 基线是降噪优化, 解析失败重置 + # 计数即可, 不能反杀整次审查 (与 _load_baseline 的 fail-safe 对齐) + try: + entry["count"] = int(entry.get("count") or 0) + 1 + except (TypeError, ValueError): + entry["count"] = 1 + entry["lastSeen"] = now + known_count += 1 + else: + staged[fp] = { + "path": (f.get("location") or {}).get("path"), + "anchor": (f.get("identity") or {}).get("anchor"), + "findingId": f.get("findingId"), + "publicClass": (f.get("identity") or {}).get("publicClass"), + "severity": f.get("severity"), + "title": f.get("title"), + "firstSeen": now, + "lastSeen": now, + "count": 1, + } + new_findings.append(f) + entries.update(staged) + findings = new_findings + pending_baseline = baseline + + # 附件 = PR 元信息 + diff + mimosa 摘要 + findings (只含两级过滤后的新增) attachment = ( f"# PR 信息\n- base: {base} → head: {head}\n" f"- 改动文件 ({len(changed)} 个):\n" @@ -1297,21 +1472,40 @@ def tool_zcode_pr_review(args): ) if findings: compact = _compact_findings(findings) - attachment += ( - f"\n# findings 清单 ({len(compact)} 条)\n" - + json.dumps(compact, ensure_ascii=False, indent=1) - ) + if scope_all: + heading = f"\n# findings 清单 ({len(compact)} 条)\n" + else: + heading = (f"\n# mimosa findings (diff 触及文件内, " + f"{len(compact)}/{total} 条)\n") + attachment += heading + json.dumps(compact, ensure_ascii=False, indent=1) + if known_count: + attachment += (f"\n\n(另有已知基线 finding {known_count} 条已过滤, " + "未列入本次核实)\n") + elif in_scope: + # diff 过滤命中但全被基线滤掉 — 如实说明, 不沿用下面"未取到"文案 + attachment += (f"\n# mimosa findings\n(diff 触及文件内 {in_scope} 条均为" + f"已知基线 finding, 已过滤, 本轮无需逐条核实)\n") + elif total: + # 扫描确有产出但都不在改动文件内 — 区别于"未取到结构化 findings" + # (后者会被误读为扫描失败), 如实说明过滤原因 + attachment += (f"\n# mimosa findings\n(diff 触及文件内 0 条, 全仓共 " + f"{total} 条已按改动文件过滤, 无需逐条核实; " + "如摘要显示有发现, 可按你的判断抽查改动文件)\n") else: attachment += ("\n# findings 清单\n(未取到结构化 findings, 以摘要为准;" "如摘要显示 0 发现, 也请按你的判断抽查关键代码)\n") + # prompt 同步过滤措辞; scope=all 回滚时不提, 免得与全量清单自相矛盾 + findings_scope_note = ( + "" if scope_all else ", findings 已按本 PR 改动文件过滤") + tmp_path = None try: tmp_path = _write_temp(attachment, "zcode-pr-review-") prompt = ( "你是资深安全审查专家。这是一次 PR 审查: 附件含 ① 本 PR 的完整 diff " f"(base={base} → {head}, 改动 {len(changed)} 个文件) ② mimosa 安全扫描结果" - f"(depth={depth}, 业务逻辑复核优先聚焦本次改动文件)。\n" + f"(depth={depth}, 业务逻辑复核优先聚焦本次改动文件{findings_scope_note})。\n" "任务:\n" "1. 审查 diff 中新引入的问题, 按 P0(阻断合并)/P1(应修)/P2(建议) 分级," "每个问题含文件位置/判定依据/修复建议。重点是'新引入';" @@ -1337,8 +1531,25 @@ def tool_zcode_pr_review(args): result = _run_zcode_headless(cmd, env, _review_timeout()) # issue #16: 报告尾附机器可读 verdict 标记, 下游 (review-gate) 直读不猜 if not result.get("isError"): + # 基线头行先拼在正文开头再转写标记 — 纯中文不含 P0/P1/P2 字面 + # token, gate 的 prose 兜底正则 (只按这些 token 匹配) 不会误读; + # 拼在开头也不动尾部, _append_verdict_marker 的位置契约不受影响 + if known_count: + result["content"][0]["text"] = ( + f"> 基线过滤: 已过滤 {known_count} 条已知 finding, " + f"本轮新增 {len(findings)} 条进入复核\n" + + result["content"][0]["text"]) result["content"][0]["text"] = _append_verdict_marker( result["content"][0]["text"]) + # 写入时机守卫 (安全关键): 仅 zcode 复核成功返回 (非 isError 且 + # 无异常) 才落基线。失败/isError/异常路径绝不写 — 否则 gate 对 + # 同 head 重试时这些 finding 已变"已知" → 假"无新增" → 假 pass。 + # 落盘失败也不反杀已成功的审查 (基线只是降噪优化, 宁可下轮重报) + if pending_baseline is not None: + try: + _save_baseline(baseline_file, pending_baseline) + except OSError as e: + log(f"⚠ 基线落盘失败 (下轮将重报这些 finding): {e}") return result finally: if tmp_path: diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index 01dd8f5..7b4a3a8 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -1,7 +1,8 @@ # zcode-review-gate — PR 自动审查闸门 常驻轮询守护进程:监控配置仓库的 open PR,对每个新 head sha 调 bridge 的 -`zcode_pr_review` 完成审查(git diff + mimosa 深扫 + ZCode 只读复核), +`zcode_pr_review` 完成审查(git diff + mimosa 深扫 + findings 按 diff +文件过滤/已知基线去重 + ZCode 只读复核), 把带 verdict(pass / concerns / 需人工核对)的结果回贴为 PR 评论。同一 head sha 不重复审(state 文件去重),失败按指数退避重试。审查前会把 clone 工作区 **checkout 到被审 head sha 并回读校验**——mimosa 扫的是 @@ -15,7 +16,8 @@ GitHub API ──轮询 open PR──► review-gate (state 文件去重/指数 checkout 到被审 head sha + rev-parse 回读校验 (issue #17) │ --call zcode_pr_review ▼ - zcode-mcp-server 子进程 (git diff + mimosa 深扫 + ZCode 只读复核; + zcode-mcp-server 子进程 (git diff + mimosa 深扫 + findings 按 diff + 文件过滤/已知基线去重 + ZCode 只读复核; 锁/限流重试/只读护栏全在 bridge 侧同源复用) │ 报告 → 解析 P0/P1/P2 → verdict ▼ @@ -135,6 +137,33 @@ zcode-review-gate --once --repo owner/repo --pr 5 zcode-review-gate --once --log-level DEBUG ``` +### findings 基线(issue #26/#27) + +`zcode_pr_review` 的 mimosa findings 会先按本 PR 改动文件过滤、再按已知 +基线去重,只有**新增** finding 进 ZCode 复核;已知 finding 以报告开头一行 +`> 基线过滤: …` 计数说明,不进 P0/P1/P2 计数。基线文件按 clone 绝对路径 +哈希存于仓外 `~/.local/state/zcode-mcp-server/baselines/`(gate 每轮 +`git clean --force -d -x`,仓内存活不了),仅在复核成功后落盘。 + +```bash +# 重置某仓基线 (下一轮所有 finding 按新增重报): 删对应文件 +rm ~/.local/state/zcode-mcp-server/baselines/.json # 或整个目录 + +# 手动全量深扫 (绕过 PR 过滤/基线, 存量问题全量可见) +zcode-mcp-server --call zcode_security_review '{"path":"","depth":"deep"}' +``` + +两个 env 开关(gate 经 `dict(os.environ)` 透传给 mcp-server 子进程; +systemd 用 `systemctl --user edit zcode-review-gate` 加 `Environment=` 行): + +| env | 默认 | 说明 | +|---|---|---| +| `ZCODE_BRIDGE_PR_FINDINGS_SCOPE` | `diff` | findings 附件范围:`diff`(只含改动文件内)/ `all`(回滚到全量进附件的旧行为) | +| `ZCODE_BRIDGE_PR_BASELINE` | `on` | 已知基线去重:`off` 关闭(不读不写基线,回滚到过滤前行为) | + +全量回滚(疑似过滤误伤时排查用):`ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all` ++ `ZCODE_BRIDGE_PR_BASELINE=off`,两者独立可组合。 + state 文件(默认 `~/.local/state/zcode-review-gate/state.json`)记录每个 PR 的 审查状态:`head_sha` / `status` / `verdict` / `counts` / `report` / `attempts` / `next_retry_at` / `comment_url` / `error`。 @@ -186,6 +215,14 @@ zcode。 工作区,静默扫错代码(狗食 review P1-1)。 - **fork PR**:走 `refs/pull/{n}/head` 拉取,无需加 fork 远端; 审查的是 PR head 快照本身。 +- **确认未修复的真漏洞首次详报后仅进 known 计数**(issue #27 基线去重的 + 取舍):finding 指纹 = 文件路径 + mimosa 内容锚(anchor),代码内容不变 + 则指纹不变,下轮 PR 只在报告头行计入"已过滤"不再逐条详报;被审文件 + 一行代码改动即指纹失效、作为新增重报。要看存量全量:删该仓基线文件 + (见运维节)或手动跑 `zcode_security_review` 深扫(不经过滤/基线)。 +- **基线键绑定 clone 绝对路径**:换机、迁移或改名 clone root 会让 + `~/.local/state/zcode-mcp-server/baselines/` 下的旧键失配,等效基线 + 重置——一轮存量噪音回潮后重新收敛,无害(方向宁多报不漏报)。 - token 不落盘:经 git≥2.31 的 `GIT_CONFIG_COUNT/KEY/VALUE` 环境变量逐 命令注入 `http.extraHeader`(env 只对本用户可见,优于 argv), clone URL / git config / state 文件里都不会有 token。 diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 22be42d..ac76b86 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -440,6 +440,25 @@ def test_huge_digit_prose_none(self): text = "P0: " + "9" * 5000 + " 条, P1: 0 条, P2: 1 条" self.assertIsNone(self.mod.parse_severity_counts(text)) + def test_baseline_header_line_does_not_disturb_parsing(self): + # issue #27: mcp-server 会把基线过滤头行 (纯中文, 不含 P0/P1/P2 字面 + # token) 拼在报告正文开头 — 钉住标记路径与 prose 兜底路径的解析结果 + # 都与无头行时一致 (gate 不受新报告形态影响) + marker = '' + plain = "汇总: P0: 0 条, P1: 0 条, P2: 2 条\n详情...\n" + marker + headed = ("> 基线过滤: 已过滤 3 条已知 finding, 本轮新增 1 条进入复核\n" + + plain) + self.assertEqual(self.mod.parse_verdict_marker(headed), + self.mod.parse_verdict_marker(plain)) + self.assertEqual(self.mod.parse_severity_counts(headed), + self.mod.parse_severity_counts(plain), (0, 0, 2)) + # 无标记时的 prose 兜底路径同样不受头行影响 (头行无 P0/P1/P2 token) + prose = "汇总: P0: 0 条, P1: 0 条, P2: 2 条\n详情..." + self.assertEqual( + self.mod.parse_severity_counts("> 基线过滤: 已过滤 3 条已知 finding" + ", 本轮新增 1 条进入复核\n" + prose), + self.mod.parse_severity_counts(prose)) + # ============================================================ # 评论 body diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 4b0ccc0..99cdf2a 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -9,6 +9,9 @@ - MimosaMcpClient: stdio JSON-RPC 握手 + tools/call (假 server 实测) - _mimosa_quick_scan: content 提取 / isError 抛异常 - tool_zcode_security_review: mimosa 缺失报错 / 扫描失败报错 / 正常管线 + - tool_zcode_pr_review: findings 按 diff 文件过滤 (issue #26) + 已知基线去重 + (issue #27): 两级过滤/回滚开关/路径归一/指纹稳定/入库时机守卫 (仅复核 + 成功才落盘)/基线损坏 fail-safe/分仓隔离/过滤后 0 条的如实文案 运行: python3 tests/test_security_review.py 依赖: 仅 Python 标准库 + zcode-mcp-server 模块 @@ -74,7 +77,10 @@ class _EnvGuard(unittest.TestCase): "ZCODE_BRIDGE_MIMOSA_POLL_INTERVAL", "ZCODE_BRIDGE_MIMOSA_RECV_TIMEOUT", "ZCODE_BRIDGE_CODE_MAX", "ZCODE_BRIDGE_MAX_OUTPUT", - "ZCODE_BRIDGE_PR_DIFF_MAX") + "ZCODE_BRIDGE_PR_DIFF_MAX", + # issue #26/#27: PR findings 过滤/基线开关与基线目录 + "ZCODE_BRIDGE_PR_FINDINGS_SCOPE", "ZCODE_BRIDGE_PR_BASELINE", + "ZCODE_BRIDGE_BASELINE_DIR") def setUp(self): self._saved = {k: os.environ.get(k) for k in self.ENV_KEYS} @@ -1020,6 +1026,14 @@ def fake_run(cmd, *a, **kw): self.assertIn("depth=deep", prompt) +def _mk_finding(path, anchor, title="SQL 注入", line=11): + """构造 mimosa findings schema 的最小 finding (含内容锚 anchor, issue #27)。""" + return {"identity": {"publicClass": "sql-injection", "anchor": anchor}, + "severity": "high", "cwe": ["CWE-89"], + "location": {"path": path, "line": line}, + "title": title, "message": "拼接查询"} + + class TestPrReview(_EnvGuard): """tool_zcode_pr_review: git diff + mimosa 聚焦 + zcode 复核""" @@ -1028,14 +1042,23 @@ 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, zcode_report="PR 报告"): + rev_ok=True, zcode_report="PR 报告", findings=None, + zcode_rc=0, zcode_stderr=""): """patch git/mimosa/zcode 三路。changed=None 表示非 git 仓库; rev_ok=False 表示所有 rev 解析失败 (测 base 自动探测失败); - zcode_report 控制假 zcode 返回的报告正文 (测 verdict 标记转写)。""" + zcode_report 控制假 zcode 返回的报告正文 (测 verdict 标记转写); + findings 控制 fake deep 扫描产出 (默认 1 条落在 app.py, 必被 + changed 命中); zcode_rc/zcode_stderr 控制 zcode 调用成败 (测基线 + 入库时机守卫)。基线目录默认指到临时目录 — 基线默认开启, 不隔离 + 会把测试跑进真实 ~/.local/state。""" import tempfile mod = self.mod proj = tempfile.mkdtemp(prefix="zcode-pr-proj-") self.addCleanup(lambda: __import__("shutil").rmtree(proj, ignore_errors=True)) + baseline_dir = tempfile.mkdtemp(prefix="zcode-baseline-") + self.addCleanup(lambda: __import__("shutil").rmtree(baseline_dir, + ignore_errors=True)) + os.environ["ZCODE_BRIDGE_BASELINE_DIR"] = baseline_dir saved = { "find_root": mod._find_mimosa_root, "deep": mod._mimosa_deep_scan, @@ -1061,19 +1084,21 @@ def fake_run(cmd, *a, **kw): if sub == "diff": return _FakeCompletedProcess(0, diff_text, "") captured["cmd"] = cmd # zcode 调用 + # 附件临时文件 finally 才清理, 这里顺便读回内容供断言 + with open(cmd[cmd.index("--attach") + 1]) as fh: + captured["attachment"] = fh.read() return _FakeCompletedProcess( - 0, json.dumps({"response": zcode_report}, ensure_ascii=False), - "") + zcode_rc, json.dumps({"response": zcode_report}, ensure_ascii=False), + zcode_stderr) def fake_find_root(): return "/fake/mimosa" def fake_deep(root, path, focus_files=None): captured["focus_files"] = focus_files - return ("deep 摘要", [{"identity": {"publicClass": "sql-injection"}, - "severity": "high", "cwe": ["CWE-89"], - "location": {"path": "app.py", "line": 11}, - "title": "SQL 注入", "message": "拼接查询"}]) + scan_findings = findings if findings is not None else [ + _mk_finding("app.py", "sha256:app-11")] + return ("deep 摘要", scan_findings) mod.subprocess.run = fake_run mod._find_mimosa_root = fake_find_root @@ -1249,6 +1274,301 @@ def test_pr12_no_verdict_line_unchanged(self): self._restore(mod, saved) self.assertEqual(result["content"][0]["text"], report) + def _preset_baseline(self, mod, proj, known_findings): + """预置基线库: 把 given findings 的指纹写成已知 entry, 返回基线路径。""" + bl_path = mod._baseline_path(os.path.abspath(proj)) + entries = {} + for f in known_findings: + entries[mod._finding_fingerprint(f)] = { + "path": f["location"]["path"], + "anchor": f["identity"].get("anchor"), + "findingId": f.get("findingId"), + "publicClass": f["identity"]["publicClass"], + "severity": f["severity"], "title": f["title"], + "firstSeen": "2026-01-01T00:00:00", + "lastSeen": "2026-01-01T00:00:00", "count": 1} + os.makedirs(os.path.dirname(bl_path), exist_ok=True) + with open(bl_path, "w") as fh: + json.dump({"schemaVersion": "zcode-pr-review-baseline/v1", + "repo": os.path.abspath(proj), + "updatedAt": "2026-01-01T00:00:00", + "entries": entries}, fh) + return bl_path + + def test_pr13_findings_filtered_by_diff_files(self): + """PR13: mimosa findings 按 diff 触及文件过滤, 附件只含命中的 (issue #26)""" + f_hit = _mk_finding("app.py", "sha256:a", title="改动内问题") + f_miss = _mk_finding("other.py", "sha256:b", title="存量噪音") + mod, saved, proj, captured = self._patch( + changed=["app.py", "util.py"], findings=[f_hit, f_miss]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("# mimosa findings (diff 触及文件内, 1/2 条)", att) + self.assertIn("改动内问题", att) + self.assertNotIn("存量噪音", att, "未落在改动文件内的 finding 应被过滤") + + def test_pr14_findings_scope_all_restores_full_list(self): + """PR14: ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all → 全量进附件 (旧行为回滚开关)""" + os.environ["ZCODE_BRIDGE_PR_FINDINGS_SCOPE"] = "all" + f1 = _mk_finding("app.py", "sha256:a") + f2 = _mk_finding("other.py", "sha256:b", title="存量噪音") + mod, saved, proj, captured = self._patch( + changed=["app.py"], findings=[f1, f2]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("# findings 清单 (2 条)", att) + self.assertIn("存量噪音", att, "scope=all 不做文件过滤") + + def test_pr15_finding_path_normalized(self): + """PR15: finding path 带 ./ 前缀仍命中 (双侧 normpath 归一)""" + f = _mk_finding("./app.py", "sha256:a") + mod, saved, proj, captured = self._patch(changed=["app.py"], findings=[f]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + + def test_pr16_baseline_filters_known(self): + """PR16: 预置基线 → known finding 排除出附件, 报告头行注入 known 计数 + (issue #27: 首次已详报的不再重复进复核)""" + known = _mk_finding("app.py", "sha256:known", title="旧问题") + fresh = _mk_finding("app.py", "sha256:fresh", title="新问题") + mod, saved, proj, captured = self._patch( + changed=["app.py"], findings=[known, fresh]) + bl_path = self._preset_baseline(mod, proj, [known]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + # 附件: 只列 fresh, known 以一行计数说明 + att = captured["attachment"] + self.assertIn("新问题", att) + self.assertNotIn("旧问题", att) + self.assertIn("另有已知基线 finding 1 条已过滤", att) + # 报告头行注入 known 计数 (在 verdict 标记转写之前拼进正文开头) + self.assertTrue(result["content"][0]["text"].startswith( + "> 基线过滤: 已过滤 1 条已知 finding, 本轮新增 1 条进入复核")) + # known 项 lastSeen/count 已刷新 (成功路径落盘) + with open(bl_path) as fh: + entry = json.load(fh)["entries"][mod._finding_fingerprint(known)] + self.assertEqual(entry["count"], 2) + + def test_pr17_baseline_saved_only_on_success(self): + """PR17: 仅 zcode 复核成功才落基线; isError/异常路径绝不写 (安全关键 — + gate 对同 head 重试, 提前入库会让新 finding 变已知 → 假"无新增" → 假 pass)""" + fresh = _mk_finding("app.py", "sha256:fresh") + # 成功路径: 本轮 new finding 指纹入库 + mod, saved, proj, _ = self._patch(changed=["app.py"], findings=[fresh]) + bl_path = mod._baseline_path(os.path.abspath(proj)) + try: + mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + with open(bl_path) as fh: + entries = json.load(fh)["entries"] + self.assertIn(mod._finding_fingerprint(fresh), entries) + finally: + self._restore(mod, saved) + # isError 路径: zcode 调用失败 → 基线文件不落 + mod, saved, proj, _ = self._patch( + changed=["app.py"], findings=[fresh], zcode_rc=1, zcode_stderr="boom") + bl_path = mod._baseline_path(os.path.abspath(proj)) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + self.assertTrue(result.get("isError")) + self.assertFalse(os.path.exists(bl_path), "isError 不应落基线") + finally: + self._restore(mod, saved) + # 异常路径: mimosa 扫描抛错 (更早返回) → 同样不落 + mod, saved, proj, _ = self._patch(changed=["app.py"], findings=[fresh]) + + def boom(root, path, focus_files=None): + raise RuntimeError("engine boom") + + mod._mimosa_deep_scan = boom + bl_path = mod._baseline_path(os.path.abspath(proj)) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + self.assertTrue(result.get("isError")) + self.assertFalse(os.path.exists(bl_path), "扫描异常不应落基线") + finally: + self._restore(mod, saved) + + def test_pr18_corrupt_baseline_fail_safe(self): + """PR18: 基线文件非法 JSON → 按空基线, 全量 in-scope finding 重报不炸 + (fail-safe: 基线是降噪优化, 读不到方向是宁多报不漏报)""" + f = _mk_finding("app.py", "sha256:a") + mod, saved, proj, captured = self._patch(changed=["app.py"], findings=[f]) + bl_path = mod._baseline_path(os.path.abspath(proj)) + os.makedirs(os.path.dirname(bl_path), exist_ok=True) + with open(bl_path, "w") as fh: + fh.write("{not-json") + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + # 成功后损坏库被合法基线覆写, 下轮起恢复正常去重 + with open(bl_path) as fh: + entries = json.load(fh)["entries"] + self.assertIn(mod._finding_fingerprint(f), entries) + + def test_pr19_baseline_isolated_per_repo(self): + """PR19: 两个 repo path → 两个基线文件互不串 (键 = repo 绝对路径哈希)""" + import tempfile + f = _mk_finding("app.py", "sha256:a") + mod, saved, proj, _ = self._patch(changed=["app.py"], findings=[f]) + proj2 = tempfile.mkdtemp(prefix="zcode-pr-proj2-") + self.addCleanup(lambda: __import__("shutil").rmtree(proj2, ignore_errors=True)) + try: + r1 = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + r2 = mod.tool_zcode_pr_review({"path": proj2, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", r1) + self.assertNotIn("isError", r2) + bl1 = mod._baseline_path(os.path.abspath(proj)) + bl2 = mod._baseline_path(os.path.abspath(proj2)) + self.assertNotEqual(bl1, bl2) + fp = mod._finding_fingerprint(f) + for bl in (bl1, bl2): + with open(bl) as fh: + entries = json.load(fh)["entries"] + self.assertIn(fp, entries) + self.assertEqual(entries[fp]["count"], 1, + "两仓各自独立计数, 无跨仓 known 吞并") + + def test_pr20_baseline_off_never_touched(self): + """PR20: ZCODE_BRIDGE_PR_BASELINE=off → 不读不写基线, 行为等同无基线""" + os.environ["ZCODE_BRIDGE_PR_BASELINE"] = "off" + f = _mk_finding("app.py", "sha256:a") + mod, saved, proj, captured = self._patch(changed=["app.py"], findings=[f]) + bl_path = self._preset_baseline(mod, proj, [f]) # 预置"已知": off 下不应被读 + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + # 不读: finding 未被基线吞, 报告无基线头行 + self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + self.assertNotIn("基线过滤", result["content"][0]["text"]) + # 不写: 预置条目原样 (count 未被刷新) + with open(bl_path) as fh: + entry = json.load(fh)["entries"][mod._finding_fingerprint(f)] + self.assertEqual(entry["count"], 1) + + def test_pr21_all_filtered_zero_kept_honest_copy(self): + """PR21: diff 过滤后 0 条但全仓有产出 → 如实说明过滤原因, + 不误称"未取到结构化 findings" (会被读成扫描失败)""" + f = _mk_finding("other.py", "sha256:b", title="存量噪音") + mod, saved, proj, captured = self._patch(changed=["app.py"], findings=[f]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("diff 触及文件内 0 条", att) + self.assertIn("全仓共 1 条", att) + self.assertNotIn("未取到结构化 findings", att) + + def test_pr22_all_in_scope_known_zero_new(self): + """PR22: in-scope findings 全为已知 → 清单 0 条但文案如实, + 头行注入"新增 0 条" (zcode 只复核 diff 本身)""" + known = _mk_finding("app.py", "sha256:known", title="旧问题") + mod, saved, proj, captured = self._patch( + changed=["app.py"], findings=[known]) + self._preset_baseline(mod, proj, [known]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("均为已知基线 finding", att) + self.assertNotIn("未取到结构化 findings", att) + self.assertTrue(result["content"][0]["text"].startswith( + "> 基线过滤: 已过滤 1 条已知 finding, 本轮新增 0 条进入复核")) + + def test_pr23_verdict_marker_still_tail_with_baseline_header(self): + """PR23: 注入基线头行后 verdict 标记仍在尾部 (位置契约不被破坏, + 仿 PR11 — gate parse_verdict_marker 只认文末标记)""" + known = _mk_finding("app.py", "sha256:known") + report = ("汇总: P0: 0 条, P1: 1 条, P2: 2 条\n详述...\n" + "VERDICT: P0=0 P1=1 P2=2 MERGE=no") + mod, saved, proj, _ = self._patch( + changed=["app.py"], findings=[known], zcode_report=report) + self._preset_baseline(mod, proj, [known]) + 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.assertTrue(text.startswith("> 基线过滤:"), "头行应拼在正文开头") + self.assertIn('', text) + self.assertTrue(text.rstrip().endswith("-->"), "标记必须仍是最后一行") + + +class TestFindingFingerprint(_EnvGuard): + """_finding_fingerprint / 基线路径: 指纹稳定性 (issue #27 基线键)""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def test_bf0_line_drift_same_fingerprint(self): + """BF0: 行号漂移 (location.line 变) → 同指纹 (anchor 锚定内容不锚行号)""" + f1 = _mk_finding("app.py", "sha256:same", line=11) + f2 = _mk_finding("app.py", "sha256:same", line=4242) + self.assertEqual(self.mod._finding_fingerprint(f1), + self.mod._finding_fingerprint(f2)) + + def test_bf1_same_anchor_different_path_differs(self): + """BF1: 同 anchor 不同 path → 不同指纹 (同段代码复制到新文件是新 finding)""" + f1 = _mk_finding("app.py", "sha256:same") + f2 = _mk_finding("util.py", "sha256:same") + self.assertNotEqual(self.mod._finding_fingerprint(f1), + self.mod._finding_fingerprint(f2)) + + def test_bf2_missing_anchor_fallback(self): + """BF2: 缺 anchor → path+publicClass+title 弱指纹 (确定性 + 区分度)""" + f = {"identity": {"publicClass": "sql-injection"}, + "severity": "high", + "location": {"path": "app.py", "line": 3}, + "title": "SQL 注入", "message": "m"} + fp = self.mod._finding_fingerprint(f) + self.assertEqual(fp, self.mod._finding_fingerprint(dict(f)), "弱指纹需确定") + self.assertEqual(len(fp), 64, "sha256 十六进制 64 字符") + # 弱指纹不同 title → 不同值 (有区分度, 不塌缩成 path 单键) + f2 = dict(f) + f2["title"] = "另一个问题" + self.assertNotEqual(fp, self.mod._finding_fingerprint(f2)) + + def test_bf3_baseline_path_per_repo_and_env(self): + """BF3: 基线路径按 repo 绝对路径哈希分仓; ZCODE_BRIDGE_BASELINE_DIR 可指他处""" + import tempfile + mod = self.mod + self.assertNotEqual(mod._baseline_path("/repo/a"), + mod._baseline_path("/repo/b")) + self.assertTrue(mod._baseline_path("/repo/a").endswith(".json")) + bl_dir = tempfile.mkdtemp(prefix="zcode-bl-dir-") + self.addCleanup(lambda: __import__("shutil").rmtree(bl_dir, ignore_errors=True)) + os.environ["ZCODE_BRIDGE_BASELINE_DIR"] = bl_dir + path = mod._baseline_path("/repo/a") + self.assertEqual(path, os.path.join(bl_dir, os.path.basename(path))) + class TestVerdictMarker(_EnvGuard): """_append_verdict_marker 单元行为 (issue #16)""" From 240bed876a8c56cad501e285b4a4f0e2af0b6a83 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:52:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E5=8F=8C=E5=AE=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=E2=80=94=20quotePath=20=E4=B8=AD=E6=96=87=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=BC=8F=E6=8A=A5=20(P1)=20+=20=E5=9F=BA=E7=BA=BF=E8=AF=BB?= =?UTF-8?q?=E5=86=99=20UTF-8=20=E7=BC=96=E7=A0=81=E6=B4=9E=20(P2=C3=972)?= =?UTF-8?q?=20+=20README=20all=20=E8=A1=8C=E5=8F=A3=E5=BE=84=20(P2)=20+=20?= =?UTF-8?q?P3=C3=976?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _pr_diff 加 -c core.quotePath=false: 非 ASCII 路径不再被 C 转义 → 过滤匹配恢复; 回归测试 gt1 (flag 钉住) + pr24 (中文路径端到端存活) - _load_baseline/_save_baseline 显式 encoding=utf-8, except 扩 UnicodeError — 非 UTF-8 locale 不再破坏「绝不抛」「落盘失败不反杀」契约 - 同扫重复指纹 count 语义与注释对齐 (出现次数+1); 附件标题改「本轮新增 X, 全仓 Y」 - test_pr17 预置基线字节级断言; gate 测试元组误作 msg 参数改钉值; scope=all 措辞分支 - 437 passed, 28 subtests --- packages/mcp-server/zcode-mcp-server | 71 +++++++++++++------- packages/review-gate/README.md | 5 +- tests/test_review_gate.py | 11 ++-- tests/test_security_review.py | 97 ++++++++++++++++++++++++---- 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index cf10aa4..89f2456 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -642,7 +642,8 @@ def _filter_findings_by_files(findings, changed_files): # 基线 schema: {"schemaVersion": "zcode-pr-review-baseline/v1", "repo": "", # "updatedAt": "", "entries": {"<指纹>": {path, anchor, # findingId, publicClass, severity, title, firstSeen, lastSeen, -# count}}} +# count}}} — count 是出现次数不是轮次: 每轮复核成功按本轮 +# 出现次数 +1, 同扫内同指纹的重复 finding 各自 +1。 _BASELINE_SCHEMA = "zcode-pr-review-baseline/v1" @@ -693,13 +694,17 @@ def _load_baseline(path): 基线是降噪优化不是正确性依赖: 读不到就当全量新 finding 重报, 方向 宁多报不漏报 (与 gate verdict 的 fail-safe 取舍一致)。 + 显式 encoding="utf-8" + 捕 UnicodeError: 基线含中文 title + (ensure_ascii=False 落盘), 非 UTF-8 locale 下裸 open 会抛 + UnicodeDecodeError (ValueError 子类, 不是 JSONDecodeError), 不捕就 + 违反本函数"绝不抛"的契约。 """ try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: return _empty_baseline() # 首轮审查, 正常路径, 不刷日志 - except (json.JSONDecodeError, OSError) as e: + except (json.JSONDecodeError, OSError, UnicodeError) as e: log(f"⚠ 基线文件 {path} 损坏, 按空基线处理: {e}") return _empty_baseline() if not isinstance(data.get("entries"), dict): @@ -713,13 +718,16 @@ def _save_baseline(path, baseline): os.replace 同目录换名是原子的 — 中途断电最多丢本轮更新, 不会留半个 JSON 让下轮 _load_baseline 整库作废。 + 显式 encoding="utf-8": 基线含中文 title, ASCII locale 下裸 open("w") + 会让 ensure_ascii=False 的 json.dump 抛 UnicodeEncodeError (ValueError, + 调用点的 except OSError 捕不住) → 反杀已成功的审查。 """ baseline["updatedAt"] = datetime.now().isoformat(timespec="seconds") directory = os.path.dirname(path) if directory: os.makedirs(directory, exist_ok=True) tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(baseline, f, ensure_ascii=False, indent=1) os.replace(tmp, path) @@ -763,14 +771,21 @@ def _pr_diff(repo, base, head): 返回 (changed_files, diff_text); 失败抛 RuntimeError。 base/head 需先过 _validate_rev; 这里再加 --end-of-options 双保险, 末尾 -- 隔离 pathspec。 + diff 一律带 -c core.quotePath=false: git 默认引用转义会把含非 ASCII/ + 引号/反斜杠的路径变成 "doc/\\346\\214\\207.md" 八进制形态, normpath + 不解引用, 与 mimosa location.path (真实 UTF-8) 永远匹配不上 → 中文 + 文件里的 finding 被过滤静默丢弃 (漏报方向)。该输出在 main 侧只喂 + focus_files (优先级提示, 无害), 本分支起是 findings 过滤的正确性依赖。 """ range_spec = f"{base}...{head}" - rc, files_out, err = _git(repo, "diff", "--name-only", "--end-of-options", + rc, files_out, err = _git(repo, "-c", "core.quotePath=false", + "diff", "--name-only", "--end-of-options", range_spec, "--", timeout=60) if rc != 0: raise RuntimeError(f"git diff {range_spec} 失败: {err.strip()[:200]}") changed = [ln.strip() for ln in files_out.splitlines() if ln.strip()] - rc, diff_text, err = _git(repo, "diff", "--end-of-options", range_spec, "--", + rc, diff_text, err = _git(repo, "-c", "core.quotePath=false", + "diff", "--end-of-options", range_spec, "--", timeout=60) if rc != 0: raise RuntimeError(f"git diff {range_spec} 失败: {err.strip()[:200]}") @@ -1446,17 +1461,22 @@ def tool_zcode_pr_review(args): entry["lastSeen"] = now known_count += 1 else: - staged[fp] = { - "path": (f.get("location") or {}).get("path"), - "anchor": (f.get("identity") or {}).get("anchor"), - "findingId": f.get("findingId"), - "publicClass": (f.get("identity") or {}).get("publicClass"), - "severity": f.get("severity"), - "title": f.get("title"), - "firstSeen": now, - "lastSeen": now, - "count": 1, - } + if fp in staged: + # 同扫内同指纹重复 finding: count 再 +1 (是次数不是轮次), + # 附件仍逐条列 — 与 known 刷新的计数口径一致 + staged[fp]["count"] += 1 + else: + staged[fp] = { + "path": (f.get("location") or {}).get("path"), + "anchor": (f.get("identity") or {}).get("anchor"), + "findingId": f.get("findingId"), + "publicClass": (f.get("identity") or {}).get("publicClass"), + "severity": f.get("severity"), + "title": f.get("title"), + "firstSeen": now, + "lastSeen": now, + "count": 1, + } new_findings.append(f) entries.update(staged) findings = new_findings @@ -1475,16 +1495,23 @@ def tool_zcode_pr_review(args): if scope_all: heading = f"\n# findings 清单 ({len(compact)} 条)\n" else: - heading = (f"\n# mimosa findings (diff 触及文件内, " - f"{len(compact)}/{total} 条)\n") + # 口径: 列出数 X 是两级过滤后的本轮新增 (不含已知基线), Y 是全仓 + # 扫描产出 — "X/Y 条"字面会让人把基线滤除也算进分母, 直接写明 + heading = (f"\n# mimosa findings (本轮新增 {len(compact)}, " + f"全仓 {total} 条)\n") attachment += heading + json.dumps(compact, ensure_ascii=False, indent=1) if known_count: attachment += (f"\n\n(另有已知基线 finding {known_count} 条已过滤, " "未列入本次核实)\n") elif in_scope: - # diff 过滤命中但全被基线滤掉 — 如实说明, 不沿用下面"未取到"文案 - attachment += (f"\n# mimosa findings\n(diff 触及文件内 {in_scope} 条均为" - f"已知基线 finding, 已过滤, 本轮无需逐条核实)\n") + # diff 过滤命中但全被基线滤掉 — 如实说明, 不沿用下面"未取到"文案。 + # scope=all 没做文件过滤 (in_scope==total), 不能误称"diff 触及文件内" + if scope_all: + attachment += (f"\n# mimosa findings\n(全仓 {in_scope} 条均为已知" + "基线 finding, 已过滤, 本轮无需逐条核实)\n") + else: + attachment += (f"\n# mimosa findings\n(diff 触及文件内 {in_scope} 条均为" + f"已知基线 finding, 已过滤, 本轮无需逐条核实)\n") elif total: # 扫描确有产出但都不在改动文件内 — 区别于"未取到结构化 findings" # (后者会被误读为扫描失败), 如实说明过滤原因 diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index 7b4a3a8..e679f9b 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -158,7 +158,7 @@ systemd 用 `systemctl --user edit zcode-review-gate` 加 `Environment=` 行) | env | 默认 | 说明 | |---|---|---| -| `ZCODE_BRIDGE_PR_FINDINGS_SCOPE` | `diff` | findings 附件范围:`diff`(只含改动文件内)/ `all`(回滚到全量进附件的旧行为) | +| `ZCODE_BRIDGE_PR_FINDINGS_SCOPE` | `diff` | findings 附件范围:`diff`(只含改动文件内)/ `all`(不按文件过滤;配合 `ZCODE_BRIDGE_PR_BASELINE=off` 才完全回到旧行为——默认基线开着时 `all` 仍会剔除已知 finding) | | `ZCODE_BRIDGE_PR_BASELINE` | `on` | 已知基线去重:`off` 关闭(不读不写基线,回滚到过滤前行为) | 全量回滚(疑似过滤误伤时排查用):`ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all` @@ -223,6 +223,9 @@ zcode。 - **基线键绑定 clone 绝对路径**:换机、迁移或改名 clone root 会让 `~/.local/state/zcode-mcp-server/baselines/` 下的旧键失配,等效基线 重置——一轮存量噪音回潮后重新收敛,无害(方向宁多报不漏报)。 +- **基线 entries 永不清理**:代码删除/重命名后旧指纹条目会永久残留 + (体量上界 = 该仓历史 distinct 指纹数,纯计数元数据,无安全影响); + 需要瘦身就删该仓基线文件重置(见运维节 `rm` 命令)。 - token 不落盘:经 git≥2.31 的 `GIT_CONFIG_COUNT/KEY/VALUE` 环境变量逐 命令注入 `http.extraHeader`(env 只对本用户可见,优于 argv), clone URL / git config / state 文件里都不会有 token。 diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index ac76b86..032974d 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -443,21 +443,24 @@ def test_huge_digit_prose_none(self): def test_baseline_header_line_does_not_disturb_parsing(self): # issue #27: mcp-server 会把基线过滤头行 (纯中文, 不含 P0/P1/P2 字面 # token) 拼在报告正文开头 — 钉住标记路径与 prose 兜底路径的解析结果 - # 都与无头行时一致 (gate 不受新报告形态影响) + # 都与无头行时一致 (gate 不受新报告形态影响)。 + # 断言直接钉具体值而非只比对两形态相等 — 两侧同为 None 的整体回归 + # 也会让相等断言通过 (审查 P3-5) marker = '' plain = "汇总: P0: 0 条, P1: 0 条, P2: 2 条\n详情...\n" + marker headed = ("> 基线过滤: 已过滤 3 条已知 finding, 本轮新增 1 条进入复核\n" + plain) + self.assertEqual(self.mod.parse_verdict_marker(headed), + ((0, 0, 2), True)) self.assertEqual(self.mod.parse_verdict_marker(headed), self.mod.parse_verdict_marker(plain)) - self.assertEqual(self.mod.parse_severity_counts(headed), - self.mod.parse_severity_counts(plain), (0, 0, 2)) + self.assertEqual(self.mod.parse_severity_counts(headed), (0, 0, 2)) # 无标记时的 prose 兜底路径同样不受头行影响 (头行无 P0/P1/P2 token) prose = "汇总: P0: 0 条, P1: 0 条, P2: 2 条\n详情..." self.assertEqual( self.mod.parse_severity_counts("> 基线过滤: 已过滤 3 条已知 finding" ", 本轮新增 1 条进入复核\n" + prose), - self.mod.parse_severity_counts(prose)) + (0, 0, 2)) # ============================================================ diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 99cdf2a..8c04e8b 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1071,7 +1071,8 @@ def fake_run(cmd, *a, **kw): if cmd[0] == "git": if changed is None: # 非 git 仓库 return _FakeCompletedProcess(128, "", "not a git repository") - sub = cmd[3] # ["git", "-C", repo, , ...] + # ["git", "-C", repo, [-c core.quotePath=false,] , ...] + sub = cmd[5] if cmd[3] == "-c" else cmd[3] if sub == "rev-parse" and "--git-dir" in cmd: return _FakeCompletedProcess(0, ".git\n", "") # 仓库探测恒过 if sub == "rev-parse" or sub == "symbolic-ref": @@ -1307,7 +1308,7 @@ def test_pr13_findings_filtered_by_diff_files(self): self._restore(mod, saved) self.assertNotIn("isError", result) att = captured["attachment"] - self.assertIn("# mimosa findings (diff 触及文件内, 1/2 条)", att) + self.assertIn("# mimosa findings (本轮新增 1, 全仓 2 条)", att) self.assertIn("改动内问题", att) self.assertNotIn("存量噪音", att, "未落在改动文件内的 finding 应被过滤") @@ -1336,7 +1337,7 @@ def test_pr15_finding_path_normalized(self): finally: self._restore(mod, saved) self.assertNotIn("isError", result) - self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + self.assertIn("(本轮新增 1, 全仓 1 条)", captured["attachment"]) def test_pr16_baseline_filters_known(self): """PR16: 预置基线 → known finding 排除出附件, 报告头行注入 known 计数 @@ -1378,14 +1379,22 @@ def test_pr17_baseline_saved_only_on_success(self): self.assertIn(mod._finding_fingerprint(fresh), entries) finally: self._restore(mod, saved) - # isError 路径: zcode 调用失败 → 基线文件不落 + # isError 路径: zcode 调用失败 → 基线不落; 预置含 known entry 的基线 + # 必须字节级原样 — 只断言"新文件不存在"捕获不了"失败路径仍刷新 + # known/写入 new"的假想 bug (审查 P3-4) + known = _mk_finding("app.py", "sha256:known", title="旧问题") mod, saved, proj, _ = self._patch( - changed=["app.py"], findings=[fresh], zcode_rc=1, zcode_stderr="boom") - bl_path = mod._baseline_path(os.path.abspath(proj)) + changed=["app.py"], findings=[fresh, known], + zcode_rc=1, zcode_stderr="boom") + bl_path = self._preset_baseline(mod, proj, [known]) + with open(bl_path) as fh: + preset_bytes = fh.read() try: result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) self.assertTrue(result.get("isError")) - self.assertFalse(os.path.exists(bl_path), "isError 不应落基线") + with open(bl_path) as fh: + self.assertEqual(fh.read(), preset_bytes, + "isError 路径不得刷新 known 或写入 new") finally: self._restore(mod, saved) # 异常路径: mimosa 扫描抛错 (更早返回) → 同样不落 @@ -1417,7 +1426,7 @@ def test_pr18_corrupt_baseline_fail_safe(self): finally: self._restore(mod, saved) self.assertNotIn("isError", result) - self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + self.assertIn("(本轮新增 1, 全仓 1 条)", captured["attachment"]) # 成功后损坏库被合法基线覆写, 下轮起恢复正常去重 with open(bl_path) as fh: entries = json.load(fh)["entries"] @@ -1460,7 +1469,7 @@ def test_pr20_baseline_off_never_touched(self): self._restore(mod, saved) self.assertNotIn("isError", result) # 不读: finding 未被基线吞, 报告无基线头行 - self.assertIn("(diff 触及文件内, 1/1 条)", captured["attachment"]) + self.assertIn("(本轮新增 1, 全仓 1 条)", captured["attachment"]) self.assertNotIn("基线过滤", result["content"][0]["text"]) # 不写: 预置条目原样 (count 未被刷新) with open(bl_path) as fh: @@ -1520,6 +1529,43 @@ def test_pr23_verdict_marker_still_tail_with_baseline_header(self): '"merge":false} -->', text) self.assertTrue(text.rstrip().endswith("-->"), "标记必须仍是最后一行") + def test_pr24_cjk_path_survives_filter(self): + """PR24: 中文路径端到端命中过滤 (审查 P1 回归) — _pr_diff 关掉 + quotePath (GT1) 后 changed 与 location.path 同为真实 UTF-8, + normpath 直接对上, 不再被引用转义形态静默吞掉""" + cjk = "doc/指南.md" + kept = _mk_finding(cjk, "sha256:cjk") + # 纯函数层: 双侧真实 UTF-8 输入 (转义发生在 git 输出侧, 由 -c 关掉) + k, dropped = self.mod._filter_findings_by_files([kept], [cjk]) + self.assertEqual((len(k), dropped), (1, 0)) + mod, saved, proj, captured = self._patch(changed=[cjk, "app.py"], + findings=[kept]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("(本轮新增 1, 全仓 1 条)", att) + self.assertIn("指南", att, "中文路径 finding 应进入附件") + + def test_pr25_scope_all_all_known_wording(self): + """PR25: scope=all 且全为已知 → 措辞用"全仓 N 条", 不误称 + "diff 触及文件内" (该模式没做文件过滤, in_scope==total)""" + os.environ["ZCODE_BRIDGE_PR_FINDINGS_SCOPE"] = "all" + known = _mk_finding("other.py", "sha256:known", title="旧问题") + mod, saved, proj, captured = self._patch( + changed=["app.py"], findings=[known]) + self._preset_baseline(mod, proj, [known]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + att = captured["attachment"] + self.assertIn("全仓 1 条均为已知基线 finding", att) + self.assertNotIn("diff 触及文件内", att) + class TestFindingFingerprint(_EnvGuard): """_finding_fingerprint / 基线路径: 指纹稳定性 (issue #27 基线键)""" @@ -1673,11 +1719,12 @@ def test_gt0_metadata_15s_diff_60s(self): seen = [] def fake_run(cmd, *a, **kw): - sub = cmd[3] # ["git", "-C", repo, , ...] + # ["git", "-C", repo, [-c core.quotePath=false,] , ...] + sub = cmd[5] if cmd[3] == "-c" else cmd[3] seen.append((sub, kw.get("timeout"))) if sub == "diff" and "--name-only" in cmd: return _FakeCompletedProcess(0, "a.py\n", "") - return _FakeCompletedProcess(0, "ok", "") + return _FakeCompletedProcess(0, "ok\n", "") saved = mod.subprocess.run mod.subprocess.run = fake_run @@ -1690,6 +1737,34 @@ def fake_run(cmd, *a, **kw): diff_timeouts = [t for sub, t in seen if sub == "diff"] self.assertEqual(diff_timeouts, [60, 60], "diff 类应 60s") + def test_gt1_diff_disables_quotepath(self): + """GT1: 两次 diff 都带 -c core.quotePath=false 且在子命令之前 — + git 默认引用转义会把中文路径变成 "doc/\\346..." 八进制形态, + normpath 不解引用, findings 过滤永远匹配不上 → 中文文件 finding + 被静默丢弃 (漏报方向, 审查 P1)""" + mod = self.mod + cmds = [] + + def fake_run(cmd, *a, **kw): + cmds.append(cmd) + if "diff" in cmd and "--name-only" in cmd: + return _FakeCompletedProcess(0, "a.py\n", "") + return _FakeCompletedProcess(0, "ok\n", "") + + saved = mod.subprocess.run + mod.subprocess.run = fake_run + try: + mod._pr_diff("/r", "main", "HEAD") + finally: + mod.subprocess.run = saved + diff_cmds = [c for c in cmds if "diff" in c] + self.assertEqual(len(diff_cmds), 2, "name-only + 全文两次 diff") + for c in diff_cmds: + self.assertIn("-c", c) + i = c.index("-c") + self.assertEqual(c[i + 1], "core.quotePath=false") + self.assertLess(i, c.index("diff"), "-c 必须在子命令 diff 之前") + class TestEmbeddedCreds(_EnvGuard): """内嵌凭证副本的 host 构造与 shared/credentials.py._safe_host 对齐 From 845f717aef78eee0a15728a53b874078037b2d3e Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:02:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=A4=8D=E5=AE=A1=E5=90=8C=E7=B1=BB?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=B4=9E=E6=94=B6=E5=8F=A3=20=E2=80=94=20?= =?UTF-8?q?=E5=85=A8=20subprocess/text=20=E9=80=9A=E9=81=93=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=20UTF-8=20(strict)=20+=20pr24=20=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E7=BA=A7=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _write_temp/_git/agent-help/zcode headless/mimosa Popen 五处显式 encoding=utf-8: quotePath 修复后 git/mimosa 通道开始承载原始 UTF-8 (中文路径/标题), locale 依赖 成为正确性承重点; strict 失败显性 (审查报错) 优于 replace 静默乱码→过滤失配→丢 finding (本 PR 刚修的 P1 症状方向) - _git except 扩 UnicodeError (保「不抛异常, 调用方判」契约, 解码失败经 _pr_diff→RuntimeError→isError 收口仍显性); _save_baseline 调用点扩 UnicodeError - pr24 升级行为级回归: fake git 在缺 -c core.quotePath=false 时回 C 转义形态 (flag 误删→转义失配→测试挂, 不再单靠 GT1 argv 钉); +pr26 同扫重复指纹计数 - 438 passed, 28 subtests --- packages/mcp-server/zcode-mcp-server | 27 ++++++++++++----- tests/test_security_review.py | 45 ++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 89f2456..ad753c3 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -351,6 +351,9 @@ class MimosaMcpClient: ["node", str(server_js)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, cwd=cwd, env=env, + # 同类编码洞收口: 通道是 JSON (含中文 path/title), text=True 默认 + # locale 编码在非 UTF-8 环境会读写失败 — 钉 UTF-8 (node 侧同) + encoding="utf-8", ) self.timeout = timeout self._id = 0 @@ -740,12 +743,17 @@ def _git(repo, *git_args, timeout=15): timeout 分档 (整体 review P2-6): 元数据类 (rev-parse/symbolic-ref) 15s 充裕; diff 类在超大仓/慢磁盘下可能超 30s, 由 _pr_diff 传 timeout=60。 + encoding="utf-8": text=True 默认按 locale 解码, quotePath=false 后 git + 输出原始 UTF-8 字节 (中文路径), 非 UTF-8 locale 下会乱码 (findings 过滤 + 静默匹配不上) 或抛 UnicodeDecodeError — 显式钉 UTF-8; 解码失败也按 + "git 失败" 返回而非抛出, 保住本函数"不抛异常"的契约 (调用方 isError 收口)。 """ try: r = subprocess.run(["git", "-C", repo, *git_args], - capture_output=True, text=True, timeout=timeout) + capture_output=True, text=True, timeout=timeout, + encoding="utf-8") return r.returncode, r.stdout, r.stderr - except (OSError, subprocess.TimeoutExpired) as e: + except (OSError, subprocess.TimeoutExpired, UnicodeError) as e: return 128, "", str(e) @@ -999,7 +1007,7 @@ def tool_get_zcode_capabilities(args): if section: cmd += ["--section", section] result = subprocess.run( - cmd, capture_output=True, text=True, timeout=30 + cmd, capture_output=True, text=True, timeout=30, encoding="utf-8" ) if result.returncode != 0: return {"content": [{"type": "text", "text": f"错误: {result.stderr}"}], @@ -1060,7 +1068,8 @@ def _run_zcode_headless(cmd, env, timeout): for attempt in range(max_retries + 1): try: result = subprocess.run( - cmd, capture_output=True, text=True, timeout=timeout, env=env + cmd, capture_output=True, text=True, timeout=timeout, + env=env, encoding="utf-8" ) output = result.stdout # 错误判定: 只解析 stderr (Claude review P1-1: 若把 stdout 审查正文 @@ -1133,9 +1142,12 @@ def _write_temp(text, prefix): 内容是调用方自己的代码/scan findings (非凭证); POSIX 下 tempfile 默认 0600 仅属主可读, 权限足够 (狗食安全 review 存疑-1 已评估)。 + encoding="utf-8": 附件含中文标题与 (quotePath 修复后) 中文路径, + ASCII locale 下裸 mode="w" 会让 write 抛 UnicodeEncodeError 反杀整次审查。 """ import tempfile - tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, prefix=prefix) + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, + prefix=prefix, encoding="utf-8") tmp.write(text) tmp.close() return tmp.name @@ -1571,11 +1583,12 @@ def tool_zcode_pr_review(args): # 写入时机守卫 (安全关键): 仅 zcode 复核成功返回 (非 isError 且 # 无异常) 才落基线。失败/isError/异常路径绝不写 — 否则 gate 对 # 同 head 重试时这些 finding 已变"已知" → 假"无新增" → 假 pass。 - # 落盘失败也不反杀已成功的审查 (基线只是降噪优化, 宁可下轮重报) + # 落盘失败也不反杀已成功的审查 (基线只是降噪优化, 宁可下轮重报); + # UnicodeError 兜孤立代理字对的理论路径 (与"不反杀"注释对齐) if pending_baseline is not None: try: _save_baseline(baseline_file, pending_baseline) - except OSError as e: + except (OSError, UnicodeError) as e: log(f"⚠ 基线落盘失败 (下轮将重报这些 finding): {e}") return result finally: diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 8c04e8b..d28b714 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1034,6 +1034,17 @@ def _mk_finding(path, anchor, title="SQL 注入", line=11): "title": title, "message": "拼接查询"} +def _git_quote(path): + """模拟 git core.quotePath=true 的 C 引用转义 (非 ASCII → 八进制字节)。 + + 简化: 只要缺 -c core.quotePath=false 就整体加引号 (真实 git 只引需 + 转义的路径) — 对回归钉子是更严的方向: ASCII 路径被误引同样失配, + 假想回归 (flag 被删) 会在任何 PR 用例上显形, 不止中文路径。 + """ + return '"' + "".join( + f"\\{b:03o}" if b > 127 else chr(b) for b in path.encode("utf-8")) + '"' + + class TestPrReview(_EnvGuard): """tool_zcode_pr_review: git diff + mimosa 聚焦 + zcode 复核""" @@ -1072,6 +1083,7 @@ def fake_run(cmd, *a, **kw): if changed is None: # 非 git 仓库 return _FakeCompletedProcess(128, "", "not a git repository") # ["git", "-C", repo, [-c core.quotePath=false,] , ...] + # 假设子命令前至多一对 -c (当前仅 _pr_diff 用单对); argv 再变需同步 sub = cmd[5] if cmd[3] == "-c" else cmd[3] if sub == "rev-parse" and "--git-dir" in cmd: return _FakeCompletedProcess(0, ".git\n", "") # 仓库探测恒过 @@ -1080,8 +1092,13 @@ def fake_run(cmd, *a, **kw): return _FakeCompletedProcess(1, "", "unknown revision") return _FakeCompletedProcess(0, "ok\n", "") if sub == "diff" and "--name-only" in cmd: + if "core.quotePath=false" in cmd: + return _FakeCompletedProcess( + 0, "".join(f + "\n" for f in changed), "") + # 缺 flag 时模拟 git 默认引用转义 — 让 quotePath 回归 + # 在 fake 层显形 (路径变 C 引用形态, 过滤失配 → pr24 挂) return _FakeCompletedProcess( - 0, "".join(f + "\n" for f in changed), "") + 0, "".join(_git_quote(f) + "\n" for f in changed), "") if sub == "diff": return _FakeCompletedProcess(0, diff_text, "") captured["cmd"] = cmd # zcode 调用 @@ -1532,7 +1549,10 @@ def test_pr23_verdict_marker_still_tail_with_baseline_header(self): def test_pr24_cjk_path_survives_filter(self): """PR24: 中文路径端到端命中过滤 (审查 P1 回归) — _pr_diff 关掉 quotePath (GT1) 后 changed 与 location.path 同为真实 UTF-8, - normpath 直接对上, 不再被引用转义形态静默吞掉""" + normpath 直接对上, 不再被引用转义形态静默吞掉。 + 行为级回归: fake git 在 argv 缺 -c core.quotePath=false 时回 C 引用 + 转义形态 (见 _patch), flag 被误删 → 转义路径过滤失配 → 本用例挂, + 不依赖 GT1 的 argv 钉子""" cjk = "doc/指南.md" kept = _mk_finding(cjk, "sha256:cjk") # 纯函数层: 双侧真实 UTF-8 输入 (转义发生在 git 输出侧, 由 -c 关掉) @@ -1566,6 +1586,26 @@ def test_pr25_scope_all_all_known_wording(self): self.assertIn("全仓 1 条均为已知基线 finding", att) self.assertNotIn("diff 触及文件内", att) + def test_pr26_duplicate_fingerprint_same_scan_counted(self): + """PR26: 同扫内同指纹两条 finding → 附件各列一条, 基线单条目 count=2 + (count 是出现次数不是轮次, 与 known 刷新口径一致)""" + dup_a = _mk_finding("app.py", "sha256:same", title="重复问题") + dup_b = _mk_finding("app.py", "sha256:same", title="重复问题") + mod, saved, proj, captured = self._patch(changed=["app.py"], + findings=[dup_a, dup_b]) + bl_path = mod._baseline_path(os.path.abspath(proj)) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + self.assertEqual(captured["attachment"].count("重复问题"), 2, + "两条重复 finding 应各自进附件") + with open(bl_path) as fh: + entries = json.load(fh)["entries"] + self.assertEqual(len(entries), 1, "同指纹只占一个基线条目") + self.assertEqual(entries[mod._finding_fingerprint(dup_a)]["count"], 2) + class TestFindingFingerprint(_EnvGuard): """_finding_fingerprint / 基线路径: 指纹稳定性 (issue #27 基线键)""" @@ -1720,6 +1760,7 @@ def test_gt0_metadata_15s_diff_60s(self): def fake_run(cmd, *a, **kw): # ["git", "-C", repo, [-c core.quotePath=false,] , ...] + # 假设子命令前至多一对 -c (当前仅 _pr_diff 用单对); argv 再变需同步 sub = cmd[5] if cmd[3] == "-c" else cmd[3] seen.append((sub, kw.get("timeout"))) if sub == "diff" and "--name-only" in cmd: From 940920c43f98b1e0eea295a0abbfa7753581305a Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:08:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?style:=20=E7=B2=BE=E7=AE=80=20pass=20?= =?UTF-8?q?=E9=87=87=E7=BA=B3=20=E2=80=94=20=5Fbaseline=5Fentry=20?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=AE=9A=E4=B9=89=E7=82=B9(=E7=94=9F?= =?UTF-8?q?=E4=BA=A7+=E6=B5=8B=E8=AF=95=E5=90=8C=E6=BA=90)/scope=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=90=88=E5=B9=B6/=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E5=8E=BB=E9=87=8D/README=20=E5=B0=BE=E5=8F=A5=E6=88=AA?= =?UTF-8?q?=E7=9F=AD=20+=20env=20=E8=A1=A8=E8=A1=A5=20BASELINE=5FDIR=20?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mcp-server/zcode-mcp-server | 45 +++++++++++++++------------- packages/review-gate/README.md | 4 +-- tests/test_security_review.py | 11 ++----- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index ad753c3..b44fac7 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -692,6 +692,25 @@ def _empty_baseline(): return {"schemaVersion": _BASELINE_SCHEMA, "entries": {}} +def _baseline_entry(finding, now): + """新 finding 首次入库的 entry 构造 (字段面见上方 schema 注释块)。 + + 供 PR 审查的 staging 与测试的 _preset_baseline 复用 — 同一 schema + 手写两处迟早漂移, 以此函数为唯一定义点。 + """ + return { + "path": (finding.get("location") or {}).get("path"), + "anchor": (finding.get("identity") or {}).get("anchor"), + "findingId": finding.get("findingId"), + "publicClass": (finding.get("identity") or {}).get("publicClass"), + "severity": finding.get("severity"), + "title": finding.get("title"), + "firstSeen": now, + "lastSeen": now, + "count": 1, + } + + def _load_baseline(path): """读基线库; 缺失/损坏/形状非法 → 空库 + log WARNING, 绝不抛。 @@ -1433,9 +1452,8 @@ def tool_zcode_pr_review(args): return {"content": [{"type": "text", "text": f"mimosa 扫描失败: {e}"}], "isError": True} - # ③ findings 两级过滤 (issue #26/#27): 先按 diff 触及文件, 再按已知基线。 - # mimosa 静态引擎永远全仓扫, 不过滤时历史仓的存量 findings 会淹没本次 - # PR 真正新引入的问题; 两级开关均可 env 回滚 (见 README 运维节)。 + # ③ findings 两级过滤 (issue #26/#27): 先按 diff 触及文件, 再按已知基线 + # (存量淹没的动因见 _filter_findings_by_files); 两级开关均可 env 回滚。 total = len(findings) # 一级 (文件): ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all 恢复全量进附件的旧行为 scope_all = os.environ.get("ZCODE_BRIDGE_PR_FINDINGS_SCOPE", "diff") == "all" @@ -1478,17 +1496,7 @@ def tool_zcode_pr_review(args): # 附件仍逐条列 — 与 known 刷新的计数口径一致 staged[fp]["count"] += 1 else: - staged[fp] = { - "path": (f.get("location") or {}).get("path"), - "anchor": (f.get("identity") or {}).get("anchor"), - "findingId": f.get("findingId"), - "publicClass": (f.get("identity") or {}).get("publicClass"), - "severity": f.get("severity"), - "title": f.get("title"), - "firstSeen": now, - "lastSeen": now, - "count": 1, - } + staged[fp] = _baseline_entry(f, now) new_findings.append(f) entries.update(staged) findings = new_findings @@ -1518,12 +1526,9 @@ def tool_zcode_pr_review(args): elif in_scope: # diff 过滤命中但全被基线滤掉 — 如实说明, 不沿用下面"未取到"文案。 # scope=all 没做文件过滤 (in_scope==total), 不能误称"diff 触及文件内" - if scope_all: - attachment += (f"\n# mimosa findings\n(全仓 {in_scope} 条均为已知" - "基线 finding, 已过滤, 本轮无需逐条核实)\n") - else: - attachment += (f"\n# mimosa findings\n(diff 触及文件内 {in_scope} 条均为" - f"已知基线 finding, 已过滤, 本轮无需逐条核实)\n") + scope_word = "全仓" if scope_all else "diff 触及文件内" + attachment += (f"\n# mimosa findings\n({scope_word} {in_scope} 条均为已知" + "基线 finding, 已过滤, 本轮无需逐条核实)\n") elif total: # 扫描确有产出但都不在改动文件内 — 区别于"未取到结构化 findings" # (后者会被误读为扫描失败), 如实说明过滤原因 diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index e679f9b..7e91e95 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -160,6 +160,7 @@ systemd 用 `systemctl --user edit zcode-review-gate` 加 `Environment=` 行) |---|---|---| | `ZCODE_BRIDGE_PR_FINDINGS_SCOPE` | `diff` | findings 附件范围:`diff`(只含改动文件内)/ `all`(不按文件过滤;配合 `ZCODE_BRIDGE_PR_BASELINE=off` 才完全回到旧行为——默认基线开着时 `all` 仍会剔除已知 finding) | | `ZCODE_BRIDGE_PR_BASELINE` | `on` | 已知基线去重:`off` 关闭(不读不写基线,回滚到过滤前行为) | +| `ZCODE_BRIDGE_BASELINE_DIR` | `~/.local/state/zcode-mcp-server/baselines/` | 基线库存放目录(文件名按仓库 clone 绝对路径哈希派生,分仓隔离) | 全量回滚(疑似过滤误伤时排查用):`ZCODE_BRIDGE_PR_FINDINGS_SCOPE=all` + `ZCODE_BRIDGE_PR_BASELINE=off`,两者独立可组合。 @@ -218,8 +219,7 @@ zcode。 - **确认未修复的真漏洞首次详报后仅进 known 计数**(issue #27 基线去重的 取舍):finding 指纹 = 文件路径 + mimosa 内容锚(anchor),代码内容不变 则指纹不变,下轮 PR 只在报告头行计入"已过滤"不再逐条详报;被审文件 - 一行代码改动即指纹失效、作为新增重报。要看存量全量:删该仓基线文件 - (见运维节)或手动跑 `zcode_security_review` 深扫(不经过滤/基线)。 + 一行代码改动即指纹失效、作为新增重报。要看存量全量见运维节。 - **基线键绑定 clone 绝对路径**:换机、迁移或改名 clone root 会让 `~/.local/state/zcode-mcp-server/baselines/` 下的旧键失配,等效基线 重置——一轮存量噪音回潮后重新收敛,无害(方向宁多报不漏报)。 diff --git a/tests/test_security_review.py b/tests/test_security_review.py index d28b714..91a070d 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -1297,14 +1297,9 @@ def _preset_baseline(self, mod, proj, known_findings): bl_path = mod._baseline_path(os.path.abspath(proj)) entries = {} for f in known_findings: - entries[mod._finding_fingerprint(f)] = { - "path": f["location"]["path"], - "anchor": f["identity"].get("anchor"), - "findingId": f.get("findingId"), - "publicClass": f["identity"]["publicClass"], - "severity": f["severity"], "title": f["title"], - "firstSeen": "2026-01-01T00:00:00", - "lastSeen": "2026-01-01T00:00:00", "count": 1} + # entry 形态与生产 staging 同源 (_baseline_entry), 防两处 schema 漂移 + entries[mod._finding_fingerprint(f)] = mod._baseline_entry( + f, "2026-01-01T00:00:00") os.makedirs(os.path.dirname(bl_path), exist_ok=True) with open(bl_path, "w") as fh: json.dump({"schemaVersion": "zcode-pr-review-baseline/v1",