From 61e08a1a8c607bb490a1a00766b9eaa0bcf37b90 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 02:36:03 +0000 Subject: [PATCH] feat: hard merge gate by default (CI + directory + contract + coordinator + reviews) - cloud/merge_gate_hard.py (new): pure hard gate; mode resolution is env-only (unset/invalid -> hard, fail-closed); five checks plus branch protection must be protected; unknown/unprotected block even with explicit confirm; no-evidence blocks (fail-closed); directory check reuses #209 check_directory_isolation (v0.3 rule); blocks codes + merge_gate_blocked audit detail are the track-3 pushback interface (no pushback state machine in this track). - cloud/server.py: POST /tasks/{id}/merge uses hard mode by default; record/enforce/off only when explicitly set via env (preserving #209 semantics); body merge_gate_mode is ignored server-side so callers cannot downgrade the gate; every gate block writes a merge_gate_blocked audit before returning 409, status unchanged; hard-mode body type errors return 400. - docs/merge-gate-branch-protection.md (new): protection payload (PR + strict status checks + no force-push + no deletion + enforce_admins), empty required_checks = unconfigured, Pro/public prerequisites, 403 causes; states protection is NOT claimed enabled. - SKILL.md (gate-mechanism wording only): four checks -> five checks; body + coordinator + client appendix gate paragraphs updated to default hard, merge_gate_blocked audit, payload doc reference. - tests: 31 new hard-gate tests (pure + endpoint + skill text); #209 endpoint tests now set record env explicitly (their intent); test_server_merge 200-path calls now carry full hard evidence. --- cloud/merge_gate_hard.py | 271 ++++++++++++ cloud/server.py | 117 ++++- cloud/skills/software-engineering/SKILL.md | 56 ++- cloud/tests/test_merge_gate_hard.py | 453 ++++++++++++++++++++ cloud/tests/test_merge_gate_protectskill.py | 6 + cloud/tests/test_server_merge.py | 55 ++- docs/merge-gate-branch-protection.md | 129 ++++++ 7 files changed, 1048 insertions(+), 39 deletions(-) create mode 100644 cloud/merge_gate_hard.py create mode 100644 cloud/tests/test_merge_gate_hard.py create mode 100644 docs/merge-gate-branch-protection.md diff --git a/cloud/merge_gate_hard.py b/cloud/merge_gate_hard.py new file mode 100644 index 0000000..2bec9ff --- /dev/null +++ b/cloud/merge_gate_hard.py @@ -0,0 +1,271 @@ +"""服务端合并硬门禁:五项全查、缺一 409、fail-closed(纯函数)。 + +本模块堆叠在 #209(cloud/merge_gate.py)之上,不改 #209 的语义: +- #209 的 ``evaluate_merge_gate`` / ``gate_from_request`` 原样保留: + record 默认、无证据不阻断、unknown 只记风险不阻断。那些是 + #209 的过渡语义,本模块不碰它们,所以 #209 的测试一字不用改。 +- 本模块只新增一个模式值 ``hard``,以及服务端用它时的模式解析: + ``hard_mode_from_env``。服务端(POST /tasks/{id}/merge)以本模块 + 的解析为准,#209 的 ``gate_from_request`` 不再是服务端的模式来源 + (它仍可作纯函数使用,但服务端不再经由它决定模式——否则调用方 + 可用请求体 ``merge_gate_mode=record`` 把硬门禁降级,那是 + fail 但照样合并的路径,本模块堵的就是它)。 + +模式(环境变量 CLOUD_MERGE_GATE_MODE,运维定、调用方改不了): +- hard(未设或非法值时都落到 hard,fail-closed,绝不回落 record/off): + 下面五项必须全部通过,缺一就阻断。调用方请求体里的 + ``merge_gate_mode`` 在服务端被忽略——模式只由环境变量决定。 +- record / enforce / off(必须显式设置):原样沿用 #209 的语义, + 属过渡/显式降级。record 下无证据调用仍按旧接口放行(只附带门禁 + 信息),enforce 下未保护一律阻断,off 下只记风险不阻断。切到 + 这三者是运维的显式决定,不是调用方的默认权利。 + +硬门禁五项(全部是调用方在 /merge 请求体里提交的证据; +本服务没有可信渠道直连 GitHub 核实,所以门禁强制的是“证据齐全 +且全绿”,不是“服务端已向 GitHub 求证”——这条诚实边界必须写清, +调用方谎报证据不在本模块的验证范围内,由审计(第 3 轨)与 +PR/Checks 的只读核对去覆盖): + ① CI 全绿:``ci_status`` 必须为 ``"success"``,且 ``ci_checks`` + 非空、每项 status 均为 ``"success"``,且 ``required_checks`` + 非空、每项都在 ``ci_checks`` 里且为 success。空 + ``required_checks``(``[]``)= 未配置,按分支保护 payload + 的同一口径处理(见 docs/merge-gate-branch-protection.md), + 不是“没有必须项所以通过”。 + ② 目录隔离:复用 #209 的 ``check_directory_isolation``(v0.3 口径: + merge-base 范围 ``git diff --name-only``,拥有目录前缀检查, + 含 ``apps/web2`` 非子目录边界与 ``../``、绝对路径逃逸)。 + ``files_changed`` 缺失 = 无证据,直接阻断(#209 里是跳过, + 硬模式里是阻断,这是本轨改动的核心之一);越目录只能靠 + ``approved_exceptions``(协调员批准的契约变更文件)放行, + 显式确认绝不覆盖目录违规。 + ③ 合同版本:``contract_version`` 非空,且 ``contract_frozen`` + 为 true;若同时给了 ``contract_expected_version``(冻结版), + 两者必须相等,否则阻断。服务端没有独立的合同版本存储, + 冻结版由调用方(协调员侧)声明, mismatch 时阻断。 + ④ 协调员批准:``coordinator_approved`` 必须为 true;缺失与 + 显式 false 用不同的 block 码区分(缺证据 vs 已否决)。 + ⑤ 无未解决评审意见:``unresolved_review_comments`` 必须为 0 + (整数)或空列表;缺失 = 无证据,阻断;大于 0 / 非空 = 阻断。 + +分支保护(硬模式下的第六条隐含项,来自 #209 的保护定义): + ``branch_protection`` 必须为 ``"protected"``。``"unprotected"`` + 即使带 ``merge_gate_confirm`` 也阻断——硬模式没有“确认即放行” + 的逃生口,确认只在 record 模式下覆盖保护风险。``"unknown"`` + (未声明或非法值)同样阻断:unknown 不等于 protected,这是 + #209 里“unknown 只记风险”的过渡语义在硬模式下的收紧。 + +与第 3 轨(独立审计 + 自动推回)的接口约定(本模块不造推回状态机): + - 阻塞依据 = 409 响应里的 ``merge_gate`` 对象:``mode``、 + ``blocks``(稳定的机器可读码,下面的 _BLOCK_DESCRIPTIONS 有 + 逐条中文说明)、以及 ``ci`` / ``directory`` / ``contract`` / + ``coordinator`` / ``reviews`` 五个分项。推回包逐条取 blocks, + 不要解析审计 detail 文本。 + - 审计依据 = 每次门禁阻断写一条 ``merge_gate_blocked`` 审计 + (action 名固定,detail 为 ``" mode= + blocks=<逗号分隔的 blocks>"``),任务状态不变(UPDATE 不执行)。 + - 门禁结论是单次调用的判定,不持久化为任务状态:一次 200 不 + 代表之后另一次合并自动通过;修复后必须重新提交完整证据、 + 重新走全部五项(完整重审,非只审改动点)。 + - 本模块不建推回任务、不发通知、不改任务状态机:推回执行、 + 轮次上限、升级路径全部归第 3 轨。 +""" + +from cloud.merge_gate import ( + PROTECTION_STATES, + RISK_UNKNOWN, + RISK_UNPROTECTED, + check_directory_isolation, +) + +HARD_MODES = ("hard", "record", "enforce", "off") + +_BLOCK_DESCRIPTIONS = { + "ci_evidence_missing": "CI 证据缺失(ci_status / ci_checks / required_checks 有一项未给)", + "ci_not_green": "CI 状态不是 success", + "ci_check_failed": "CI 检查项中有非 success 项", + "ci_required_checks_unconfigured": "required_checks 为空 = 未配置", + "ci_required_check_missing": "required_checks 中有项不在 CI 检查里或不是 success", + "directory_evidence_missing": "目录证据缺失(files_changed 未给)", + "directory_owned_dir_unknown": "拥有目录未知,无法核对目录隔离", + "directory_isolation_violation": "目录隔离违规(越拥有目录)", + "contract_version_missing": "合同版本缺失", + "contract_not_frozen": "合同未冻结(contract_frozen 不是 true)", + "contract_version_mismatch": "合同版本与冻结版不一致", + "coordinator_approval_missing": "协调员批准证据缺失", + "coordinator_not_approved": "协调员未批准", + "review_evidence_missing": "评审意见证据缺失", + "unresolved_review_comments": "存在未解决评审意见", + "branch_protection_unknown": "分支保护状态未知(unknown 不等于 protected)", + "branch_protection_unprotected": "分支未保护(硬模式下显式确认也不放行)", +} + + +def hard_mode_from_env(env=None): + """服务端有效的门禁模式:环境变量定,调用方改不了。 + + 未设、空值或非法值一律回落 hard(fail-closed)。注意这与 + #209 的 gate_mode_from_env 不同:后者非法值回落 record,那是 + #209 纯函数的过渡语义;服务端模式解析必须 fail-closed,所以 + 这里绝不回落到 record/off。 + """ + import os + + raw = (env if env is not None else os.environ).get( + "CLOUD_MERGE_GATE_MODE", "") + mode = (raw or "").strip().lower() + return mode if mode in HARD_MODES else "hard" + + +def _ci_checks_index(ci_checks): + """ci_checks 列表 -> {name: status};条目不合规时返回 None。""" + index = {} + for entry in ci_checks: + if not isinstance(entry, dict): + return None + name = entry.get("name") + status = entry.get("status") + if not isinstance(name, str) or not name \ + or not isinstance(status, str): + return None + index[name] = status + return index + + +def evaluate_hard_merge_gate(files_changed=None, owned_dir=None, + approved_exceptions=(), protection="unknown", + ci_status=None, ci_checks=None, + required_checks=None, contract_version=None, + contract_frozen=False, + contract_expected_version=None, + coordinator_approved=None, + unresolved_review_comments=None): + """硬门禁纯函数:五项 + 保护,缺一即 allowed=False。 + + files_changed=None、ci_checks=None、required_checks=None、 + unresolved_review_comments=None 全部是“无证据”,不是“跳过”。 + protection 非法值归一化为 unknown,随后按 unknown 阻断。 + 返回 dict:allowed / mode / protection / directory / ci / + contract / coordinator / reviews / risks / blocks。 + """ + protection = protection if protection in PROTECTION_STATES \ + else "unknown" + risks = [] + blocks = [] + + # ① CI 全绿。 + if ci_status is None or ci_status == "" \ + or ci_checks is None or required_checks is None: + blocks.append("ci_evidence_missing") + else: + if ci_status != "success": + blocks.append("ci_not_green") + if not isinstance(ci_checks, list) or not ci_checks: + blocks.append("ci_evidence_missing") + index = None + else: + index = _ci_checks_index(ci_checks) + if index is None: + blocks.append("ci_check_failed") + elif any(status != "success" for status in index.values()): + blocks.append("ci_check_failed") + if isinstance(required_checks, list) and not required_checks: + blocks.append("ci_required_checks_unconfigured") + elif isinstance(required_checks, list) and index is not None: + missing = [name for name in required_checks + if index.get(name) != "success"] + if missing: + blocks.append("ci_required_check_missing") + elif not isinstance(required_checks, list): + blocks.append("ci_evidence_missing") + ci = {"status": ci_status, "checks": ci_checks, + "required_checks": required_checks, + "all_green": False} # 末尾按 CI 分项 blocks 重算,见下。 + + # ② 目录隔离(复用 #209,不另造口径)。 + directory = None + if files_changed is None: + blocks.append("directory_evidence_missing") + else: + directory = check_directory_isolation( + files_changed, owned_dir, approved_exceptions) + if directory["inconclusive"]: + blocks.append("directory_owned_dir_unknown") + elif not directory["passed"]: + blocks.append("directory_isolation_violation") + + # ③ 合同版本。 + if contract_version is None or ( + isinstance(contract_version, str) + and not contract_version.strip()): + blocks.append("contract_version_missing") + if contract_frozen is not True: + blocks.append("contract_not_frozen") + if contract_expected_version is not None \ + and contract_version is not None \ + and contract_version != contract_expected_version: + blocks.append("contract_version_mismatch") + contract = {"version": contract_version, "frozen": contract_frozen, + "expected": contract_expected_version} + + # ④ 协调员批准。 + if coordinator_approved is None: + blocks.append("coordinator_approval_missing") + elif coordinator_approved is not True: + blocks.append("coordinator_not_approved") + coordinator = {"approved": coordinator_approved} + + # ⑤ 无未解决评审意见。 + if unresolved_review_comments is None: + blocks.append("review_evidence_missing") + reviews = {"unresolved": None} + elif isinstance(unresolved_review_comments, bool): + blocks.append("review_evidence_missing") + reviews = {"unresolved": None} + elif isinstance(unresolved_review_comments, list): + reviews = {"unresolved": unresolved_review_comments} + if unresolved_review_comments: + blocks.append("unresolved_review_comments") + elif isinstance(unresolved_review_comments, int): + reviews = {"unresolved": unresolved_review_comments} + if unresolved_review_comments > 0: + blocks.append("unresolved_review_comments") + elif unresolved_review_comments < 0: + blocks.append("review_evidence_missing") + else: + blocks.append("review_evidence_missing") + reviews = {"unresolved": None} + + # 分支保护:硬模式下 unknown 与 unprotected 一律阻断,确认无效。 + if protection == "unprotected": + risks.append(RISK_UNPROTECTED) + blocks.append("branch_protection_unprotected") + elif protection == "unknown": + risks.append(RISK_UNKNOWN) + blocks.append("branch_protection_unknown") + + # ci.all_green 只反映 CI 分项本身,不被其它项的 blocks 污染。 + ci_block_codes = ("ci_evidence_missing", "ci_not_green", + "ci_check_failed", "ci_required_checks_unconfigured", + "ci_required_check_missing") + ci["all_green"] = not any(code in blocks for code in ci_block_codes) + + return {"allowed": not blocks, "mode": "hard", + "protection": protection, "directory": directory, + "ci": ci, "contract": contract, + "coordinator": coordinator, "reviews": reviews, + "risks": risks, "blocks": blocks} + + +def block_descriptions(blocks): + """blocks 码 -> 中文说明(供第 3 轨推回包逐条引用)。""" + return [_BLOCK_DESCRIPTIONS.get(code, code) for code in blocks or []] + + +def blocked_audit_detail(task_id, gate): + """merge_gate_blocked 审计的 detail:只含任务 id、模式与 blocks 码。 + + 推回包不要解析这段文本,取 409 响应的 merge_gate 对象为准。 + """ + return "%s mode=%s blocks=%s" % ( + task_id, gate.get("mode", "hard"), + ",".join(gate.get("blocks") or [])) diff --git a/cloud/server.py b/cloud/server.py index 7a09334..88e1804 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -74,7 +74,11 @@ from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups from cloud.peer_messages import peer_message_prefix as _peer_prefix from cloud.peer_messages import row_get as _row_get from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults -from cloud.merge_gate import gate_from_request as _merge_gate_from_request +from cloud.merge_gate import evaluate_merge_gate as _merge_gate_evaluate +from cloud.merge_gate import protection_from_env as _merge_gate_protection +from cloud.merge_gate_hard import blocked_audit_detail as _gate_blocked_detail +from cloud.merge_gate_hard import evaluate_hard_merge_gate as _merge_gate_hard +from cloud.merge_gate_hard import hard_mode_from_env as _merge_gate_hard_mode from cloud.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES from cloud.tree import build_tree as _build_tree @@ -1960,6 +1964,43 @@ def sweep_stale_tasks(db_path, threshold_seconds=300): return swept +def _validate_hard_gate_body(body): + """硬模式请求体类型校验:畸形字段回 400,不进门禁判定。 + + 只在 hard 模式调用;record/enforce/off 下这些字段按 #209 语义 + 被忽略,不因此 400。缺失字段不是畸形,是无证据,交门禁判 409。 + 返回错误串,无错返回 None。 + """ + for field in ("ci_checks", "required_checks", "approved_exceptions"): + value = body.get(field) + if value is not None and not isinstance(value, list): + return "%s must be a list" % field + checks = body.get("ci_checks") + if isinstance(checks, list): + for entry in checks: + if not isinstance(entry, dict) \ + or not isinstance(entry.get("name"), str) \ + or not isinstance(entry.get("status"), str): + return "ci_checks entries must be objects " \ + "with name and status" + for field in ("contract_frozen", "coordinator_approved"): + value = body.get(field) + if value is not None and not isinstance(value, bool): + return "%s must be a boolean" % field + version = body.get("contract_version") + if version is not None and not isinstance(version, str): + return "contract_version must be a string" + expected = body.get("contract_expected_version") + if expected is not None and not isinstance(expected, str): + return "contract_expected_version must be a string" + reviews = body.get("unresolved_review_comments") + if reviews is not None and not isinstance(reviews, list) \ + and not (isinstance(reviews, int) + and not isinstance(reviews, bool)): + return "unresolved_review_comments must be a list or an integer" + return None + + class CloudControlPlaneHandler(BaseHTTPRequestHandler): """控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。""" @@ -7616,17 +7657,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): 必须是 completed(其它状态一律 409,含已 merged 的重复 调用,避免二次合并混淆)。成功后写审计 merge_task。 - 合并门禁(cloud/merge_gate,补 v0.3 技能文本检查在服务端 - 缺失的缺口;v0.3 之前本接口只改状态,不查目录、不查保护): - 请求体可选字段 files_changed / owned_dir / - approved_exceptions / branch_protection / merge_gate_confirm - / merge_gate_mode;一个都不给时行为与旧接口一致(只在响应 - 里附带门禁结果,不阻断)。给了证据才按门禁判:越目录 - (v0.3 口径)或未保护且无确认(record 模式)或未保护 - (enforce 模式)时 409 且不改状态;未保护 + 确认时放行, - 风险记入响应与 merge_gate_risk 审计。确认只覆盖保护风险, - 绝不覆盖目录违规。保护状态 unknown 时记风险、不阻断—— - 本服务没有可信渠道核实 GitHub 保护,unknown 不等于已保护。 + 合并门禁(cloud/merge_gate + cloud/merge_gate_hard,补 v0.3 + 技能文本检查在服务端缺失的缺口;v0.3 之前本接口只改状态, + 不查目录、不查保护): + 模式只由环境变量 CLOUD_MERGE_GATE_MODE 决定,请求体里的 + merge_gate_mode 在服务端被忽略(调用方不能自行降级): + - hard(默认:环境变量未设或非法值都落到 hard):五项全查, + 缺一 409 且不改状态,并写 merge_gate_blocked 审计。五项: + ① CI 全绿(ci_status=success + ci_checks 全 success + + required_checks 非空且全 success);② 目录隔离(v0.3 口径, + files_changed 缺失即无证据阻断,越目录只能靠 + approved_exceptions 放行);③ 合同版本(contract_version + 非空 + contract_frozen=true,与 contract_expected_version + 给出时须相等);④ 协调员批准(coordinator_approved=true); + ⑤ 无未解决评审意见(unresolved_review_comments=0 或空列表)。 + 另:branch_protection 必须为 protected;unprotected 即使带 + merge_gate_confirm 也阻断,unknown 同样阻断(unknown + 不等于 protected)。无证据不放行(fail-closed)。 + - record / enforce / off(必须显式设置,属过渡/显式降级): + 原样沿用 #209 语义:record 下无证据调用仍按旧接口放行 + (只在响应里附带门禁结果),未保护 + 无确认阻断、+ 确认 + 放行并记 merge_gate_risk;enforce 下未保护一律阻断; + off 下只记风险不阻断。确认只覆盖保护风险,绝不覆盖目录违规。 + 门禁阻断(任一模式)都写 merge_gate_blocked 审计后才回 409, + 这是第 3 轨自动推回的阻塞依据:推回包取响应 merge_gate 的 + blocks 码,不要解析审计文本;本接口不建推回任务、不改状态机。 + 本服务没有可信渠道直连 GitHub 核实 CI/保护:门禁强制的是 + “调用方提交的证据齐全且全绿”,谎报证据由审计与 PR/Checks + 只读核对覆盖,不在本接口的验证范围内。 """ row = self._fetch_task(task_id) if row is None: @@ -7652,8 +7710,41 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): body.get("files_changed"), list): self._send_json(400, {"error": "files_changed must be a list"}) return - gate = _merge_gate_from_request(body) + mode = _merge_gate_hard_mode() + protection = body.get("branch_protection") \ + or _merge_gate_protection() + if mode == "hard": + hard_err = _validate_hard_gate_body(body) + if hard_err: + self._send_json(400, {"error": hard_err}) + return + gate = _merge_gate_hard( + files_changed=body.get("files_changed"), + owned_dir=body.get("owned_dir"), + approved_exceptions=body.get("approved_exceptions") or (), + protection=protection, + ci_status=body.get("ci_status"), + ci_checks=body.get("ci_checks"), + required_checks=body.get("required_checks"), + contract_version=body.get("contract_version"), + contract_frozen=body.get("contract_frozen", False), + contract_expected_version=body.get( + "contract_expected_version"), + coordinator_approved=body.get("coordinator_approved"), + unresolved_review_comments=body.get( + "unresolved_review_comments")) + else: + gate = _merge_gate_evaluate( + files_changed=body.get("files_changed"), + owned_dir=body.get("owned_dir"), + approved_exceptions=body.get("approved_exceptions") or (), + protection=protection, mode=mode, + explicit_confirm=bool(body.get("merge_gate_confirm"))) if not gate["allowed"]: + with _auth_store(self.server.db_path) as store: + store.audit(user_id, task["project_id"], + "merge_gate_blocked", + _gate_blocked_detail(task_id, gate)) self._send_json(409, { "error": "merge gate blocked", "merge_gate": gate, diff --git a/cloud/skills/software-engineering/SKILL.md b/cloud/skills/software-engineering/SKILL.md index 7934e92..d73179b 100644 --- a/cloud/skills/software-engineering/SKILL.md +++ b/cloud/skills/software-engineering/SKILL.md @@ -524,10 +524,10 @@ tests/ # QA 线程专属,含跨端一致性用例 ### 合并门:缺一不可,与主分支保护 -- 合并前四项全过:① GitHub Checks 全绿;② 目录归属检查通过 +- 合并前五项全过:① GitHub Checks 全绿;② 目录归属检查通过 (只改拥有目录,契约变更经批准除外;检查命令与输出文件清单必须 记入合并记录,见“目录与分支规则”);③ 契约版本是冻结版; - ④ 协调员评审通过,且无未解决评审意见。有一项不过就不合并。 + ④ 协调员评审通过;⑤ 无未解决评审意见。有一项不过就不合并。 - CI 怎么查:用只读方式查,例如本平台的 GET /tasks/{id}/pr/checks。 它是只读诊断,绝不重跑检查、不批准、不合并;还没建 PR 时它会 409 (查无可查),这时先补 PR,不要把它当成 CI 通过。 @@ -536,17 +536,24 @@ tests/ # QA 线程专属,含跨端一致性用例 协调员要在决策记录里写明风险并去开通,不能假装已保护, 不能靠“大家自觉”代替保护。 - 合并门禁(2026-10-30 补,代码在 cloud/merge_gate.py,服务端 - POST /tasks/{id}/merge 已接线):保护状态只能由调用方或环境变量 + POST /tasks/{id}/merge 已接线;2026-10-31 硬门禁轨加 + cloud/merge_gate_hard.py):保护状态只能由调用方或环境变量 CLOUD_BRANCH_PROTECTION 显式声明,代码不去 GitHub 核实、不声称 - 已保护;状态 unknown 时记风险、不阻断。模式由 - CLOUD_MERGE_GATE_MODE 控制,默认 record:未保护时须显式确认 - (merge_gate_confirm)才放行,无确认阻断;enforce 模式下未保护 - 一律阻断,确认也不放行——切 enforce 前必须先由用户决定并开通 - 保护(升级 Pro、公开仓库、或保持纪律 + 记风险,三者用户侧定, - 代码不替选)。显式确认只覆盖保护风险,绝不覆盖目录隔离违规; - 目录检查复用 v0.3 的 git diff --name-only 口径,越目录只能靠 - 契约变更批准文件放行。技能播种仍只复制 SKILL.md,不做整目录复制 - (租户已改文件绝不被覆盖,见 cloud/skill_seed.py 的评估结论)。 + 已保护。模式只由环境变量 CLOUD_MERGE_GATE_MODE 控制,默认 + hard:上面五项全查,缺一 409 且任务状态不变,并写 + merge_gate_blocked 审计;无证据不放行;unknown 不等于 + protected,同样阻断;未保护即使带显式确认也不放行。record / + enforce / off 必须显式设置才生效(过渡/显式降级):record 下 + 未保护时须显式确认(merge_gate_confirm)才放行,无确认阻断, + 无证据调用仍按旧接口放行;enforce 模式下未保护一律阻断, + 确认也不放行——切 enforce 前必须先由用户决定并开通保护 + (升级 Pro、公开仓库、或保持纪律 + 记风险,三者用户侧定, + 代码不替选,操作说明与 payload 见 + docs/merge-gate-branch-protection.md)。显式确认只覆盖保护风险, + 绝不覆盖目录隔离违规;目录检查复用 v0.3 的 git diff --name-only + 口径,越目录只能靠契约变更批准文件放行。技能播种仍只复制 + SKILL.md,不做整目录复制(租户已改文件绝不被覆盖,见 + cloud/skill_seed.py 的评估结论)。 ### 合并方式与合并记录 @@ -828,18 +835,19 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单 测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理, 未解决的评审意见不能合并。 - 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、 - 你评审通过且无未解决评审意见,四项缺一不可。 + 你评审通过、无未解决评审意见,五项缺一不可。 CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、 不批准、不合并;无 PR 时它会 409,先补 PR。 - 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置, 不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。 -- 合并门禁:同正文,代码在 cloud/merge_gate.py,模式由 - CLOUD_MERGE_GATE_MODE 控制,默认 record:未保护时须显式确认 - 才放行,无确认阻断;enforce 模式下未保护一律阻断,切 enforce - 前须由用户决定并开通保护(升级 Pro、公开仓库、或保持纪律 + - 记风险,三者用户侧定)。显式确认只覆盖保护风险,绝不覆盖 - 目录隔离违规;目录检查复用 v0.3 的 git diff --name-only 口径; - 技能播种仍只复制 SKILL.md,不做整目录复制。 +- 合并门禁:同正文,代码在 cloud/merge_gate.py 与 + cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制, + 默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked + 审计,无证据不放行,unknown 不等于 protected,未保护即使带 + 显式确认也不放行;record / enforce / off 须显式设置才生效, + record 下未保护时须显式确认才放行。显式确认只覆盖保护风险, + 绝不覆盖目录隔离违规;目录检查复用 v0.3 的 git diff --name-only + 口径;技能播种仍只复制 SKILL.md,不做整目录复制。 - 合并记录:默认 merge commit;记录必须含 PR 号、合并提交 hash、 CI 运行链接、执行人(人或宿主操作,不是你自己合); 合并后删远程分支,删前确认合并提交已在主线。 @@ -1068,9 +1076,11 @@ TODO-UPDATE 要求,缺一项就不发任务)。派生提示词里的精简 - 合并门由协调员判定:Checks 全绿、目录归属通过、契约版本是冻结版、 评审通过且无未解决意见;你不自己合并、不绕过评审推主线。 - 合并门禁:同正文,模式由 CLOUD_MERGE_GATE_MODE 控制,默认 - record:未保护时须显式确认才放行;显式确认只覆盖保护风险, - 绝不覆盖目录隔离违规;目录检查复用 v0.3 的 - git diff --name-only 口径;技能播种仍只复制 SKILL.md, + hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked 审计, + 无证据不放行,unknown 不等于 protected,未保护即使带显式确认 + 也不放行;record 须显式设置才生效,未保护时须显式确认才放行; + 显式确认只覆盖保护风险,绝不覆盖目录隔离违规;目录检查复用 + v0.3 的 git diff --name-only 口径;技能播种仍只复制 SKILL.md, 不做整目录复制。 - 安全:密钥、口令、token 绝不进代码、commit、命令行、PR 正文; 需要调用 GitHub 时 token 只进子进程环境,不写进示例命令。 diff --git a/cloud/tests/test_merge_gate_hard.py b/cloud/tests/test_merge_gate_hard.py new file mode 100644 index 0000000..88795bd --- /dev/null +++ b/cloud/tests/test_merge_gate_hard.py @@ -0,0 +1,453 @@ +"""服务端合并硬门禁(cloud/merge_gate_hard,默认 hard 模式)。 + +与 #209 的分工(不要把两套语义混成一套断言): +- #209 的纯函数与 record/enforce/off 语义原样保留,见 + test_merge_gate_protectskill.py;本文件不重测 record 的 + “无证据放行”,只测:record 仍是显式 env 才能选到的过渡模式。 +- 本文件测硬模式:默认(env 未设或非法值)即 hard,五项全查、 + 缺一 409、状态不变、merge_gate_blocked 审计;unknown 保护 + 不等于 protected;显式确认在硬模式下不覆盖任何一项; + 调用方请求体里的 merge_gate_mode 不能把硬门禁降级。 +""" + +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.merge_gate_hard import ( + blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env) + from cloud.server import create_server +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.merge_gate_hard import ( + blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env) + from cloud.server import create_server + + +def _hard_kwargs(**overrides): + kwargs = { + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + } + kwargs.update(overrides) + return kwargs + + +class HardModeEnvTestCase(unittest.TestCase): + def test_unset_and_invalid_mode_fall_to_hard(self): + self.assertEqual(hard_mode_from_env({}), "hard") + self.assertEqual(hard_mode_from_env( + {"CLOUD_MERGE_GATE_MODE": ""}), "hard") + self.assertEqual(hard_mode_from_env( + {"CLOUD_MERGE_GATE_MODE": "bogus"}), "hard") + self.assertEqual(hard_mode_from_env( + {"CLOUD_MERGE_GATE_MODE": "record"}), "record") + self.assertEqual(hard_mode_from_env( + {"CLOUD_MERGE_GATE_MODE": "HARD"}), "hard") + + +class HardGateUnitTestCase(unittest.TestCase): + def test_full_evidence_passes(self): + gate = evaluate_hard_merge_gate(**_hard_kwargs()) + self.assertTrue(gate["allowed"]) + self.assertEqual(gate["blocks"], []) + self.assertEqual(gate["mode"], "hard") + self.assertTrue(gate["ci"]["all_green"]) + + def test_no_evidence_blocks_every_missing_item(self): + gate = evaluate_hard_merge_gate() + self.assertFalse(gate["allowed"]) + for code in ("ci_evidence_missing", "directory_evidence_missing", + "contract_version_missing", "contract_not_frozen", + "coordinator_approval_missing", + "review_evidence_missing", + "branch_protection_unknown"): + self.assertIn(code, gate["blocks"]) + + def test_ci_variants_block(self): + pending = evaluate_hard_merge_gate(**_hard_kwargs(ci_status="pending")) + self.assertIn("ci_not_green", pending["blocks"]) + failed_check = evaluate_hard_merge_gate(**_hard_kwargs( + ci_checks=[{"name": "ci", "status": "failure"}])) + self.assertIn("ci_check_failed", failed_check["blocks"]) + self.assertFalse(failed_check["ci"]["all_green"]) + empty_required = evaluate_hard_merge_gate(**_hard_kwargs( + required_checks=[])) + self.assertIn("ci_required_checks_unconfigured", + empty_required["blocks"]) + missing_required = evaluate_hard_merge_gate(**_hard_kwargs( + required_checks=["ci", "lint"])) + self.assertIn("ci_required_check_missing", + missing_required["blocks"]) + bare_success = evaluate_hard_merge_gate(**_hard_kwargs( + ci_checks=None, required_checks=None)) + self.assertIn("ci_evidence_missing", bare_success["blocks"]) + + def test_directory_reuses_v03_rule(self): + edge = evaluate_hard_merge_gate(**_hard_kwargs( + files_changed=["apps/web2/x.py"])) + self.assertIn("directory_isolation_violation", edge["blocks"]) + escape = evaluate_hard_merge_gate(**_hard_kwargs( + files_changed=["../secret.py", "/abs.py"])) + self.assertEqual(escape["directory"]["violations"], + ["../secret.py", "/abs.py"]) + exc = evaluate_hard_merge_gate(**_hard_kwargs( + files_changed=["apps/web/a.py", "contracts/api.md"], + approved_exceptions=["contracts/api.md"])) + self.assertTrue(exc["allowed"]) + self.assertEqual(exc["directory"]["exceptions_used"], + ["contracts/api.md"]) + no_dir = evaluate_hard_merge_gate(**_hard_kwargs( + files_changed=None)) + self.assertIn("directory_evidence_missing", no_dir["blocks"]) + no_owned = evaluate_hard_merge_gate(**_hard_kwargs(owned_dir=None)) + self.assertIn("directory_owned_dir_unknown", no_owned["blocks"]) + + def test_contract_variants_block(self): + missing = evaluate_hard_merge_gate(**_hard_kwargs( + contract_version=None)) + self.assertIn("contract_version_missing", missing["blocks"]) + unfrozen = evaluate_hard_merge_gate(**_hard_kwargs( + contract_frozen=False)) + self.assertIn("contract_not_frozen", unfrozen["blocks"]) + mismatch = evaluate_hard_merge_gate(**_hard_kwargs( + contract_expected_version="v3")) + self.assertIn("contract_version_mismatch", mismatch["blocks"]) + matched = evaluate_hard_merge_gate(**_hard_kwargs( + contract_expected_version="v2")) + self.assertTrue(matched["allowed"]) + + def test_coordinator_and_reviews_block(self): + missing = evaluate_hard_merge_gate(**_hard_kwargs( + coordinator_approved=None)) + self.assertIn("coordinator_approval_missing", missing["blocks"]) + denied = evaluate_hard_merge_gate(**_hard_kwargs( + coordinator_approved=False)) + self.assertIn("coordinator_not_approved", denied["blocks"]) + unresolved = evaluate_hard_merge_gate(**_hard_kwargs( + unresolved_review_comments=["c1"])) + self.assertIn("unresolved_review_comments", + unresolved["blocks"]) + count = evaluate_hard_merge_gate(**_hard_kwargs( + unresolved_review_comments=2)) + self.assertIn("unresolved_review_comments", count["blocks"]) + missing_reviews = evaluate_hard_merge_gate(**_hard_kwargs( + unresolved_review_comments=None)) + self.assertIn("review_evidence_missing", + missing_reviews["blocks"]) + + def test_protection_unknown_and_unprotected_block(self): + unknown = evaluate_hard_merge_gate(**_hard_kwargs( + protection="unknown")) + self.assertIn("branch_protection_unknown", unknown["blocks"]) + illegal = evaluate_hard_merge_gate(**_hard_kwargs( + protection="bogus")) + self.assertEqual(illegal["protection"], "unknown") + self.assertIn("branch_protection_unknown", illegal["blocks"]) + + def test_unprotected_no_confirm_blocks(self): + gate = evaluate_hard_merge_gate(**_hard_kwargs( + protection="unprotected")) + self.assertFalse(gate["allowed"]) + self.assertIn("branch_protection_unprotected", gate["blocks"]) + + def test_confirm_never_overrides_anything_in_hard_mode(self): + # 硬模式没有“确认即放行”:确认不是本函数的入参,调用方 + # 即使在请求体里带 merge_gate_confirm,服务端硬路径也不读它; + # 这里钉住纯函数侧:未保护 + 目录违规同时存在时两项都拦。 + gate = evaluate_hard_merge_gate(**_hard_kwargs( + protection="unprotected", + files_changed=["apps/web/a.py", "apps/ios/b.py"])) + self.assertIn("branch_protection_unprotected", gate["blocks"]) + self.assertIn("directory_isolation_violation", gate["blocks"]) + + def test_blocked_audit_detail_format(self): + gate = evaluate_hard_merge_gate() + detail = blocked_audit_detail("task-1", gate) + self.assertTrue(detail.startswith("task-1 mode=hard blocks=")) + self.assertIn("ci_evidence_missing", detail) + + +class HardGateEndpointTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + # 默认即 hard:每个用例开始时清掉模式/保护 env,个别用例 + # 再显式设置 record/enforce/off/bogus。 + self._saved_env = {k: os.environ.get(k) for k in + ("CLOUD_MERGE_GATE_MODE", + "CLOUD_BRANCH_PROTECTION")} + os.environ.pop("CLOUD_MERGE_GATE_MODE", None) + os.environ.pop("CLOUD_BRANCH_PROTECTION", None) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + if key is not None: + hdrs["X-API-Key"] = key + try: + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + data = resp.read() + finally: + conn.close() + try: + parsed = json.loads(data.decode("utf-8")) if data else None + except ValueError: + parsed = None + return resp.status, parsed + + def _completed_task(self): + status, task = self._req( + "POST", "/tasks", + {"project_id": self.project["id"], "prompt": "x"}, + self.owner_key) + self.assertEqual(status, 201) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET status = 'completed' WHERE id = ?", + (task["id"],)) + conn.commit() + finally: + conn.close() + return task + + def _hard_body(self, **overrides): + body = { + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + } + body.update(overrides) + return body + + def _status_of(self, task_id): + _s, got = self._req("GET", "/tasks/%s" % task_id, + key=self.owner_key) + return got["status"] + + def test_default_no_fields_blocks_status_unchanged_and_audits(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + self.assertIn("ci_evidence_missing", + body["merge_gate"]["blocks"]) + self.assertEqual(self._status_of(task["id"]), "completed") + audit = self.auth.list_audit(self.project["id"]) + blocked = [a for a in audit + if a["action"] == "merge_gate_blocked"] + self.assertTrue(blocked) + self.assertIn(task["id"], blocked[0]["detail"]) + self.assertIn("ci_evidence_missing", blocked[0]["detail"]) + + def test_default_full_evidence_merges(self): + task = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["status"], "merged") + self.assertEqual(merged["merge_gate"]["mode"], "hard") + self.assertEqual(merged["merge_gate"]["blocks"], []) + + def test_default_missing_one_item_blocks(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(unresolved_review_comments=2), + self.owner_key) + self.assertEqual(status, 409) + self.assertIn("unresolved_review_comments", + body["merge_gate"]["blocks"]) + self.assertEqual(self._status_of(task["id"]), "completed") + + def test_default_unknown_protection_blocks(self): + task = self._completed_task() + body = self._hard_body() + del body["branch_protection"] + status, resp = self._req( + "POST", "/tasks/%s/merge" % task["id"], body, self.owner_key) + self.assertEqual(status, 409) + self.assertIn("branch_protection_unknown", + resp["merge_gate"]["blocks"]) + + def test_default_unprotected_blocks_even_with_confirm(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(branch_protection="unprotected", + merge_gate_confirm=True), + self.owner_key) + self.assertEqual(status, 409) + self.assertIn("branch_protection_unprotected", + body["merge_gate"]["blocks"]) + self.assertEqual(self._status_of(task["id"]), "completed") + + def test_default_confirm_never_overrides_directory_violation(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body( + files_changed=["apps/web/a.py", "apps/ios/b.py"], + merge_gate_confirm=True), + self.owner_key) + self.assertEqual(status, 409) + self.assertIn("directory_isolation_violation", + body["merge_gate"]["blocks"]) + + def test_body_mode_cannot_downgrade_hard(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"merge_gate_mode": "record"}, self.owner_key) + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + + def test_invalid_env_mode_falls_to_hard(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "bogus" + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + + def test_record_env_preserves_old_no_fields_behavior(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" + task = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["status"], "merged") + + def test_record_env_unprotected_confirm_still_passes_with_risk(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"branch_protection": "unprotected"}, self.owner_key) + self.assertEqual(status, 409) + task2 = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task2["id"], + {"branch_protection": "unprotected", + "merge_gate_confirm": True}, + self.owner_key) + self.assertEqual(status, 200) + audit = self.auth.list_audit(self.project["id"]) + self.assertTrue(any(a["action"] == "merge_gate_risk" + for a in audit)) + + def test_enforce_env_blocks_unprotected_even_with_confirm(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "enforce" + os.environ["CLOUD_BRANCH_PROTECTION"] = "unprotected" + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"merge_gate_confirm": True}, self.owner_key) + self.assertEqual(status, 409) + self.assertIn("branch_protection_enforce", + body["merge_gate"]["blocks"]) + + def test_off_env_is_explicit_opt_down_only(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "off" + task = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"branch_protection": "unprotected"}, self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["merge_gate"]["mode"], "off") + + def test_malformed_hard_body_returns_400(self): + task = self._completed_task() + status, _ = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(ci_checks="ci"), self.owner_key) + self.assertEqual(status, 400) + status, _ = self._req( + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(unresolved_review_comments=True), + self.owner_key) + self.assertEqual(status, 400) + self.assertEqual(self._status_of(task["id"]), "completed") + + +class HardGateSkillTextTestCase(unittest.TestCase): + def test_hard_keywords_in_body_and_appendix(self): + # 正文换行会把“默认 hard”断成两行,断言前先把空白归一化。 + import re + raw = (Path(__file__).resolve().parents[1] / "skills" + / "software-engineering" / "SKILL.md").read_text( + encoding="utf-8") + text = re.sub(r"\s+", " ", raw) + main, sep, appendix = text.partition("## 附录:角色模板") + self.assertTrue(sep, "appendix marker missing") + coord = text.split("## 附录角色:00-coordinator", 1)[1].split( + "## 附录角色:01-", 1)[0] + client = text.split("## 附录角色:04-client-developer", 1)[1].split( + "## 附录角色:05-", 1)[0] + for kw in ("默认 hard", "merge_gate_blocked"): + self.assertIn(kw, main, "正文缺失:" + kw) + self.assertIn(kw, coord, "附录协调员章节缺失:" + kw) + self.assertIn(kw, client, "附录客户端章节缺失:" + kw) + self.assertIn("五项", main, "正文缺失:五项") + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_merge_gate_protectskill.py b/cloud/tests/test_merge_gate_protectskill.py index 468b27e..ff12a36 100644 --- a/cloud/tests/test_merge_gate_protectskill.py +++ b/cloud/tests/test_merge_gate_protectskill.py @@ -261,6 +261,10 @@ class MergeEndpointGateTestCase(unittest.TestCase): return task def test_no_gate_fields_behaves_like_old_endpoint(self): + # 本用例测的是 record 模式的过渡语义(#209 原意):默认已是 + # hard,见 test_merge_gate_hard.py 的默认 409 用例;这里显式 + # 切 record 才沿用“无证据放行、只附带门禁信息”。 + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() status, merged = self._req( "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) @@ -283,6 +287,8 @@ class MergeEndpointGateTestCase(unittest.TestCase): self.assertEqual(got["status"], "completed") def test_unprotected_confirm_allows_and_audits_risk(self): + # record 模式语义:硬模式下未保护 + 确认仍阻断,见硬门禁测试。 + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" task = self._completed_task() status, body = self._req( "POST", "/tasks/%s/merge" % task["id"], diff --git a/cloud/tests/test_server_merge.py b/cloud/tests/test_server_merge.py index f365dcf..e7acd88 100644 --- a/cloud/tests/test_server_merge.py +++ b/cloud/tests/test_server_merge.py @@ -4,6 +4,10 @@ (本地或 Docker 容器内)执行,成功后再调本接口把云端 任务从 completed 改为 merged。规则:404/401/403/409 语义、maintainer 以上才能合并、审计里有 merge_task。 +门禁注意:默认模式已是 hard(见 test_merge_gate_hard.py), +所以本文件里期望 200 的合并调用必须带完整硬门禁证据; +无证据调用在默认下 409(本文件末尾两项新用例钉住它), +record 下的旧行为由硬门禁测试文件覆盖。 """ import http.client @@ -26,9 +30,28 @@ except ImportError: class MergeTestCase(unittest.TestCase): + def _hard_body(self): + return { + "files_changed": ["apps/web/a.py"], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + } + def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.db_path = os.path.join(self.tmp.name, "control.db") + self._saved_env = {k: os.environ.get(k) for k in + ("CLOUD_MERGE_GATE_MODE", + "CLOUD_BRANCH_PROTECTION")} + os.environ.pop("CLOUD_MERGE_GATE_MODE", None) + os.environ.pop("CLOUD_BRANCH_PROTECTION", None) self.auth = AuthStore(self.db_path) self.owner_id, self.owner_key = self.auth.create_user("owner") self.maint_id, self.maint_key = self.auth.create_user("maint") @@ -50,6 +73,11 @@ class MergeTestCase(unittest.TestCase): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value try: self.auth.conn.close() except Exception: @@ -97,7 +125,8 @@ class MergeTestCase(unittest.TestCase): def test_merge_completed_by_owner(self): task = self._completed_task() status, merged = self._req( - "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.owner_key) self.assertEqual(status, 200) self.assertEqual(merged["status"], "merged") status, got = self._req( @@ -112,7 +141,8 @@ class MergeTestCase(unittest.TestCase): "POST", "/tasks/%s/merge" % task["id"], {}, self.contrib_key) self.assertEqual(status, 403) status, merged = self._req( - "POST", "/tasks/%s/merge" % task["id"], {}, self.maint_key) + "POST", "/tasks/%s/merge" % task["id"], + self._hard_body(), self.maint_key) self.assertEqual(status, 200) self.assertEqual(merged["status"], "merged") @@ -128,13 +158,32 @@ class MergeTestCase(unittest.TestCase): self.assertEqual(body["status"], "queued") done = self._completed_task() status, _ = self._req( - "POST", "/tasks/%s/merge" % done["id"], {}, self.owner_key) + "POST", "/tasks/%s/merge" % done["id"], + self._hard_body(), self.owner_key) self.assertEqual(status, 200) status, body = self._req( "POST", "/tasks/%s/merge" % done["id"], {}, self.owner_key) self.assertEqual(status, 409) self.assertEqual(body["status"], "merged") + def test_merge_default_hard_blocks_no_evidence_call(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 409) + self.assertEqual(body["merge_gate"]["mode"], "hard") + status, got = self._req( + "GET", "/tasks/%s" % task["id"], key=self.owner_key) + self.assertEqual(got["status"], "completed") + + def test_merge_record_env_preserves_old_no_evidence_call(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "record" + task = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["status"], "merged") + def test_merge_missing_and_no_key(self): status, _ = self._req( "POST", "/tasks/nope/merge", {}, self.owner_key) diff --git a/docs/merge-gate-branch-protection.md b/docs/merge-gate-branch-protection.md new file mode 100644 index 0000000..3f6fb73 --- /dev/null +++ b/docs/merge-gate-branch-protection.md @@ -0,0 +1,129 @@ +# 分支保护与合并硬门禁:GitHub 侧操作说明 + +状态声明(先读):本仓库当前是否已开启主分支保护,**本文件不声称**。 +保护只能由仓库管理员在 GitHub 侧开通,本平台代码(cloud/merge_gate.py、 +cloud/merge_gate_hard.py)只能在调用方明确声明保护状态时做门禁, +绝不代替 GitHub 开通、绝不声称“已保护”。服务端硬门禁(默认 hard) +要求 `branch_protection == "protected"` 才会放行;`unknown` 与 +`unprotected` 一律 409。 + +## 1. 分支保护 payload(对默认分支,如 main) + +GitHub REST:`PUT /repos/{owner}/{repo}/branches/{branch}/protection` + +```json +{ + "required_status_checks": { + "strict": true, + "checks": [ + {"context": "ci"} + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "required_linear_history": false, + "allow_fork_syncing": false, + "block_creations": false +} +``` + +逐项对应任务要求: + +- PR:`required_pull_request_reviews`——必须经 PR 评审才能合并, + 本平台技能要求“没有 PR 号不算完成”,与此一致。 +- strict status checks:`required_status_checks.strict = true`—— + PR 分支必须与目标分支最新状态同步后检查才算通过,防“旧绿灯合新代码”。 +- no force-push:`allow_force_pushes = false`(默认分支绝不 force-push, + 与技能“绝不对默认分支 force-push”一致)。 +- no deletion:`allow_deletions = false`。 +- enforce_admins:`enforce_admins = true`——管理员同样受保护规则约束, + 不能绕过(注意:仓库所有者/组织所有者在部分场景下仍可通过 + 设置页调整规则本身,enforce_admins 约束的是“绕过规则合并”, + 不是“不能改规则”)。 + +API 命名注意(新旧两套,别抄错): + +- 较新的 API 用 `required_status_checks.checks[]`,每项含 `context` + 与可选 `app_id`;较旧文档/客户端用 `contexts[]`(字符串数组)。 + 两者语义相同:列出必须通过的检查名。调用前以实际 API 返回为准, + 不要凭记忆混用。 +- `allow_force_pushes` / `allow_deletions` 在新 API 里是顶层布尔值; + 旧版曾用 `allow_force_pushes: {enabled: ...}` 对象形,同样以实际 + API 为准。 + +## 2. 空 required_checks = 未配置(硬门禁的同一口径) + +- GitHub 侧:`required_status_checks.checks`(或 `contexts`)为空数组, + 即使 `strict: true`,也等于“没有必须通过的检查”——任何 CI 状态 + 都不会被保护规则拦住。不要把“开了保护”理解成“CI 已被强制”。 +- 服务端硬门禁侧:`required_checks` 为 `[]` 时判 + `ci_required_checks_unconfigured`,409;`required_checks` 缺失时判 + `ci_evidence_missing`,同样 409。两者都是 fail-closed,只是 + “显式空配置”与“没给证据”的区分,便于第 3 轨推回包定位问题。 +- 开通保护后必须做的核对:保护设置页(或 GET 同一 protection 端点) + 返回的 checks 列表非空,且与仓库真实 CI 检查名逐一对应; + 改名/新增 CI 检查后,保护里的检查名必须同步更新,否则 + 旧检查名永远等不到、PR 永远合并不了,或新检查根本不在强制列表里。 + +## 3. Pro / 公开的前提(三选一,用户侧决定,代码不替选) + +GitHub 对私有仓库的分支保护规则有版本限制(以 GitHub 当前定价页为准, +本文件不复述具体价格): + +1. **升级到 Pro / Team / Enterprise**:私有仓库可用完整的 + required_pull_request_reviews 与 required_status_checks。 +2. **公开仓库**:公开仓库免费可用分支保护。但公开是不可逆影响 + 可见性的决定:公开前必须确认仓库里没有密钥、内部设计、私有 + 数据与历史提交里的敏感内容;**本轨绝不擅自公开仓库**。 +3. **保持现状 + 纪律 + 记风险**:不开保护,靠硬门禁的 + `branch_protection_unprotected` 阻断 + 审计记录 + 人工流程约束。 + 这不是“等同于保护”,只是三者中唯一不花钱、不改可见性的选项; + 选择它意味着接受“保护规则本身不存在,门禁只拦本平台的合并端点, + 直接推默认分支仍可由有写权限者做到”这一残留风险。 + +在用户做出三选一决定之前,硬门禁默认 hard 会把未声明/未保护的 +合并全部拦下(409),这是符合 fail-closed 的预期行为,不是故障。 + +## 4. 常见 403 原因(按排查顺序) + +保护端点返回 403 时,先区分是“版本限制”还是“权限不足”: + +- **版本限制**:私有仓库免费版调用含 required_pull_request_reviews / + required_status_checks 的保护设置,GitHub 以 403 拒绝(文案大意为 + 需要升级)。这是第 3 节三选一要解决的前提,不是 token 写错。 +- **调用方不是管理员**:只有仓库管理员(Admin 权限)能改分支保护; + Maintainer/Write 权限调该端点会 403。检查调用身份的仓库角色。 +- **GitHub App 权限不足或未安装**:App 需要 Repository administration + (写分支保护)权限,且必须安装在目标仓库上;权限是安装时授予的, + 之后在 App 设置里加权限不会自动生效,必须由安装方重新接受新权限。 + App token 的权限缩小、过期或被吊销同样表现为 403/401。 +- **OAuth token 权限不足**:个人 token 需含 `repo` 作用域(私有仓) + 才能写保护;细粒度 token 需含对应的仓库管理权限。 +- **组织/企业策略**:组织规则集(rulesets)或企业策略可能禁止 + 修改、或强制覆盖仓库级保护,导致写入被拒或写入后不生效; + 此时以组织规则集的实际生效状态为准,不要只看仓库设置页。 + +排查时只读核对:`GET /repos/{owner}/{repo}/branches/{branch}/protection` +返回 404 表示该分支未设保护(不是“请求错了”),200 则返回当前规则; +不要用“试一次合并看拦不拦”来反推保护状态。 + +## 5. 与服务端硬门禁的对应关系 + +| GitHub 侧状态 | 服务端硬门禁(默认 hard) | +| --- | --- | +| 保护已开,checks 非空且全绿 | 调用方声明 `protected` + CI 证据全绿时放行(其余四项也须过) | +| 保护已开,但 checks 为空 | 调用方 `required_checks: []` → `ci_required_checks_unconfigured`,409 | +| 保护未开 | 调用方声明 `unprotected` → `branch_protection_unprotected`,409(确认无效) | +| 保护状态未声明 | `branch_protection_unknown`,409(unknown 不等于 protected) | + +服务端门禁不向 GitHub 求证:调用方声明 `protected` 但 GitHub 实际 +未开时,服务端无法从声明本身发现谎报——这正是“保护必须由管理员 +在 GitHub 侧开通并用 GET 核对”的原因,也是第 3 轨审计要覆盖的 +核对项(审计结论与 GitHub 实际状态不一致即为偏差)。