Skip to content
361 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/** What a refused request gets back, so the agent knows why. */
78export function refusal(host: string): Response {
79 const name = normalizeHost(host);
80 return new Response(
81 `g1t guardrails: ${name} is not on this project's allowed domains, so this sandbox cannot reach it. ` +
82 "A member can allow it under the project's Settings, Guardrails.\n",
83 { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-guardrails": "blocked" } },
84 );
85}
86
87/** A refused host as a step of the run. */
88export function blockedStep(host: string): string {
89 return `Blocked: ${normalizeHost(host)} (not an allowed domain)`;
90}
91
92/**
93 * The variables a guarded sandbox needs so that its programs trust the
94 * certificate HTTPS is re-signed with as it passes through the Worker.
95 * The runner adds that certificate to the system's store when it starts;
96 * these point the tools that keep their own list at it.
97 */
98export const EGRESS_CA = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
99const SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
100export const EGRESS_ENV: Record<string, string> = {
101 G1T_EGRESS_CA: EGRESS_CA,
102 NODE_EXTRA_CA_CERTS: EGRESS_CA,
103 SSL_CERT_FILE: SYSTEM_BUNDLE,
104 REQUESTS_CA_BUNDLE: SYSTEM_BUNDLE,
105 PIP_CERT: SYSTEM_BUNDLE,
106 CURL_CA_BUNDLE: SYSTEM_BUNDLE,
107 CARGO_HTTP_CAINFO: SYSTEM_BUNDLE,
108 GIT_SSL_CAINFO: SYSTEM_BUNDLE,
109};
110
111/**
112 * Tools that report usage home by default, told not to: in a guarded
113 * sandbox the report would only be refused, and a refused host is noted on
114 * the run. `DO_NOT_TRACK` is the convention many tools follow; the rest are
115 * the tools' own switches.
116 */
117export const QUIET_ENV: Record<string, string> = {
118 DO_NOT_TRACK: "1",
119 WRANGLER_SEND_METRICS: "false",
120 NEXT_TELEMETRY_DISABLED: "1",
121 ASTRO_TELEMETRY_DISABLED: "1",
122 NUXT_TELEMETRY_DISABLED: "1",
123 GATSBY_TELEMETRY_DISABLED: "1",
124 STORYBOOK_DISABLE_TELEMETRY: "1",
125 TURBO_TELEMETRY_DISABLED: "1",
126 DOTNET_CLI_TELEMETRY_OPTOUT: "1",
127 HOMEBREW_NO_ANALYTICS: "1",
128 CHECKPOINT_DISABLE: "1",
129};
130
131/** What a sandbox's guardrails come to for one run. */
132export type RunGuard = {
133 policy: Guardrails;
134 /** The time cap of this kind of run, in minutes. */
135 minutes: number;
136};
137
138/** The most refused hosts reported as steps of one run. */
139const MAX_BLOCKED_REPORTED = 25;
140
141/** What the harness is told about the run's guardrails. */
142export function harnessEnv(guard: RunGuard, sandboxEnv: Record<string, string>, restricted: boolean): Record<string, string> {
143 const vars: Record<string, string> = {
144 GUARDRAILS: JSON.stringify({
145 rules: guard.policy.rules,
146 deny: guard.policy.deny,
147 budgetUsd: guard.policy.budgetUsd,
148 minutes: guard.minutes,
149 restrictNetwork: restricted,
150 defaultBranch: sandboxEnv.UPSTREAM_BRANCH ?? null,
151 }),
152 };
153 return restricted ? { ...vars, ...EGRESS_ENV, ...QUIET_ENV } : vars;
154}
155
156/**
157 * Records a refused host as a step of the run, once per host. `seen` is
158 * the hosts reported so far, kept by the sandbox.
159 */
160export function newlyBlocked(seen: readonly string[], host: string): { step: string; seen: string[] } | null {
161 const step = blockedStep(host);
162 if (seen.includes(step) || seen.length >= MAX_BLOCKED_REPORTED) return null;
163 return { step, seen: [...seen, step] };
164}
165
166/** What a run stopped by its time cap is told. */
167export function timeCapMessage(minutes: number): string {
168 return `Stopped: it reached its time cap of ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`;
169}
170
171/**
172 * Where a sandbox tells the runner it stopped itself for mining
173 * (crates/runner abuse.rs). The runner's Durable Object answers it; it
174 * never leaves the machine.
175 */
176export const ABUSE_HOST = "sandbox.g1t.internal";
177/** What a sandbox that stopped itself for mining exits with. */
178export const ABUSE_EXIT_CODE = 86;
179/** What such a run, check, job or build says. */
180export const ABUSE_MESSAGE = "Stopped: unusual CPU use; contact support if this was a real job.";
181
182/**
183 * What workflow jobs and deploy builds may reach on top of the project's
184 * allowed domains and registries: where `actions/checkout`, `uses:`
185 * actions and the `setup-*` actions fetch from, the package registries
186 * builds install from, the public container registries a job's Docker
187 * Engine pulls images from (crates/runner docker/), and (for deploys)
188 * Cloudflare's API, which a build uploads its app to. No mining pool is on
189 * it, and no general host.
190 */
191export const BUILD_HOSTS: readonly string[] = [
192 // Actions by `uses:`, and releases the setup actions download.
193 "github.com",
194 "api.github.com",
195 "codeload.github.com",
196 "objects.githubusercontent.com",
197 "raw.githubusercontent.com",
198 "release-assets.githubusercontent.com",
199 "ghcr.io",
200 "pkg-containers.githubusercontent.com",
201 // Toolchains.
202 "nodejs.org",
203 "go.dev",
204 "dl.google.com",
205 "static.rust-lang.org",
206 "sh.rustup.rs",
207 // Package registries, whatever the project turned on for agents.
208 "registry.npmjs.org",
209 "registry.yarnpkg.com",
210 "repo.yarnpkg.com",
211 "pypi.org",
212 "files.pythonhosted.org",
213 "crates.io",
214 "index.crates.io",
215 "static.crates.io",
216 "proxy.golang.org",
217 "sum.golang.org",
218 "rubygems.org",
219 "index.rubygems.org",
220 "repo.packagist.org",
221 "api.nuget.org",
222 "repo.maven.apache.org",
223 "repo1.maven.org",
224 "services.gradle.org",
225 "plugins.gradle.org",
226 "deb.debian.org",
227 "security.debian.org",
228 // Container images, for a job's own Docker Engine: Docker Hub (and the
229 // CDN its layers come from), Google's public mirror of it, which the
230 // Engine asks first, and Quay. GitHub's registry is above.
231 "registry-1.docker.io",
232 "auth.docker.io",
233 "index.docker.io",
234 "production.cloudflare.docker.com",
235 "production.cloudfront.docker.com",
236 "mirror.gcr.io",
237 "quay.io",
238 "cdn01.quay.io",
239 "cdn02.quay.io",
240 "cdn03.quay.io",
241 // Docker's own packages (apt), for images that install the CLI.
242 "download.docker.com",
243];
244
245/**
246 * Whether a workflow job gets a Docker Engine of its own, from the
247 * Worker's `DOCKER` setting: on unless it says `off`. Its containers share
248 * the job's network, so the hosts above, and its guardrails, are theirs too.
249 */
250export function dockerFor(setting: string | undefined): "on" | "off" {
251 return setting?.trim().toLowerCase() === "off" ? "off" : "on";
252}
253
254/**
255 * A workflow job, for the guardrails' workflow-only domains: its workflow
256 * file, the environment it names, and whether its run is trusted (not a
257 * pull request from a fork). Only a trusted run's jobs get them.
258 */
259export type WorkflowJob = { workflow: string | null; environment: string | null; trusted: boolean };
260
261/** The file name of a workflow's path: `.g1t/workflows/deploy.yml` is `deploy.yml`. */
262function workflowFile(path: string): string {
263 return path.trim().split(/[\\/]/).pop() ?? "";
264}
265
266/**
267 * The workflow-only hosts a job of `workflow` (its path) in `environment`
268 * may reach, as `Guardrails::workflow_hosts` decides it: names compare
269 * without regard to case, and an empty list of workflows or environments
270 * is any.
271 */
272export function workflowHosts(entries: readonly WorkflowDomain[] | undefined, workflow: string, environment: string | null | undefined): string[] {
273 const file = workflowFile(workflow).toLowerCase();
274 const env = environment?.trim().toLowerCase() ?? null;
275 const hosts: string[] = [];
276 for (const entry of entries ?? []) {
277 const workflowOk = !entry.workflows.length || entry.workflows.some((w) => workflowFile(w).toLowerCase() === file);
278 const envOk = !entry.environments.length || (env != null && entry.environments.some((e) => e.toLowerCase() === env));
279 if (workflowOk && envOk && !hosts.includes(entry.domain)) hosts.push(entry.domain);
280 }
281 return hosts;
282}
283
284/**
285 * The workflow-only domains a sandbox gets: for a workflow job of a
286 * trusted run, those whose workflows and environments include its own.
287 * Never for a deploy build, an agent or a pull request from a fork.
288 */
289export function jobHosts(
290 policy: { workflowDomains?: readonly WorkflowDomain[] },
291 kind: "actions" | "deploy" | "bump",
292 job: WorkflowJob | null | undefined,
293): string[] {
294 if (kind !== "actions" || !job?.trusted || !job.workflow) return [];
295 return workflowHosts(policy.workflowDomains, job.workflow, job.environment);
296}
297
298/**
299 * The Durable Object namespace behind each sandbox class, by its binding:
300 * a sandbox's outbound handlers are told its class, and report back to its
301 * own object. Larger machines are classes of their own (wrangler.jsonc).
302 */
303export const SANDBOX_BINDINGS: Record<string, string> = {
304 AttemptSandbox: "SANDBOX",
305 Sandbox2Core: "SANDBOX_2CORE",
306 Sandbox4Core: "SANDBOX_4CORE",
307};
308
309/** The namespace of a sandbox of `className`; the standard one when unknown. */
310export function sandboxNamespace(env: object, className: string | undefined): DurableObjectNamespace {
311 const bindings = env as Record<string, DurableObjectNamespace | undefined>;
312 const binding = (className && SANDBOX_BINDINGS[className]) || "SANDBOX";
313 return (bindings[binding] ?? bindings.SANDBOX) as DurableObjectNamespace;
314}
315
316/**
317 * What a security update's sandbox may reach on top of its project's
318 * list: the package registries its lockfile tools resolve versions from
319 * (npm, corepack's pnpm and yarn, crates.io, Go's module proxy and
320 * checksum database, PyPI), and nothing else. Mode `bump` in
321 * crates/runner bump.rs.
322 */
323export const BUMP_HOSTS: readonly string[] = [
324 "registry.npmjs.org",
325 "registry.yarnpkg.com",
326 "repo.yarnpkg.com",
327 "crates.io",
328 "index.crates.io",
329 "static.crates.io",
330 "proxy.golang.org",
331 "sum.golang.org",
332 "pypi.org",
333 "files.pythonhosted.org",
334];
335
336/** What a build sandbox may reach on top of its project's list. */
337export function buildHosts(kind: "actions" | "deploy" | "bump"): string[] {
338 if (kind === "bump") return [...BUMP_HOSTS];
339 return kind === "deploy" ? [...BUILD_HOSTS, "api.cloudflare.com"] : [...BUILD_HOSTS];
340}
341
342/** The lower of two caps, where null, zero or less means no cap. */
343function lower(a: number | null | undefined, b: number | null | undefined): number | null {
344 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
345 return caps.length ? Math.min(...caps) : null;
346}
347
348/** A plan's caps on one run, from its entitlements; null where it sets none. */
349export type PlanLimits = { minutes?: number | null; budgetUsd?: number | null };
350
351/**
352 * A run's guardrails under its workspace's plan: the time and cost caps are
353 * each the lower of the two.
354 */
355export function withPlanLimits(guard: RunGuard, limits: PlanLimits | null | undefined): RunGuard {
356 if (!limits) return guard;
357 return {
358 policy: { ...guard.policy, budgetUsd: lower(guard.policy.budgetUsd, limits.budgetUsd) },
359 minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes,
360 };
361}