Skip to content

g1t/services/runner/src/egress.ts

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