flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/runner/src/egress.ts

315 lines12,005 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, and (for deploys) Cloudflare's API, which a build
167 * uploads its app to. No mining pool is on it, and no general host.
168 */
169export const BUILD_HOSTS: readonly string[] = [
170 // Actions by `uses:`, and releases the setup actions download.
171 "github.com",
172 "api.github.com",
173 "codeload.github.com",
174 "objects.githubusercontent.com",
175 "raw.githubusercontent.com",
176 "release-assets.githubusercontent.com",
177 "ghcr.io",
178 "pkg-containers.githubusercontent.com",
179 // Toolchains.
180 "nodejs.org",
181 "go.dev",
182 "dl.google.com",
183 "static.rust-lang.org",
184 "sh.rustup.rs",
185 // Package registries, whatever the project turned on for agents.
186 "registry.npmjs.org",
187 "registry.yarnpkg.com",
188 "repo.yarnpkg.com",
189 "pypi.org",
190 "files.pythonhosted.org",
191 "crates.io",
192 "index.crates.io",
193 "static.crates.io",
194 "proxy.golang.org",
195 "sum.golang.org",
196 "rubygems.org",
197 "index.rubygems.org",
198 "repo.packagist.org",
199 "api.nuget.org",
200 "repo.maven.apache.org",
201 "repo1.maven.org",
202 "services.gradle.org",
203 "plugins.gradle.org",
204 "deb.debian.org",
205 "security.debian.org",
206];
207
208/**
209 * A workflow job, for the guardrails' workflow-only domains: its workflow
210 * file, the environment it names, and whether its run is trusted (not a
211 * pull request from a fork). Only a trusted run's jobs get them.
212 */
213export type WorkflowJob = { workflow: string | null; environment: string | null; trusted: boolean };
214
215/** The file name of a workflow's path: `.g1t/workflows/deploy.yml` is `deploy.yml`. */
216function workflowFile(path: string): string {
217 return path.trim().split(/[\\/]/).pop() ?? "";
218}
219
220/**
221 * The workflow-only hosts a job of `workflow` (its path) in `environment`
222 * may reach, as `Guardrails::workflow_hosts` decides it: names compare
223 * without regard to case, and an empty list of workflows or environments
224 * is any.
225 */
226export function workflowHosts(entries: readonly WorkflowDomain[] | undefined, workflow: string, environment: string | null | undefined): string[] {
227 const file = workflowFile(workflow).toLowerCase();
228 const env = environment?.trim().toLowerCase() ?? null;
229 const hosts: string[] = [];
230 for (const entry of entries ?? []) {
231 const workflowOk = !entry.workflows.length || entry.workflows.some((w) => workflowFile(w).toLowerCase() === file);
232 const envOk = !entry.environments.length || (env != null && entry.environments.some((e) => e.toLowerCase() === env));
233 if (workflowOk && envOk && !hosts.includes(entry.domain)) hosts.push(entry.domain);
234 }
235 return hosts;
236}
237
238/**
239 * The workflow-only domains a sandbox gets: for a workflow job of a
240 * trusted run, those whose workflows and environments include its own.
241 * Never for a deploy build, an agent or a pull request from a fork.
242 */
243export function jobHosts(
244 policy: { workflowDomains?: readonly WorkflowDomain[] },
245 kind: "actions" | "deploy" | "bump",
246 job: WorkflowJob | null | undefined,
247): string[] {
248 if (kind !== "actions" || !job?.trusted || !job.workflow) return [];
249 return workflowHosts(policy.workflowDomains, job.workflow, job.environment);
250}
251
252/**
253 * The Durable Object namespace behind each sandbox class, by its binding:
254 * a sandbox's outbound handlers are told its class, and report back to its
255 * own object. Larger machines are classes of their own (wrangler.jsonc).
256 */
257export const SANDBOX_BINDINGS: Record<string, string> = {
258 AttemptSandbox: "SANDBOX",
259 Sandbox2Core: "SANDBOX_2CORE",
260 Sandbox4Core: "SANDBOX_4CORE",
261};
262
263/** The namespace of a sandbox of `className`; the standard one when unknown. */
264export function sandboxNamespace(env: object, className: string | undefined): DurableObjectNamespace {
265 const bindings = env as Record<string, DurableObjectNamespace | undefined>;
266 const binding = (className && SANDBOX_BINDINGS[className]) || "SANDBOX";
267 return (bindings[binding] ?? bindings.SANDBOX) as DurableObjectNamespace;
268}
269
270/**
271 * What a security update's sandbox may reach on top of its project's
272 * list: the package registries its lockfile tools resolve versions from
273 * (npm, corepack's pnpm and yarn, crates.io, Go's module proxy and
274 * checksum database, PyPI), and nothing else. Mode `bump` in
275 * crates/runner bump.rs.
276 */
277export const BUMP_HOSTS: readonly string[] = [
278 "registry.npmjs.org",
279 "registry.yarnpkg.com",
280 "repo.yarnpkg.com",
281 "crates.io",
282 "index.crates.io",
283 "static.crates.io",
284 "proxy.golang.org",
285 "sum.golang.org",
286 "pypi.org",
287 "files.pythonhosted.org",
288];
289
290/** What a build sandbox may reach on top of its project's list. */
291export function buildHosts(kind: "actions" | "deploy" | "bump"): string[] {
292 if (kind === "bump") return [...BUMP_HOSTS];
293 return kind === "deploy" ? [...BUILD_HOSTS, "api.cloudflare.com"] : [...BUILD_HOSTS];
294}
295
296/** The lower of two caps, where null, zero or less means no cap. */
297function lower(a: number | null | undefined, b: number | null | undefined): number | null {
298 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
299 return caps.length ? Math.min(...caps) : null;
300}
301
302/** A plan's caps on one run, from its entitlements; null where it sets none. */
303export type PlanLimits = { minutes?: number | null; budgetUsd?: number | null };
304
305/**
306 * A run's guardrails under its workspace's plan: the time and cost caps are
307 * each the lower of the two.
308 */
309export function withPlanLimits(guard: RunGuard, limits: PlanLimits | null | undefined): RunGuard {
310 if (!limits) return guard;
311 return {
312 policy: { ...guard.policy, budgetUsd: lower(guard.policy.budgetUsd, limits.budgetUsd) },
313 minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes,
314 };
315}