g1t/services/runner/src/egress.ts
| 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 | |
| 12 | import type { Guardrails, WorkflowDomain } from "@g1t/contracts"; |
| 13 | |
| 14 | /** A host name as it is compared: lower case, no port, no trailing dot. */ |
| 15 | export 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 | */ |
| 29 | export 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. */ |
| 43 | function 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. */ |
| 53 | export 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 | */ |
| 64 | export 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. */ |
| 78 | export 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. */ |
| 88 | export 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 | */ |
| 98 | export const EGRESS_CA = "/etc/cloudflare/certs/cloudflare-containers-ca.crt"; |
| 99 | const SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; |
| 100 | export 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. */ |
| 112 | export 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. */ |
| 119 | const MAX_BLOCKED_REPORTED = 25; |
| 120 | |
| 121 | /** What the harness is told about the run's guardrails. */ |
| 122 | export 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 | */ |
| 140 | export 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. */ |
| 147 | export 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 | */ |
| 156 | export const ABUSE_HOST = "sandbox.g1t.internal"; |
| 157 | /** What a sandbox that stopped itself for mining exits with. */ |
| 158 | export const ABUSE_EXIT_CODE = 86; |
| 159 | /** What such a run, check, job or build says. */ |
| 160 | export 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 | */ |
| 169 | export 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 | */ |
| 213 | export 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`. */ |
| 216 | function 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 | */ |
| 226 | export 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 | */ |
| 243 | export 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 | */ |
| 257 | export 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. */ |
| 264 | export 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 | */ |
| 277 | export 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. */ |
| 291 | export 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. */ |
| 297 | function 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. */ |
| 303 | export 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 | */ |
| 309 | export 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 | } |