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

232 lines8,578 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 } 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/** What a build sandbox may reach on top of its project's list. */
209export function buildHosts(kind: "actions" | "deploy"): string[] {
210 return kind === "deploy" ? [...BUILD_HOSTS, "api.cloudflare.com"] : [...BUILD_HOSTS];
211}
212
213/** The lower of two caps, where null, zero or less means no cap. */
214function lower(a: number | null | undefined, b: number | null | undefined): number | null {
215 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
216 return caps.length ? Math.min(...caps) : null;
217}
218
219/** A plan's caps on one run, from its entitlements; null where it sets none. */
220export type PlanLimits = { minutes?: number | null; budgetUsd?: number | null };
221
222/**
223 * A run's guardrails under its workspace's plan: the time and cost caps are
224 * each the lower of the two.
225 */
226export function withPlanLimits(guard: RunGuard, limits: PlanLimits | null | undefined): RunGuard {
227 if (!limits) return guard;
228 return {
229 policy: { ...guard.policy, budgetUsd: lower(guard.policy.budgetUsd, limits.budgetUsd) },
230 minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes,
231 };
232}