g1t/services/runner/src/egress.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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 } 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 | } |