Public: anyone can read this; the crew can change it.
One trunk again: bring Forgejo's 4 grokbot commits into main, and land outside agents only through the forge's pulls #2615
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2614-one-trunk-again-bring-forgejo-s-4-grokbo → main 2 commits fork 1 thread · 1 unresolved
4 files
changed +169
−2
docs/THREATS.md +4
| @@ -85,6 +85,10 @@ person approved. | ||
| 85 | 85 | Test: `test/crew-sandbox-real.test.ts` |
| 86 | 86 | - An agent cannot push to the default branch or merge; a person merges. |
| 87 | 87 | Test: `test/protection.test.ts` |
| 88 | - An outside agent that drives a person's account cannot push past the pulls | |
| 89 | either: a protected default takes a push from its admins only, so every | |
| 90 | other writer lands through a pull, its gate and its review. | |
| 91 | Test: `test/protected-default-push.test.ts` | |
| 88 | 92 | - Merge gates (checks, review) are enforced in the merge path, not by convention. |
| 89 | 93 | Test: `test/security-merge.test.ts` |
| 90 | 94 | - Spend, time and turns per run are limited. |
src/http/server.ts +16 −1
| @@ -51,7 +51,13 @@ import { | ||
| 51 | 51 | REQUEST_MAX, |
| 52 | 52 | settleBody, |
| 53 | 53 | } from "./limits.ts"; |
| 54 | import { canRead, canWrite, getRepo, getRepoById } from "../core/repos.ts"; | |
| 54 | import { | |
| 55 | canAdmin, | |
| 56 | canRead, | |
| 57 | canWrite, | |
| 58 | getRepo, | |
| 59 | getRepoById, | |
| 60 | } from "../core/repos.ts"; | |
| 55 | 61 | import { trackPullHead } from "../core/pulls.ts"; |
| 56 | 62 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts"; |
| 57 | 63 | import { sessionOf } from "../core/sessions.ts"; |
| @@ -1766,6 +1772,15 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 1766 | 1772 | }, |
| 1767 | 1773 | ); |
| 1768 | 1774 | } |
| 1775 | // A protected default takes commits from its admins only, as the web | |
| 1776 | // editor's (`actions/contents.ts`): every other writer lands through | |
| 1777 | // a pull. A merge moves the ref itself and never comes here. | |
| 1778 | if (!canAdmin(app.db, repo, actor)) { | |
| 1779 | return new Response( | |
| 1780 | `${repo.defaultBranch} is protected: only its admins push it; open a pull from a branch`, | |
| 1781 | { status: 403 }, | |
| 1782 | ); | |
| 1783 | } | |
| 1769 | 1784 | if (!ZERO_OID.test(update.oldOid)) { |
| 1770 | 1785 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 1771 | 1786 | // Quarantine env forwarded from the hook makes the new objects visible. |
src/web/pages/settings.ts +2 −1
| @@ -256,7 +256,8 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 256 | 256 | ${repo.protectDefault ? html`checked` : null} |
| 257 | 257 | /> |
| 258 | 258 | <span |
| 259 | >Protect the default branch — no force-push, no deletion. Enforced | |
| 259 | >Protect the default branch — no force-push, no deletion, and only | |
| 260 | its admins push it; everyone else lands through a pull. Enforced | |
| 260 | 261 | by the pre-receive hook, so it holds for every client.</span |
| 261 | 262 | > |
| 262 | 263 | </label> |
test/protected-default-push.test.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * A protected default branch takes a `git push` from its admins only, the | |
| 3 | * rule the web editor already keeps (`actions/contents.ts`): every other | |
| 4 | * writer — a person's account an outside agent drives included — lands | |
| 5 | * through a pull, which the forge merges by moving the ref itself (#2614). | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 15 | import { createRepo, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { resolveOid } from "../src/git/merge.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { commitFiles } from "../src/git/write.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | const clientEnv = gitClientEnv(); | |
| 28 | ||
| 29 | async function sh(args: string[], cwd: string) { | |
| 30 | const proc = Bun.spawn(args, { cwd, env: clientEnv, stdout: "pipe", stderr: "pipe" }); | |
| 31 | const [out, err, code] = await Promise.all([ | |
| 32 | new Response(proc.stdout).text(), | |
| 33 | new Response(proc.stderr).text(), | |
| 34 | proc.exited, | |
| 35 | ]); | |
| 36 | return { code, out: out.trim(), err: err.trim() }; | |
| 37 | } | |
| 38 | ||
| 39 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 40 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 41 | return r.value; | |
| 42 | }; | |
| 43 | ||
| 44 | describe("a protected default branch takes pushes from its admins only", () => { | |
| 45 | let dataDir: string; | |
| 46 | let work: string; | |
| 47 | let app: App; | |
| 48 | let servers: Servers; | |
| 49 | let repo: Repo; | |
| 50 | let founder: User; | |
| 51 | let writer: User; | |
| 52 | let writerClone = ""; | |
| 53 | let founderClone = ""; | |
| 54 | ||
| 55 | const mainTip = () => | |
| 56 | resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), "refs/heads/main"); | |
| 57 | const commit = async (clone: string, file: string) => { | |
| 58 | await Bun.write(join(clone, file), `${file}\n`); | |
| 59 | expect((await sh(["git", "add", file], clone)).code).toBe(0); | |
| 60 | expect((await sh(["git", "commit", "-qm", file], clone)).code).toBe(0); | |
| 61 | }; | |
| 62 | const protect = (on: boolean) => { | |
| 63 | repo = updateRepoSettings(app.db, repo, { protectDefault: on }); | |
| 64 | }; | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dataDir = await mkdtemp(join(tmpdir(), "forge-protected-push-")); | |
| 68 | work = await mkdtemp(join(tmpdir(), "forge-protected-push-work-")); | |
| 69 | app = createApp({ dataDir, port: 0 }); | |
| 70 | servers = await serve(app); | |
| 71 | const seed = (username: string, adminIfFirst: boolean) => | |
| 72 | createUser(app.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username, | |
| 76 | email: `${username}@forge.local`, | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst, | |
| 79 | }); | |
| 80 | // The site admin is someone else: the founder is admin by the org alone. | |
| 81 | ok(await seed("siteadmin", true)); | |
| 82 | founder = ok(await seed("carol", false)); | |
| 83 | writer = ok(await seed("dave", false)); | |
| 84 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 85 | const team = ok(createTeam(app.db, org.id, "writers", "write")); | |
| 86 | addTeamMember(app.db, team, writer.id); | |
| 87 | repo = ok( | |
| 88 | await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "app", visibility: "public" }), | |
| 89 | ); | |
| 90 | ok( | |
| 91 | await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), { | |
| 92 | branch: "main", | |
| 93 | message: "seed", | |
| 94 | files: [{ path: "README.md", content: "hi\n" }], | |
| 95 | author: { name: "Founder", email: "[email protected]" }, | |
| 96 | }), | |
| 97 | ); | |
| 98 | const remote = (user: User) => | |
| 99 | `${servers.url.replace("://", `://${user.username}:${createToken(app.db, user.id, "push", "rw").token}@`)}/acme/app.git`; | |
| 100 | writerClone = join(work, "writer"); | |
| 101 | founderClone = join(work, "founder"); | |
| 102 | expect((await sh(["git", "clone", "-q", remote(writer), writerClone], work)).code).toBe(0); | |
| 103 | expect((await sh(["git", "clone", "-q", remote(founder), founderClone], work)).code).toBe(0); | |
| 104 | }, 30_000); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | servers?.stop(); | |
| 108 | app?.close(); | |
| 109 | await rm(dataDir, { recursive: true, force: true }); | |
| 110 | await rm(work, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("unprotected, a writer's fast-forward to main still lands", async () => { | |
| 114 | protect(false); | |
| 115 | await commit(writerClone, "open.txt"); | |
| 116 | expect((await sh(["git", "push", "-q", "origin", "HEAD:main"], writerClone)).code).toBe(0); | |
| 117 | }); | |
| 118 | ||
| 119 | test("protected, a writer's push to main is refused with a line pointing at pulls", async () => { | |
| 120 | protect(true); | |
| 121 | const before = await mainTip(); | |
| 122 | await commit(writerClone, "direct.txt"); | |
| 123 | const pushed = await sh(["git", "push", "-q", "origin", "HEAD:main"], writerClone); | |
| 124 | expect(pushed.code).not.toBe(0); | |
| 125 | expect(pushed.err).toContain("main is protected: only its admins push it; open a pull from a branch"); | |
| 126 | expect(await mainTip()).toBe(before); | |
| 127 | }); | |
| 128 | ||
| 129 | test("protected, the writer's branch lands through a pull an admin merges", async () => { | |
| 130 | const branch = await sh(["git", "push", "-q", "origin", "HEAD:refs/heads/change"], writerClone); | |
| 131 | expect(branch.code).toBe(0); | |
| 132 | const pull = ok( | |
| 133 | await openPull(app, writer, repo, { title: "A change", baseBranch: "main", headBranch: "change" }), | |
| 134 | ); | |
| 135 | ok(await mergePull(app, founder, repo, pull, "merge")); | |
| 136 | const landed = await sh(["git", "fetch", "-q", "origin"], writerClone); | |
| 137 | expect(landed.code).toBe(0); | |
| 138 | const contains = await sh(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], writerClone); | |
| 139 | expect(contains.code).toBe(0); | |
| 140 | }); | |
| 141 | ||
| 142 | test("protected, its admin's fast-forward to main lands", async () => { | |
| 143 | expect((await sh(["git", "pull", "-q", "--ff-only", "origin", "main"], founderClone)).code).toBe(0); | |
| 144 | await commit(founderClone, "admin.txt"); | |
| 145 | expect((await sh(["git", "push", "-q", "origin", "HEAD:main"], founderClone)).code).toBe(0); | |
| 146 | }); | |
| 147 | }); |
new above
offline
Correct place for the check: it only runs when protection is on and the ref is the default branch, and after the agent and suspension refusals.
canAdmin(undefined)is false, but both receive-pack doors already require an authenticated writer, so no actorless push reaches here.