Files
chichuan ad6837d694 feat(eval-dispatch): allowlist tier for self-service PR evaluation
Users listed in .github/eval-allowlist.txt (default branch, PR-reviewed)
may dispatch /eval for their own PRs only; write/maintain/admin retain
dispatch for any PR. Fail-closed on permission API 404/network errors.
2026-08-11 15:45:33 +08:00

128 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""校验 `/eval` 派发的仓库权限与已审核 PR head。"""
import json
import os
import re
import sys
TRUSTED_PERMISSIONS = frozenset({"write", "maintain", "admin"})
FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
def _read_api_response():
try:
value = json.load(sys.stdin)
except (json.JSONDecodeError, OSError) as exc:
raise ValueError(f"GitHub API response is not valid JSON: {exc}") from exc
if not isinstance(value, dict):
raise ValueError("GitHub API response must be a JSON object")
return value
def load_allowlist(path: str) -> frozenset:
"""读取自助触发允许名单(默认分支文件;# 注释与空行忽略;大小写不敏感)。"""
if not path:
return frozenset()
try:
with open(path, encoding="utf-8") as f:
lines = f.read().splitlines()
except FileNotFoundError:
return frozenset()
entries = set()
for line in lines:
entry = line.split("#", 1)[0].strip()
if entry:
entries.add(entry.lower())
return frozenset(entries)
def authorize_dispatch(response, commenter: str, pr_author: str, allowlist: frozenset):
"""两级授权:仓库写权限可派发任意 PR;名单内用户仅可派发自己创建的 PR。"""
permission = response.get("permission")
if permission in TRUSTED_PERMISSIONS:
return f"maintainer:{permission}"
commenter_key = (commenter or "").strip().lower()
author_key = (pr_author or "").strip().lower()
if commenter_key and commenter_key in allowlist:
if commenter_key == author_key:
return "allowlisted-author"
raise PermissionError(
f"{commenter or 'commenter'} is allowlisted for self-service only "
"and may not dispatch other authors' PRs"
)
raise PermissionError(
f"{commenter or 'commenter'} does not have write, maintain, or admin permission "
"and is not an allowlisted PR author"
)
def require_reviewed_current_head(response, expected_pr_number: str, reviewed_sha: str, commenter: str):
try:
expected_number = int(expected_pr_number)
except (TypeError, ValueError) as exc:
raise ValueError("expected PR number is invalid") from exc
if response.get("number") != expected_number:
raise ValueError("GitHub API response does not match the requested PR")
if response.get("state") != "open":
raise ValueError("PR is not open")
head = response.get("head")
current_sha = head.get("sha") if isinstance(head, dict) else ""
if not FULL_SHA_RE.fullmatch(current_sha or ""):
raise ValueError("GitHub API response does not contain a valid PR head SHA")
if reviewed_sha:
if not FULL_SHA_RE.fullmatch(reviewed_sha):
raise ValueError("reviewed SHA must be a full 40-character commit SHA")
if current_sha.lower() != reviewed_sha.lower():
raise ValueError(
f"PR head changed after review: reviewed {reviewed_sha.lower()}, "
f"current {current_sha.lower()}"
)
return reviewed_sha.lower()
# sha 省略:仅限评论者本人创建的 PR(自助模式)——作者对自己分支的
# tip 背书不存在第三方偷换窗口;钉住派发时刻的当前 head,内网侧
# FETCH_HEAD 校验继续兜底派发→执行窗口。评测他人 PR 必须显式带 sha=。
author = ((response.get("user") or {}).get("login") or "").strip().lower()
commenter_key = (commenter or "").strip().lower()
if not author or commenter_key != author:
raise ValueError(
"dispatching another author's PR requires an explicit reviewed sha=<full-head-sha>"
)
return current_sha.lower()
def main() -> int:
mode = sys.argv[1] if len(sys.argv) == 2 else ""
try:
response = _read_api_response()
if mode == "permission":
role = authorize_dispatch(
response,
os.environ.get("COMMENTER", ""),
os.environ.get("PR_AUTHOR", ""),
load_allowlist(os.environ.get("EVAL_ALLOWLIST_PATH", "")),
)
print(f"authorized={role}")
return 0
if mode == "head":
head_sha = require_reviewed_current_head(
response,
os.environ.get("EXPECTED_PR_NUMBER", ""),
os.environ.get("REVIEWED_SHA", ""),
os.environ.get("COMMENTER", ""),
)
print(f"head_sha={head_sha}")
return 0
raise ValueError(f"unknown guard mode: {mode}")
except (PermissionError, ValueError) as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())