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

Merged opened this in answer to

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

Merged

torque/forge:torque/3658-a-crew-run-can-open-its-own-repository-s → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/3658-a-crew-run-can-open-its-own-repository-s was merged into main as fc0d200.

Review

Sign in to review this change.
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
8181 Wants: to read what it was not given, reach what it may not, or land code no
8282 person approved.
8383
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
8587 forge's repositories, index, key and every other agent's clone are denied by
8688 name and by allow-list, and a listing that fails refuses the run.
8789 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 @@
77 * rules (docs/APPS.md) hold whether or not the agent reads them.
88 */
99
10 import type { OwnApp } from "./app_hosts.ts";
1011 import type { Owed } from "./team_share.ts";
1112 import type { Agent, CrewRole } from "../core/agents.ts";
1213 import type { Comment, Issue } from "../core/issues.ts";
@@ -41,6 +42,8 @@ export interface BriefInput {
4142 readonly forgeUrl: string;
4243 readonly isApp: boolean;
4344 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;
4447 /** Whether a session is being continued (the task was already given). */
4548 readonly resuming: boolean;
4649 /** What this agent is for on this repository; a planner writes maps. */
@@ -68,7 +71,7 @@ export interface PolicyBrief {
6871 */
6972 export const SANDBOX_LINES = [
7073 "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.",
7275 ];
7376
7477 /**
@@ -147,6 +150,7 @@ export function systemPrompt(input: BriefInput): string {
147150 : [
148151 `- The preview of your pull is at ${input.previewUrl} once it is up.`,
149152 ]),
153 ...ownAppLines(input.ownApp),
150154 );
151155 if (input.policy !== undefined)
152156 lines.push("", ...policyLines(input.policy));
@@ -738,3 +742,20 @@ export function policyLines(policy: PolicyBrief): string[] {
738742 "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.",
739743 ];
740744 }
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}`);

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.

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";
3131 import { announceRun } from "../actions/runs.ts";
3232 import { previewEnv } from "../apps/data.ts";
3333 import { appsRuntime } from "../apps/runtime.ts";
34 import { ownApp, ownAppUrls } from "./app_hosts.ts";
3435 import { crewRole, getAgent, type Agent } from "../core/agents.ts";
3536 import { getAppByRepo, type AppRecord } from "../core/apps.ts";
3637 import {
@@ -929,6 +930,7 @@ async function work(
929930 forgeUrl: options.forgeUrl,
930931 isApp: record !== undefined,
931932 previewUrl,
933 ownApp: ownApp(app, repo),
932934 policy,
933935 // A review reads one head afresh: a resumed session would carry an
934936 // older head's diff and preview into the verdict on this one.
@@ -1171,6 +1173,7 @@ async function work(
11711173 dataDir: app.config.dataDir,
11721174 agentName: agent.user.username,
11731175 urls: [options.localUrl, options.forgeUrl],
1176 appUrls: ownAppUrls(ownApp(app, repo)),
11741177 extraHosts: [
11751178 ...(options.allowHosts ?? []),
11761179 ...hosts,
src/crew/sandbox.ts +16 −2
@@ -1,8 +1,9 @@
11 /**
22 * What an agent's shell may touch. Claude Code sandboxes its Bash tool
33 * (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
67 * own default); reads are refused for everything on this machine that is
78 * not the agent's business — the forge's repositories, its index and key,
89 * the other agents' clones, and the operator's own credentials.
@@ -196,6 +197,12 @@ export function sandboxPolicy(input: {
196197 agentName: string;
197198 /** URLs the agent must reach: the forge, locally and publicly. */
198199 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;
199206 /** Hosts the operator vouches for beyond the forge and the registry. */
200207 extraHosts?: readonly string[] | undefined;
201208 /** Whether the host can enforce it (`sandboxAvailable`); yes by default. */
@@ -218,6 +225,13 @@ export function sandboxPolicy(input: {
218225 // Not a URL: nothing to allow.
219226 }
220227 }
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 }
221235 for (const host of input.extraHosts ?? [])
222236 if (host.trim() !== "") hosts.add(host.trim());
223237
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs