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.
This commit is contained in:
host committed 2026-11-03 02:48:55 +00:00
1 parent d553f9549c
commit 61e08a1a8c
7 files changed
+1048 -39

No files matched your search

+271
View File
@@ -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 为 ``"<task_id> mode=<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 []))