- 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.
325 lines
14 KiB
Python
325 lines
14 KiB
Python
"""主分支保护 + 目录隔离门禁(cloud/merge_gate)与技能工程双向断言。
|
|
|
|
门禁口径:
|
|
- 目录隔离复用 v0.3 技能文本的 git diff --name-only 检查,不另造;
|
|
服务端 POST /tasks/{id}/merge 在此之前只改状态、不查目录、不查保护。
|
|
- 保护默认 record:未保护 + 无确认阻断,未保护 + 确认放行并记风险;
|
|
enforce 下未保护一律阻断;unknown 记风险、不阻断(代码无法核实
|
|
GitHub 保护,unknown 不等于已保护,也不等于未保护)。
|
|
- 显式确认只覆盖保护风险,绝不覆盖目录违规。
|
|
技能工程:
|
|
- 正文与附录双向断言:每个新关键词在正文、附录都要出现,失败
|
|
信息指明缺失的一侧(正文缺失 / 附录缺失),不是笼统的 assertIn。
|
|
- 播种仍只复制 SKILL.md:包内多余文件不进租户 home,租户已改
|
|
的 SKILL.md 绝不被覆盖;整目录复制明确不做(见 skill_seed.py)。
|
|
"""
|
|
|
|
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 import (
|
|
check_directory_isolation, directory_diff_command,
|
|
evaluate_merge_gate, gate_from_request, gate_mode_from_env,
|
|
protection_from_env)
|
|
from cloud.server import create_server
|
|
from cloud.skill_seed import ensure_codex_home_skills
|
|
except ImportError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
from cloud.auth import AuthStore
|
|
from cloud.merge_gate import (
|
|
check_directory_isolation, directory_diff_command,
|
|
evaluate_merge_gate, gate_from_request, gate_mode_from_env,
|
|
protection_from_env)
|
|
from cloud.server import create_server
|
|
from cloud.skill_seed import ensure_codex_home_skills
|
|
|
|
SKILL = Path(__file__).resolve().parents[1] / "skills" \
|
|
/ "software-engineering" / "SKILL.md"
|
|
BOTH_KEYWORDS = ("合并门禁", "CLOUD_MERGE_GATE_MODE", "显式确认",
|
|
"git diff --name-only", "不做整目录复制")
|
|
|
|
|
|
class MergeGateUnitTestCase(unittest.TestCase):
|
|
def test_record_unprotected_needs_confirm(self):
|
|
blocked = evaluate_merge_gate(protection="unprotected")
|
|
self.assertFalse(blocked["allowed"])
|
|
self.assertIn("branch_protection_risk_unconfirmed",
|
|
blocked["blocks"])
|
|
ok = evaluate_merge_gate(protection="unprotected",
|
|
explicit_confirm=True)
|
|
self.assertTrue(ok["allowed"])
|
|
self.assertIn("branch protection is not enabled",
|
|
" ".join(ok["risks"]))
|
|
|
|
def test_enforce_blocks_even_with_confirm(self):
|
|
gate = evaluate_merge_gate(protection="unprotected", mode="enforce",
|
|
explicit_confirm=True)
|
|
self.assertFalse(gate["allowed"])
|
|
self.assertIn("branch_protection_enforce", gate["blocks"])
|
|
|
|
def test_off_allows_but_records_risk(self):
|
|
gate = evaluate_merge_gate(protection="unprotected", mode="off")
|
|
self.assertTrue(gate["allowed"])
|
|
self.assertTrue(gate["risks"])
|
|
|
|
def test_unknown_is_risk_not_block(self):
|
|
gate = evaluate_merge_gate(protection="unknown")
|
|
self.assertTrue(gate["allowed"])
|
|
self.assertTrue(gate["risks"])
|
|
self.assertEqual(gate["directory"], None)
|
|
|
|
def test_protected_allows(self):
|
|
self.assertTrue(evaluate_merge_gate(
|
|
protection="protected")["allowed"])
|
|
|
|
def test_invalid_mode_falls_back_to_record_not_off(self):
|
|
self.assertEqual(gate_mode_from_env({"CLOUD_MERGE_GATE_MODE": "x"}),
|
|
"record")
|
|
self.assertEqual(gate_mode_from_env({}), "record")
|
|
self.assertEqual(protection_from_env({}), "unknown")
|
|
gate = gate_from_request({}, env={"CLOUD_MERGE_GATE_MODE": "bogus",
|
|
"CLOUD_BRANCH_PROTECTION":
|
|
"unprotected"})
|
|
self.assertFalse(gate["allowed"])
|
|
|
|
def test_directory_reuses_v03_rule(self):
|
|
self.assertEqual(
|
|
directory_diff_command(),
|
|
"git diff --name-only $(git merge-base origin/main HEAD)...HEAD")
|
|
ok = check_directory_isolation(
|
|
["apps/web/a.py", "apps/web/sub/b.py"], "apps/web")
|
|
self.assertTrue(ok["passed"])
|
|
self.assertEqual(ok["violations"], [])
|
|
bad = check_directory_isolation(
|
|
["apps/web/a.py", "apps/ios/b.py", "server/c.py"], "apps/web")
|
|
self.assertFalse(bad["passed"])
|
|
self.assertEqual(sorted(bad["violations"]),
|
|
["apps/ios/b.py", "server/c.py"])
|
|
# 目录前缀边界:apps/web2 不是 apps/web 的子目录。
|
|
edge = check_directory_isolation(["apps/web2/x.py"], "apps/web")
|
|
self.assertEqual(edge["violations"], ["apps/web2/x.py"])
|
|
# 越界路径与契约变更批准:前者违规,后者放行但要记入 used。
|
|
esc = check_directory_isolation(["../secret.py", "/abs.py"],
|
|
"apps/web")
|
|
self.assertEqual(sorted(esc["violations"]),
|
|
["../secret.py", "/abs.py"])
|
|
exc = check_directory_isolation(
|
|
["apps/web/a.py", "contracts/api.md"], "apps/web",
|
|
approved_exceptions=["contracts/api.md"])
|
|
self.assertTrue(exc["passed"])
|
|
self.assertEqual(exc["exceptions_used"], ["contracts/api.md"])
|
|
|
|
def test_confirm_never_overrides_directory_violation(self):
|
|
gate = evaluate_merge_gate(
|
|
files_changed=["apps/ios/b.py"], owned_dir="apps/web",
|
|
protection="unprotected", explicit_confirm=True)
|
|
self.assertFalse(gate["allowed"])
|
|
self.assertIn("directory_isolation_violation", gate["blocks"])
|
|
|
|
def test_files_without_owned_dir_is_inconclusive_and_blocks(self):
|
|
gate = evaluate_merge_gate(files_changed=["apps/web/a.py"],
|
|
owned_dir=None)
|
|
self.assertFalse(gate["allowed"])
|
|
self.assertIn("directory_owned_dir_unknown", gate["blocks"])
|
|
no_evidence = evaluate_merge_gate()
|
|
self.assertTrue(no_evidence["allowed"])
|
|
self.assertIsNone(no_evidence["directory"])
|
|
|
|
|
|
class SkillBidirectionalTestCase(unittest.TestCase):
|
|
def test_protectskill_keywords_in_body_and_appendix(self):
|
|
text = SKILL.read_text(encoding="utf-8")
|
|
main, sep, appendix = text.partition("## 附录:角色模板")
|
|
self.assertTrue(sep, "appendix marker missing")
|
|
for kw in BOTH_KEYWORDS:
|
|
self.assertIn(kw, main, "正文缺失:" + kw)
|
|
self.assertIn(kw, appendix, "附录缺失:" + kw)
|
|
|
|
def test_gate_in_coordinator_and_client_appendix_sections(self):
|
|
text = SKILL.read_text(encoding="utf-8")
|
|
coord = text.split("## 附录角色:00-coordinator", 1)[1].split(
|
|
"## 附录角色:01-", 1)[0]
|
|
client = text.split("## 附录角色:04-client-developer", 1)[1].split(
|
|
"## 附录角色:05-", 1)[0]
|
|
self.assertIn("合并门禁", coord, "附录协调员章节缺失:合并门禁")
|
|
self.assertIn("合并门禁", client, "附录客户端章节缺失:合并门禁")
|
|
|
|
|
|
class SkillSeedNoWholeDirTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _pack(self):
|
|
src = os.path.join(self.tmp.name, "pack", "demo-skill")
|
|
os.makedirs(src, exist_ok=True)
|
|
open(os.path.join(src, "SKILL.md"), "w").write("pack version\n")
|
|
# 整目录复制才会带走的多余文件:现行行为必须忽略它们。
|
|
open(os.path.join(src, "extra.txt"), "w").write("extra\n")
|
|
os.makedirs(os.path.join(src, "templates"), exist_ok=True)
|
|
open(os.path.join(src, "templates", "x.md"), "w").write("x\n")
|
|
return os.path.join(self.tmp.name, "pack")
|
|
|
|
def test_only_skill_md_seeded_and_tenant_edit_never_overwritten(self):
|
|
home = os.path.join(self.tmp.name, "home")
|
|
out = ensure_codex_home_skills(home, self._pack())
|
|
self.assertEqual(out["skills_copied"], ["demo-skill"])
|
|
dest_dir = os.path.join(home, "skills", "demo-skill")
|
|
self.assertTrue(os.path.isfile(
|
|
os.path.join(dest_dir, "SKILL.md")))
|
|
self.assertFalse(os.path.exists(
|
|
os.path.join(dest_dir, "extra.txt")))
|
|
self.assertFalse(os.path.exists(
|
|
os.path.join(dest_dir, "templates")))
|
|
dest = os.path.join(dest_dir, "SKILL.md")
|
|
open(dest, "w").write("tenant edited version\n")
|
|
out2 = ensure_codex_home_skills(home, self._pack())
|
|
self.assertEqual(out2["skills_skipped_differ"], ["demo-skill"])
|
|
self.assertEqual(open(dest).read(), "tenant edited version\n")
|
|
|
|
|
|
class MergeEndpointGateTestCase(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)
|
|
self._saved_env = {k: os.environ.get(k) for k in
|
|
("CLOUD_MERGE_GATE_MODE",
|
|
"CLOUD_BRANCH_PROTECTION")}
|
|
|
|
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 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)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(merged["status"], "merged")
|
|
self.assertIn("merge_gate", merged)
|
|
|
|
def test_directory_violation_blocks_without_changing_status(self):
|
|
task = self._completed_task()
|
|
status, body = self._req(
|
|
"POST", "/tasks/%s/merge" % task["id"],
|
|
{"files_changed": ["apps/web/a.py", "apps/ios/b.py"],
|
|
"owned_dir": "apps/web"},
|
|
self.owner_key)
|
|
self.assertEqual(status, 409)
|
|
self.assertIn("directory_isolation_violation",
|
|
body["merge_gate"]["blocks"])
|
|
_s, got = self._req("GET", "/tasks/%s" % task["id"],
|
|
key=self.owner_key)
|
|
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"],
|
|
{"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)
|
|
self.assertEqual(merged["merge_gate"]["protection"], "unprotected")
|
|
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_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"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|