diff --git a/README.md b/README.md index 17f2c2f..b7ed73b 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,14 @@ zcode --prompt "继续" --resume sess_xxxx ### MCP server(`zcode-mcp-server`) -暴露三个 MCP tool: +暴露四个 MCP tool: | Tool | 作用 | |------|------| | `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)截断保清单 | > **只读原理(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 层另有「只审不修」职责约束(不修改文件、不提议帮忙修复)作双保险。 diff --git a/packages/agent-help/zcode-agent-help b/packages/agent-help/zcode-agent-help index 23f0df3..8e8a22d 100755 --- a/packages/agent-help/zcode-agent-help +++ b/packages/agent-help/zcode-agent-help @@ -522,6 +522,7 @@ ECOSYSTEM = { {"name": "get_zcode_capabilities", "description": "返回 zcode 能力清单 (调 agent-help)"}, {"name": "zcode_review", "description": "调 zcode 审查代码 (yolo+写工具物理禁用: 免授权只读)"}, {"name": "zcode_security_review", "description": "mimosa 预扫 (normal 快扫/deep 业务逻辑深扫) + zcode 只读复核 (安全专项)"}, + {"name": "zcode_pr_review", "description": "PR 审查: git diff 改动清单 + mimosa 聚焦深扫 (focus_files) + zcode P0/P1/P2 复核报告"}, ], "config": "~/.zcode/cli/config.json 的 mcp.servers.zcode-mcp", "use_case": "让 MCP client (zcode 自身/Claude Code/Cursor) 标准化调用 zcode", diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 1316fc6..3228a58 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -9,6 +9,7 @@ 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 复核报告 协议: MCP over stdio (JSON-RPC 2.0, 每行一条消息) 日志: 全部走 stderr (绝不污染 stdout 协议流) @@ -38,7 +39,7 @@ ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode") # MCP 协议版本 PROTOCOL_VERSION = "2024-11-05" -SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.1.0"} +SERVER_INFO = {"name": "zcode-mcp-server", "version": "1.2.0"} def log(msg): @@ -558,6 +559,70 @@ def _compact_findings(findings): return out +# ============================================================ +# PR 审查: git diff 计算 + base 自动探测 +# ============================================================ +def _git(repo, *git_args): + """跑 git 子命令, 返回 (returncode, stdout, stderr)。不抛异常, 调用方判。""" + try: + r = subprocess.run(["git", "-C", repo, *git_args], + capture_output=True, text=True, timeout=30) + return r.returncode, r.stdout, r.stderr + except (OSError, subprocess.TimeoutExpired) as e: + return 128, "", str(e) + + +def _resolve_pr_base(repo): + """自动探测 PR base 分支: origin/HEAD → main/master (本地) → origin/main|master。""" + rc, out, _ = _git(repo, "symbolic-ref", "--quiet", "--short", + "refs/remotes/origin/HEAD") + candidates = [] + if rc == 0 and out.strip(): + candidates.append(out.strip()) # 形如 origin/main + candidates += ["main", "master", "origin/main", "origin/master"] + for cand in candidates: + rc, _, _ = _git(repo, "rev-parse", "--verify", "--quiet", + "--end-of-options", cand) + if rc == 0: + return cand + return None + + +def _pr_diff(repo, base, head): + """计算 base...head 的改动文件清单 + diff 全文 (三点 = merge-base 语义)。 + + 返回 (changed_files, diff_text); 失败抛 RuntimeError。 + base/head 需先过 _validate_rev; 这里再加 --end-of-options 双保险, + 末尾 -- 隔离 pathspec。 + """ + range_spec = f"{base}...{head}" + rc, files_out, err = _git(repo, "diff", "--name-only", "--end-of-options", + range_spec, "--") + 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, "--") + if rc != 0: + raise RuntimeError(f"git diff {range_spec} 失败: {err.strip()[:200]}") + return changed, diff_text + + +def _validate_rev(repo, rev, what): + """校验 git rev 合法: 非 - 开头 (防 git 选项注入, 自审 P1-1: --output= 等 + 会让只读 diff 写出文件) 且 rev-parse 可解析。返回错误文案或 None。 + + rev-parse 带 --end-of-options (git ≥ 2.24, 复审 P1-1 补强): 彻底关掉 + rev 被当选项解析的残余路径 (如 rev-parse 自身选项别名)。 + """ + if not rev or rev.startswith("-"): + return f"非法 {what}: {rev!r} (空值或 - 开头会被 git 当选项解析, 已拒绝)" + rc, _, err = _git(repo, "rev-parse", "--verify", "--quiet", + "--end-of-options", rev) + if rc != 0: + return f"{what} 不存在: {rev!r} (git: {err.strip()[:150]})" + return None + + # ============================================================ # Tool 定义 # ============================================================ @@ -659,6 +724,49 @@ TOOLS = [ "additionalProperties": False, }, }, + { + "name": "zcode_pr_review", + "description": ( + "PR 审查模式: 自动算 git diff (base...HEAD, merge-base 语义) 得到改动清单," + "mimosa 全仓扫描 + 业务逻辑复核优先聚焦改动文件 (focus_files)," + "ZCode 拿着 diff+findings 出 PR 复核报告 (P0/P1/P2 分级 + 能否合并结论)。" + "全程只读: yolo+写工具物理禁用, 免人工授权。需要本机装有 mimosa 插件。" + ), + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "git 仓库目录,默认当前目录", + }, + "base": { + "type": "string", + "description": "PR base 分支/commit,不传则自动探测 " + "(origin/HEAD → main/master → origin/main|master)", + }, + "head": { + "type": "string", + "description": "PR head (默认 HEAD)", + "default": "HEAD", + }, + "depth": { + "type": "string", + "enum": ["normal", "deep"], + "description": "mimosa 扫描深度,PR 审查默认 deep (业务逻辑投研聚焦改动文件)", + "default": "deep", + }, + "focus": { + "type": "string", + "description": "可选,额外审查重点", + }, + "cwd": { + "type": "string", + "description": "zcode 工作目录,默认与 path 相同", + }, + }, + "additionalProperties": False, + }, + }, ] @@ -775,10 +883,20 @@ def _write_temp(text, prefix): return tmp.name +def _clamp_focus(focus): + """focus 来自 MCP client (信任边界外): 长度钳 2000 字符 (复审 P2-3: + 超长 focus 会撑大 prompt; 三个 review tool 统一在此收口)。""" + focus = focus or "" + if len(focus) > 2000: + log(f"⚠ focus 超 2000 字符 ({len(focus)}), 截断") + focus = focus[:2000] + return focus + + def tool_zcode_review(args): files = args.get("files", []) code = args.get("code") - focus = args.get("focus", "全面审查: 安全、正确性、可维护性") + focus = _clamp_focus(args.get("focus")) or "全面审查: 安全、正确性、可维护性" cwd = args.get("cwd") # 构造 prompt — "只审不修"职责钉死在文本层, 写工具在工具集层物理禁用, @@ -832,7 +950,7 @@ def tool_zcode_security_review(args): 读工具全开可查证上下文, 写工具物理禁用保证只审不修。 """ scan_path = args.get("path") or args.get("cwd") or os.getcwd() - focus = args.get("focus", "") + focus = _clamp_focus(args.get("focus")) depth = args.get("depth", "normal") focus_files = args.get("focus_files") or [] @@ -922,6 +1040,148 @@ def tool_zcode_security_review(args): pass +def tool_zcode_pr_review(args): + """PR 审查模式: git diff + mimosa 聚焦深扫 + zcode 复核报告。 + + ① git diff base...head (merge-base 语义) 算改动清单与完整 diff; + ② mimosa 全仓扫描 (focus_files=改动文件, deep 档业务逻辑投研聚焦改动); + ③ diff+findings 作附件喂 zcode 出 PR 复核报告 (P0/P1/P2 + 能否合并)。 + 全程只读: 写工具物理禁用; git/mimosa 都是只读调用。 + """ + scan_path = args.get("path") or args.get("cwd") or os.getcwd() + focus = _clamp_focus(args.get("focus")) + depth = args.get("depth", "deep") + head = args.get("head", "HEAD") + + if depth not in ("normal", "deep"): + return {"content": [{"type": "text", + "text": f"非法 depth: {depth!r} (只支持 normal|deep)"}], + "isError": True} + if not os.path.isdir(scan_path): + return {"content": [{"type": "text", + "text": f"仓库目录不存在或不是目录: {scan_path}"}], + "isError": True} + + # ① git diff (只读) + rc, _, err = _git(scan_path, "rev-parse", "--git-dir") + if rc != 0: + return {"content": [{"type": "text", + "text": f"{scan_path} 不是 git 仓库: {err.strip()[:200]}"}], + "isError": True} + base = args.get("base") + if not base: + base = _resolve_pr_base(scan_path) + if not base: + return {"content": [{"type": "text", "text": ( + "无法自动探测 PR base 分支 (试了 origin/HEAD、main/master、" + "origin/main|master)。请显式传 base 参数, 如 base='main'。" + )}], "isError": True} + for rev, what in ((base, "base"), (head, "head")): + bad = _validate_rev(scan_path, rev, what) + if bad: + return {"content": [{"type": "text", "text": bad}], "isError": True} + try: + changed, diff_text = _pr_diff(scan_path, base, head) + except RuntimeError as e: + return {"content": [{"type": "text", "text": str(e)}], "isError": True} + if not changed: + return {"content": [{"type": "text", "text": ( + f"相对 {base}...{head} 没有任何改动, 无需审查。" + "如果改动还在工作区未 commit, 请先提交或调整 base/head。" + )}]} + + # diff 体积上限 (附件体积保护, ZCODE_BRIDGE_PR_DIFF_MAX 可配) + max_bytes = max(10_000, _env_int("ZCODE_BRIDGE_PR_DIFF_MAX", 500_000, + maximum=5_000_000)) + diff_bytes = diff_text.encode("utf-8") + if len(diff_bytes) > max_bytes: + # 按字节截断: 先退到最后一个完整行 (复审 P1-2: 防半行误导 diff 结构), + # 再容错解码防多字节字符切半 + cut = diff_bytes[:max_bytes] + nl = cut.rfind(b"\n") + if nl > 0: + cut = cut[:nl] + diff_text = cut.decode("utf-8", errors="ignore") + ( + f"\n\n[... diff 超 {max_bytes} 字节已截断; 改动文件清单完整, " + "可用只读工具阅读完整文件 ...]") + + root = _find_mimosa_root() + if not root: + return {"content": [{"type": "text", "text": ( + "未找到 mimosa 安全扫描插件。请安装 mimosa,或设 ZCODE_BRIDGE_MIMOSA_ROOT " + "指向插件根目录 (含 payload/ 的那层);也可改用 zcode_review 做纯 AI 审查。" + )}], "isError": True} + + # ② mimosa 扫描 (focus_files=改动文件, 上限 200 与 security_review 一致; + # deep 档业务逻辑投研聚焦, 全仓仍全量扫) + focus_files = changed[:200] + if len(changed) > 200: + log(f"⚠ 改动文件 {len(changed)} 超 200, focus_files 截断 (清单在附件里完整)") + log(f"mimosa {depth} 扫描 (PR 模式): {scan_path} (root={root}, " + f"{len(changed)} 个改动文件)") + try: + if depth == "deep": + summary_text, findings = _mimosa_deep_scan(root, scan_path, focus_files) + else: + summary_text, findings = _mimosa_quick_scan(root, scan_path) + except Exception as e: + return {"content": [{"type": "text", "text": f"mimosa 扫描失败: {e}"}], + "isError": True} + + # ③ 附件 = PR 元信息 + diff + mimosa 摘要 + findings + attachment = ( + f"# PR 信息\n- base: {base} → head: {head}\n" + f"- 改动文件 ({len(changed)} 个):\n" + + "".join(f" - {f}\n" for f in changed) + + f"\n# PR diff\n```diff\n{diff_text}\n```\n" + + f"\n# mimosa 扫描摘要 (depth={depth})\n{summary_text}\n" + ) + if findings: + compact = _compact_findings(findings) + attachment += ( + f"\n# findings 清单 ({len(compact)} 条)\n" + + json.dumps(compact, ensure_ascii=False, indent=1) + ) + else: + attachment += ("\n# findings 清单\n(未取到结构化 findings, 以摘要为准;" + "如摘要显示 0 发现, 也请按你的判断抽查关键代码)\n") + + 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" + "任务:\n" + "1. 审查 diff 中新引入的问题, 按 P0(阻断合并)/P1(应修)/P2(建议) 分级," + "每个问题含文件位置/判定依据/修复建议。重点是'新引入';" + "存量老问题不阻断本次合并的, 标为 P2 提示即可。\n" + "2. 核实 mimosa findings: 逐条判定【确认漏洞】/【误报】/【存疑】," + "优先核实落在改动文件里的; 每条给出依据 (攻击路径或为何不可利用)。\n" + "3. 可以用只读工具 (Read/Grep/Glob) 阅读仓库代码获取上下文。\n" + "规则: 绝对不要修改、创建或删除任何文件 (写/执行工具已被物理禁用);" + "不要提出\"帮你修复\"的提议; 只输出审查报告。\n" + "输出格式: 先给汇总 (P0/P1/P2 各几条 + findings 确认/误报/存疑各几条" + " + 能否合并的结论), 再逐条详述。用中文。" + ) + if focus: + prompt += f"\n额外审查重点: {focus}。" + + cmd = _build_review_cmd(prompt, args.get("cwd") or scan_path) + cmd += ["--attach", tmp_path] + + log(f"调用 zcode PR 复核 (yolo+只读黑名单, {len(changed)} 个改动文件)") + env = _merge_env_with_creds(load_zcode_credentials()) + return _run_zcode_headless(cmd, env, _review_timeout()) + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + def _env_int(name, default, maximum=None): """从环境变量读整数, 失败用默认值; maximum 给上界 (复审 R2 P2-5: 之前只靠调用点 max() 兜下限, env 误设天文数字没有防线)。""" @@ -938,6 +1198,7 @@ TOOL_HANDLERS = { "get_zcode_capabilities": tool_get_zcode_capabilities, "zcode_review": tool_zcode_review, "zcode_security_review": tool_zcode_security_review, + "zcode_pr_review": tool_zcode_pr_review, } diff --git a/skills/zcode-bridge-guide/SKILL.md b/skills/zcode-bridge-guide/SKILL.md index 83fe69a..7d3796c 100644 --- a/skills/zcode-bridge-guide/SKILL.md +++ b/skills/zcode-bridge-guide/SKILL.md @@ -329,6 +329,17 @@ MCP server 暴露三个标准 MCP tool,供 Claude Code / Cursor 等 MCP client | `get_zcode_capabilities` | 返回 ZCode 完整能力清单 | 只读 | | `zcode_review` | 调用 ZCode 审查代码 | 只读(`--mode yolo` + `--disallowed-tools` 物理禁用写/执行工具,全程免授权但改不了文件) | | `zcode_security_review` | 安全专项审查:mimosa 规则引擎全仓预扫 → ZCode 逐条核实 findings | 只读(同上;需本机装有 mimosa 或设 `ZCODE_BRIDGE_MIMOSA_ROOT`) | +| `zcode_pr_review` | PR 审查:git diff 改动清单 + mimosa 聚焦深扫(focus_files)→ ZCode 出 P0/P1/P2 复核报告 + 能否合并结论 | 只读(同上;base 不传自动探测,默认 depth=deep) | + +### `zcode_pr_review` 参数 +- `path`:git 仓库目录(默认当前目录) +- `base`:PR base 分支/commit,不传自动探测(origin/HEAD → main/master → origin/main|master) +- `head`:默认 HEAD +- `depth`:mimosa 扫描深度,PR 审查默认 `deep` +- `focus`:可选,额外审查重点 +- `cwd`:zcode 工作目录(默认与 path 相同) + +> 流程:`git diff base...head`(三点 = merge-base 语义)算改动清单与完整 diff → mimosa 全仓扫描(`focus_files`=改动文件,deep 档业务逻辑投研聚焦)→ diff+findings 作附件喂 zcode,出「P0 阻断/P1 应修/P2 建议 + findings 确认/误报/存疑 + 能否合并」报告。diff 超 `ZCODE_BRIDGE_PR_DIFF_MAX`(默认 500KB)截断但改动文件清单完整。相对 base 无改动时直接返回提示、不消耗 LLM 调用。 ### `zcode_review` 参数 - `files`:要审查的文件路径列表 diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 64fca88..125f161 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -71,7 +71,8 @@ class _EnvGuard(unittest.TestCase): ENV_KEYS = ("ZCODE_BRIDGE_REVIEW_LOCK", "ZCODE_BRIDGE_MIMOSA_ROOT", "ZCODE_BRIDGE_REVIEW_TIMEOUT", "ZCODE_BRIDGE_MIMOSA_SCAN_ROOT", "ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT", - "ZCODE_BRIDGE_MIMOSA_POLL_INTERVAL") + "ZCODE_BRIDGE_MIMOSA_POLL_INTERVAL", + "ZCODE_BRIDGE_PR_DIFF_MAX") def setUp(self): self._saved = {k: os.environ.get(k) for k in self.ENV_KEYS} @@ -828,5 +829,205 @@ def fake_run(cmd, *a, **kw): self.assertIn("depth=deep", prompt) +class TestPrReview(_EnvGuard): + """tool_zcode_pr_review: git diff + mimosa 聚焦 + zcode 复核""" + + @classmethod + 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): + """patch git/mimosa/zcode 三路。changed=None 表示非 git 仓库; + rev_ok=False 表示所有 rev 解析失败 (测 base 自动探测失败)。""" + import tempfile + mod = self.mod + proj = tempfile.mkdtemp(prefix="zcode-pr-proj-") + self.addCleanup(lambda: __import__("shutil").rmtree(proj, ignore_errors=True)) + saved = { + "find_root": mod._find_mimosa_root, + "deep": mod._mimosa_deep_scan, + "quick": mod._mimosa_quick_scan, + "run": mod.subprocess.run, + } + captured = {} + + 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, , ...] + if sub == "rev-parse" and "--git-dir" in cmd: + return _FakeCompletedProcess(0, ".git\n", "") # 仓库探测恒过 + if sub == "rev-parse" or sub == "symbolic-ref": + if not rev_ok: # rev 校验/分支探测失败 + return _FakeCompletedProcess(1, "", "unknown revision") + return _FakeCompletedProcess(0, "ok\n", "") + if sub == "diff" and "--name-only" in cmd: + return _FakeCompletedProcess( + 0, "".join(f + "\n" for f in changed), "") + if sub == "diff": + return _FakeCompletedProcess(0, diff_text, "") + captured["cmd"] = cmd # zcode 调用 + return _FakeCompletedProcess( + 0, json.dumps({"response": "PR 报告"}, ensure_ascii=False), "") + + 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": "拼接查询"}]) + + mod.subprocess.run = fake_run + mod._find_mimosa_root = fake_find_root + mod._mimosa_deep_scan = fake_deep + mod._mimosa_quick_scan = lambda r, p: ("normal 摘要", []) + os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0" + return mod, saved, proj, captured + + def _restore(self, mod, saved): + mod._find_mimosa_root = saved["find_root"] + mod._mimosa_deep_scan = saved["deep"] + mod._mimosa_quick_scan = saved["quick"] + mod.subprocess.run = saved["run"] + + def test_pr0_not_a_git_repo(self): + """PR0: 非 git 仓库 → 明确报错""" + mod, saved, proj, _ = self._patch(changed=None) + try: + result = mod.tool_zcode_pr_review({"path": proj}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertIn("不是 git 仓库", result["content"][0]["text"]) + + def test_pr1_no_changes_friendly_exit(self): + """PR1: 无改动 → 友好提示, 不调 mimosa/zcode""" + mod, saved, proj, captured = self._patch(changed=[]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + self.assertIn("没有任何改动", result["content"][0]["text"]) + self.assertNotIn("cmd", captured, "无改动不应调 zcode") + + def test_pr2_happy_path(self): + """PR2: 正常管线 — diff 进附件, focus_files=改动文件, 默认 deep""" + mod, saved, proj, captured = self._patch(changed=["app.py", "util.py"]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + self.assertEqual(result["content"][0]["text"], "PR 报告") + # focus_files 透传改动清单 + self.assertEqual(captured["focus_files"], ["app.py", "util.py"]) + cmd = captured["cmd"] + self.assertEqual(cmd[cmd.index("--mode") + 1], "yolo") + self.assertIn("--disallowed-tools", cmd) + prompt = cmd[cmd.index("--prompt") + 1] + self.assertIn("PR 审查", prompt) + self.assertIn("P0", prompt) + self.assertIn("能否合并", prompt) + + def test_pr3_base_autodetect_used(self): + """PR3: 不传 base → 走自动探测 (fake git 的 rev-parse 全通过)""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + try: + result = mod.tool_zcode_pr_review({"path": proj}) + finally: + self._restore(mod, saved) + self.assertNotIn("isError", result) + + def test_pr4_bad_depth_rejected(self): + """PR4: 非法 depth 在校验 git 之前就被拒""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + try: + result = mod.tool_zcode_pr_review({"path": proj, "depth": "x"}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertNotIn("cmd", captured) + + def test_pr5_diff_truncation(self): + """PR5: diff 超 ZCODE_BRIDGE_PR_DIFF_MAX → 截断并标注""" + os.environ["ZCODE_BRIDGE_PR_DIFF_MAX"] = "10000" + big_diff = "x" * 20000 + mod, saved, proj, captured = self._patch(changed=["a.py"], diff_text=big_diff) + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + os.environ.pop("ZCODE_BRIDGE_PR_DIFF_MAX", None) + self.assertNotIn("isError", result) + + def test_pr6_dash_base_rejected(self): + """PR6: base 以 - 开头 → 拒绝 (自审 P1-1: git 选项注入防护)""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + try: + result = mod.tool_zcode_pr_review( + {"path": proj, "base": "--output=/tmp/pwn"}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertIn("非法 base", result["content"][0]["text"]) + self.assertNotIn("cmd", captured, "注入企图不应到达 zcode") + + def test_pr7_dash_head_rejected(self): + """PR7: head 以 - 开头 → 拒绝 (head 同样校验, 不再漏检)""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + try: + result = mod.tool_zcode_pr_review( + {"path": proj, "base": "main", "head": "--stdout"}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertIn("非法 head", result["content"][0]["text"]) + self.assertNotIn("cmd", captured) + + def test_pr8_base_autodetect_failure(self): + """PR8: base 自动探测全部失败 → 明确报错建议显式传 base (复审 P2-4)""" + mod, saved, proj, captured = self._patch(changed=["a.py"], rev_ok=False) + try: + result = mod.tool_zcode_pr_review({"path": proj}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertIn("无法自动探测", result["content"][0]["text"]) + self.assertNotIn("cmd", captured) + + def test_pr9_mimosa_failure_clean_error(self): + """PR9: mimosa 扫描失败 → 明确报错, 不调 zcode (复审 P2-4)""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + + def boom(root, path, focus_files=None): + raise RuntimeError("engine boom") + + mod._mimosa_deep_scan = boom + try: + result = mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + self.assertTrue(result.get("isError")) + self.assertIn("mimosa 扫描失败", result["content"][0]["text"]) + self.assertNotIn("cmd", captured) + + def test_pr10_attachment_tmpfile_cleaned(self): + """PR10: PR 附件临时文件用后被清理 (复审 P2-4)""" + mod, saved, proj, captured = self._patch(changed=["a.py"]) + try: + mod.tool_zcode_pr_review({"path": proj, "base": "main"}) + finally: + self._restore(mod, saved) + attach_path = captured["cmd"][captured["cmd"].index("--attach") + 1] + self.assertIn("zcode-pr-review-", attach_path) + self.assertFalse(os.path.exists(attach_path), "PR 附件临时文件应被清理") + + if __name__ == "__main__": unittest.main(verbosity=2)