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

149 lines5,641 bytesCodeBlame

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 API1/**
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}