diff --git a/README.md b/README.md index a23b986..fe5e0c1 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ PR 自动审查闸门守护进程(第 4 组件,experimental):常驻轮 安装、配置参考、systemd 部署与运维详见 [packages/review-gate/README.md](packages/review-gate/README.md)。 +> **拒审 = 终态告警**(issue #49 洞三):被审分支含 `zcode.json` / `.zcode/config.json` 时,mcp-server 返回结构化 `refusal` 键,gate 对该 head 立即 `gave_up` 并回贴「🚫 审查被拒绝(安全策略)」评论(同 head 不重试、新 head 自动复活);告警评论发布失败(限流/网络)会缓存原文,下一轮只补发拒审评论、不重跑审查。判定只认结构化键——报告正文/错误文本里的 `ZOB-REFUSED` 字样不参与(防伪造,R1 P2-2)。 + > 配套能力:mcp-server 新增 `--call TOOL ''` 一次性调用模式(脚本化入口,exit 0/1/2 分别对应 成功 / 用法错误或 handler 异常 / tool 执行失败),stdio 模式行为不变。 ## 会话存储 @@ -261,6 +263,7 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se | 机制 | 行为 | 配置 | |------|------|------| | **进程级文件锁** | 多个 MCP client 并发调用时,串行化 headless review,防并发触发限流 | `ZCODE_BRIDGE_REVIEW_LOCK=0` 关闭 | +| **项目配置拒审**(issue #49 洞三) | 被审目录链(cwd 到 git 根)存在 `zcode.json` / `.zcode/config.json` 时 **fail-closed 拒审**(`isError` + 顶层结构化 `refusal` 键;review-gate 据此按终态告警处理,不当临时失败重试)——该文件的 `mcp.servers` 会被 zcode 启动时直接 spawn。审查子进程 `--cwd` 恒为隔离沙箱(含空 `.git` 阻断配置上溯),被审仓库根目录经 prompt 以绝对路径提供 | `ZCODE_BRIDGE_TRUST_PROJECT_CONFIG=1` 显式放行可信仓库(`0/false/no/off/disabled` 均视为关) | | **provider 错误解析** | 识别 429 / 1302 / `Too Many Requests` / `请求过于频繁` / `retry-after`,区分限流/配额/其他 | — | | **有限重试 + 退避** | 仅对**限流**错误重试(配额/Unauthorized 不重试),退避用 retry-after 或指数退避(`2^n+1`) | `ZCODE_BRIDGE_MAX_RETRIES`(默认 3) | | **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 300s,下限 30s) | diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index fc34355..5ff9ab0 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -18,6 +18,7 @@ Claude Code、Cursor 等) 能标准化地发现并调用 headless zcode。 依赖: 仅 Python3 标准库 (零第三方依赖) """ +import atexit import fcntl import hashlib import json @@ -27,6 +28,7 @@ import select import shutil import subprocess import sys +import tempfile import time from datetime import datetime, timedelta, timezone from pathlib import Path @@ -321,12 +323,23 @@ class ReviewFileLock: # 而是直接报 "No permission client configured" 失败; # - --allowed-tools / --max-turns 帮助文案有, 但未注册 parseArgs (实测 # Unknown option), 不可用; auto 模式是保留值未实现。 +# App 3.14.0 / CLI 0.16.9 补充 (2026-09-19 实测, 与 Node REPL 同族问题): +# - 动态工作流工具族 (EvalWorkflowSnippet/CreateWorkflow/...) 是新的执行原语: +# snippet 里的 world.run 可跑任意命令, 不在此黑名单时 Bash 禁令形同虚设 +# (实测: 黑名单原样下 world.run("node", [...fs.writeFileSync...]) 成功写入 +# 文件, 平台自身遥测把 EvalWorkflowSnippet 标为 read_only=0); +# - 黑名单按工具名精确匹配 (Write/Bash 被挡住即证), 故按名逐个列; 工具面随 +# 版本增长, 新版本上线后需重新核对下发工具清单 (见 docs/recheck-3.14.0.md)。 # 组合: --mode yolo (免授权) + 全量写/执行黑名单 (物理只读, 读工具全开)。 # ============================================================ REVIEW_DISALLOWED_TOOLS = ( "Write Edit MultiEdit ApplyPatch Bash " "js js_reset js_add_node_module_dir " - "mcp__node_repl__js mcp__node_repl__js_reset mcp__node_repl__js_add_node_module_dir" + "mcp__node_repl__js mcp__node_repl__js_reset mcp__node_repl__js_add_node_module_dir " + # 动态工作流一族 (0.16.9): 漏禁即等于放行任意命令执行与文件写入 + "EvalWorkflowSnippet CreateWorkflow AmendWorkflow SaveWorkflow " + "ListWorkflowRuns GetWorkflowRun ResumeWorkflowRun " + "ListSavedWorkflows ResolveWorkflowQuestion" ) # deep 轮询: status 单次响应超时的最大连续容忍次数 (复审 P1-3: @@ -936,7 +949,9 @@ TOOLS = [ }, "cwd": { "type": "string", - "description": "工作目录 (影响 zcode 的项目上下文),默认当前目录", + "description": "被审仓库根目录 (经 prompt 告知审查模型, 供其用绝对路径" + "读上下文),默认当前目录。注: zcode 子进程的 --cwd 恒为" + "隔离沙箱 (防被审目录的项目配置触发命令执行, issue #49)", }, }, "additionalProperties": False, @@ -985,7 +1000,8 @@ TOOLS = [ }, "cwd": { "type": "string", - "description": "zcode 工作目录,默认与 path 相同", + "description": "被审仓库根目录 (经 prompt 告知审查模型),默认与 path 相同。" + "注: zcode 子进程的 --cwd 恒为隔离沙箱 (issue #49)", }, }, "additionalProperties": False, @@ -1039,7 +1055,8 @@ TOOLS = [ }, "cwd": { "type": "string", - "description": "zcode 工作目录,默认与 path 相同", + "description": "被审仓库根目录 (经 prompt 告知审查模型),默认与 path 相同。" + "注: zcode 子进程的 --cwd 恒为隔离沙箱 (issue #49)", }, }, "additionalProperties": False, @@ -1103,14 +1120,112 @@ def _extract_response(output): return output, None -def _build_review_cmd(prompt, cwd): - """构造只读 review 的 zcode 命令 (yolo 免授权 + 写/执行工具物理禁用 + JSON 输出)。""" +# ============================================================ +# 项目配置防护 (2026-09-19, issue #49 洞三) +# ------------------------------------------------------------ +# 被审目录里的 zcode.json / .zcode/config.json 会被 zcode 启动时装载, 其中 +# mcp.servers 定义的工具**进程会被直接 spawn** — 不经过模型、不经过工具黑名单 +# (实测: 黑名单全量在位的 review 调用下, 该文件里的命令照常执行)。发现范围是 +# 从工作目录向上直到 git 根 (实测逆向 zcode bundle 的发现顺序), 所以只清仓库 +# 根不够, 要沿链看。 +# +# 对策两层: +# ① 主门锁 — 审查子进程的 --cwd 改指一个只含空 .git 的临时目录 (发现链就 +# 此终止, 看不到被审仓库); 被审仓库路径改由 prompt 提供。4 样本实测 +# (2 注入真 bug + 2 真实 diff) 检出能力与读上下文量均不降 (详见 +# docs/recheck-3.14.0.md)。 +# ② 报警器 — 本函数: 被审目录链上存在这两个文件时 fail-closed 拒审, 既是 +# 拦截也是"有人在试探"的信号。确信任用时可用环境变量放行。 +# ============================================================ +_PROJECT_CONFIG_RELS = ("zcode.json", os.path.join(".zcode", "config.json")) +_TRUST_PROJECT_CONFIG_ENV = "ZCODE_BRIDGE_TRUST_PROJECT_CONFIG" + +_scratch_cwd_cache = None + + +def _cleanup_scratch_cwd(): + """清掉沙箱目录并置空缓存 (atexit 注册, 幂等可重入)。""" + global _scratch_cwd_cache + if _scratch_cwd_cache: + shutil.rmtree(_scratch_cwd_cache, ignore_errors=True) + _scratch_cwd_cache = None + + +def _review_scratch_cwd(): + """审查用的干净工作目录 (只含一个空 .git, 令配置发现链止步于此)。 + + 进程退出时清理 (R1 P2-3): gate 的 --call 模式一次审查一个进程, + 不清理则每审漏一个目录无限缓慢累积; stdio 常驻模式进程级一份, 退出即清。 + """ + global _scratch_cwd_cache + if _scratch_cwd_cache and os.path.isdir(_scratch_cwd_cache): + return _scratch_cwd_cache + d = tempfile.mkdtemp(prefix="zcode-review-cwd-") + os.mkdir(os.path.join(d, ".git")) # 空目录即可: zcode 见到 .git 就停止上溯 + atexit.register(_cleanup_scratch_cwd) + _scratch_cwd_cache = d + return d + + +def _project_config_files(root): + """沿 root → git 根 (含) 列出存在的 zcode 项目配置文件 (与 zcode 发现范围一致)。 + + realpath 先解析符号链接: 与 zcode 落地后的真实 cwd 对齐, 链接链上的 + 配置不漏报 (报警器层; 主锁是 --cwd 沙箱, 不依赖本函数的完整性)。 + """ + start = Path(os.path.realpath(root)) + found = [] + for d in (start, *start.parents): + found += [str(d / rel) for rel in _PROJECT_CONFIG_RELS if (d / rel).exists()] + if (d / ".git").exists(): + break + return found + + +def _project_config_guard(root): + """被审目录链上有 zcode 项目配置文件则返回拒绝 result, 否则 None。""" + trust = (os.environ.get(_TRUST_PROJECT_CONFIG_ENV) or "").strip().lower() + # off/disabled 也算关 (R1 P3-1): 关安全护栏的开关, 反直觉值不得触发放行 + if trust not in ("", "0", "false", "no", "off", "disabled"): + return None + bad = _project_config_files(root) + if not bad: + return None + log(f"⛔ 拒审: 被审目录链上存在 zcode 项目配置 {bad}") + return {"content": [{"type": "text", "text": ( + # 文本标记放首行便于肉眼/日志 grep; 机器契约是顶层 "refusal" 键 + # (R1 P2-2): 失败 detail 会混入 mimosa 错误体/子进程回显等攻击者 + # 可影响的文本, gate 解析文本存在伪造面 (可把临时失败伪造成终态 + # 拒审), 结构化键只有本护栏会写, 无伪造面。 + "ZOB-REFUSED: project_config\n" + "拒绝审查: 被审目录 (或其上层路径) 存在 ZCode 项目配置文件:\n" + + "\n".join(f" - {p}" for p in bad) + + "\n\n原因: zcode 启动时会装载该文件的 mcp.servers 并**直接执行**其中定义的命令" + " (进程 spawn, 不经过模型也不受工具黑名单约束) — 审查不受信内容时等于把" + "执行权交给被审内容 (issue #49 洞三)。\n" + "处理: ① 审查他人代码/PR: 请在副本中删除这两个文件后再审 (review-gate 应在" + "克隆副本里做); ② 确认该目录可信 (自己的仓库且确有项目配置): 设 " + f"{_TRUST_PROJECT_CONFIG_ENV}=1 放行。\n" + "注意: 沿工作目录到 git 根的整条链都会被 zcode 检查, 不只仓库根。" + )}], "isError": True, + # 结构化拒审键 (R1 P2-2): review-gate 只认它判定"安全策略拒绝" + "refusal": "project_config"} + + +def _build_review_cmd(prompt, repo): + """构造只读 review 的 zcode 命令 (yolo 免授权 + 写/执行工具物理禁用 + JSON 输出)。 + + --cwd 固定指向干净临时目录 (项目配置防护①): 被审仓库的真实路径由调用方写进 + prompt ("被审仓库根目录: "), 审查模型用绝对路径读上下文 — 4 样本实测 + 与 --cwd 指向仓库等价 (读取类工具调用量不降, 注入 bug 检出率相同)。 + """ cmd = [ZCODE_BIN, "--prompt", prompt, "--mode", "yolo", "--disallowed-tools", REVIEW_DISALLOWED_TOOLS, - "--no-color", "--json"] - if cwd: - cmd += ["--cwd", cwd] + "--no-color", "--json", + "--cwd", _review_scratch_cwd()] + if repo: + log(f"审查工作目录: {_review_scratch_cwd()} (被审仓库 {repo} 经 prompt 提供)") return cmd @@ -1280,10 +1395,17 @@ def tool_zcode_review(args): f"\n\n[... code 超 {code_max} 字节已截断; " "超大代码建议改用 files 传文件路径 ...]") + # 项目配置防护②: 被审目录链上有 zcode 项目配置 → fail-closed (issue #49 洞三) + guard = _project_config_guard(cwd or os.getcwd()) + if guard: + return guard + # 构造 prompt — "只审不修"职责钉死在文本层, 写工具在工具集层物理禁用, # 双保险防 "审着审着顺手改了" (plan→build 惯性, 2026-08-08 重构动因)。 prompt = ( f"你是资深代码审查专家。请审查附件中的代码,重点: {focus}。\n" + f"被审仓库根目录: {os.path.abspath(cwd or os.getcwd())}" + " (用绝对路径 + 只读工具读上下文; 当前工作目录是隔离沙箱, 不是你审查的仓库)\n" "规则:\n" "1. 你的任务只是审查并输出报告 — 绝对不要修改、创建或删除任何文件" " (写/执行工具已被物理禁用,不要尝试)。\n" @@ -1357,6 +1479,10 @@ def tool_zcode_security_review(args): return {"content": [{"type": "text", "text": f"扫描目录不存在或不是目录: {scan_path}"}], "isError": True} + # 项目配置防护②: 被审目录链上有 zcode 项目配置 → fail-closed (issue #49 洞三) + guard = _project_config_guard(scan_path) + if guard: + return guard root = _find_mimosa_root() if not root: @@ -1395,6 +1521,8 @@ def tool_zcode_security_review(args): prompt = ( f"你是资深安全审查专家。附件是安全扫描器 (mimosa,确定性规则引擎,depth={depth})" "对本项目的扫描结果。\n" + f"被审仓库根目录: {os.path.abspath(scan_path)}" + " (用绝对路径 + 只读工具读上下文; 当前工作目录是隔离沙箱, 不是你审查的仓库)\n" "任务:\n" "1. 逐条核实每个 finding: 用只读工具 (Read/Grep/Glob) 阅读相关代码,判定为" "【确认漏洞】/【误报】/【存疑】,给出依据 (可利用的攻击路径,或为什么不可利用)。\n" @@ -1407,7 +1535,7 @@ def tool_zcode_security_review(args): if focus: prompt += f"\n额外审查重点: {focus}。" - cmd = _build_review_cmd(prompt, args.get("cwd") or scan_path) + cmd = _build_review_cmd(prompt, scan_path) cmd += ["--attach", tmp_path] log("调用 zcode 复核 mimosa findings (yolo+只读黑名单)") @@ -1446,6 +1574,11 @@ def tool_zcode_pr_review(args): return {"content": [{"type": "text", "text": f"仓库目录不存在或不是目录: {scan_path}"}], "isError": True} + # 项目配置防护②: 被审目录链上有 zcode 项目配置 → fail-closed (issue #49 洞三)。 + # PR 审查场景最危险: path 通常是刚 check out 的 PR head 副本, 内容不受信。 + guard = _project_config_guard(scan_path) + if guard: + return guard # ① git diff (只读) rc, _, err = _git(scan_path, "rev-parse", "--git-dir") @@ -1611,6 +1744,8 @@ def tool_zcode_pr_review(args): "你是资深安全审查专家。这是一次 PR 审查: 附件含 ① 本 PR 的完整 diff " f"(base={base} → {head}, 改动 {len(changed)} 个文件) ② mimosa 安全扫描结果" f"(depth={depth}, 业务逻辑复核优先聚焦本次改动文件{findings_scope_note})。\n" + f"被审仓库根目录: {os.path.abspath(scan_path)}" + " (用绝对路径 + 只读工具读上下文; 当前工作目录是隔离沙箱, 不是你审查的仓库)\n" "任务:\n" "1. 审查 diff 中新引入的问题, 按 P0(阻断合并)/P1(应修)/P2(建议)/P3(提示) 分级," "每个问题含文件位置/判定依据/修复建议。重点是'新引入';" @@ -1628,7 +1763,7 @@ def tool_zcode_pr_review(args): if focus: prompt += f"\n额外审查重点: {focus}。" - cmd = _build_review_cmd(prompt, args.get("cwd") or scan_path) + cmd = _build_review_cmd(prompt, scan_path) cmd += ["--attach", tmp_path] log(f"调用 zcode PR 复核 (yolo+只读黑名单, {len(changed)} 个改动文件)") diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index 8d7df65..6fdbfad 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -661,6 +661,8 @@ def run_review(cfg, clone, base_ref, head_sha): info (#35) 结构化旁路信息, 从 result 顶层附加键直读: quota_limit: {"code": 1308, "reset_at": } | None — 仅 stderr 命中 1308 才有 (mcp-server 侧已完成北京 naive → unix 转换); + refusal: str | None — 安全策略拒审 (issue #49 洞三) 时 mcp-server + 护栏写入的原因串 (如 "project_config"), 机器契约只此一处; total_tokens: int — 成功时 usage.total_tokens 透传, 无则 0。 子进程异常/非 JSON 输出时为空 info。quota_limit 原样透传 (键缺失为 None), 合法性校验全在消费端 (quota_limit_reset_ts), 这里不猜不修。 @@ -676,7 +678,7 @@ def run_review(cfg, clone, base_ref, head_sha): json.dumps(args, ensure_ascii=False)] env = dict(os.environ) env["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = str(ZCODE_REVIEW_TIMEOUT) - info = {"quota_limit": None, "total_tokens": 0} + info = {"quota_limit": None, "total_tokens": 0, "refusal": None} try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=REVIEW_TIMEOUT, env=env) @@ -693,6 +695,9 @@ def run_review(cfg, clone, base_ref, head_sha): # quota_limit 原样透传: 形状校验全在消费端 (quota_limit_reset_ts), # 非 dict 在那里回 None → 正则兜底, 这里不重复判 info["quota_limit"] = result.get("quota_limit") + # 拒审原因串原样透传 (#49): 只有 mcp-server 的项目配置护栏会写这个键, + # 正文文本不参与判定 (伪造面, R1 P2-2) + info["refusal"] = result.get("refusal") usage = result.get("usage") if isinstance(usage, dict): tokens = usage.get("total_tokens") @@ -734,6 +739,68 @@ def parse_verdict_marker(report): m.group(5) == "true") +# 拒审 (issue #49 洞三): mcp-server 的项目配置护栏在 isError result 顶层附 +# 结构化键 "refusal": "project_config" (文本里那行 ZOB-REFUSED 仅供人看)。 +# gate 只认结构化键判定"安全策略拒绝" (R1 P2-2): 失败 detail 会混入 mimosa +# 错误体/子进程回显等攻击者可影响的文本, 解析文本可把任意临时失败伪造成 +# 终态拒审 (跳过 5 次重试 + 假告警); 结构化键只有 mcp-server 护栏会写。 +# 拒审按终态处理 (gave_up + 🚫 告警评论), 不当临时失败退避重试: +# 重试不会变, 且 5 次后静默 gave_up 会吞掉"有人在投毒"的信号。 + + +def build_refusal_comment(head_sha, detail): + """拒审告警评论体 (供人工立即看见; 不参与 verdict 解析)。""" + return ( + "## 🚫 审查被拒绝(安全策略)\n\n" + "被审分支的仓库里存在 ZCode 项目配置文件(`zcode.json` 或 " + "`.zcode/config.json`)。该文件会在审查进程启动时被装载,其中 " + "`mcp.servers` 定义的命令会被**直接执行**(进程 spawn,不经过模型、" + "不受工具黑名单约束)——等于把执行权交给被审内容;因此本次审查按 " + "fail-closed 策略拒绝(zcode-open-bridge issue #49 洞三)。\n\n" + f"- head: `{(head_sha or '')[:12]}`\n" + "- 同 head 不再重试;推送新 commit 会自动重新触发审查。\n" + "- **若这是本 PR 新增的文件**:请从 PR 中移除后重推。\n" + "- **若该文件本就在目标分支上**:属仓库自身配置,需人工确认后处理" + "(可设 `ZCODE_BRIDGE_TRUST_PROJECT_CONFIG=1` 放行,或在审查副本中移除)。\n\n" + "
护栏原始输出\n\n" + f"```\n{(detail or '')[:1500]}\n```\n\n
" + ) + + +def _post_refusal_alert(cfg, token, entry, key, head_sha, owner, repo, + pr_number, text, now): + """发拒审告警评论; 发不出去则缓存原文转 comment_failed 等下轮补发。 + + 两个调用点共用 (首发于拒审分支 / 补发于 comment_failed 重试轮), + 失败口径与正常评论路径一致: + - RateLimited (R1 P1-1): 仓级事件不烧 attempts, 缓存 + comment_failed + 后上抛停本轮 — 早期版本只捕 Retryable/RepoHard, RateLimited 穿透时 + gave_up 已写而缓存未落, 下一轮 needs_review 跳过 gave_up, 告警 + 永久丢失; + - Retryable/RepoHard: mark_failure(comment_failed) 走退避, 缓存保留。 + 返回 True=已发布, False=未尝试 (评论禁用/无 token) 或发布失败 (已转 + comment_failed, 状态见 entry)。 + """ + if not (cfg.comment_enabled and token): + log(f"{key}: 拒审告警未发 (评论禁用或无 token), 详见 state/日志") + return False + try: + resp = post_comment(token, cfg.github_api, owner, repo, pr_number, + build_refusal_comment(head_sha, text)) + log(f"{key}: 拒审告警已发布 {(resp or {}).get('html_url') or ''}") + return True + except RateLimited: + entry["report"] = text[:cfg.comment_max_body] + entry["status"] = "comment_failed" + entry["next_retry_at"] = 0.0 + raise + except (RetryableError, RepoHardError) as e: + entry["report"] = text[:cfg.comment_max_body] + mark_failure(entry, f"拒审告警发布失败: {e}", now, cfg, + status="comment_failed") + return False + + def parse_severity_counts(report): """解析 (P0, P1, P2, P3) 条数; P0/P1/P2 任一缺失 → None, P3 缺省计 0。 @@ -1339,6 +1406,21 @@ def process_pr(cfg, state, token, owner, repo, clone, info): # 审查已成功过、只是评论没发出去: 沿用缓存结果, 只补评论, # 不为一次评论失败重跑昂贵的 zcode 审查 report = entry["report"] + if entry.get("refusal"): + # 拒审告警补发 (R1 P1-2): 缓存的是拒审文案, 补发的必须是拒审 + # 评论 — 走下方通用路径会把安全拒审销毁成 "concerns 质量告警" + # 并翻成 reviewed, 下游读 state 拿到错误结论。 + if _post_refusal_alert(cfg, token, entry, key, head_sha, owner, + repo, info["number"], report, now): + entry["status"] = "gave_up" # 补发成功, 回终态 + entry["next_retry_at"] = 0.0 + entry["report"] = None + elif entry["status"] != "comment_failed": + # 未尝试 (评论被禁用/无 token): 烧退避老化, 不无限空转; + # 缓存保留, gave_up 后仍可供人工排查 + mark_failure(entry, "拒审告警未发 (评论禁用或无 token)", + now, cfg, status="comment_failed") + return verdict = entry.get("verdict") or "concerns" counts = tuple(entry["counts"]) if entry.get("counts") else None log(f"{key}: 沿用缓存审查结果 (verdict={verdict}), 只补评论") @@ -1368,6 +1450,23 @@ def process_pr(cfg, state, token, owner, repo, clone, info): f"base=origin/{info['base_ref']} depth={cfg.review_depth}") ok, payload, rinfo = run_review(cfg, clone, info["base_ref"], head_sha) if not ok: + # 拒审 (issue #49 洞三): 安全策略拒绝 → 终态 + 告警评论。 + # 不能当临时失败退避重试: 重试结果不会变, 且 5 次后静默 gave_up + # 会把"有人在投毒"的信号吞掉 (判定只认 mcp-server 护栏写的 + # 结构化 refusal 键, 文本标记仅供人看)。 + # 复用 gave_up 语义: 同 head 不再动, 新 head 自动复活重审。 + refused = rinfo.get("refusal") + if refused: + entry["error"] = payload[:500] + entry["verdict"] = None + entry["refusal"] = refused + entry["status"] = "gave_up" + entry["next_retry_at"] = 0.0 + log(f"{key}: ⛔ 拒审 (refusal: {refused}), 终态 " + f"(head={head_sha[:12]}) — 发告警评论") + _post_refusal_alert(cfg, token, entry, key, head_sha, owner, + repo, info["number"], payload, now) + return # 判据优先级 (#35①): 结构化 quota_limit 键优先 (mcp-server 侧已转 # unix 秒), 键缺失/形状不符退正则兜底 (旧 mcp-server 兼容)。两条 # 路径汇成同一个 reset_ts 后过同一道 is_valid_quota_window — diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index c808a7c..d910a84 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -269,11 +269,13 @@ def _fake_run(self, cmd, *a, **kw): return _CP(returncode=0, stdout=json.dumps(payload), stderr="") raise AssertionError(f"未预期命令: {cmd}") - def _err_stdout(self, text, quota_limit=None): + def _err_stdout(self, text, quota_limit=None, refusal=None): """伪造 mcp-server --call 失败输出 (isError result, 可选结构化键)""" result = {"content": [{"type": "text", "text": text}], "isError": True} if quota_limit is not None: result["quota_limit"] = quota_limit + if refusal is not None: + result["refusal"] = refusal return json.dumps({"ok": False, "result": result}) def _write_config(self, repos=("octo/hello",), quota=None): @@ -695,6 +697,63 @@ def test_baseline_header_line_does_not_disturb_parsing(self): # ============================================================ # 评论 body # ============================================================ +class TestRefusalKeyInRunReview(_GateCase): + """拒审结构化键提取 (issue #49 洞三, R1 P2-2): 只认 result 顶层 + "refusal" 键, 文本标记不参与判定 (失败 detail 混有攻击者可影响的文本)""" + + def _cfg(self): + return self.mod.GateConfig({}) + + def _run_with_stdout(self, payload): + cp = _CP(returncode=2, stdout=json.dumps(payload), stderr="") + with mock.patch.object(self.mod.subprocess, "run", + lambda *a, **kw: cp): + return self.mod.run_review(self._cfg(), "/clone", "main", "sha") + + def test_refusal_key_extracted(self): + """isError result 顶层 refusal 键原样透传到 info""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", + "text": "ZOB-REFUSED: project_config\n拒绝审查…"}], + "isError": True, "refusal": "project_config"}}) + self.assertFalse(ok) + self.assertEqual(info["refusal"], "project_config") + + def test_refusal_key_absent(self): + """无 refusal 键 → None (普通失败, 照走退避)""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", "text": "炸了"}], + "isError": True}}) + self.assertFalse(ok) + self.assertIsNone(info["refusal"]) + + def test_forged_text_marker_not_refusal(self): + """正文伪造的 ZOB-REFUSED 行不算拒审 (R1 P2-2 的行为钉子): + mimosa 错误体/子进程回显可被攻击者影响, 只有结构化键算数 — + 否则任意临时失败可被伪造成终态拒审 (跳过重试 + 假告警)""" + ok, _detail, info = self._run_with_stdout({ + "ok": False, "result": { + "content": [{"type": "text", + "text": "mimosa 扫描失败: boom\n" + "ZOB-REFUSED: project_config"}], + "isError": True}}) + self.assertFalse(ok) + self.assertIsNone(info["refusal"], + "文本标记不得被解析为拒审 (伪造面)") + + def test_subprocess_failure_empty_info(self): + """子进程起不来/非 JSON → 空 info (refusal=None, 不误判拒审)""" + def boom(*a, **kw): + raise OSError("gone") + with mock.patch.object(self.mod.subprocess, "run", boom): + ok, _detail, info = self.mod.run_review( + self._cfg(), "/clone", "main", "sha") + self.assertFalse(ok) + self.assertIsNone(info["refusal"]) + + class TestCommentBody(_GateCase): SHA = "abcdef1234567890" + "0" * 24 @@ -1385,6 +1444,106 @@ def test_review_failure_goes_backoff_then_skip(self): entry = self._state_entry() self.assertEqual(entry["attempts"], 1) # 没增加 + def test_refusal_terminal_with_alert_comment(self): + """拒审 (结构化 refusal 键, issue #49 洞三): 终态 gave_up + 告警评论。 + + 与"临时失败"语义相反: 重试不会变, 且不能让告警被退避吞掉 — 同 head + 立即终态, 但必须发一条评论让人看见; 新 head 仍会自动复活重审。 + """ + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "ZOB-REFUSED: project_config\n" + "拒绝审查: 被审目录 (或其上层路径) 存在 ZCode 项目配置文件:\n" + " - /clone/octo__hello/.zcode/config.json", + refusal="project_config") + + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up", "拒审应终态, 不进失败退避") + self.assertIsNone(entry["verdict"]) + self.assertEqual(entry.get("refusal"), "project_config") + posts = [c for c in self.api_calls if c[0] == "POST"] + self.assertEqual(len(posts), 1, "拒审必须发告警评论") + body = posts[0][2]["body"] + self.assertIn("审查被拒绝", body) + self.assertIn(".zcode/config.json", body) + self.assertIn("/repos/octo/hello/issues/5/comments", posts[0][1]) + + # 同 head 第二轮: 终态不处理 (无第二次审查调用, 也不重复评论) + self.assertEqual(self._run_once(cfg_path), 0) + self.assertEqual(len(self.mcp_calls), 1, "同 head 拒审不应重试") + self.assertEqual(len([c for c in self.api_calls if c[0] == "POST"]), 1) + + def test_refusal_comment_failure_cached_for_retry(self): + """拒审告警发不出去 (Retryable): 转 comment_failed 缓存拒审原文; + 下一轮补发的仍是拒审评论 (不是 verdict 评论), 补发成功回 gave_up + 终态 — 走通用补评论路径会把安全拒审销毁成 concerns (R1 P1-2)""" + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "ZOB-REFUSED: project_config\n拒绝审查: 命中项目配置护栏", + refusal="project_config") + self.comment_behavior = "retryable" + + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "comment_failed") + self.assertEqual(entry.get("refusal"), "project_config") + self.assertIn("ZOB-REFUSED", entry.get("report", ""), + "拒审原文须缓存, 供补评论使用") + + # 下一轮 (退避到点): 只补发拒审评论, 不重跑审查, 回 gave_up 终态 + self._force_retry_due() + self.comment_behavior = "ok" + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up", "补发成功应回拒审终态") + self.assertIsNone(entry["verdict"], "不得翻成 verdict 状态") + self.assertEqual(len(self.mcp_calls), 1, "补评论不得重跑 mcp 审查") + posts = [c for c in self.api_calls if c[0] == "POST"] + # 第一轮失败尝试也会被 fake 记录: 1 (失败) + 1 (补发成功) + self.assertEqual(len(posts), 2) + self.assertIn("审查被拒绝", posts[-1][2]["body"], + "补发的必须是拒审评论") + self.assertNotIn("concerns", posts[-1][2]["body"], + "不得发成通用 verdict 评论") + + def test_refusal_alert_ratelimited_then_reposted(self): + """R1 P1-1: 拒审告警撞限流 — 早期版本 RateLimited 穿透 except, + gave_up 已写而缓存未落, 下一轮 needs_review 跳过 gave_up, 告警 + 永久丢失。现在: 缓存 + comment_failed (不烧 attempts) + 上抛停轮; + 下一轮 (无退避) 补发拒审评论, 回 gave_up 终态""" + cfg_path = self._write_config() + self.mcp_rc = 2 + self.mcp_stdout = self._err_stdout( + "ZOB-REFUSED: project_config\n拒绝审查: 命中项目配置护栏", + refusal="project_config") + self.comment_behavior = "ratelimited" + + # 第一轮: 限流停轮, --once 退出码仍 0 + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "comment_failed", + "限流不得让告警丢失 (须转可补发状态)") + self.assertEqual(entry.get("refusal"), "project_config") + self.assertIn("ZOB-REFUSED", entry.get("report", "")) + self.assertEqual(entry.get("attempts") or 0, 0, "限流不烧 attempts") + self.assertEqual(entry.get("next_retry_at"), 0) + self.assertEqual(len(self.mcp_calls), 1) + + # 下一轮: 补发拒审评论成功 → 回 gave_up 终态, 不重跑审查 + self.comment_behavior = "ok" + self.assertEqual(self._run_once(cfg_path), 0) + entry = self._state_entry() + self.assertEqual(entry["status"], "gave_up") + self.assertIsNone(entry["verdict"]) + posts = [c for c in self.api_calls if c[0] == "POST"] + # 第一轮限流尝试也被 fake 记录: 1 (限流失败) + 1 (补发成功) + self.assertEqual(len(posts), 2, "限流失败一次 + 补发成功一次") + self.assertIn("审查被拒绝", posts[-1][2]["body"]) + self.assertEqual(len(self.mcp_calls), 1) + def test_comment_retryable_failure_caches_and_comment_only_retry(self): """审查成功+评论 RetryableError → comment_failed+退避+缓存; 退避到点后的下一轮: 只补评论, 不重跑 mcp 审查""" diff --git a/tests/test_security_review.py b/tests/test_security_review.py index edacf9a..2b98d04 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -24,8 +24,10 @@ import json import os +import shutil import subprocess import sys +import tempfile import types import unittest @@ -85,7 +87,9 @@ class _EnvGuard(unittest.TestCase): "ZCODE_BRIDGE_PR_DIFF_MAX", # issue #26/#27: PR findings 过滤/基线开关与基线目录 "ZCODE_BRIDGE_PR_FINDINGS_SCOPE", "ZCODE_BRIDGE_PR_BASELINE", - "ZCODE_BRIDGE_BASELINE_DIR") + "ZCODE_BRIDGE_BASELINE_DIR", + # issue #49 洞三: 项目配置拒审的显式放行开关 + "ZCODE_BRIDGE_TRUST_PROJECT_CONFIG") def setUp(self): self._saved = {k: os.environ.get(k) for k in self.ENV_KEYS} @@ -97,16 +101,11 @@ def tearDown(self): else: os.environ[k] = v - -class TestReviewCmd(_EnvGuard): - """只读护栏: 命令构造 + prompt 约束""" - - @classmethod - def setUpClass(cls): - cls.mod = _load_mcp_module() - def _capture_cmd(self, **kwargs): - """patch subprocess.run 捕获 cmd, 返回 (cmd, result)。""" + """patch subprocess.run 捕获 cmd, 返回 (cmd, result)。 + + (R1 精简 #4: 原为 TestReviewCmd 私有, PG9 同形状需求, 上移共用。) + """ mod = self.mod captured = {} @@ -123,6 +122,14 @@ def fake_run(cmd, *a, **kw): mod.subprocess.run = saved return captured["cmd"], result + +class TestReviewCmd(_EnvGuard): + """只读护栏: 命令构造 + prompt 约束""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + def test_rc0_yolo_not_plan(self): """RC0: 用 --mode yolo, 绝不再出现 plan""" cmd, _ = self._capture_cmd() @@ -141,6 +148,21 @@ def test_rc1_denylist_blocks_write_and_repl(self): "mcp__node_repl__js_add_node_module_dir"): self.assertIn(tool, deny.split(), f"黑名单缺 {tool}") + def test_rc1b_denylist_blocks_dynamic_workflow_family(self): + """RC1b: 黑名单含动态工作流一族 (0.16.9 新增的执行原语)。 + + EvalWorkflowSnippet 的 world.run 等价任意命令执行 + 文件写入, 与 Node REPL + 同族 (2026-09-19 实测复现: 黑名单原样下仍写入成功, 平台遥测标该工具 + read_only=0; 详见 docs/recheck-3.14.0.md)。漏禁则 Bash/Write 禁令被绕开。 + """ + cmd, _ = self._capture_cmd() + deny = cmd[cmd.index("--disallowed-tools") + 1].split() + for tool in ("EvalWorkflowSnippet", "CreateWorkflow", "AmendWorkflow", + "SaveWorkflow", "ListWorkflowRuns", "GetWorkflowRun", + "ResumeWorkflowRun", "ListSavedWorkflows", + "ResolveWorkflowQuestion"): + self.assertIn(tool, deny, f"黑名单缺动态工作流工具 {tool}") + def test_rc2_json_output(self): """RC2: 带 --json (结构化输出)""" cmd, _ = self._capture_cmd() @@ -220,6 +242,191 @@ def fake_run(cmd, *a, **kw): self.assertLess(len(captured["content"]), 11000, "截断后应在上限附近") +class TestProjectConfigGuard(_EnvGuard): + """issue #49 洞三防护: 被审目录链上的 zcode 项目配置 → 拒审 + 干净工作目录。 + + 背景: zcode 启动时装载 cwd 链上的 zcode.json / .zcode/config.json, 其中的 + mcp.servers **进程会被直接 spawn** (不经过模型/黑名单)。两条护栏: + ① _build_review_cmd 的 --cwd 指向只含空 .git 的临时目录 (发现链止步); + ② 被审目录链上有这两个文件时 fail-closed 拒审 (可显式放行)。 + """ + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def setUp(self): + super().setUp() + mod = self.mod + self._tmp = tempfile.mkdtemp(prefix="zob-guard-test-") + # scratch 缓存跨测试复用, 置空避免互相影响 + self._saved_scratch = mod._scratch_cwd_cache + mod._scratch_cwd_cache = None + + def tearDown(self): + self.mod._scratch_cwd_cache = self._saved_scratch + shutil.rmtree(self._tmp, ignore_errors=True) + super().tearDown() + + def _mk_repo(self, name, rel=None, git=True): + root = os.path.join(self._tmp, name) + os.makedirs(root, exist_ok=True) + if git: + os.mkdir(os.path.join(root, ".git")) + if rel: + p = os.path.join(root, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as f: + f.write("{}") + return root + + # ---------- _project_config_files ---------- + + def test_pg1_finds_dot_zcode_config(self): + """PG1: /.zcode/config.json 被识别""" + root = self._mk_repo("r1", rel=".zcode/config.json") + got = self.mod._project_config_files(root) + self.assertEqual(len(got), 1) + self.assertTrue(got[0].endswith(".zcode/config.json")) + + def test_pg2_finds_zcode_json(self): + """PG2: /zcode.json 被识别""" + root = self._mk_repo("r2", rel="zcode.json") + got = self.mod._project_config_files(root) + self.assertEqual(len(got), 1) + self.assertTrue(got[0].endswith("zcode.json")) + + def test_pg2b_symlink_resolved(self): + """PG2b: realpath 先解析符号链接 (R1 P3-4), 经链接进入的仓库照常识别""" + real = self._mk_repo("r2b-real", rel="zcode.json") + link = os.path.join(self._tmp, "r2b-link") + os.symlink(real, link) + got = self.mod._project_config_files(link) + self.assertEqual(len(got), 1) + self.assertTrue(got[0].endswith("zcode.json")) + + def test_pg3_clean_repo_is_empty(self): + """PG3: 干净仓库无命中""" + root = self._mk_repo("r3") + self.assertEqual(self.mod._project_config_files(root), []) + + def test_pg4_walks_up_to_git_root(self): + """PG4: 从子目录向上到 git 根 (含) — 仓库根的配置也被发现""" + root = self._mk_repo("r4", rel="zcode.json") + sub = os.path.join(root, "pkg", "inner") + os.makedirs(sub, exist_ok=True) + got = self.mod._project_config_files(sub) + self.assertEqual(len(got), 1, "应沿子目录→git 根发现仓库根的配置") + + def test_pg5_stops_at_git_root(self): + """PG5: git 根之上不再上溯 (与 zcode 的发现顺序一致)""" + outside = os.path.join(self._tmp, "pg5-outside") + os.makedirs(outside, exist_ok=True) + with open(os.path.join(outside, "zcode.json"), "w") as f: + f.write("{}") + repo = os.path.join(outside, "repo") + os.makedirs(repo) + os.mkdir(os.path.join(repo, ".git")) + self.assertEqual(self.mod._project_config_files(repo), [], + "git 根之上的配置不属于该项目, 不应误报") + + # ---------- _project_config_guard ---------- + + def test_pg6_guard_blocks_and_allows(self): + """PG6: 命中即拒审 (isError, 带结构化 refusal 键); 放行开关值矩阵""" + root = self._mk_repo("r6", rel=".zcode/config.json") + res = self.mod._project_config_guard(root) + self.assertIsNotNone(res) + self.assertTrue(res["isError"]) + # 机器契约: 结构化 refusal 键 (R1 P2-2), gate 只认它判定拒审 + self.assertEqual(res.get("refusal"), "project_config") + text = res["content"][0]["text"] + self.assertIn("拒绝审查", text) + self.assertIn("ZCODE_BRIDGE_TRUST_PROJECT_CONFIG", text) + # 文本标记保留在首行, 仅供肉眼/日志 grep + self.assertIn("ZOB-REFUSED: project_config", text) + # 放行开关值矩阵 (R1 P3-1): off/disabled 是"关"不是"开" — + # 关安全护栏的开关, 反直觉值不得触发放行 + for v in ("1", "yes", "true"): + os.environ["ZCODE_BRIDGE_TRUST_PROJECT_CONFIG"] = v + self.assertIsNone( + self.mod._project_config_guard(root), f"TRUST={v!r} 应放行") + for v in ("0", "false", "no", "off", "disabled"): + os.environ["ZCODE_BRIDGE_TRUST_PROJECT_CONFIG"] = v + self.assertIsNotNone( + self.mod._project_config_guard(root), f"TRUST={v!r} 不应放行") + os.environ.pop("ZCODE_BRIDGE_TRUST_PROJECT_CONFIG", None) + + def test_pg7_guard_passes_clean(self): + """PG7: 干净目录放行""" + self.assertIsNone(self.mod._project_config_guard(self._mk_repo("r7"))) + + def test_pg7b_structured_refusal_key(self): + """PG7b: 拒审 result 带结构化 refusal 键 (机器契约)。 + + R1 P2-2: 失败 detail 会混入 mimosa 错误体/子进程回显等攻击者可影响 + 的文本, gate 解析文本标记存在伪造面 (可把临时失败伪造成终态拒审), + 故机器契约改为 result 顶层 "refusal" 键 — 只有本护栏会写它; + 文本首行的 ZOB-REFUSED 保留, 仅供肉眼/日志 grep。 + """ + root = self._mk_repo("r7b", rel=".zcode/config.json", git=False) + res = self.mod._project_config_guard(root) + self.assertIsNotNone(res) + self.assertTrue(res["isError"]) + self.assertEqual(res.get("refusal"), "project_config") + self.assertTrue(res["content"][0]["text"].startswith( + "ZOB-REFUSED: project_config"), "文本标记保持首行, 便于日志 grep") + + # ---------- 命令构造: 干净工作目录 + prompt 带仓库根 ---------- + + def test_pg8_review_cmd_uses_scratch_not_repo(self): + """PG8: --cwd 指向隔离沙箱 (含空 .git), 不是被审仓库""" + repo = self._mk_repo("r8") + cmd = self.mod._build_review_cmd("PROMPT", repo) + cwd = cmd[cmd.index("--cwd") + 1] + self.assertNotEqual(os.path.abspath(cwd), os.path.abspath(repo)) + self.assertTrue(os.path.isdir(os.path.join(cwd, ".git")), + "沙箱须含空 .git 令 zcode 的配置发现链止步") + self.assertFalse(os.path.exists(os.path.join(cwd, "zcode.json"))) + + def test_pg8b_scratch_cwd_cleanup(self): + """PG8b: 沙箱目录退出时清理 (atexit), 清理后可重建 (R1 P2-3)""" + mod = self.mod + d1 = mod._review_scratch_cwd() + self.assertTrue(os.path.isdir(d1)) + mod._cleanup_scratch_cwd() + self.assertFalse(os.path.exists(d1), "清理后目录应删除") + self.assertIsNone(mod._scratch_cwd_cache) + d2 = mod._review_scratch_cwd() + self.assertNotEqual(d1, d2) + self.assertTrue(os.path.isdir(os.path.join(d2, ".git")), + "重建的沙箱同样含空 .git") + + def test_pg9_review_prompt_carries_repo_root(self): + """PG9: tool_zcode_review 的 prompt 带被审仓库绝对路径""" + repo = self._mk_repo("r9") + cmd, result = self._capture_cmd(cwd=repo) + self.assertNotIn("isError", result) + prompt = cmd[cmd.index("--prompt") + 1] + self.assertIn(f"被审仓库根目录: {os.path.abspath(repo)}", prompt) + + def test_pg10_tool_refuses_project_config(self): + """PG10: 被审目录含项目配置 → tool_zcode_review 直接拒审, 不调 zcode""" + repo = self._mk_repo("r10", rel=".zcode/config.json") + mod = self.mod + calls = [] + saved = mod.subprocess.run + mod.subprocess.run = lambda *a, **kw: calls.append(a) or _FakeCompletedProcess(0, "OK", "") + os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0" + try: + result = mod.tool_zcode_review({"code": "print(1)", "cwd": repo}) + finally: + mod.subprocess.run = saved + self.assertTrue(result["isError"]) + self.assertIn("拒绝审查", result["content"][0]["text"]) + self.assertEqual(calls, [], "拒审时不应启动 zcode 子进程") + + class TestExtractResponse(unittest.TestCase): """--json 输出的 (response, usage.totalTokens) 提取"""