diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index 3b88696b..7a81b432 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -59,7 +59,34 @@ agent remove the rule that stops it merging. **Do this before the agents hold tokens**, so there is no window in which they can push to `main`. -Repo → Settings → Branches → *Protected Branches* → add rule for `main`: +**Apply it through the API, not the form** — `tools/gitea-protect`. Six settings +of which two are load-bearing, and both of those were missing from the first +draft of this phase: that is the shape of thing that gets mis-clicked. An API +call is reviewable in a diff and repeatable, and the same file re-checks it later. + +```bash +tools/gitea-protect --dry-run # the exact rule, no credential read +tools/gitea-protect # create or update, then verify +tools/gitea-protect --verify # assert it still holds; exit 1 if not +``` + +📌 **Run it on the agent box, not the Pi.** Branch protection is a +repository-scope endpoint, so `~/.sylph-gitea-api-token` cannot do it — that +token is deliberately issue-only. The credential that can is the one already +sitting on that machine, `~/.sylph-git-credentials`, which the tool reads. Doing +it there means no new credential, and no second machine holding push rights just +to close a one-time setup step. + +🔴 The tool sets `block_admin_merge_override: false`, deliberately. Turning it on +would lock **you** out of your 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 could never merge. The admin override is what keeps that +door open, and it is not a hole in the agent gate for exactly one reason: the +agents are **Write, not Admin**. That is what Phase 1.2 is buying, and this is +where it gets spent. + +Or by hand — Repo → Settings → Branches → *Protected Branches* → add rule for +`main`: | setting | value | why | |---|---|---| @@ -275,8 +302,10 @@ Not blockers for Phase 7, but the workflow is not finished without them: existing refusals stay: no `main`, no force-push. * **an attachment uploader** — the MCP exposes `attachment_read` only, so putting a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`. -* **`gitea-verify`** — asserts protection is still on and the agents are still - Write-not-Admin. Phase 2 is checked once; this checks it every day. +* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every + field of the rule *independently* of what the apply path sends, and that both + agents are still Write-not-Admin. What is still missing is only the *every + day* part — nothing runs it on a timer yet. * **wiki landing page** — bundles in flight and what each agent is on. There is currently no view of what is happening except container logs. diff --git a/tools/gitea-protect b/tools/gitea-protect new file mode 100755 index 00000000..c1ac40e8 --- /dev/null +++ b/tools/gitea-protect @@ -0,0 +1,231 @@ +#!/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) + if status == 404: + print(f" ⚪ {agent:<42} not a collaborator (yet)") + 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())