Public: anyone can read this; the crew can change it.

Roles, part 5: the outsider suite's member actor tries every takeover path and is refused #2577

Merged opened this

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

Merged

scout/forge:scout/2305-roles-part-5-the-outsider-suite-s-member → main 1 commit fork

  • Merged. scout/2305-roles-part-5-the-outsider-suite-s-member was merged into main as 51e0fc0.

Review

Sign in to review this change.
3 files changed +345 −82
test/contain.test.ts +1 −82
@@ -47,6 +47,7 @@ import type { AppRecord } from "../src/core/apps.ts";
4747 import type { Repo } from "../src/core/repos.ts";
4848 import { serve, type Servers } from "../src/http/server.ts";
4949 import { readRunLog } from "../src/runs/log.ts";
50 import { expectContained, probeSource } from "./helpers/cell-probe.ts";
5051 import { missing } from "./helpers/tools.ts";
5152 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
5253
@@ -497,88 +498,6 @@ const noCells =
497498 process.platform !== "linux" ||
498499 portInsteadOfSocket();
499500
500 /** What the probe tries, from inside a deploy or a check. */
501 function probeSource(targets: {
502 dataDir: string;
503 otherAppDir: string;
504 canary: string;
505 listenerPort: number;
506 /** The forge's own pid: its environment holds every secret it was started with. */
507 forgePid: number;
508 }): string {
509 return `
510 import { readdirSync, readFileSync } from "node:fs";
511 const T = ${JSON.stringify(targets)};
512 const reached = (detail) => "reached: " + String(detail).slice(0, 80);
513 async function attempt(fn) {
514 try {
515 const found = await fn();
516 return found === false ? "refused" : reached(found);
517 } catch {
518 return "refused";
519 }
520 }
521 const answered = async (url, init) => {
522 const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
523 await res.body?.cancel();
524 return res.ok ? res.status : false;
525 };
526 async function probe() {
527 return {
528 forgeDb: await attempt(() => readFileSync(T.dataDir + "/forge.db").length),
529 secretKey: await attempt(() => readFileSync(T.dataDir + "/secret.key").length),
530 forgeData: await attempt(() => {
531 const names = readdirSync(T.dataDir);
532 return names.some((n) => n === "forge.db" || n === "secret.key" || n === "repos") && names.join(",");
533 }),
534 otherApp: await attempt(() => readdirSync(T.otherAppDir).join(",")),
535 environment: await attempt(() => {
536 if (Object.values(process.env).some((v) => v?.includes(T.canary))) return "own env";
537 // The pid is the forge's only outside a pid namespace of its own; inside
538 // one the same number may be a process of the cell's.
539 const cmdline = readFileSync("/proc/" + T.forgePid + "/cmdline", "latin1");
540 const forge = readFileSync("/proc/" + T.forgePid + "/environ", "latin1");
541 if (cmdline.includes("test") && forge.length > 0) return "the forge's environment, " + forge.length + " bytes";
542 for (const pid of readdirSync("/proc")) {
543 if (!/^\\d+$/.test(pid)) continue;
544 try {
545 if (readFileSync("/proc/" + pid + "/environ", "latin1").includes(T.canary)) return "pid " + pid;
546 } catch {}
547 }
548 return false;
549 }),
550 undeclaredHost: await attempt(() => answered("http://example.com/")),
551 cluster: await attempt(() => answered("http://127.0.0.1:" + T.listenerPort + "/")),
552 declaredButPrivate: await attempt(() =>
553 answered("http://localhost:" + T.listenerPort + "/", { proxy: process.env.HTTP_PROXY ?? undefined }),
554 ),
555 forge: await attempt(() => answered(process.env.FORGE_URL + "/healthz")),
556 };
557 }
558 if (process.argv[2] === "check") {
559 console.log("PROBE " + JSON.stringify(await probe()));
560 // A terminal is the app's own client: its app answers it on its port.
561 if (process.env.FORGE_APP_TTY === "1")
562 console.log("OWNAPP " + (await attempt(() => answered("http://127.0.0.1:" + process.env.PORT + "/"))));
563 } else {
564 Bun.serve({
565 port: Number(process.env.PORT),
566 hostname: "127.0.0.1",
567 fetch: async (req) =>
568 new URL(req.url).pathname === "/probe" ? Response.json(await probe()) : new Response("ok"),
569 });
570 }
571 `;
572 }
573
574 /** Everything refused, and the forge — the one declared way out — answering. */
575 function expectContained(results: Record<string, string>): void {
576 const { forge, ...rest } = results;
577 for (const [name, outcome] of Object.entries(rest))
578 expect(`${name}: ${outcome}`).toBe(`${name}: refused`);
579 expect(forge).toStartWith("reached");
580 }
581
582501 describe.skipIf(noCells)(
583502 "cells: repository code cannot take over the forge",
584503 () => {
test/helpers/cell-probe.ts added +83
@@ -0,0 +1,83 @@
1 import { expect } from "bun:test";
2
3 /** What the probe tries, from inside a deploy or a check. */
4 export function probeSource(targets: {
5 dataDir: string;
6 otherAppDir: string;
7 canary: string;
8 listenerPort: number;
9 /** The forge's own pid: its environment holds every secret it was started with. */
10 forgePid: number;
11 }): string {
12 return `
13 import { readdirSync, readFileSync } from "node:fs";
14 const T = ${JSON.stringify(targets)};
15 const reached = (detail) => "reached: " + String(detail).slice(0, 80);
16 async function attempt(fn) {
17 try {
18 const found = await fn();
19 return found === false ? "refused" : reached(found);
20 } catch {
21 return "refused";
22 }
23 }
24 const answered = async (url, init) => {
25 const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
26 await res.body?.cancel();
27 return res.ok ? res.status : false;
28 };
29 async function probe() {
30 return {
31 forgeDb: await attempt(() => readFileSync(T.dataDir + "/forge.db").length),
32 secretKey: await attempt(() => readFileSync(T.dataDir + "/secret.key").length),
33 forgeData: await attempt(() => {
34 const names = readdirSync(T.dataDir);
35 return names.some((n) => n === "forge.db" || n === "secret.key" || n === "repos") && names.join(",");
36 }),
37 otherApp: await attempt(() => readdirSync(T.otherAppDir).join(",")),
38 environment: await attempt(() => {
39 if (Object.values(process.env).some((v) => v?.includes(T.canary))) return "own env";
40 // The pid is the forge's only outside a pid namespace of its own; inside
41 // one the same number may be a process of the cell's.
42 const cmdline = readFileSync("/proc/" + T.forgePid + "/cmdline", "latin1");
43 const forge = readFileSync("/proc/" + T.forgePid + "/environ", "latin1");
44 if (cmdline.includes("test") && forge.length > 0) return "the forge's environment, " + forge.length + " bytes";
45 for (const pid of readdirSync("/proc")) {
46 if (!/^\\d+$/.test(pid)) continue;
47 try {
48 if (readFileSync("/proc/" + pid + "/environ", "latin1").includes(T.canary)) return "pid " + pid;
49 } catch {}
50 }
51 return false;
52 }),
53 undeclaredHost: await attempt(() => answered("http://example.com/")),
54 cluster: await attempt(() => answered("http://127.0.0.1:" + T.listenerPort + "/")),
55 declaredButPrivate: await attempt(() =>
56 answered("http://localhost:" + T.listenerPort + "/", { proxy: process.env.HTTP_PROXY ?? undefined }),
57 ),
58 forge: await attempt(() => answered(process.env.FORGE_URL + "/healthz")),
59 };
60 }
61 if (process.argv[2] === "check") {
62 console.log("PROBE " + JSON.stringify(await probe()));
63 // A terminal is the app's own client: its app answers it on its port.
64 if (process.env.FORGE_APP_TTY === "1")
65 console.log("OWNAPP " + (await attempt(() => answered("http://127.0.0.1:" + process.env.PORT + "/"))));
66 } else {
67 Bun.serve({
68 port: Number(process.env.PORT),
69 hostname: "127.0.0.1",
70 fetch: async (req) =>
71 new URL(req.url).pathname === "/probe" ? Response.json(await probe()) : new Response("ok"),
72 });
73 }
74 `;
75 }
76
77 /** Everything refused, and the forge — the one declared way out — answering. */
78 export function expectContained(results: Record<string, string>): void {
79 const { forge, ...rest } = results;
80 for (const [name, outcome] of Object.entries(rest))
81 expect(`${name}: ${outcome}`).toBe(`${name}: refused`);
82 expect(forge).toStartWith("reached");
83 }
test/outsiders-member.test.ts added +261
@@ -0,0 +1,261 @@
1 /**
2 * The outsider suite's member actor (#2305, the last part of #673). A member
3 * is trusted to build, not to take over: each takeover path is tried as one
4 * and each is refused — the forge's data, secret key and environment from an
5 * app, a check and a crew shell; the forge's own model credential; a token
6 * wider than the member's own; another account's repository.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { existsSync, mkdirSync, writeFileSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { enableApp } from "../src/actions/apps.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { signUp } from "../src/actions/users.ts";
18 import { assignIssue, openIssue } from "../src/actions/issues.ts";
19 import { cellsAvailable } from "../src/apps/contain.ts";
20 import { envDir } from "../src/apps/data.ts";
21 import { childEnv } from "../src/apps/process.ts";
22 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
23 import { listRuns, type Run } from "../src/core/runs.ts";
24 import { getRepo, type Repo } from "../src/core/repos.ts";
25 import { ADMIN_TOKEN_WORDS, createToken } from "../src/core/tokens.ts";
26 import { getUserByName, type User } from "../src/core/users.ts";
27 import { getUsage } from "../src/core/usage.ts";
28 import { sandboxPolicy } from "../src/crew/sandbox.ts";
29 import { AppsRuntime } from "../src/apps/runtime.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
32 import { readRunLog } from "../src/runs/log.ts";
33 import { expectContained, probeSource } from "./helpers/cell-probe.ts";
34 import { agentRunSettled, startStandInCrew, stopStandInCrew } from "./helpers/crew-standin.ts";
35 import { seedAdmin } from "./helpers/admin.ts";
36 import { quietLogs } from "./helpers/globals.ts";
37 import { promote } from "./helpers/members.ts";
38 import { fixtureSlot } from "./helpers/slot.ts";
39 import { missing } from "./helpers/tools.ts";
40
41 quietLogs();
42
43 const PASS = "a-long-member-passphrase";
44 const CANARY = `forge-canary-${crypto.randomUUID()}`;
45
46 // Cells bind unix sockets, which a port-mode sandbox refuses; the forge's check runs them.
47 const noCells =
48 process.env["FORGE_CELLS"] === "off" ||
49 missing("bwrap", "socat") ||
50 process.platform !== "linux" ||
51 portInsteadOfSocket();
52
53 describe("the member actor", () => {
54 let dataDir = "";
55 let app: App;
56 let servers: Servers;
57 let olive: User;
58 let mallory: User;
59 let malloryToken = "";
60 let ledger: Repo;
61 let listener: ReturnType<typeof Bun.serve>;
62 let runtime: AppsRuntime;
63 let checks: ChecksHandle;
64
65 const call = (path: string, init: RequestInit & { json?: unknown } = {}) =>
66 fetch(`${servers.url}${path}`, {
67 ...init,
68 headers: {
69 authorization: `Bearer ${malloryToken}`,
70 "content-type": "application/json",
71 ...init.headers,
72 },
73 ...(init.json === undefined ? {} : { body: JSON.stringify(init.json) }),
74 });
75
76 beforeAll(async () => {
77 process.env["FORGE_TEST_CANARY"] = CANARY;
78 dataDir = await mkdtemp(join(tmpdir(), "forge-outsiders-member-"));
79 app = createApp({ dataDir, port: 0 });
80 servers = await serve(app);
81 const seeded = await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASS });
82 if (!seeded.ok) throw new Error(seeded.error);
83 olive = seeded.value;
84 const made = await signUp(app, { username: "mallory", email: "[email protected]", password: PASS }, { slot: fixtureSlot(app) });
85 if (!made.ok) throw new Error(made.error);
86 promote(app.db, made.value);
87 mallory = getUserByName(app.db, "mallory")!;
88 malloryToken = createToken(app.db, mallory.id, "t", "rw").token;
89 const theirs = await createRepo(app, olive, { name: "ledger", visibility: "public" });
90 if (!theirs.ok) throw new Error(String(theirs.error));
91 ledger = theirs.value;
92 const seed = await commitToBranch(app, olive, ledger, {
93 message: "Start",
94 files: [{ path: "README.md", content: "# ledger\n" }],
95 });
96 if (!seed.ok) throw new Error(JSON.stringify(seed.error));
97 if (!noCells) {
98 runtime = new AppsRuntime(app, {
99 domain: "member.test",
100 scheme: "http",
101 publicPort: servers.port,
102 forgeUrl: servers.url,
103 concurrency: 2,
104 });
105 await runtime.start();
106 checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 });
107 listener = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("inside") });
108 }
109 }, 60_000);
110
111 afterAll(async () => {
112 delete process.env["FORGE_TEST_CANARY"];
113 await checks?.stop();
114 await runtime?.stop();
115 listener?.stop(true);
116 servers?.stop();
117 app?.close();
118 await rm(dataDir, { recursive: true, force: true });
119 });
120
121 describe("reads the forge's data, key and environment", () => {
122 test("from a crew shell: every entry of the data directory but crew/ is denied, and the environment is cut", () => {
123 for (const dir of ["repos/olive", "apps/01app/production", "checks/01c", "crew/scout/01r/1"])
124 mkdirSync(join(dataDir, dir), { recursive: true });
125 writeFileSync(join(dataDir, "secret.key"), "x");
126 const { denyRead } = sandboxPolicy({ dataDir, agentName: "scout", urls: [], runId: "01m46abcdefghjkmnpqrstvwxy" });
127 const denied = (p: string) => denyRead.some((d) => p === d || p.startsWith(`${d}/`));
128 for (const entry of ["forge.db", "secret.key", "repos", "apps", "checks"])
129 expect([entry, denied(join(dataDir, entry))]).toEqual([entry, true]);
130 expect(denied(join(dataDir, "crew/scout/01r/1"))).toBe(false);
131 // What a shell, a check or an app starts from names no forge secret.
132 const env = childEnv({});
133 expect(JSON.stringify(env)).not.toContain(CANARY);
134 expect(Object.keys(env).filter((k) => /KEY|TOKEN|SECRET|PASSWORD/.test(k))).toEqual([]);
135 });
136
137 test.skipIf(noCells)("from an app, its preview and a check: each is refused, the forge still answers", async () => {
138 expect(await cellsAvailable()).toEqual({ ok: true });
139 const made = await createRepo(app, mallory, { name: "probe", visibility: "public" });
140 if (!made.ok) throw new Error(String(made.error));
141 const repo = made.value;
142 // Another tenant's app data: there is something to steal.
143 const theirs = join(envDir(dataDir, "01other", "production"), "data");
144 mkdirSync(theirs, { recursive: true });
145 writeFileSync(join(theirs, "books.txt"), "private");
146 const committed = await commitToBranch(app, mallory, repo, {
147 message: "A probe",
148 files: [
149 {
150 path: "forge.json",
151 content: JSON.stringify({
152 egress: ["localhost"],
153 app: { run: "bun probe.ts serve", health: "/" },
154 check: { run: "bun probe.ts check", timeout: 120 },
155 }),
156 },
157 {
158 path: "probe.ts",
159 content: probeSource({
160 dataDir,
161 otherAppDir: envDir(dataDir, "01other", "production"),
162 canary: CANARY,
163 listenerPort: listener.port ?? 0,
164 forgePid: process.pid,
165 }),
166 },
167 ],
168 });
169 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
170 const record = enableApp(app, mallory, repo, { slug: "probe" });
171 if (!record.ok) throw new Error(String(record.error));
172
173 const settled = async (kind: "deploy" | "check"): Promise<Run> => {
174 const deadline = Date.now() + 90_000;
175 while (Date.now() < deadline) {
176 const run = listRuns(app.db, repo.id, { kind, limit: 50 }).find((r) => r.commitOid === committed.value.commitOid);
177 if (run && ["ok", "failed", "cancelled"].includes(run.state)) return run;
178 await Bun.sleep(100);
179 }
180 throw new Error(`${kind} did not settle`);
181 };
182 const deploy = await settled("deploy");
183 expect(deploy.state).toBe("ok");
184 expect(deploy.sandboxEnforced).toBe(true);
185 const fromApp = await fetch(`${servers.url}/probe`, {
186 headers: { host: new URL(runtime.urlFor(record.value, "production")).host },
187 });
188 expectContained((await fromApp.json()) as Record<string, string>);
189
190 const check = await settled("check");
191 expect(check.state).toBe("ok");
192 expect(check.sandboxEnforced).toBe(true);
193 const line = readRunLog(dataDir, check.id).lines.find((l) => l.startsWith("PROBE "));
194 expect(line).toBeDefined();
195 expectContained(JSON.parse(line!.slice(6)) as Record<string, string>);
196 }, 240_000);
197 });
198
199 describe("widens a token", () => {
200 test("minting admin scope is a 403 in one sentence; an rw token reaches no admin door", async () => {
201 const minted = (scope: string) =>
202 fetch(`${servers.url}/api/v1/tokens`, {
203 method: "POST",
204 headers: { "content-type": "application/json" },
205 body: JSON.stringify({ username: "mallory", password: PASS, name: "w", scope, adminConfirm: true }),
206 });
207 const denied = await minted("admin");
208 expect(denied.status).toBe(403);
209 expect(((await denied.json()) as { message: string }).message).toBe(ADMIN_TOKEN_WORDS);
210 expect((await call("/api/v1/admin/accounts")).status).toBe(403);
211 expect((await call("/api/v1/admin/accounts/olive/suspend", { method: "POST" })).status).toBe(403);
212 expect(getUserByName(app.db, "olive")!.suspendedAt).toBeFalsy();
213 });
214 });
215
216 describe("pushes to or administers another account's repository", () => {
217 test("a push, a commit over the API, a settings change and a delete are all refused, and the repository is as it was", async () => {
218 const before = JSON.stringify(getRepo(app.db, "olive", "ledger"));
219 const basic = `Basic ${btoa(`mallory:${malloryToken}`)}`;
220 const adv = await fetch(`${servers.url}/olive/ledger.git/info/refs?service=git-receive-pack`, {
221 headers: { authorization: basic },
222 });
223 expect(adv.status).toBeGreaterThanOrEqual(401);
224 expect(adv.status).toBeLessThan(500);
225 const tries: [string, string, unknown?][] = [
226 ["POST", "/api/v1/repos/olive/ledger/contents", { message: "mine", files: [{ path: "README.md", content: "owned\n" }] }],
227 ["PATCH", "/api/v1/repos/olive/ledger", { description: "owned", visibility: "private" }],
228 ["DELETE", "/api/v1/repos/olive/ledger"],
229 ];
230 for (const [method, path, json] of tries) {
231 const res = await call(path, { method, ...(json === undefined ? {} : { json }) });
232 expect([method, path, [403, 404].includes(res.status)]).toEqual([method, path, true]);
233 }
234 expect(JSON.stringify(getRepo(app.db, "olive", "ledger"))).toBe(before);
235 });
236 });
237 });
238
239 describe("the member actor and the forge's own model credential", () => {
240 test("a member's repository run never starts an agent with it, and spends nothing", async () => {
241 const standIn = await startStandInCrew();
242 process.env["ANTHROPIC_API_KEY"] = "sk-forge-own-token";
243 try {
244 const { app, alice, bot, repo } = standIn;
245 app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id);
246 promote(app.db, alice);
247 const issue = openIssue(app, alice, repo, { title: "Prune the roses", body: "Before spring." });
248 if (!issue.ok) throw new Error(String(issue.error));
249 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
250 const run = await agentRunSettled(app, repo, (r) => r.issueId === issue.value.id);
251 expect(run.state).toBe("failed");
252 expect(run.summary).toContain("This repository has no model account: ask an admin to grant one");
253 expect(existsSync(standIn.argsFile)).toBe(false);
254 expect(getUsage(app.db, run.id)?.costUsd ?? 0).toBe(0);
255 expect(run.costUsd).toBe(0);
256 } finally {
257 delete process.env["ANTHROPIC_API_KEY"];
258 await stopStandInCrew(standIn);
259 }
260 }, 90_000);
261 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs