Skip to content
375 linesCodeBlameRaw
1/**
2 * Which hosts a sandbox may reach, and what its guardrails come to inside
3 * it. Pure, so it can be tested on its own; guard.ts applies it.
4 *
5 * A guarded sandbox starts with no internet. Every HTTP and HTTPS request
6 * it makes is handed to the runner Worker (Cloudflare Containers' outbound
7 * interception), which asks `allows` here and either forwards the request
8 * or refuses it with `refusal`. Other protocols and ports have no route
9 * out at all.
10 */
11
12import type { Guardrails, WorkflowDomain } from "@g1t/contracts";
13
14/** A host name as it is compared: lower case, no port, no trailing dot. */
15export function normalizeHost(host: string): string {
16 let name = host.trim().toLowerCase();
17 // An IPv6 literal keeps its colons.
18 if (name.startsWith("[")) return name.slice(0, name.indexOf("]") + 1);
19 const colon = name.indexOf(":");
20 if (colon >= 0) name = name.slice(0, colon);
21 return name.replace(/\.+$/, "");
22}
23
24/**
25 * Whether `host` is on the list. `example.com` allows exactly that host;
26 * `*.example.com` allows its subdomains, at any depth, but not
27 * `example.com` itself.
28 */
29export function allows(patterns: readonly string[], host: string): boolean {
30 const name = normalizeHost(host);
31 if (!name) return false;
32 return patterns.some((raw) => {
33 const pattern = raw.trim().toLowerCase();
34 if (pattern.startsWith("*.")) {
35 const suffix = pattern.slice(1);
36 return name.length > suffix.length && name.endsWith(suffix);
37 }
38 return pattern === name;
39 });
40}
41
42/** The host of a URL, or null if it is not one. */
43function hostOf(url: string | undefined): string | null {
44 if (!url) return null;
45 try {
46 return normalizeHost(new URL(url).host);
47 } catch {
48 return null;
49 }
50}
51
52/** Where the runner sends model traffic, which the sandbox must reach. */
53export type ModelHosts = {
54 MODELS_URL?: string;
55 AI_GATEWAY_ID?: string;
56 ANTHROPIC_API_KEY?: string;
57};
58
59/**
60 * Every host a guarded sandbox may reach: the guardrails' list, plus where
61 * this deployment sends model traffic and g1t's tools, which no setting
62 * can take away.
63 */
64export function sandboxHosts(policy: readonly string[], env: ModelHosts, sandboxEnv: Record<string, string>): string[] {
65 const hosts = new Set(policy.map((host) => host.trim().toLowerCase()).filter(Boolean));
66 for (const url of [env.MODELS_URL, sandboxEnv.ANTHROPIC_BASE_URL, sandboxEnv.G1T_API, sandboxEnv.G1T_MCP, sandboxEnv.GIT_REMOTE]) {
67 const host = hostOf(url);
68 if (host) hosts.add(host);
69 }
70 // A deployment without the model proxy sends model traffic to the
71 // gateway, or straight to the provider.
72 if (!env.MODELS_URL && env.AI_GATEWAY_ID) hosts.add("gateway.ai.cloudflare.com");
73 if (!env.MODELS_URL && !env.AI_GATEWAY_ID && env.ANTHROPIC_API_KEY) hosts.add("api.anthropic.com");
74 return [...hosts];
75}
76
77/**
78 * What a refused request gets back. Many tools show only the status line
79 * of a failed request (a GraphQL client prints its reason phrase, a
80 * package manager "HTTP 403: Forbidden"), so the reason phrase names the
81 * host too, and the body and a header say the rest.
82 */
83export function refusal(host: string): Response {
84 // A reason phrase is printable ASCII; a host from a URL already is.
85 const name = normalizeHost(host).replace(/[^\x21-\x7e]/g, "");
86 return new Response(
87 `g1t guardrails refused ${name}: it is not on this project's allowed domains, so this sandbox cannot reach it. ` +
88 "A member can allow it in the project's Settings, Guardrails: under Allowed domains for every sandbox, " +
89 "or under Workflow-only domains for the jobs of one workflow and environment.\n",
90 {
91 status: 403,
92 statusText: `Forbidden by g1t guardrails: ${name} is not an allowed domain`,
93 headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-guardrails": "blocked", "x-g1t-blocked-host": name },
94 },
95 );
96}
97
98/** A refused host as a step of the run. */
99export function blockedStep(host: string): string {
100 return `Blocked: ${normalizeHost(host)} (not an allowed domain)`;
101}
102
103/**
104 * The variables a guarded sandbox needs so that its programs trust the
105 * certificate HTTPS is re-signed with as it passes through the Worker.
106 * The runner adds that certificate to the system's store when it starts;
107 * these point the tools that keep their own list at it.
108 */
109export const EGRESS_CA = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
110const SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
111export const EGRESS_ENV: Record<string, string> = {
112 G1T_EGRESS_CA: EGRESS_CA,
113 NODE_EXTRA_CA_CERTS: EGRESS_CA,
114 SSL_CERT_FILE: SYSTEM_BUNDLE,
115 REQUESTS_CA_BUNDLE: SYSTEM_BUNDLE,
116 PIP_CERT: SYSTEM_BUNDLE,
117 CURL_CA_BUNDLE: SYSTEM_BUNDLE,
118 CARGO_HTTP_CAINFO: SYSTEM_BUNDLE,
119 GIT_SSL_CAINFO: SYSTEM_BUNDLE,
120};
121
122/**
123 * Tools that report usage home by default, told not to: in a guarded
124 * sandbox the report would only be refused, and a refused host is noted on
125 * the run. `DO_NOT_TRACK` is the convention many tools follow; the rest are
126 * the tools' own switches.
127 */
128export const QUIET_ENV: Record<string, string> = {
129 DO_NOT_TRACK: "1",
130 WRANGLER_SEND_METRICS: "false",
131 NEXT_TELEMETRY_DISABLED: "1",
132 ASTRO_TELEMETRY_DISABLED: "1",
133 NUXT_TELEMETRY_DISABLED: "1",
134 GATSBY_TELEMETRY_DISABLED: "1",
135 STORYBOOK_DISABLE_TELEMETRY: "1",
136 TURBO_TELEMETRY_DISABLED: "1",
137 DOTNET_CLI_TELEMETRY_OPTOUT: "1",
138 HOMEBREW_NO_ANALYTICS: "1",
139 CHECKPOINT_DISABLE: "1",
140};
141
142/** What a sandbox's guardrails come to for one run. */
143export type RunGuard = {
144 policy: Guardrails;
145 /** The time cap of this kind of run, in minutes. */
146 minutes: number;
147};
148
149/** The most refused hosts reported as steps of one run. */
150const MAX_BLOCKED_REPORTED = 25;
151
152/** What the harness is told about the run's guardrails. */
153export function harnessEnv(guard: RunGuard, sandboxEnv: Record<string, string>, restricted: boolean): Record<string, string> {
154 const vars: Record<string, string> = {
155 GUARDRAILS: JSON.stringify({
156 rules: guard.policy.rules,
157 deny: guard.policy.deny,
158 budgetUsd: guard.policy.budgetUsd,
159 minutes: guard.minutes,
160 restrictNetwork: restricted,
161 defaultBranch: sandboxEnv.UPSTREAM_BRANCH ?? null,
162 }),
163 };
164 return restricted ? { ...vars, ...EGRESS_ENV, ...QUIET_ENV } : vars;
165}
166
167/**
168 * Records a refused host as a step of the run, once per host. `seen` is
169 * the hosts reported so far, kept by the sandbox.
170 */
171export function newlyBlocked(seen: readonly string[], host: string): { step: string; seen: string[] } | null {
172 const step = blockedStep(host);
173 if (seen.includes(step) || seen.length >= MAX_BLOCKED_REPORTED) return null;
174 return { step, seen: [...seen, step] };
175}
176
177/** What a run stopped by its time cap is told. */
178export function timeCapMessage(minutes: number): string {
179 return `Stopped: it reached its time cap of ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`;
180}
181
182/**
183 * Where a sandbox tells the runner it stopped itself for mining
184 * (crates/runner abuse.rs). The runner's Durable Object answers it; it
185 * never leaves the machine.
186 */
187export const ABUSE_HOST = "sandbox.g1t.internal";
188/** What a sandbox that stopped itself for mining exits with. */
189export const ABUSE_EXIT_CODE = 86;
190/** What such a run, check, job or build says. */
191export const ABUSE_MESSAGE = "Stopped: unusual CPU use; contact support if this was a real job.";
192
193/**
194 * What workflow jobs and deploy builds may reach on top of the project's
195 * allowed domains and registries: where `actions/checkout`, `uses:`
196 * actions and the `setup-*` actions fetch from, the package registries
197 * builds install from, the public container registries a job's Docker
198 * Engine pulls images from (crates/runner docker/), and (for deploys)
199 * Cloudflare's API, which a build uploads its app to. No mining pool is on
200 * it, and no general host.
201 */
202export const BUILD_HOSTS: readonly string[] = [
203 // Actions by `uses:`, and releases the setup actions download.
204 "github.com",
205 "api.github.com",
206 "codeload.github.com",
207 "objects.githubusercontent.com",
208 "raw.githubusercontent.com",
209 "release-assets.githubusercontent.com",
210 "ghcr.io",
211 "pkg-containers.githubusercontent.com",
212 // Toolchains.
213 "nodejs.org",
214 "go.dev",
215 "dl.google.com",
216 "static.rust-lang.org",
217 "sh.rustup.rs",
218 // Package registries, whatever the project turned on for agents.
219 "registry.npmjs.org",
220 "registry.yarnpkg.com",
221 "repo.yarnpkg.com",
222 "pypi.org",
223 "files.pythonhosted.org",
224 "crates.io",
225 "index.crates.io",
226 "static.crates.io",
227 "proxy.golang.org",
228 "sum.golang.org",
229 "rubygems.org",
230 "index.rubygems.org",
231 "repo.packagist.org",
232 "api.nuget.org",
233 "repo.maven.apache.org",
234 "repo1.maven.org",
235 "services.gradle.org",
236 "plugins.gradle.org",
237 "deb.debian.org",
238 "security.debian.org",
239 // Container images, for a job's own Docker Engine: Docker Hub (and the
240 // CDN its layers come from), Google's public mirror of it, which the
241 // Engine asks first, and Quay. GitHub's registry is above.
242 "registry-1.docker.io",
243 "auth.docker.io",
244 "index.docker.io",
245 "production.cloudflare.docker.com",
246 "production.cloudfront.docker.com",
247 "mirror.gcr.io",
248 "quay.io",
249 "cdn01.quay.io",
250 "cdn02.quay.io",
251 "cdn03.quay.io",
252 // Docker's own packages (apt), for images that install the CLI.
253 "download.docker.com",
254 // Cloudflare's container registry, which a g1t.page container build
255 // pushes its image to with the push-only login it is given.
256 "registry.cloudflare.com",
257];
258
259/**
260 * Whether a workflow job gets a Docker Engine of its own, from the
261 * Worker's `DOCKER` setting: on unless it says `off`. Its containers share
262 * the job's network, so the hosts above, and its guardrails, are theirs too.
263 */
264export function dockerFor(setting: string | undefined): "on" | "off" {
265 return setting?.trim().toLowerCase() === "off" ? "off" : "on";
266}
267
268/**
269 * A workflow job, for the guardrails' workflow-only domains: its workflow
270 * file, the environment it names, and whether its run is trusted (not a
271 * pull request from a fork). Only a trusted run's jobs get them.
272 */
273export type WorkflowJob = { workflow: string | null; environment: string | null; trusted: boolean };
274
275/** The file name of a workflow's path: `.g1t/workflows/deploy.yml` is `deploy.yml`. */
276function workflowFile(path: string): string {
277 return path.trim().split(/[\\/]/).pop() ?? "";
278}
279
280/**
281 * The workflow-only hosts a job of `workflow` (its path) in `environment`
282 * may reach, as `Guardrails::workflow_hosts` decides it: names compare
283 * without regard to case, and an empty list of workflows or environments
284 * is any.
285 */
286export function workflowHosts(entries: readonly WorkflowDomain[] | undefined, workflow: string, environment: string | null | undefined): string[] {
287 const file = workflowFile(workflow).toLowerCase();
288 const env = environment?.trim().toLowerCase() ?? null;
289 const hosts: string[] = [];
290 for (const entry of entries ?? []) {
291 const workflowOk = !entry.workflows.length || entry.workflows.some((w) => workflowFile(w).toLowerCase() === file);
292 const envOk = !entry.environments.length || (env != null && entry.environments.some((e) => e.toLowerCase() === env));
293 if (workflowOk && envOk && !hosts.includes(entry.domain)) hosts.push(entry.domain);
294 }
295 return hosts;
296}
297
298/**
299 * The workflow-only domains a sandbox gets: for a workflow job of a
300 * trusted run, those whose workflows and environments include its own.
301 * Never for a deploy build, an agent or a pull request from a fork.
302 */
303export function jobHosts(
304 policy: { workflowDomains?: readonly WorkflowDomain[] },
305 kind: "actions" | "deploy" | "bump",
306 job: WorkflowJob | null | undefined,
307): string[] {
308 if (kind !== "actions" || !job?.trusted || !job.workflow) return [];
309 return workflowHosts(policy.workflowDomains, job.workflow, job.environment);
310}
311
312/**
313 * The Durable Object namespace behind each sandbox class, by its binding:
314 * a sandbox's outbound handlers are told its class, and report back to its
315 * own object. Larger machines are classes of their own (wrangler.jsonc).
316 */
317export const SANDBOX_BINDINGS: Record<string, string> = {
318 AttemptSandbox: "SANDBOX",
319 Sandbox2Core: "SANDBOX_2CORE",
320 Sandbox4Core: "SANDBOX_4CORE",
321};
322
323/** The namespace of a sandbox of `className`; the standard one when unknown. */
324export function sandboxNamespace(env: object, className: string | undefined): DurableObjectNamespace {
325 const bindings = env as Record<string, DurableObjectNamespace | undefined>;
326 const binding = (className && SANDBOX_BINDINGS[className]) || "SANDBOX";
327 return (bindings[binding] ?? bindings.SANDBOX) as DurableObjectNamespace;
328}
329
330/**
331 * What a security update's sandbox may reach on top of its project's
332 * list: the package registries its lockfile tools resolve versions from
333 * (npm, corepack's pnpm and yarn, crates.io, Go's module proxy and
334 * checksum database, PyPI), and nothing else. Mode `bump` in
335 * crates/runner bump.rs.
336 */
337export const BUMP_HOSTS: readonly string[] = [
338 "registry.npmjs.org",
339 "registry.yarnpkg.com",
340 "repo.yarnpkg.com",
341 "crates.io",
342 "index.crates.io",
343 "static.crates.io",
344 "proxy.golang.org",
345 "sum.golang.org",
346 "pypi.org",
347 "files.pythonhosted.org",
348];
349
350/** What a build sandbox may reach on top of its project's list. */
351export function buildHosts(kind: "actions" | "deploy" | "bump"): string[] {
352 if (kind === "bump") return [...BUMP_HOSTS];
353 return kind === "deploy" ? [...BUILD_HOSTS, "api.cloudflare.com"] : [...BUILD_HOSTS];
354}
355
356/** The lower of two caps, where null, zero or less means no cap. */
357function lower(a: number | null | undefined, b: number | null | undefined): number | null {
358 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
359 return caps.length ? Math.min(...caps) : null;
360}
361
362/** A plan's caps on one run, from its entitlements; null where it sets none. */
363export type PlanLimits = { minutes?: number | null; budgetUsd?: number | null };
364
365/**
366 * A run's guardrails under its workspace's plan: the time and cost caps are
367 * each the lower of the two.
368 */
369export function withPlanLimits(guard: RunGuard, limits: PlanLimits | null | undefined): RunGuard {
370 if (!limits) return guard;
371 return {
372 policy: { ...guard.policy, budgetUsd: lower(guard.policy.budgetUsd, limits.budgetUsd) },
373 minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes,
374 };
375}