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

+104 -13
View File
@@ -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,