Files
codex-app-server/cloud/merge_gate.py
T
host 32620c4517 code-to10 v6 A1: delete server merge-gate modes, hard only
Server POST /tasks/{id}/merge no longer has record/enforce/off:
hard_mode_from_env and HARD_MODES deleted (not re-defaulted),
handler always evaluates the hard gate, env CLOUD_MERGE_GATE_MODE
and body merge_gate_mode are ignored server-side. The record
'no-evidence passes' and 'unprotected+confirm passes with
merge_gate_risk' server paths are gone with the dead risk audit;
no production caller justified keeping them (only production
caller of the non-hard evaluator was the merge handler itself;
audit_loop_wiring already used the hard gate). cloud/merge_gate
pure functions keep their #209 semantics but are documented as
non-server: they are no longer wired to any endpoint.
Server-level record/enforce/off tests rewritten to the new
behavior (renamed, not deleted silently); pure-function tests
unchanged.

(cherry picked from commit 08e75edbd6d04701965c5db951fa054cfbb75bb0)

Reconcile-port: handler_lifecycle.py hunks redirected into cloud/server.py _merge_task (target keeps 6-mixin split; mixin file does not exist here); handler_support.py kept at HEAD (its A1 hunk is a docstring of _validate_hard_gate_body, ported to server.py copy); test mock paths cloud.handler_lifecycle.* rewritten to cloud.server.*.
2026-11-03 02:49:26 +00:00

