Public: anyone can read this; the crew can change it.
A crew run can open its own repository's app and previews: the sandbox allows that repo's app hosts #3663
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3658-a-crew-run-can-open-its-own-repository-s → main 1 commit fork 1 thread · 1 unresolved
6 files
changed +239
−4
docs/THREATS.md +3 −1
| @@ -81,7 +81,9 @@ trusts: a package, a model provider, a hosting provider, an origin it syncs from | ||
| 81 | 81 | Wants: to read what it was not given, reach what it may not, or land code no |
| 82 | 82 | person approved. |
| 83 | 83 | |
| 84 | - The agent's shell may reach only the forge and the package registry; the | |
| 84 | - The agent's shell may reach only the forge, the package registry and its | |
| 85 | own repository's app — production and each open pull's preview, by exact | |
| 86 | host, never another repository's app; the | |
| 85 | 87 | forge's repositories, index, key and every other agent's clone are denied by |
| 86 | 88 | name and by allow-list, and a listing that fails refuses the run. |
| 87 | 89 | Test: `test/crew-sandbox.test.ts` |
src/crew/app_hosts.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The app a run's own repository is: where its production and every open | |
| 3 | * pull's preview answer. A crew run on R may reach these from its shell — | |
| 4 | * an end-to-end check, a screenshot, the public launch check need the | |
| 5 | * running app — and no other repository's app: the URLs are made from R's | |
| 6 | * own slug and R's own pulls, never from a pattern a neighbour would match | |
| 7 | * (`notes--*` would cover `notes--3` and the app named `notes--3` alike). | |
| 8 | * Asked again for every invocation, like the rest of the sandbox, so a pull | |
| 9 | * opened between the work and the wrap-up is reachable in the second. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { previewEnv } from "../apps/data.ts"; | |
| 14 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 15 | import { getAppByRepo } from "../core/apps.ts"; | |
| 16 | import { LIST_LIMIT_MAX } from "../core/issues.ts"; | |
| 17 | import { listPulls } from "../core/pulls.ts"; | |
| 18 | import type { Repo } from "../core/repos.ts"; | |
| 19 | ||
| 20 | export interface OwnApp { | |
| 21 | /** Production: the verified domain when there is one, else the slug's host. */ | |
| 22 | readonly production: string; | |
| 23 | /** One preview per open pull, by number. */ | |
| 24 | readonly previews: readonly { readonly number: number; readonly url: string }[]; | |
| 25 | } | |
| 26 | ||
| 27 | /** Repo R's own app URLs, or undefined when R is not an app this forge serves. */ | |
| 28 | export function ownApp(app: App, repo: Repo): OwnApp | undefined { | |
| 29 | const record = getAppByRepo(app.db, repo.id); | |
| 30 | const runtime = appsRuntime(app); | |
| 31 | if (record === undefined || runtime === undefined) return undefined; | |
| 32 | const production = runtime.urlFor(record, "production"); | |
| 33 | const previews = listPulls(app.db, repo.id, { | |
| 34 | state: "open", | |
| 35 | limit: LIST_LIMIT_MAX, | |
| 36 | }) | |
| 37 | .map((pull) => ({ | |
| 38 | number: pull.number, | |
| 39 | url: runtime.urlFor(record, previewEnv(pull.number)), | |
| 40 | })) | |
| 41 | .sort((a, b) => a.number - b.number); | |
| 42 | return { production, previews }; | |
| 43 | } | |
| 44 | ||
| 45 | /** Every URL of `own`, for the sandbox to allow. */ | |
| 46 | export function ownAppUrls(own: OwnApp | undefined): string[] { | |
| 47 | if (own === undefined) return []; | |
| 48 | return [own.production, ...own.previews.map((p) => p.url)]; | |
| 49 | } |
src/crew/brief.ts +22 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * rules (docs/APPS.md) hold whether or not the agent reads them. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import type { OwnApp } from "./app_hosts.ts"; | |
| 10 | 11 | import type { Owed } from "./team_share.ts"; |
| 11 | 12 | import type { Agent, CrewRole } from "../core/agents.ts"; |
| 12 | 13 | import type { Comment, Issue } from "../core/issues.ts"; |
| @@ -41,6 +42,8 @@ export interface BriefInput { | ||
| 41 | 42 | readonly forgeUrl: string; |
| 42 | 43 | readonly isApp: boolean; |
| 43 | 44 | readonly previewUrl?: string | undefined; |
| 45 | /** Where this repository's app answers; the shell may reach these and no other app. */ | |
| 46 | readonly ownApp?: OwnApp | undefined; | |
| 44 | 47 | /** Whether a session is being continued (the task was already given). */ |
| 45 | 48 | readonly resuming: boolean; |
| 46 | 49 | /** What this agent is for on this repository; a planner writes maps. */ |
| @@ -68,7 +71,7 @@ export interface PolicyBrief { | ||
| 68 | 71 | */ |
| 69 | 72 | export const SANDBOX_LINES = [ |
| 70 | 73 | "Your shell is sandboxed. It can run: `bun install` (the registry is reachable; run it first when there is a package.json and no node_modules, or the type check has no types), `bun x tsc --noEmit`, and `bun test` — FORGE_TEST_SOCKET=port is already set, so servers listen on a loopback port in place of a unix socket; the suites about unix sockets and cells themselves (stale-socket recovery, the egress proxy, cell limits) are skipped in that mode and named as skipped, and the forge's check runs them on every push. Run what covers your change; the forge runs the whole check on your push and sends you its log.", |
| 71 | "It cannot: reach any host but the forge and the package registry, write outside the clone and `$TMPDIR`, read the forge's data or the machine's, bind a unix socket, or signal a process started in an earlier command. Never leave the sandbox: it cannot be asked to. If you start the app to try it, do it inside one command that also stops it (`bun run src/server.ts & pid=$!; sleep 2; curl …; kill $pid`). Anything left running is stopped by the forge when you finish; it is not a failure, but it is noise.", | |
| 74 | "It cannot: reach any host but the forge, the package registry and, when this repository is an app, its own production and previews, write outside the clone and `$TMPDIR`, read the forge's data or the machine's, bind a unix socket, or signal a process started in an earlier command. Never leave the sandbox: it cannot be asked to. If you start the app to try it, do it inside one command that also stops it (`bun run src/server.ts & pid=$!; sleep 2; curl …; kill $pid`). Anything left running is stopped by the forge when you finish; it is not a failure, but it is noise.", | |
| 72 | 75 | ]; |
| 73 | 76 | |
| 74 | 77 | /** |
| @@ -147,6 +150,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 147 | 150 | : [ |
| 148 | 151 | `- The preview of your pull is at ${input.previewUrl} once it is up.`, |
| 149 | 152 | ]), |
| 153 | ...ownAppLines(input.ownApp), | |
| 150 | 154 | ); |
| 151 | 155 | if (input.policy !== undefined) |
| 152 | 156 | lines.push("", ...policyLines(input.policy)); |
| @@ -738,3 +742,20 @@ export function policyLines(policy: PolicyBrief): string[] { | ||
| 738 | 742 | "Never compare roles by hand in an action; ask `permit`. A new kind of person is a new role under an existing one. A cell loosened — anything an identity could not do before that it can after — is a wall: the forge shows that pull to a person and never lands it on its own, so do not loosen a cell unless the issue asks for exactly that, and say so in the pull.", |
| 739 | 743 | ]; |
| 740 | 744 | } |
| 745 | ||
| 746 | /** | |
| 747 | * The app's own URLs, which the shell may fetch: an end-to-end check or a | |
| 748 | * screenshot runs against them. A preview of a pull opened after the run | |
| 749 | * started is reachable from the run's next invocation, not this one. | |
| 750 | */ | |
| 751 | export function ownAppLines(own: OwnApp | undefined): string[] { | |
| 752 | if (own === undefined) return []; | |
| 753 | const previews = own.previews.map((p) => `#${p.number} at ${p.url}`); | |
| 754 | return [ | |
| 755 | `- Your shell may reach this app, and no other: production at ${own.production}` + | |
| 756 | (previews.length === 0 | |
| 757 | ? "; no pull has a preview yet." | |
| 758 | : `, and the previews of the open pulls: ${previews.join(", ")}.`) + | |
| 759 | " Use them for checks that need the running app (end-to-end, screenshots, the public launch check).", | |
| 760 | ]; | |
| 761 | } |
src/crew/runner.ts +3
| @@ -31,6 +31,7 @@ import { forkRepo, sealedForkFor } from "../actions/repos.ts"; | ||
| 31 | 31 | import { announceRun } from "../actions/runs.ts"; |
| 32 | 32 | import { previewEnv } from "../apps/data.ts"; |
| 33 | 33 | import { appsRuntime } from "../apps/runtime.ts"; |
| 34 | import { ownApp, ownAppUrls } from "./app_hosts.ts"; | |
| 34 | 35 | import { crewRole, getAgent, type Agent } from "../core/agents.ts"; |
| 35 | 36 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; |
| 36 | 37 | import { |
| @@ -929,6 +930,7 @@ async function work( | ||
| 929 | 930 | forgeUrl: options.forgeUrl, |
| 930 | 931 | isApp: record !== undefined, |
| 931 | 932 | previewUrl, |
| 933 | ownApp: ownApp(app, repo), | |
| 932 | 934 | policy, |
| 933 | 935 | // A review reads one head afresh: a resumed session would carry an |
| 934 | 936 | // older head's diff and preview into the verdict on this one. |
| @@ -1171,6 +1173,7 @@ async function work( | ||
| 1171 | 1173 | dataDir: app.config.dataDir, |
| 1172 | 1174 | agentName: agent.user.username, |
| 1173 | 1175 | urls: [options.localUrl, options.forgeUrl], |
| 1176 | appUrls: ownAppUrls(ownApp(app, repo)), | |
| 1174 | 1177 | extraHosts: [ |
| 1175 | 1178 | ...(options.allowHosts ?? []), |
| 1176 | 1179 | ...hosts, |
src/crew/sandbox.ts +16 −2
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What an agent's shell may touch. Claude Code sandboxes its Bash tool |
| 3 | 3 | * (seatbelt on macOS, bubblewrap on Linux when installed); the forge hands |
| 4 | * it the policy: the network is the forge and the package registry, and | |
| 5 | * nothing else; writes stay in the clone and the temp dir (the sandbox's | |
| 4 | * it the policy: the network is the forge, the package registry and the | |
| 5 | * run's own repository's app — production and its open pulls' previews | |
| 6 | * (`crew/app_hosts.ts`) — and nothing else; writes stay in the clone and the temp dir (the sandbox's | |
| 6 | 7 | * own default); reads are refused for everything on this machine that is |
| 7 | 8 | * not the agent's business — the forge's repositories, its index and key, |
| 8 | 9 | * the other agents' clones, and the operator's own credentials. |
| @@ -196,6 +197,12 @@ export function sandboxPolicy(input: { | ||
| 196 | 197 | agentName: string; |
| 197 | 198 | /** URLs the agent must reach: the forge, locally and publicly. */ |
| 198 | 199 | urls: readonly string[]; |
| 200 | /** | |
| 201 | * The run's own repository's app URLs (`crew/app_hosts.ts`). Allowed by | |
| 202 | * host and port together: an app domain of `localhost` resolves to | |
| 203 | * loopback, and a bare `notes.localhost` would be every local port. | |
| 204 | */ | |
| 205 | appUrls?: readonly string[] | undefined; | |
| 199 | 206 | /** Hosts the operator vouches for beyond the forge and the registry. */ |
| 200 | 207 | extraHosts?: readonly string[] | undefined; |
| 201 | 208 | /** Whether the host can enforce it (`sandboxAvailable`); yes by default. */ |
| @@ -218,6 +225,13 @@ export function sandboxPolicy(input: { | ||
| 218 | 225 | // Not a URL: nothing to allow. |
| 219 | 226 | } |
| 220 | 227 | } |
| 228 | for (const url of input.appUrls ?? []) { | |
| 229 | try { | |
| 230 | hosts.add(new URL(url).host); | |
| 231 | } catch { | |
| 232 | // Not a URL: nothing to allow. | |
| 233 | } | |
| 234 | } | |
| 221 | 235 | for (const host of input.extraHosts ?? []) |
| 222 | 236 | if (host.trim() !== "") hosts.add(host.trim()); |
| 223 | 237 |
test/crew-app-hosts.test.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * A crew run on repository R may reach R's own app — production and every | |
| 3 | * open pull's preview — and no other repository's app (`crew/app_hosts.ts`). | |
| 4 | * The hosts are exact: R's slug and R's pull numbers, so `arm--3` (R's | |
| 5 | * preview) is allowed and the app `armory`, or another repo's preview, is | |
| 6 | * not. The brief names the URLs the shell may reach. | |
| 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 as createForge, type App } from "../src/app.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | import { createApp } from "../src/core/apps.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { ownApp, ownAppUrls } from "../src/crew/app_hosts.ts"; | |
| 20 | import { ownAppLines } from "../src/crew/brief.ts"; | |
| 21 | import { sandboxPolicy } from "../src/crew/sandbox.ts"; | |
| 22 | ||
| 23 | const DOMAIN = "--forge-trevato-apps.open-platform.sh"; | |
| 24 | ||
| 25 | describe("a crew run's own app hosts", () => { | |
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let alice: User; | |
| 29 | let arm: Repo; | |
| 30 | let other: Repo; | |
| 31 | let plain: Repo; | |
| 32 | let pullNumber: number; | |
| 33 | ||
| 34 | async function pullOn(repo: Repo, branch: string): Promise<number> { | |
| 35 | const committed = await commitToBranch(app, alice, repo, { | |
| 36 | branch, | |
| 37 | base: repo.defaultBranch, | |
| 38 | message: "a change", | |
| 39 | files: [{ path: `${branch}.md`, content: "hi\n" }], | |
| 40 | }); | |
| 41 | if (!committed.ok) throw new Error(String(committed.error)); | |
| 42 | const opened = await openPull(app, alice, repo, { | |
| 43 | title: branch, | |
| 44 | baseBranch: repo.defaultBranch, | |
| 45 | headBranch: branch, | |
| 46 | }); | |
| 47 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 48 | return opened.value.number; | |
| 49 | } | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-hosts-")); | |
| 53 | app = createForge({ dataDir, port: 0 }); | |
| 54 | new AppsRuntime(app, { | |
| 55 | domain: DOMAIN, | |
| 56 | scheme: "https", | |
| 57 | forgeUrl: "https://forge.open-platform.sh", | |
| 58 | }); | |
| 59 | const made = await createUser(app.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username: "alice", | |
| 63 | email: "[email protected]", | |
| 64 | password: "a-good-passphrase", | |
| 65 | adminIfFirst: true, | |
| 66 | }); | |
| 67 | if (!made.ok) throw new Error(made.error); | |
| 68 | alice = made.value; | |
| 69 | const repo = async (name: string) => { | |
| 70 | const r = await createRepo(app, alice, { name }); | |
| 71 | if (!r.ok) throw new Error(String(r.error)); | |
| 72 | await commitToBranch(app, alice, r.value, { | |
| 73 | branch: r.value.defaultBranch, | |
| 74 | message: "init", | |
| 75 | files: [{ path: "README.md", content: "hi\n" }], | |
| 76 | }); | |
| 77 | return r.value; | |
| 78 | }; | |
| 79 | arm = await repo("arm"); | |
| 80 | other = await repo("armory"); | |
| 81 | plain = await repo("plain"); | |
| 82 | for (const r of [arm, other]) { | |
| 83 | const record = createApp(app.db, { | |
| 84 | repoId: r.id, | |
| 85 | slug: r.name, | |
| 86 | access: "public", | |
| 87 | }); | |
| 88 | if (!record.ok) throw new Error(record.error); | |
| 89 | } | |
| 90 | pullNumber = await pullOn(arm, "feature"); | |
| 91 | await pullOn(other, "theirs"); | |
| 92 | }, 30_000); | |
| 93 | ||
| 94 | afterAll(async () => { | |
| 95 | app.close(); | |
| 96 | await rm(dataDir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("lists production and each open pull's preview of the run's own repository", () => { | |
| 100 | expect(ownApp(app, arm)).toEqual({ | |
| 101 | production: `https://arm${DOMAIN}`, | |
| 102 | previews: [{ number: pullNumber, url: `https://arm--${pullNumber}${DOMAIN}` }], | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a repository that is not an app has none", () => { | |
| 107 | expect(ownApp(app, plain)).toBeUndefined(); | |
| 108 | expect(ownAppUrls(undefined)).toEqual([]); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a run on R may fetch R's preview and production, and no other repository's app", () => { | |
| 112 | const hosts = sandboxPolicy({ | |
| 113 | dataDir, | |
| 114 | agentName: "torque", | |
| 115 | urls: ["https://forge.open-platform.sh"], | |
| 116 | appUrls: ownAppUrls(ownApp(app, arm)), | |
| 117 | }).allowHosts; | |
| 118 | expect(hosts).toContain(`arm${DOMAIN}`); | |
| 119 | expect(hosts).toContain(`arm--${pullNumber}${DOMAIN}`); | |
| 120 | expect(hosts).not.toContain(`armory${DOMAIN}`); | |
| 121 | expect(hosts.some((h) => h.startsWith("armory"))).toBe(false); | |
| 122 | expect(hosts.some((h) => h.includes("*"))).toBe(false); | |
| 123 | }); | |
| 124 | ||
| 125 | test("an app on loopback is allowed by host and port, never every port", () => { | |
| 126 | const hosts = sandboxPolicy({ | |
| 127 | dataDir, | |
| 128 | agentName: "torque", | |
| 129 | urls: ["http://localhost:3000"], | |
| 130 | appUrls: ["http://arm.localhost:3000", "not a url"], | |
| 131 | }).allowHosts; | |
| 132 | expect(hosts).toContain("arm.localhost:3000"); | |
| 133 | expect(hosts).not.toContain("arm.localhost"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("the brief names the URLs the shell may reach", () => { | |
| 137 | const [line] = ownAppLines(ownApp(app, arm)); | |
| 138 | expect(line).toContain(`production at https://arm${DOMAIN}`); | |
| 139 | expect(line).toContain(`#${pullNumber} at https://arm--${pullNumber}${DOMAIN}`); | |
| 140 | expect(line).not.toContain("armory"); | |
| 141 | expect(ownAppLines(undefined)).toEqual([]); | |
| 142 | expect( | |
| 143 | ownAppLines({ production: "https://x.example", previews: [] })[0], | |
| 144 | ).toContain("no pull has a preview yet"); | |
| 145 | }); | |
| 146 | }); |
new above
offline
Non-blocking: on a busy repository (this one), this line lists every open pull's preview, up to LIST_LIMIT_MAX = 200, in every run's system prompt, including previews that are not running. That costs a lot of prompt for little benefit. The sandbox should keep allowing all of them, but the brief could name production plus the run's own pull's preview (it already prints
previewUrl) and summarise the rest as "and the preview of any open pull at <slug>--N…". Worth a follow-up if prompt size starts to matter.