Public: anyone can read this; the crew can change it.
Test: an outside pull's check runs in a cell and cannot reach the forge's data (#1789 follow-up) #2127
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2080-test-an-outside-pull-s-check-runs-in-a-c → main 1 commit fork
1 file
changed +128
−3
test/contain.test.ts +128 −3
| @@ -11,14 +11,14 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 14 | import { existsSync } from "node:fs"; | |
| 14 | import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 15 | 15 | import { mkdtemp, rm } from "node:fs/promises"; |
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import { createApp, type App } from "../src/app.ts"; |
| 19 | 19 | import { enableApp } from "../src/actions/apps.ts"; |
| 20 | import { openPull } from "../src/actions/pulls.ts"; | |
| 21 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 20 | import { openPull, submitReview } from "../src/actions/pulls.ts"; | |
| 21 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 22 | 22 | import { |
| 23 | 23 | cellCounters, |
| 24 | 24 | cellsAvailable, |
| @@ -1007,3 +1007,128 @@ describe.skipIf(noCells)( | ||
| 1007 | 1007 | }, 60_000); |
| 1008 | 1008 | }, |
| 1009 | 1009 | ); |
| 1010 | ||
| 1011 | describe.skipIf(noCells)( | |
| 1012 | "cells: an outside pull's check cannot reach the forge's data (#2080)", | |
| 1013 | () => { | |
| 1014 | const CANARY = `forge-canary-${crypto.randomUUID()}`; | |
| 1015 | let dataDir: string; | |
| 1016 | let app: App; | |
| 1017 | let servers: Servers; | |
| 1018 | let checks: ChecksHandle; | |
| 1019 | let listener: ReturnType<typeof Bun.serve>; | |
| 1020 | ||
| 1021 | beforeAll(async () => { | |
| 1022 | process.env["FORGE_TEST_CANARY"] = CANARY; | |
| 1023 | dataDir = await mkdtemp(join(tmpdir(), "forge-cells-outside-")); | |
| 1024 | app = createApp({ dataDir, port: 0 }); | |
| 1025 | servers = await serve(app); | |
| 1026 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 1027 | listener = Bun.serve({ | |
| 1028 | port: 0, | |
| 1029 | hostname: "127.0.0.1", | |
| 1030 | fetch: () => new Response("inside"), | |
| 1031 | }); | |
| 1032 | }, 60_000); | |
| 1033 | ||
| 1034 | afterAll(async () => { | |
| 1035 | delete process.env["FORGE_TEST_CANARY"]; | |
| 1036 | await checks?.stop(); | |
| 1037 | listener?.stop(true); | |
| 1038 | servers?.stop(); | |
| 1039 | app?.close(); | |
| 1040 | await rm(dataDir, { recursive: true, force: true }); | |
| 1041 | }); | |
| 1042 | ||
| 1043 | test("a fork's code, run by the base's check once approved, is refused everything but the forge", async () => { | |
| 1044 | const user = async (username: string, first = false): Promise<User> => { | |
| 1045 | const made = await createUser(app.db, { | |
| 1046 | trusted: true, | |
| 1047 | username, | |
| 1048 | email: `${username}@forge.local`, | |
| 1049 | password: "a-good-passphrase", | |
| 1050 | adminIfFirst: first, | |
| 1051 | }); | |
| 1052 | if (!made.ok) throw new Error(made.error); | |
| 1053 | return made.value; | |
| 1054 | }; | |
| 1055 | const alice = await user("alice", true); | |
| 1056 | const mallory = await user("mallory"); | |
| 1057 | const made = await createRepo(app, alice, { | |
| 1058 | name: "lib", | |
| 1059 | visibility: "public", | |
| 1060 | }); | |
| 1061 | if (!made.ok) throw new Error(String(made.error)); | |
| 1062 | const repo = made.value; | |
| 1063 | ||
| 1064 | // Something of another tenant's to steal, where an app's data lives. | |
| 1065 | const stolen = envDir(dataDir, "someone-elses-app", "production"); | |
| 1066 | mkdirSync(join(stolen, "data"), { recursive: true }); | |
| 1067 | writeFileSync(join(stolen, "data", "books.txt"), "private"); | |
| 1068 | ||
| 1069 | const source = probeSource({ | |
| 1070 | dataDir, | |
| 1071 | otherAppDir: stolen, | |
| 1072 | canary: CANARY, | |
| 1073 | listenerPort: listener.port ?? 0, | |
| 1074 | forgePid: process.pid, | |
| 1075 | }); | |
| 1076 | // The base declares a harmless check; the fork's tree supplies the code. | |
| 1077 | const gate = await commitToBranch(app, alice, repo, { | |
| 1078 | message: "The base's check", | |
| 1079 | files: [ | |
| 1080 | { | |
| 1081 | path: "forge.json", | |
| 1082 | content: JSON.stringify({ | |
| 1083 | egress: ["localhost"], | |
| 1084 | check: { run: "bun probe.ts check", timeout: 120 }, | |
| 1085 | }), | |
| 1086 | }, | |
| 1087 | { path: "probe.ts", content: "console.log('base');\n" }, | |
| 1088 | ], | |
| 1089 | }); | |
| 1090 | if (!gate.ok) throw new Error(JSON.stringify(gate.error)); | |
| 1091 | ||
| 1092 | const forked = await forkRepo(app, mallory, repo); | |
| 1093 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 1094 | const committed = await commitToBranch(app, mallory, forked.value, { | |
| 1095 | branch: "from-fork", | |
| 1096 | message: "From the fork", | |
| 1097 | files: [{ path: "probe.ts", content: source }], | |
| 1098 | }); | |
| 1099 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 1100 | const opened = await openPull(app, mallory, repo, { | |
| 1101 | title: "From the fork", | |
| 1102 | baseBranch: "main", | |
| 1103 | headBranch: "from-fork", | |
| 1104 | headRepo: forked.value, | |
| 1105 | }); | |
| 1106 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1107 | expect(opened.value.crossRepo).toBe(true); | |
| 1108 | const approved = await submitReview(app, alice, repo, opened.value, { | |
| 1109 | state: "approved", | |
| 1110 | }); | |
| 1111 | if (!approved.ok) throw new Error(String(approved.error)); | |
| 1112 | ||
| 1113 | const deadline = Date.now() + 90_000; | |
| 1114 | let run: Run | undefined; | |
| 1115 | while (Date.now() < deadline) { | |
| 1116 | run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 1117 | (r) => | |
| 1118 | r.issueId === opened.value.id && | |
| 1119 | r.commitOid === committed.value.commitOid, | |
| 1120 | ); | |
| 1121 | if (run && (run.state === "ok" || run.state === "failed")) break; | |
| 1122 | await Bun.sleep(100); | |
| 1123 | } | |
| 1124 | if (!run) throw new Error("the fork's check did not run"); | |
| 1125 | expect(run.state).toBe("ok"); | |
| 1126 | expect(run.sandboxEnforced).toBe(true); | |
| 1127 | const line = readRunLog(dataDir, run.id).lines.find((l) => | |
| 1128 | l.startsWith("PROBE "), | |
| 1129 | ); | |
| 1130 | expect(line).toBeDefined(); | |
| 1131 | expectContained(JSON.parse(line!.slice(6)) as Record<string, string>); | |
| 1132 | }, 240_000); | |
| 1133 | }, | |
| 1134 | ); |
new above
offline