Files
Sylpheed/tools/gitea-protect
Claude (Pi session) e55221f7d1 tools: a missing collaborator is a failure, not a blank
--verify's collaborator loop printed  and continued on 404 without touching
`ok`, so the one instrument that checks Phase 1.2 could not report Phase 1.2
being undone. An agent removed from the repository read as "nothing to say"
rather than as a gate that is no longer there.

It has never fired: Gitea answers that endpoint with permission "read" for a
non-collaborator rather than 404, so the case was caught by the role test two
lines down. Correct outcome, wrong reason -- the same shape as the check that
passed on an instance with no rule at all, and not worth keeping because the
luck has held so far.

Found by the port agent reading the file rather than running it, which is the
only way this one was ever going to surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 17:59:51 +02:00

244 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md.
tools/gitea-protect --dry-run print the exact rule it would send; no token
tools/gitea-protect create or update the rule (idempotent)
tools/gitea-protect --verify assert the live rule still holds; exit 1 if not
Six settings, of which two were missing from the first draft of the runbook and
both of those are the ones that close the gate. That is the shape of thing that
gets mis-clicked in a web form at 1am, so it goes through the API instead: what
was applied is reviewable in a diff, and `--verify` re-checks it every day
rather than once.
── Why each field is what it is ─────────────────────────────────────────────
Read out of Gitea's own models/git/protected_branch.go, not inferred:
EnableMergeWhitelist=false merging falls back on "whether the user has
write permission" -- and both agents have
Write. This is THE gate; without it every
other row is decoration.
EnableApprovalsWhitelist=false "anyone with write access is considered
official reviewer". Gitea refuses to let an
author approve their OWN pull request and does
nothing about sylph-decoder approving
sylph-port's, so without this the two agents
satisfy the human gate between themselves.
enable_push=false blocks PUSHES to main. It has no effect on
merging whatsoever, which is the assumption
that made the first version of this phase read
as protection while being none.
🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the
human out of their own work: approvals are whitelisted to `fabi`, Gitea will not
let `fabi` approve a `fabi` PR, so a human-authored PR could never reach one
approval and -- with the override blocked -- could never be merged at all. The
admin override is what keeps that door open, and it is not a hole in the agent
gate because the agents are Write, not Admin. That is what "Write, not Admin" in
Phase 1.2 is buying, and this is where it gets spent.
── Where the token comes from ───────────────────────────────────────────────
Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token`
(write:issue, read:repository) cannot do it -- that token exists precisely so the
issue work needs no repository rights.
The credential that CAN is one you already have: `~/.sylph-git-credentials`, on
the agent box, scoped write:repository. Reusing it means this needs no new
credential and no second machine holding push rights, which is the whole reason
to run this here rather than on the Pi.
"""
import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request
HOST = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev")
REPO = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed")
HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi")
BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main")
AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",")
RULE = {
"rule_name": BRANCH,
"enable_push": False,
"required_approvals": 1,
"dismiss_stale_approvals": True,
"block_on_rejected_reviews": True,
"enable_merge_whitelist": True,
"merge_whitelist_usernames": [HUMAN],
"enable_approvals_whitelist": True,
"approvals_whitelist_username": [HUMAN],
"block_admin_merge_override": False, # see the module docstring
}
# What --verify asserts. Kept separate from RULE because a check that is written
# as "whatever we sent" cannot fail: it would re-derive the expectation from the
# thing under test. These are stated independently, on purpose.
EXPECTED = {
"enable_push": (lambda v: v is False, "pushes to the branch are blocked"),
"required_approvals": (lambda v: v >= 1, "at least one approval required"),
"dismiss_stale_approvals": (lambda v: v is True, "stale approvals dismissed"),
"block_on_rejected_reviews": (lambda v: v is True, "rejected reviews block the merge"),
"enable_merge_whitelist": (lambda v: v is True, "MERGE WHITELIST ON -- the gate"),
"merge_whitelist_usernames": (lambda v: v == [HUMAN], f"only {HUMAN} may merge"),
"enable_approvals_whitelist": (lambda v: v is True, "APPROVALS WHITELIST ON"),
"approvals_whitelist_username": (lambda v: v == [HUMAN], f"only {HUMAN}'s approval counts"),
}
def token():
"""The first credential that can plausibly do this, and a clear no otherwise."""
explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN")
if explicit and os.path.exists(explicit):
return open(explicit).read().strip(), explicit
cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS",
"~/.sylph-git-credentials"))
if os.path.exists(cred):
for line in open(cred):
line = line.strip()
if HOST in line and "@" in line:
parsed = urllib.parse.urlsplit(line)
if parsed.password:
return urllib.parse.unquote(parsed.password), cred
sys.exit(
f"gitea-protect: no repository-scoped credential found.\n\n"
f" Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n"
f" NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n"
f" by design, and every branch-protection endpoint refuses it. Run this on\n"
f" the machine that already holds the push credential rather than issuing a\n"
f" repository-scoped token to a second box.\n"
)
def api(method, path, tok, body=None):
url = f"https://{HOST}/api/v1/repos/{REPO}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"token {tok}",
"Content-Type": "application/json",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
if e.code in (401, 403) and "scope" in raw:
sys.exit(f"🔴 that credential lacks repository scope:\n {raw.strip()}")
return e.code, raw
except urllib.error.URLError as e:
sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}")
def apply_rule(tok):
status, existing = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 200:
status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok,
{k: v for k, v in RULE.items() if k != "rule_name"})
verb = "updated"
elif status == 404:
status, out = api("POST", "/branch_protections", tok, RULE)
verb = "created"
else:
sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}")
if status not in (200, 201):
sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}")
print(f" {verb} the protection rule on {BRANCH}")
return out
def verify(tok):
"""Assert, one line per property, and say which one failed rather than 'no'."""
ok = True
status, rule = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 404:
print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.")
return False
if status != 200:
sys.exit(f"🔴 could not read the rule ({status}): {rule}")
for key, (pred, why) in EXPECTED.items():
got = rule.get(key)
good = pred(got)
ok &= good
print(f" {'' if good else '🔴'} {why:<42} {key}={got!r}")
# The other half of what a daily check is for: Phase 1.2's "Write, not
# Admin". An agent promoted to Admin could edit the rule above and then
# merge, so a green rule proves nothing on its own.
for agent in AGENTS:
status, perm = api("GET", f"/collaborators/{agent}/permission", tok)
# 🔴 A MISSING COLLABORATOR IS A FAILURE, not a blank. This branch used
# to print ⚪ and `continue`, leaving `ok` untouched -- so the one
# instrument that checks Phase 1.2 could not report Phase 1.2 being
# undone. An agent removed from the repository read as "nothing to say"
# rather than as a gate that is no longer there.
#
# It never actually fired: Gitea answers this endpoint with permission
# "read" for a non-collaborator rather than 404, so the case was caught
# by the role test below -- by luck, not by design. That is the same
# shape as a check that passes on an instance with no rule at all, and
# it is not worth keeping just because the luck has held.
if status == 404:
print(f" 🔴 {agent + ' is not a collaborator':<42} Phase 1.2 is undone")
ok = False
continue
if status != 200:
print(f" 🔴 {agent:<42} permission unreadable ({status})")
ok = False
continue
role = perm.get("permission")
good = role == "write"
ok &= good
print(f" {'' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}")
return ok
def main():
p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0])
g = p.add_mutually_exclusive_group()
g.add_argument("--dry-run", action="store_true",
help="print the rule that would be sent; needs no credential")
g.add_argument("--verify", action="store_true",
help="check the live rule against what this file asserts")
a = p.parse_args()
print(f"repo https://{HOST}/{REPO}")
print(f"branch {BRANCH}\n")
if a.dry_run:
print(f"would PUT this rule (no credential read, nothing sent):\n")
print(json.dumps(RULE, indent=2))
print(f"\ndry run -- nothing was changed.")
return 0
tok, where = token()
print(f"credential from {where}\n")
if a.verify:
ok = verify(tok)
print()
print("protection holds." if ok else
"🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.")
return 0 if ok else 1
apply_rule(tok)
print()
ok = verify(tok)
print()
if ok:
print("Now run the check that a settings page cannot give you, from")
print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway")
print("PR yourself, then confirm sylph-port STILL has no merge button.")
print("Steps 1-3 pass on an instance with no rule at all.")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())