Public: anyone can read this; the crew can change it.
"Let <forge> propose": a revocable per-repository read grant for an agent's forge, so it forks and opens one clean pull (from #3687) #3746
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3704-let-forge-propose-a-revocable-per-reposi → main 2 commits fork 4 threads · 4 unresolved
README.md +5 −2
| @@ -177,7 +177,8 @@ the same token Settings mints; revoke it there. Details in `docs/MCP.md`. | ||
| 177 | 177 | ### Proposal tokens |
| 178 | 178 | |
| 179 | 179 | A writer of a repository can hand a child (a fork, or a project on another |
| 180 | forge) a credential that proposes and nothing more: | |
| 180 | forge) a credential that reads this repository's code (so the child's forge can | |
| 181 | fork it), proposes, and nothing more: | |
| 181 | 182 | |
| 182 | 183 | sh |
| 183 | 184 | curl -sX POST localhost:3000/api/v1/repos/you/hello/proposal-tokens \ |
| @@ -189,7 +190,9 @@ curl -sX POST localhost:3000/api/v1/repos/you/hello/proposal-tokens \ | ||
| 189 | 190 | `childRepo` (optional) a fork of this repository, and deleting it deletes the |
| 190 | 191 | token, as does deleting the repository. The token expires in 30 days. It is |
| 191 | 192 | listed as `proposal:<child>` among your tokens, and revoking it there ends it. |
| 192 | Over HTTPS it pushes only `child/<name>/…` branches of this repository (anything | |
| 193 | Over HTTPS it clones and fetches this one repository (private or not; no other | |
| 194 | repository, secret, app or issue; a fetch sees every branch, other children's | |
| 195 | `child/*` included), pushes only `child/<name>/…` branches of this repository (anything | |
| 193 | 196 | else is refused with one line) and `POST …/pulls` from one of them, with a |
| 194 | 197 | body of at most 64 KiB and 20 requests a minute. Every other request is a |
| 195 | 198 | 403, decided in `src/http/proposal_gate.ts` before any handler runs. A proposed pull |
src/core/proposal_token.ts +2 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Proposal tokens: a credential a writer of a parent repository hands to |
| 3 | 3 | * one named child. It is a `rw` token for the writer that minted it, but |
| 4 | * the gate in `http/proposal_gate.ts` lets it do exactly two things — push | |
| 4 | * the gate in `http/proposal_gate.ts` lets it do exactly three things — read | |
| 5 | * this one repository (so the child's forge can fork and follow it), push | |
| 5 | 6 | * branches under `child/<name>/` and open a pull from one. This module is |
| 6 | 7 | * the data and the rules about names; it decides nothing about requests. |
| 7 | 8 | */ |
src/http/proposal_gate.ts +11 −10
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The one gate every proposal-token request passes, before any handler. |
| 3 | 3 | * |
| 4 | * A proposal token (`core/proposal_token.ts`) may do four things at the | |
| 5 | * parent repository it was minted for and nothing else: advertise | |
| 6 | * receive-pack, run receive-pack, open a pull, and read back one it opened. Everything is refused | |
| 4 | * A proposal token (`core/proposal_token.ts`) may do six things at the | |
| 5 | * parent repository it was minted for and nothing else: fetch its code | |
| 6 | * (advertise and run upload-pack: the read grant that lets the child's | |
| 7 | * forge fork it), advertise receive-pack, run receive-pack, open a pull, | |
| 8 | * and read back one it opened. Revoking the token ends all six. Everything is refused | |
| 7 | 9 | * unless it matches one of those exactly, so a route added tomorrow is |
| 8 | 10 | * closed to these tokens until someone writes it in here. Which refs the |
| 9 | 11 | * push may touch is decided at the ref update (`handleHook`), where the |
| @@ -40,7 +42,7 @@ const refuse = (): Response => | ||
| 40 | 42 | apiError( |
| 41 | 43 | "forbidden", |
| 42 | 44 | 403, |
| 43 | "a proposal token can only push child/<name>/… branches and open pulls from them", | |
| 45 | "a proposal token can only read this repository, push child/<name>/… branches and open pulls from them", | |
| 44 | 46 | ); |
| 45 | 47 | |
| 46 | 48 | /** The refusal for this request, or undefined when it may proceed. */ |
| @@ -76,13 +78,12 @@ export function proposalGate( | ||
| 76 | 78 | `too many proposals: ${verdict.limit} per minute per token — retry in ${verdict.retryAfter}s`, |
| 77 | 79 | ); |
| 78 | 80 | |
| 79 | if ( | |
| 80 | method === "GET" && | |
| 81 | path === `${at}/info/refs` && | |
| 82 | url.searchParams.get("service") === "git-receive-pack" | |
| 83 | ) | |
| 81 | if (method === "GET" && path === `${at}/info/refs`) { | |
| 82 | const service = url.searchParams.get("service"); | |
| 83 | if (service === "git-receive-pack" || service === "git-upload-pack") return undefined; | |
| 84 | } | |
| 85 | if (method === "POST" && (path === `${at}/git-receive-pack` || path === `${at}/git-upload-pack`)) | |
This is correct and narrow: upload-pack runs only on the token's own repository, and revoke ends it. But nothing on the child forge calls it with the token yet. |
||
| 84 | 86 | return undefined; |
| 85 | if (method === "POST" && path === `${at}/git-receive-pack`) return undefined; | |
| 86 | 87 | if (method === "POST" && path === pulls) { |
| 87 | 88 | // An early refusal on the length the client wrote, nothing more: the |
| 88 | 89 | // handler's `boundedBody` counts the bytes actually read and is the cap. |
src/spawn/upstream.ts +10 −2
| @@ -19,7 +19,9 @@ import { getRepo, type Repo } from "../core/repos.ts"; | ||
| 19 | 19 | import { getUserById, getUserByName } from "../core/users.ts"; |
| 20 | 20 | import { runGit } from "../git/env.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| 22 | import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentials.ts"; | |
| 22 | 23 | import { forgeLinkOf, originOf } from "./chain.ts"; |
| 24 | import { parentCredential } from "./parent_credential.ts"; | |
| 23 | 25 | import { newestCommits, takeFromParent, type Taken } from "./take.ts"; |
| 24 | 26 | |
| 25 | 27 | export const CHECK_MS = 60 * 60_000; |
| @@ -125,14 +127,20 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 125 | 127 | const [o, n] = link.repo.split("/") as [string, string]; |
| 126 | 128 | const dir = repoDir(app.reposRoot, o, n); |
| 127 | 129 | const checkedAt = options.now ?? Date.now(); |
| 130 | // A parent that handed this forge a proposal token is read with it, so a | |
| 131 | // private parent can be followed; the header is scoped to the remote's host. | |
| 132 | const cred = await parentCredential(app); | |
| 133 | const scope = URL.parse(remote)?.origin; // a local path has none, and is sent nothing | |
| 134 | const auth = cred === undefined || scope === undefined ? {} : gitAuthEnv(scope, basicHeader(cred.child, cred.token)); | |
| 135 | const secrets = cred === undefined ? [] : [...secretsIn(auth), cred.token]; | |
| 128 | 136 | const fetched = await runGit( |
| 129 | 137 | ["fetch", "--quiet", "--no-tags", "--prune", "--", remote, "+refs/heads/*:refs/upstream/*", "+HEAD:refs/upstream/HEAD"], |
| 130 | { cwd: dir, timeoutMs: FETCH_TIMEOUT_MS }, | |
| 138 | { cwd: dir, timeoutMs: FETCH_TIMEOUT_MS, env: auth }, | |
| 131 | 139 | ); |
| 132 | 140 | let state: UpstreamState; |
| 133 | 141 | if (!fetched.ok) { |
| 134 | 142 | const line = fetched.error.stderr.split("\n").find((l) => l.trim() !== "")?.trim(); |
| 135 | const error = line ?? (fetched.error.code === 137 ? "the fetch timed out after 60 s" : "the fetch failed"); | |
| 143 | const error = redact(line ?? "", ...secrets) || (fetched.error.code === 137 ? "the fetch timed out after 60 s" : "the fetch failed"); | |
| 136 | 144 | const before = await upstreamState(app); |
| 137 | 145 | state = { behind: before?.behind ?? null, parentTip: before?.parentTip ?? "", checkedAt, error: error.slice(0, 200), toldTip: before?.toldTip ?? "" }; |
| 138 | 146 | } else { |
src/web/pages/proposal-tokens.ts +2 −2
| @@ -22,9 +22,9 @@ export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null { | ||
| 22 | 22 | const tokens = listed.ok ? listed.value : []; |
| 23 | 23 | return card( |
| 24 | 24 | html`<p class="muted"> |
| 25 | A proposal token lets one named child forge push | |
| 25 | A proposal token lets one named child forge read this repository's code (to fork it), push | |
| 26 | 26 | <code>child/<name>/…</code> branches here and open pulls from |
| 27 | them — nothing else. It lives 30 days. | |
| 27 | them — nothing else, and revoking it ends all of it. It lives 30 days. | |
| 28 | 28 | </p> |
| 29 | 29 | ${tokens.length === 0 |
| 30 | 30 | ? html`<p class="muted">No proposal tokens.</p>` |
test/proposal-token.test.ts +68 −2
| @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { revokeProposal } from "../src/actions/proposal_tokens.ts"; | |
| 11 | 12 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 14 | import { |
| @@ -375,8 +376,9 @@ describe("over HTTP", () => { | ||
| 375 | 376 | ["POST", "/api/v1/repos/alice/lib/issues"], |
| 376 | 377 | ["POST", "/api/v1/repos/alice/lib/pulls/1/merge"], |
| 377 | 378 | ["POST", "/api/v1/repos/alice/other/pulls"], |
| 378 | ["POST", "/alice/lib.git/git-upload-pack"], | |
| 379 | ["GET", "/alice/lib.git/info/refs?service=git-upload-pack"], | |
| 379 | ["POST", "/alice/other.git/git-upload-pack"], | |
| 380 | ["GET", "/alice/other.git/info/refs?service=git-upload-pack"], | |
| 381 | ["GET", "/alice/lib.git/info/refs?service=git-bogus"], | |
| 380 | 382 | ["GET", "/alice/lib"], |
| 381 | 383 | ["GET", "/nonesuch"], |
| 382 | 384 | ] as const) { |
| @@ -434,4 +436,68 @@ describe("over HTTP", () => { | ||
| 434 | 436 | // Others are untouched. |
| 435 | 437 | expect(count(tokenId)).toBe(1); |
| 436 | 438 | }); |
| 439 | ||
| 440 | test("the grant reads one private repository: fork-fetch, then propose; a revoke stops both", async () => { | |
| 441 | const hush = await createRepo(app, alice, { name: "hush", visibility: "private" }); | |
| 442 | if (!hush.ok) throw new Error(String(hush.error)); | |
| 443 | const seeded = await commitToBranch(app, alice, hush.value, { | |
| 444 | branch: "main", message: "Secret start", files: [{ path: "a.md", content: "private\n" }], | |
| 445 | }); | |
| 446 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 447 | const vault = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 448 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 449 | await commitToBranch(app, alice, vault.value, { | |
| 450 | branch: "main", message: "Vault", files: [{ path: "v.md", content: "v\n" }], | |
| 451 | }); | |
| 452 | const grant = mintProposalToken(app.db, alice.id, hush.value.id, { child: "kid", origin: ORIGIN }); | |
| 453 | if (!grant.ok) throw new Error(grant.error); | |
| 454 | const at = (name: string, token: string) => | |
| 455 | servers.url.replace("://", `://child:${token}@`) + `/alice/${name}.git`; | |
| 456 | const dest = (n: string) => join(dataDir, n); | |
| 457 | ||
| 458 | // Without a grant nothing is readable; with it, that repository is. | |
| 459 | expect((await git(["clone", `${servers.url}/alice/hush.git`, dest("anon")], dataDir)).code).not.toBe(0); | |
| 460 | const cloned = await git(["clone", at("hush", grant.value.token), dest("fork")], dataDir); | |
| 461 | expect(cloned.code).toBe(0); | |
| 462 | ||
| 463 | // Not another repository of the same owner, nor the API's secrets, apps or issues. | |
| 464 | expect((await git(["clone", at("vault", grant.value.token), dest("vault")], dataDir)).code).not.toBe(0); | |
| 465 | for (const path of [ | |
| 466 | "/api/v1/repos/alice/hush/secrets", | |
| 467 | "/api/v1/repos/alice/hush/issues", | |
| 468 | "/api/v1/repos/alice/hush/apps", | |
| 469 | "/api/v1/repos/alice/vault", | |
| 470 | ]) expect([path, [403, 404].includes((await api(path, {}, grant.value.token)).status)]).toEqual([path, true]); | |
| 471 | ||
| 472 | // It never pushes the default branch, nor anything outside its namespace. | |
| 473 | await git(["config", "user.email", "[email protected]"], dest("fork")); | |
| 474 | await git(["config", "user.name", "kid"], dest("fork")); | |
| 475 | await Bun.write(join(dest("fork"), "kid.md"), "x\n"); | |
| 476 | await git(["add", "."], dest("fork")); | |
| 477 | await git(["commit", "-m", "Kid"], dest("fork")); | |
| 478 | const before = await Bun.$`git -C ${join(dataDir, "repos", "alice", "hush.git")} rev-parse main`.quiet().nothrow(); | |
| 479 | for (const ref of ["main", "refs/heads/other"]) | |
| 480 | expect((await git(["push", at("hush", grant.value.token), `HEAD:${ref}`], dest("fork"))).code).not.toBe(0); | |
| 481 | expect((await git(["push", at("hush", grant.value.token), "HEAD:refs/heads/child/kid/idea"], dest("fork"))).code).toBe(0); | |
| 482 | if (before.exitCode === 0) { | |
| 483 | const after = await Bun.$`git -C ${join(dataDir, "repos", "alice", "hush.git")} rev-parse main`.quiet().nothrow(); | |
| 484 | expect(after.stdout.toString()).toBe(before.stdout.toString()); | |
| 485 | } | |
| 486 | const opened = await api("/api/v1/repos/alice/hush/pulls", { | |
| 487 | method: "POST", | |
| 488 | body: JSON.stringify({ title: "Idea", base: "main", head: "child/kid/idea" }), | |
| 489 | }, grant.value.token); | |
| 490 | expect(opened.status).toBe(201); | |
| 491 | ||
| 492 | // Revoke: fetch and propose both stop. | |
| 493 | expect(revokeProposal(app, alice, hush.value, grant.value.id).ok).toBe(true); | |
| 494 | expect((await git(["fetch", at("hush", grant.value.token)], dest("fork"))).code).not.toBe(0); | |
| 495 | expect((await git(["clone", at("hush", grant.value.token), dest("again")], dataDir)).code).not.toBe(0); | |
| 496 | expect((await git(["push", at("hush", grant.value.token), "HEAD:refs/heads/child/kid/more"], dest("fork"))).code).not.toBe(0); | |
| 497 | const late = await api("/api/v1/repos/alice/hush/pulls", { | |
| 498 | method: "POST", | |
| 499 | body: JSON.stringify({ title: "Idea", base: "main", head: "child/kid/idea" }), | |
| 500 | }, grant.value.token); | |
| 501 | expect(late.status).toBe(401); | |
| 502 | }); | |
| 437 | 503 | }); |
test/spawn-upstream-private.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * A child forge follows a *private* parent with the proposal token the | |
| 3 | * parent minted for it: the upstream check fetches with it, and once the | |
| 4 | * token is revoked the failure is recorded without the token in it. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdir, mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { revokeProposal } from "../src/actions/proposal_tokens.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { mintProposalToken } from "../src/core/proposal_token.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { runGit } from "../src/git/env.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { checkUpstream } from "../src/spawn/upstream.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | describe("following a private parent with a proposal token", () => { | |
| 25 | let dir = ""; | |
| 26 | let parent: App; | |
| 27 | let child: App; | |
| 28 | let servers: Servers; | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dir = await mkdtemp(join(tmpdir(), "forge-upstream-private-")); | |
| 32 | parent = createApp({ dataDir: join(dir, "parent"), port: 0, host: "forge.test" }); | |
| 33 | servers = await serve(parent); | |
| 34 | const made = await createUser(parent.db, { | |
| 35 | origin: "seed", trusted: true, username: "boss", email: "[email protected]", | |
| 36 | password: "a-good-passphrase", adminIfFirst: true, | |
| 37 | }); | |
| 38 | if (!made.ok) throw new Error(made.error); | |
| 39 | const owner = made.value; | |
| 40 | const repo = await createRepo(parent, owner, { name: "forge", visibility: "private" }); | |
| 41 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 42 | await commitToBranch(parent, owner, repo.value, { | |
| 43 | branch: "main", message: "One", files: [{ path: "a.md", content: "a\n" }], | |
| 44 | }); | |
| 45 | const minted = mintProposalToken(parent.db, owner.id, repo.value.id, { | |
| 46 | child: "kid", origin: "https://kid.example.com/", | |
| 47 | }); | |
| 48 | if (!minted.ok) throw new Error(minted.error); | |
| 49 | Object.assign(globalThis, { __grant: { ...minted.value, owner, repo: repo.value } }); | |
Hold the grant in a |
||
| 50 | ||
| 51 | child = createApp({ dataDir: join(dir, "child"), url: "https://kid.example.com", port: 0 }); | |
| 52 | const home = repoDir(child.reposRoot, "kid", "forge"); | |
| 53 | await mkdir(join(child.reposRoot, "kid"), { recursive: true }); | |
| 54 | const init = await runGit(["init", "-q", "--bare", "-b", "main", home]); | |
| 55 | if (!init.ok) throw new Error(init.error.stderr); | |
| 56 | // The child has history of its own, so "behind" has something to count from. | |
| 57 | const c = await runGit(["-c", "user.name=t", "-c", "[email protected]", "commit-tree", "-m", "own", "4b825dc642cb6eb9a060e54bf8d69288fbee4904"], { cwd: home }); | |
| 58 | if (!c.ok) throw new Error(c.error.stderr); | |
| 59 | await runGit(["update-ref", "refs/heads/main", c.value], { cwd: home }); | |
| 60 | await Bun.write( | |
| 61 | join(child.config.dataDir, "parent.json"), | |
| 62 | JSON.stringify({ | |
| 63 | upstream: servers.url, | |
| 64 | proposal: { child: "kid", token: minted.value.token }, | |
| 65 | forgeCode: { repo: "kid/forge", upstream: "boss/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] }, | |
| 66 | }), | |
| 67 | ); | |
| 68 | }); | |
| 69 | afterAll(async () => { | |
| 70 | await servers.stop(); | |
| 71 | await rm(dir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the grant lets the child fetch; a revoke is a recorded error without the token", async () => { | |
| 75 | const grant = (globalThis as any).__grant; | |
| 76 | const first = await checkUpstream(child, { now: 1000 }); | |
| 77 | expect(first?.error).toBe(""); | |
| 78 | expect(first?.parentTip).toMatch(/^[0-9a-f]{40}$/); | |
| 79 | ||
| 80 | expect(revokeProposal(parent, grant.owner, grant.repo, grant.id).ok).toBe(true); | |
| 81 | const next = await checkUpstream(child, { now: 2000 }); | |
| 82 | expect(next?.error).not.toBe(""); | |
| 83 | expect(JSON.stringify(next)).not.toContain(grant.token); | |
| 84 | expect(next?.parentTip).toBe(first!.parentTip); | |
| 85 | }); | |
| 86 | ||
| 87 | test("without a token the private parent is not readable", async () => { | |
This runs after the revoke in the first test, so the token in |
||
| 88 | const doc = await Bun.file(join(child.config.dataDir, "parent.json")).json(); | |
| 89 | delete doc.proposal; | |
| 90 | await Bun.write(join(child.config.dataDir, "parent.json"), JSON.stringify(doc)); | |
| 91 | expect((await checkUpstream(child, { now: 3000 }))?.error).not.toBe(""); | |
| 92 | }); | |
| 93 | }); |
Say here that a fetch sees every branch of this repository, including other children's
child/*branches. Then whoever mints a token knows what the read covers.