feat: configurable merge gate (branch protection + directory isolation) and skill body/appendix checks

- cloud/merge_gate.py: pure gate; directory check reuses the v0.3
  git diff --name-only merge-base command; protection modes
  record (default, explicit confirm required when unprotected),
  enforce (always blocks when unprotected), off (explicit only).
  Confirm covers protection risk only, never directory violations.
  Protection status is caller/env-declared only; unknown is a
  recorded risk, not a claim of protection.
- server POST /tasks/{id}/merge: applies the gate only when gate
  evidence is supplied (or env declares protection); no-evidence
  calls behave as before plus a merge_gate field in the response.
  Blocked merges return 409 without changing status; confirmed
  unprotected merges write a merge_gate_risk audit entry.
- skill_seed.py: whole-directory seeding evaluated and rejected
  (cannot preserve tenant deletions; no benefit while the pack
  holds only SKILL.md); SKILL.md-only behaviour unchanged.
- software-engineering SKILL.md: gate paragraph added to the body
  and to the coordinator + client appendix sections (v0.2 text on
  this base; port to v0.3 text on rebase after #199 merges).
- tests: 16 new tests covering gate modes, directory reuse,
  body/appendix bidirectional assertions naming the missing side,
  SKILL.md-only seeding and tenant-edit preservation.
This commit is contained in:
host committed 2026-11-03 02:48:55 +00:00
1 parent 44616a03dc
commit d553f9549c
5 files changed
+565

No files matched your search

+33
View File
@@ -74,6 +74,7 @@ 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.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
@@ -7614,6 +7615,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
必须是项目 maintainer 以上(approve_merge,403)、任务
必须是 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 不等于已保护。
"""
row = self._fetch_task(task_id)
if row is None:
@@ -7631,6 +7644,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"status": task["status"],
})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
if body.get("files_changed") is not None and not isinstance(
body.get("files_changed"), list):
self._send_json(400, {"error": "files_changed must be a list"})
return
gate = _merge_gate_from_request(body)
if not gate["allowed"]:
self._send_json(409, {
"error": "merge gate blocked",
"merge_gate": gate,
})
return
now = time.time()
conn = self._db()
try:
@@ -7649,6 +7677,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "merge_task", task_id)
if gate["protection"] == "unprotected":
store.audit(user_id, task["project_id"],
"merge_gate_risk",
"%s: %s" % (task_id, "; ".join(gate["risks"])))
task["merge_gate"] = gate
self._send_json(200, task)
# -- 运行时审批队列 ------------------------------------------------