191 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""合并门禁:主分支保护 + 目录隔离(纯函数,无网络、无 GitHub 调用)。
A1 声明(服务端唯一门禁是 hard,不要拿本模块的模式推断服务端):
- 自 A1 起,服务端 POST /tasks/{id}/merge 一律按
cloud/merge_gate_hard 的 hard 求值;环境变量
CLOUD_MERGE_GATE_MODE 与请求体 merge_gate_mode 在服务端一律被
忽略,旧值 record/enforce/off 不再是服务端可选项。
- 本模块以下写到的 record/enforce/off 只是 #209 纯函数的历史语义:
它们只描述 evaluate_merge_gate / gate_from_request 这两个纯函数
本身,不再接线到任何服务端端点;纯函数单测保留这些语义,不代表
服务端会按此放行。需要服务端行为时只看 merge_gate_hard。
背景与诚实边界(先读,再用):
- 本模块**不能**替仓库开通主分支保护。保护是 GitHub 仓库设置,
只能由仓库管理员在 GitHub 侧开通;GitHub App id 5476837 的权限
也不会自动变化,新权限需安装方重新接受。本模块只能在调用方
明确告诉它保护状态时做门禁,绝不声称“已保护”。
- 保护状态的唯一来源是调用方传入的值或环境变量
CLOUD_BRANCH_PROTECTION(protected / unprotected,未设即
unknown)。unknown 不是“通过”,是“未知”:门禁把它记为风险,
但不因此阻断——因为本服务没有可信渠道去 GitHub 核实,拿未知
当未保护去硬堵,会把当前所有合并(当前仓库本身未保护)全停掉。
想把门禁武装起来,运维要显式设置该环境变量为 unprotected,
再由调用方在每次合并时传确认,或把模式切到 enforce。
- 目录隔离复用 v0.3 技能的检查口径(见
cloud/skills/software-engineering/SKILL.md“目录与分支规则”):
人 / 宿主跑 ``git diff --name-only $(git merge-base origin/main
HEAD)...HEAD``,逐项核对每个文件是否落在拥有目录内,契约变更
经协调员批准的文件除外。本模块不另造一套口径:
``check_directory_isolation`` 就是这条规则的可执行版,
``directory_diff_command`` 返回的正是 v0.3 文本里的命令。
v0.3 之前服务端合并端(POST /tasks/{id}/merge)只改任务状态,
不做 git 合并、不做目录检查、不做保护检查——那是本模块要补的
缺口,不是重复造检查。
模式(以下仅为本模块纯函数的历史语义,不是服务端行为;服务端
唯一门禁是 hard,见模块头 A1 声明):
- record(纯函数默认):未保护时必须显式确认(explicit_confirm)才放行;
无确认时阻断,错误码 branch_protection_risk_unconfirmed。
确认只覆盖“保护风险”,绝不覆盖目录隔离违规——越目录的写
只能靠 approved_exceptions(契约变更批准)放行,不能靠确认蒙混。
- enforce:未保护时一律阻断,确认也不放行。切到 enforce 之前
必须先由用户决定并开通保护:升级 Pro、公开仓库、或保持现状
纪律 + 记风险——三者都是用户侧决定,代码里只留开关,不替选。
- off:门禁显式关闭,只记风险不阻断。off 必须显式设置,
非法模式值不会悄悄落到 off,而是回落到 record(fail-safe)。
"""
import os
MODES = ("record", "enforce", "off")
PROTECTION_STATES = ("protected", "unprotected", "unknown")
RISK_UNPROTECTED = "branch protection is not enabled for this repo"
RISK_UNKNOWN = "branch protection status is unknown (not declared)"
RISK_GATE_OFF = "merge gate is disabled (mode=off)"
def gate_mode_from_env(env=None):
"""纯函数模式解析(非服务端行为,服务端不读它)。
环境变量里的模式;未设或非法时回落 record,绝不回落 off。"""
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 MODES else "record"
def protection_from_env(env=None):
"""环境变量里的保护状态;未设或非法时为 unknown,不是 protected。"""
raw = (env if env is not None else os.environ).get(
"CLOUD_BRANCH_PROTECTION", "")
state = (raw or "").strip().lower()
return state if state in PROTECTION_STATES else "unknown"
def directory_diff_command(base="origin/main", head="HEAD"):
"""v0.3 技能文本里的目录检查命令(原样口径,不另造)。"""
return "git diff --name-only $(git merge-base %s %s)...%s" % (
base, head, head)
def _normalize_path(path):
return (path or "").strip().replace("\\", "/")
def _is_escape(path):
parts = [p for p in _normalize_path(path).split("/") if p not in ("", ".")]
return (not parts) or parts[0] == ".." or ".." in parts
def check_directory_isolation(files_changed, owned_dir,
approved_exceptions=()):
"""v0.3 目录隔离规则的可执行版。
files_changed:diff 输出的文件清单(相对仓库根)。
owned_dir:本线程唯一拥有目录,例如 apps/web;为 None/空时
无法核对,返回 inconclusive(不算通过,也不伪装成通过)。
approved_exceptions:经协调员批准的契约变更文件(精确路径),
命中者不算违规,但会记入 exceptions_used 以便合并记录核对。
返回 dict:passed / violations / exceptions_used / inconclusive。
"""
owned = _normalize_path(owned_dir)
exceptions = {_normalize_path(p) for p in approved_exceptions or ()}
if not owned:
return {"passed": False, "violations": [],
"exceptions_used": [], "inconclusive": True,
"reason": "owned_dir_unknown"}
prefix = owned.rstrip("/") + "/"
violations = []
used = []
for raw in files_changed or []:
path = _normalize_path(raw)
if not path:
continue
if path in exceptions:
used.append(path)
continue
if path.startswith("/") or _is_escape(path) \
or not path.startswith(prefix):
violations.append(path)
return {"passed": not violations, "violations": violations,
"exceptions_used": used, "inconclusive": False,
"reason": ""}
def evaluate_merge_gate(files_changed=None, owned_dir=None,
approved_exceptions=(), protection="unknown",
mode="record", explicit_confirm=False):
"""合并门禁纯函数(非服务端门禁;服务端唯一门禁是 hard)。
files_changed=None 表示调用方没给 diff 证据:目录检查跳过
(directory=None),不因“没证据”就判违规,也不伪装成已检查。
protection="unknown" 同理:记风险、不阻断。
返回 dict:allowed / mode / protection / directory / risks / blocks。
blocks 为空即 allowed;每个 block 都是可查的错误码。
"""
mode = mode if mode in MODES else "record"
protection = protection if protection in PROTECTION_STATES \
else "unknown"
risks = []
blocks = []
directory = None
if files_changed is not None:
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 protection == "unprotected":
risks.append(RISK_UNPROTECTED)
if mode == "enforce":
blocks.append("branch_protection_enforce")
elif mode == "record" and not explicit_confirm:
blocks.append("branch_protection_risk_unconfirmed")
elif protection == "unknown":
risks.append(RISK_UNKNOWN)
if mode == "off" and protection == "unprotected":
risks.append(RISK_GATE_OFF)
return {"allowed": not blocks, "mode": mode,
"protection": protection, "directory": directory,
"risks": risks, "blocks": blocks}
def gate_from_request(body, env=None):
"""从请求体 + 环境变量拼出门禁输入并求值(纯函数,非服务端路径。
服务端 POST /tasks/{id}/merge 不走本函数,一律按 hard 求值。
请求体字段(全是可选的;一个都不给时行为与旧接口一致,
只在响应里附带门禁结果,不阻断):
files_changed(str 列表)、owned_dir、approved_exceptions、
branch_protection、merge_gate_confirm(bool)。
模式(纯函数内)优先取环境变量,环境变量未设时才取请求体
merge_gate_mode——纯函数内请求体不能把环境变量定的 enforce
降级为 record;服务端则根本不读模式,一律 hard。
"""
body = body or {}
env_map = env if env is not None else os.environ
mode = gate_mode_from_env(env_map) \
if env_map.get("CLOUD_MERGE_GATE_MODE") \
else (body.get("merge_gate_mode") or "record")
protection = body.get("branch_protection") \
or protection_from_env(env_map)
return evaluate_merge_gate(
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")))