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:
1 parent
d553f9549c
commit
61e08a1a8c
7 files changed
+1048
-39
No files matched your search
+104
-13
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user