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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3704-let-forge-propose-a-revocable-per-reposi → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/3704-let-forge-propose-a-revocable-per-reposi was merged into main as 37fe17b.

Review

Sign in to review this change.
7 files changed +191 −19
README.md +5 −2
@@ -177,7 +177,8 @@ the same token Settings mints; revoke it there. Details in `docs/MCP.md`.
177177 ### Proposal tokens
178178
179179 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:
181182
182183 ```sh
183184 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 \
189190 `childRepo` (optional) a fork of this repository, and deleting it deletes the
190191 token, as does deleting the repository. The token expires in 30 days. It is
191192 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

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.

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
193196 else is refused with one line) and `POST …/pulls` from one of them, with a
194197 body of at most 64 KiB and 20 requests a minute. Every other request is a
195198 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 @@
11 /**
22 * Proposal tokens: a credential a writer of a parent repository hands to
33 * 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
56 * branches under `child/<name>/` and open a pull from one. This module is
67 * the data and the rules about names; it decides nothing about requests.
78 */
src/http/proposal_gate.ts +11 −10
@@ -1,9 +1,11 @@
11 /**
22 * The one gate every proposal-token request passes, before any handler.
33 *
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
79 * unless it matches one of those exactly, so a route added tomorrow is
810 * closed to these tokens until someone writes it in here. Which refs the
911 * push may touch is decided at the ref update (`handleHook`), where the
@@ -40,7 +42,7 @@ const refuse = (): Response =>
4042 apiError(
4143 "forbidden",
4244 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",
4446 );
4547
4648 /** The refusal for this request, or undefined when it may proceed. */
@@ -76,13 +78,12 @@ export function proposalGate(
7678 `too many proposals: ${verdict.limit} per minute per token — retry in ${verdict.retryAfter}s`,
7779 );
7880
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. src/spawn/upstream.ts fetches the parent anonymously, so a private parent still cannot be followed or forked by the child's forge. Pass gitAuthEnv(cred.url, basicHeader(cred.child, cred.token)) from parentCredential(app) into that fetch, and redact the token from errors.

8486 return undefined;
85 if (method === "POST" && path === `${at}/git-receive-pack`) return undefined;
8687 if (method === "POST" && path === pulls) {
8788 // An early refusal on the length the client wrote, nothing more: the
8889 // 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";
1919 import { getUserById, getUserByName } from "../core/users.ts";
2020 import { runGit } from "../git/env.ts";
2121 import { repoDir } from "../git/repos.ts";
22 import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentials.ts";
2223 import { forgeLinkOf, originOf } from "./chain.ts";
24 import { parentCredential } from "./parent_credential.ts";
2325 import { newestCommits, takeFromParent, type Taken } from "./take.ts";
2426
2527 export const CHECK_MS = 60 * 60_000;
@@ -125,14 +127,20 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
125127 const [o, n] = link.repo.split("/") as [string, string];
126128 const dir = repoDir(app.reposRoot, o, n);
127129 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];
128136 const fetched = await runGit(
129137 ["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 },
131139 );
132140 let state: UpstreamState;
133141 if (!fetched.ok) {
134142 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");
136144 const before = await upstreamState(app);
137145 state = { behind: before?.behind ?? null, parentTip: before?.parentTip ?? "", checkedAt, error: error.slice(0, 200), toldTip: before?.toldTip ?? "" };
138146 } else {
src/web/pages/proposal-tokens.ts +2 −2
@@ -22,9 +22,9 @@ export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null {
2222 const tokens = listed.ok ? listed.value : [];
2323 return card(
2424 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
2626 <code>child/&lt;name&gt;/…</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.
2828 </p>
2929 ${tokens.length === 0
3030 ? 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";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { revokeProposal } from "../src/actions/proposal_tokens.ts";
1112 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1213 import { createApp, type App } from "../src/app.ts";
1314 import {
@@ -375,8 +376,9 @@ describe("over HTTP", () => {
375376 ["POST", "/api/v1/repos/alice/lib/issues"],
376377 ["POST", "/api/v1/repos/alice/lib/pulls/1/merge"],
377378 ["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"],
380382 ["GET", "/alice/lib"],
381383 ["GET", "/nonesuch"],
382384 ] as const) {
@@ -434,4 +436,68 @@ describe("over HTTP", () => {
434436 // Others are untouched.
435437 expect(count(tokenId)).toBe(1);
436438 });
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 });
437503 });
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 let in the describe scope, like parent and child. Stashing it on globalThis and reading it back through as any loses the types for no reason.

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 parent.json is already dead. The test would pass even if the credential were still sent. To make it prove that the private parent is unreadable without a token, mint a fresh, live token for this test, or move this test before the revoke.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs