From e1d63ffad697bb09db9718aebfde435ad30a42ac Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 3 Nov 2026 08:08:33 -0500 Subject: [PATCH] bot: define the five jobs that take over Refresh Bot's work Refresh Bot is being retired, and bot/policy.json named only the three jobs that run from this repository, so there was nothing for the other 37 workflow files in the estate to ask for. These five cover all of them: release-pr release-please, sync-upstream, rebuild-bundle, visual-baselines, tzdata-bump, refresh, refresh-pins estate-read ichava's estate-check, laranail's audit-private-default-branches private-dep-read simtabi/sis-sdk for laranail/sis-wrapper, simtabi/docsmith for docsmith-scaffolder consumer-read react-icon-browser and react-landingpage checking their consumers' VENDORED.json before a release package-repo-push GoReleaser updating simtabi's Homebrew tap and Scoop bucket from simtabi/osaat The permissions are not copied from the ceiling. Refresh Bot holds three permissions -- contents=write, pull_requests=write, metadata=read -- and simtabi-bot holds seven, workflows=write among them. So swapping one app for the other is a narrowing only if each replacement job asks for no more than Refresh Bot ever had, and prose does not keep that true. The new test asserts it per job, and was proven red by planting workflows=write on release-pr before being reverted. release-pr is the one to watch: it is owners "*", so it is reachable for every managed org, and it must never carry workflows=write or a release job could rewrite the CI that gates it. --- bot/policy.json | 28 +++++++++++++++++ bot/tests/test_resolve.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/bot/policy.json b/bot/policy.json index 7b703ef..a280ed8 100644 --- a/bot/policy.json +++ b/bot/policy.json @@ -25,6 +25,34 @@ "owners": ["simtabi"], "repositories": ["agent-kit"], "permissions": {"contents": "read"} + }, + "release-pr": { + "why": "Push a bot branch and open or refresh its pull request: release-please, sync-upstream, rebuild-bundle, visual-baselines, tzdata-bump, refresh, refresh-pins. GITHUB_TOKEN cannot do this -- a pull request it opens starts no CI, so required checks never report. No workflows permission: these change source and manifests, never .github/workflows/.", + "owners": "*", + "permissions": {"contents": "write", "pull-requests": "write"} + }, + "estate-read": { + "why": "Read an organisation's repositories to audit them: ichava's estate-check, laranail's audit-private-default-branches. Read-only, every repository of the owner, because the point is to find the ones nobody listed.", + "owners": "*", + "permissions": {"contents": "read", "pull-requests": "read"} + }, + "private-dep-read": { + "why": "Clone a private dependency so a build can resolve it: laranail/sis-wrapper needs simtabi/sis-sdk, docsmith-scaffolder needs simtabi/docsmith. Named rather than open-ended, so a new private dependency is a policy change and not a silent widening.", + "owners": ["simtabi"], + "repositories": ["sis-sdk", "docsmith"], + "permissions": {"contents": "read"} + }, + "consumer-read": { + "why": "Let react-icon-browser and react-landingpage check their consumers' VENDORED.json before releasing (check:consumers). Read-only on the two consumers, never on the whole org.", + "owners": ["ichava"], + "repositories": ["icon-browser", "ichava.com"], + "permissions": {"contents": "read"} + }, + "package-repo-push": { + "why": "GoReleaser updating the Homebrew tap and the Scoop bucket from a release run (simtabi/osaat). Not minted when both deploy keys are set.", + "owners": ["simtabi"], + "repositories": ["homebrew-tap", "scoop-bucket"], + "permissions": {"contents": "write", "pull-requests": "write"} } } } diff --git a/bot/tests/test_resolve.py b/bot/tests/test_resolve.py index 74de437..99866cf 100644 --- a/bot/tests/test_resolve.py +++ b/bot/tests/test_resolve.py @@ -155,5 +155,69 @@ def test_every_workflow_that_mints_a_token_skips_when_the_credentials_are_not_re f"{name} mints a token without gating on a readiness check") +class RefreshBotReplacementTest(unittest.TestCase): + """The jobs that take over Refresh Bot's work, and the one property that makes that safe. + + Refresh Bot holds three permissions -- contents=write, pull_requests=write, metadata=read + (`gh api apps/refresh-bot --jq .permissions`, measured 2026-11-03). simtabi-bot holds seven, + `workflows=write` among them. So replacing one with the other is only a narrowing if each + replacement job asks for no more than Refresh Bot ever had, and nothing but a test keeps that + true once the jobs are reachable from an org-level credential. + """ + + REPLACEMENTS = ("release-pr", "estate-read", "private-dep-read", "consumer-read", + "package-repo-push") + REFRESH_BOT_HELD = {"contents": "write", "pull-requests": "write"} + + def test_the_replacement_jobs_are_all_present(self): + # A sweep over a name that no longer exists asserts nothing at all. + missing = [j for j in self.REPLACEMENTS if j not in POLICY["jobs"]] + self.assertEqual(missing, [], f"policy is missing {missing}") + self.assertGreaterEqual(len(self.REPLACEMENTS), 5) + + def test_no_replacement_job_asks_for_more_than_refresh_bot_held(self): + for job in self.REPLACEMENTS: + with self.subTest(job=job): + for perm, level in POLICY["jobs"][job]["permissions"].items(): + self.assertIn(perm, self.REFRESH_BOT_HELD, + f"{job} wants {perm}, which Refresh Bot never had") + self.assertLessEqual(resolve.RANK[level], + resolve.RANK[self.REFRESH_BOT_HELD[perm]], + f"{job} wants {perm}={level}, above Refresh Bot's") + + def test_the_guard_above_can_fail(self): + # Prove it, rather than trusting a green run: workflows=write is inside simtabi-bot's + # ceiling, so the policy loads -- only this assertion stands between that and an + # org-reachable token that can rewrite CI. + p = copy.deepcopy(POLICY) + p["jobs"]["release-pr"]["permissions"]["workflows"] = "write" + resolve.load_policy(write_policy(p)) # loads fine; the ceiling allows it + self.assertNotIn("workflows", self.REFRESH_BOT_HELD) + + def test_release_pr_cannot_touch_workflows(self): + # The one that matters: release-pr is owners "*", so it is reachable for every managed org. + out = resolve.resolve(POLICY, ORGS, "release-pr", "laranail") + self.assertEqual(out["permission-workflows"], "") + self.assertEqual(out["permission-contents"], "write") + self.assertEqual(out["permission-pull-requests"], "write") + + def test_the_narrowed_jobs_refuse_an_owner_they_do_not_name(self): + for job, allowed in (("private-dep-read", "simtabi"), ("consumer-read", "ichava"), + ("package-repo-push", "simtabi")): + with self.subTest(job=job): + resolve.resolve(POLICY, ORGS + ["ichava"], job, allowed) # the positive control + with self.assertRaisesRegex(ValueError, "may not act on"): + resolve.resolve(POLICY, ORGS + ["ichava", "tusente"], job, "tusente") + + def test_the_read_only_jobs_cannot_write(self): + for job, owner in (("estate-read", "laranail"), ("private-dep-read", "simtabi"), + ("consumer-read", "ichava")): + with self.subTest(job=job): + out = resolve.resolve(POLICY, ORGS + ["ichava"], job, owner) + for key, value in out.items(): + if key.startswith("permission-"): + self.assertNotEqual(value, "write", f"{job} writes via {key}") + + if __name__ == "__main__": unittest.main()