diff --git a/app/publish.ts b/app/publish.ts index 9947a3d..d6df65e 100644 --- a/app/publish.ts +++ b/app/publish.ts @@ -223,7 +223,11 @@ export async function inAppsClone( work: (clone: AppsClone) => Promise, ): Promise { const repo = appsRepo(); - const sandbox = await createSandbox({ timeoutSeconds }); + // githubRemoteUrl in git.ts always clones from github.com. + const sandbox = await createSandbox({ + timeoutSeconds, + egress: ["github.com"], + }); try { const token = await githubToken(); const remoteUrl = await cloneAppsRepo( diff --git a/app/sandbox.ts b/app/sandbox.ts index 160871a..c8dd040 100644 --- a/app/sandbox.ts +++ b/app/sandbox.ts @@ -15,9 +15,7 @@ export function shellEscape(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } -type SandboxesClient = InstanceType< - typeof Render ->["experimental"]["sandboxes"]; +type SandboxesClient = InstanceType["sandboxes"]; export type UploadData = Buffer | Uint8Array | string | Readable; @@ -32,6 +30,11 @@ export interface ExecResult { export interface CreateSandboxOptions { timeoutSeconds?: number; + /** + * The only hosts the sandbox can reach, over HTTPS. Required, so that no + * sandbox gets Render's default of unrestricted egress. + */ + egress: readonly string[]; } let cached: { client: SandboxesClient; ownerId?: `tea-${string}` } | undefined; @@ -46,7 +49,7 @@ function api(): { client: SandboxesClient; ownerId?: `tea-${string}` } { useLocalDev: false, baseUrl: "https://api.render.com", }); - cached = { client: render.experimental.sandboxes, ownerId }; + cached = { client: render.sandboxes, ownerId }; } return cached; } @@ -113,13 +116,30 @@ export class Sandbox { } export async function createSandbox( - opts?: CreateSandboxOptions, + opts: CreateSandboxOptions, ): Promise { const { client, ownerId } = api(); - const created = await client.create({ - ownerId, - timeoutSeconds: opts?.timeoutSeconds, - }); + let created: Awaited>; + try { + created = await client.create({ + ownerId, + timeoutSeconds: opts.timeoutSeconds, + networkPolicy: { + type: "allow-list", + rules: opts.egress.map((domain) => ({ domain })), + }, + }); + } catch (error) { + throw new Error( + `Render refused a sandbox that can reach only ${opts.egress.join(", ")}. The workspace may not support allow-list egress yet: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const wider = widerEgress(created.networkPolicy, opts.egress); + if (wider) { + await client.terminate(created.id, ownerId).catch(() => {}); + throw new Error(`Sandbox ${created.id} refused: ${wider}`); + } const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { @@ -138,6 +158,23 @@ export async function createSandbox( ); } +/** + * Why the policy Render reports admits more than `requested`, or null. A + * sandbox the factory did not ask for could reach hosts nobody reviewed. + */ +export function widerEgress( + policy: { type: string; rules?: { domain: string }[] } | undefined, + requested: readonly string[], +): string | null { + if (policy?.type !== "allow-list") { + return `its network policy is ${policy?.type ?? "unknown"}, not allow-list`; + } + const extra = (policy.rules ?? []) + .map((rule) => rule.domain) + .filter((domain) => !requested.includes(domain)); + return extra.length > 0 ? `it can also reach ${extra.join(", ")}` : null; +} + /** Agent tasks receive a sandbox id from workflow code, never a model. */ export function connectSandbox(sandboxId: string): Sandbox { return new Sandbox(sandboxId); @@ -168,9 +205,19 @@ const START_POSTGRES = ` set -e # psql ships in the image; the server does not. +# +# The image's apt sources use plain HTTP, and the sandbox's allow-list drops +# everything that is not HTTPS. So apt reads only these two sources, over +# HTTPS, from hosts in factoryConfig.sandbox.buildEgress. if ! command -v pg_ctlcluster >/dev/null 2>&1; then - apt-get update -qq - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql >/tmp/pg-install.log 2>&1 + codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" + cat >/tmp/pg-apt.list </tmp/pg-install.log 2>&1 fi version="$(ls /etc/postgresql | sort -n | tail -1)" diff --git a/app/workflow.ts b/app/workflow.ts index 2744f11..5459ea2 100644 --- a/app/workflow.ts +++ b/app/workflow.ts @@ -136,6 +136,7 @@ async function run( const sandbox = await createSandbox({ timeoutSeconds: SANDBOX_TIMEOUT_SECONDS, + egress: factoryConfig.sandbox.buildEgress, }); try { await recordSandbox(runId, sandbox); diff --git a/docs/1_EXTENSION.md b/docs/1_EXTENSION.md index cd2c7bf..1b162e8 100644 --- a/docs/1_EXTENSION.md +++ b/docs/1_EXTENSION.md @@ -21,6 +21,9 @@ publishing, or deletion boundaries. The canonical safety invariants live in - `render.region`: region for generated services and databases. - `render.servicePlan` and `render.databasePlan`: generated resource plans. - `render.postgresMajorVersion`: generated Postgres version. +- `sandbox.buildEgress`: the only hosts the build Sandbox can reach, over + HTTPS. Add a host when a generated stack downloads from somewhere other + than the npm registry; its install fails without it. - `assets.allowedHosts`, `maxBytes`, and `imageWidth`: image download policy. - `maxConcurrentRuns`: global number of app builds the gateway may accept. - `models`: concrete model ID for the `medium` and `large` tiers. diff --git a/docs/2_ARCHITECTURE.md b/docs/2_ARCHITECTURE.md index ef06f33..f4b1410 100644 --- a/docs/2_ARCHITECTURE.md +++ b/docs/2_ARCHITECTURE.md @@ -197,6 +197,11 @@ The enclosing Git repository has no remote. The Sandbox receives no GitHub token. The builder may execute arbitrary shell commands, so the absence of credentials and unrelated source is the real boundary. +The Sandbox can also reach only the hosts in +`factoryConfig.sandbox.buildEgress`, over HTTPS: the npm registry and the two +apt mirrors its Postgres installs from. `createSandbox()` requires an egress +list and refuses a Sandbox whose reported policy admits more. + When the plan includes them, trusted workflow code: - installs a real Postgres inside the Sandbox; @@ -238,8 +243,8 @@ The workflow turns the verified manifest into an `AppSpec`, which becomes 1. `readAppFiles()` packs only commit-visible files from the build Sandbox. 2. `appFiles()` accepts regular files with safe relative paths under a 50 MB total cap. -3. `inAppsClone()` creates a fresh Sandbox, obtains a short-lived GitHub - credential, and clones the apps repository. +3. `inAppsClone()` creates a fresh Sandbox that can reach only `github.com`, + obtains a short-lived GitHub credential, and clones the apps repository. 4. Trusted code writes the app files, `factory.json`, the app-local `render.yaml`, and a regenerated repository-root `render.yaml`. 5. Git stages only `apps///` and the root Blueprint. @@ -460,11 +465,14 @@ The important boundaries are: - **Gateway → workflow:** only `{ prompt, user, runId }`, validated again. - **Agent → machine:** explicit tools, pre-tool policy, scoped paths, and a credential-free Sandbox. +- **Agent code → network:** HTTPS to the `buildEgress` hosts only. DNS + lookups still leave the Sandbox, so a determined program could signal out + through them, but it holds no credential to leak. - **Agent → Render:** read-only MCP allowlist; no direct log access. - **Agent output → workflow:** schema validation and deterministic pass/fail checks. - **Build Sandbox → GitHub:** regular-file transfer as untrusted data, then a - clean credentialed publish Sandbox. + clean credentialed publish Sandbox that can reach only `github.com`. - **Workflow → infrastructure:** validated Blueprint creation; scoped API deletion only after the app leaves the Blueprint. diff --git a/factory.config.ts b/factory.config.ts index b260a73..170ee12 100644 --- a/factory.config.ts +++ b/factory.config.ts @@ -25,6 +25,13 @@ export interface FactoryConfig { databasePlan: string; postgresMajorVersion: string; }; + sandbox: { + /** + * Hosts the build sandbox can reach, over HTTPS only. The agent's code + * runs there, so this is everything that code can talk to. + */ + buildEgress: string[]; + }; assets: { /** Hosts that the image download accepts. Nothing else is reachable. */ allowedHosts: string[]; @@ -51,6 +58,12 @@ export const factoryConfig: FactoryConfig = { databasePlan: "0.1c-256mb", postgresMajorVersion: "18", }, + sandbox: { + // npm installs, then the two apt mirrors the sandbox's own Postgres + // installs from. A stack that downloads from anywhere else needs its + // host added here. + buildEgress: ["registry.npmjs.org", "deb.debian.org", "apt.postgresql.org"], + }, assets: { // Commons serves thumbnails from both hosts, mixed within one response. // Without thumb.wikimedia.org, whichever subjects land on it fail with diff --git a/package-lock.json b/package-lock.json index 723a131..5a124f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.0", "@hono/node-server": "^2.0.0", "@modelcontextprotocol/sdk": "^1.30.1", - "@renderinc/sdk": "^1.1.0", + "@renderinc/sdk": "^1.5.0", "hono": "^4.0.0", "pg": "^8.23.0", "tar": "^7.5.22", @@ -927,9 +927,9 @@ } }, "node_modules/@renderinc/sdk": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-1.1.0.tgz", - "integrity": "sha512-8KQs5YRkY7zPMfGEJINra7NiAsNr8dbD0da3zNnNuxkxVlTsiStXE1oqlWPHcFjnAl8ofUftexZ6J0XaAebTqA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-1.5.0.tgz", + "integrity": "sha512-xSJkPp8cx52SEXyO2uFwEaZHi0LAUlPI8hTQj1wEli+hEx4Dnb4ELg+dJeFnNiqSke0pbCgi6S3Ydmi7G0h+DQ==", "license": "MIT", "dependencies": { "eventsource": "^4.0.0", diff --git a/package.json b/package.json index f7879d4..f7fd7c3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.0", "@hono/node-server": "^2.0.0", "@modelcontextprotocol/sdk": "^1.30.1", - "@renderinc/sdk": "^1.1.0", + "@renderinc/sdk": "^1.5.0", "hono": "^4.0.0", "pg": "^8.23.0", "tar": "^7.5.22", diff --git a/tests/sandbox.test.ts b/tests/sandbox.test.ts new file mode 100644 index 0000000..e2dc5c5 --- /dev/null +++ b/tests/sandbox.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { widerEgress } from "../app/sandbox.js"; + +const allowList = (...domains: string[]) => ({ + type: "allow-list", + rules: domains.map((domain) => ({ domain, protocol: "https" })), +}); + +describe("widerEgress", () => { + it("accepts the allow-list that was asked for", () => { + expect(widerEgress(allowList("github.com"), ["github.com"])).toBeNull(); + }); + + // Render's default is allow-all. A sandbox that ignored the request would + // let the agent's code reach any host. + it("refuses a sandbox with any other policy, or with hosts nobody asked for", () => { + expect(widerEgress({ type: "allow-all" }, ["github.com"])).toContain( + "allow-all", + ); + expect(widerEgress(undefined, ["github.com"])).toContain("unknown"); + expect( + widerEgress(allowList("github.com", "example.com"), ["github.com"]), + ).toContain("example.com"); + }); +}); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 51fbcab..df031be 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -232,6 +232,7 @@ let ran: { name: string; input: unknown }[]; * Render, the input and the result go through JSON. */ const tasks: TaskContext = { + metadata: {}, run: async (task, ...args) => { ran.push({ name: task.name, input: args[0] }); return wire(await task.func(tasks, ...wire(args))); @@ -590,6 +591,10 @@ describe("promptToApp", () => { siteOnly, ); expect(runSandbox.terminate).toHaveBeenCalled(); + // The agent's sandbox reaches installs only; the token's reaches GitHub only. + expect( + mocks.createSandbox.mock.calls.map(([options]) => options.egress), + ).toEqual([factoryConfig.sandbox.buildEgress, ["github.com"]]); }); it("starts a plan with a web service from the fullstack template and a sandbox Postgres", async () => {