Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,11 @@ export async function inAppsClone<T>(
work: (clone: AppsClone) => Promise<T>,
): Promise<T> {
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(
Expand Down
69 changes: 58 additions & 11 deletions app/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ export function shellEscape(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}

type SandboxesClient = InstanceType<
typeof Render
>["experimental"]["sandboxes"];
type SandboxesClient = InstanceType<typeof Render>["sandboxes"];

export type UploadData = Buffer | Uint8Array | string | Readable;

Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -113,13 +116,30 @@ export class Sandbox {
}

export async function createSandbox(
opts?: CreateSandboxOptions,
opts: CreateSandboxOptions,
): Promise<Sandbox> {
const { client, ownerId } = api();
const created = await client.create({
ownerId,
timeoutSeconds: opts?.timeoutSeconds,
});
let created: Awaited<ReturnType<SandboxesClient["create"]>>;
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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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 <<EOF
deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian $codename main
deb https://apt.postgresql.org/pub/repos/apt $codename-pgdg main
EOF
apt_sources="-o Dir::Etc::SourceList=/tmp/pg-apt.list -o Dir::Etc::SourceParts=-"
apt-get $apt_sources update -qq
DEBIAN_FRONTEND=noninteractive apt-get $apt_sources install -y -qq postgresql >/tmp/pg-install.log 2>&1
fi

version="$(ls /etc/postgresql | sort -n | tail -1)"
Expand Down
1 change: 1 addition & 0 deletions app/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ async function run(

const sandbox = await createSandbox({
timeoutSeconds: SANDBOX_TIMEOUT_SECONDS,
egress: factoryConfig.sandbox.buildEgress,
});
try {
await recordSandbox(runId, sandbox);
Expand Down
3 changes: 3 additions & 0 deletions docs/1_EXTENSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions docs/2_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<user>/<app>/` and the root Blueprint.
Expand Down Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions factory.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions tests/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading