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/guard.ts

128 lines5,203 bytesCodeBlame
1/**
2 * Guardrails, as the runner applies them to a sandbox: what it may reach,
3 * what its harness refuses, and how long and how much a run may take.
4 *
5 * - The network list is enforced here, outside the sandbox: a guarded
6 * sandbox starts with no internet, and every HTTP(S) request it makes
7 * comes to `egress` below, which forwards it or refuses it.
8 * - Command rules and the cost cap are handed to the harness inside the
9 * sandbox as `GUARDRAILS`, which applies them to the agent.
10 * - The time cap is enforced twice: by the harness, and by the sandbox's
11 * own alarm here, which stops the whole sandbox a little after.
12 */
13import type { OutboundHandlerContext } from "@cloudflare/containers";
14
15import { type RepoPath, type RunKind, type ServiceBinding, guardrailsClient } from "@g1t/contracts";
16
17import { ABUSE_HOST, type ModelHosts, type RunGuard, allows, buildHosts, refusal, sandboxHosts } from "./egress";
18
19export { ABUSE_EXIT_CODE, ABUSE_HOST, ABUSE_MESSAGE, harnessEnv, newlyBlocked, timeCapMessage, withPlanLimits } from "./egress";
20export type { PlanLimits, RunGuard } from "./egress";
21
22/** What the outbound handler is given: the hosts this sandbox may reach. */
23export type EgressParams = { hosts: string[] };
24
25/** The stop by the sandbox's alarm comes this long after the harness's own. */
26export const ALARM_GRACE_SECONDS = 3 * 60;
27
28/**
29 * The guardrails of a run in `repo`. Throws when they cannot be read: a
30 * sandbox is not started without them.
31 */
32export async function guardFor(work: ServiceBinding, repo: RepoPath, kind: RunKind): Promise<RunGuard> {
33 const found = await guardrailsClient(work).runGuardrails(repo);
34 if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`);
35 const policy = found.value;
36 return { policy, minutes: policy.minutes[kind] ?? 60 };
37}
38
39/**
40 * The guardrails of a workflow job or deploy build in `repo`: its
41 * project's network list, plus what builds need (`buildHosts`), and the
42 * time cap it was given. Throws when they cannot be read: no build starts
43 * without them.
44 */
45export async function buildGuardFor(
46 work: ServiceBinding,
47 repo: RepoPath,
48 kind: "actions" | "deploy",
49 minutes: number,
50): Promise<RunGuard> {
51 const found = await guardrailsClient(work).runGuardrails(repo);
52 if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`);
53 const policy = found.value;
54 return { policy: { ...policy, hosts: [...new Set([...policy.hosts, ...buildHosts(kind)])] }, minutes };
55}
56
57/** Every host the sandbox may reach, for the outbound handler. */
58export function egressHosts(guard: RunGuard, env: ModelHosts, sandboxEnv: Record<string, string>): string[] {
59 return sandboxHosts(guard.policy.hosts, env, sandboxEnv);
60}
61
62/**
63 * The outbound handler of a guarded sandbox: every HTTP and HTTPS request
64 * it makes. An allowed host is fetched as asked; any other is refused, and
65 * the sandbox told so it can say so on the run.
66 */
67export async function egress(
68 request: Request,
69 env: { SANDBOX: DurableObjectNamespace },
70 ctx: OutboundHandlerContext<EgressParams>,
71): Promise<Response> {
72 const host = new URL(request.url).host;
73 // A sandbox reporting that it stopped itself for mining.
74 if (host === ABUSE_HOST) return abuse(request, env, ctx);
75 if (allows(ctx.params?.hosts ?? [], host)) return fetch(request);
76 try {
77 const sandbox = env.SANDBOX.get(env.SANDBOX.idFromString(ctx.containerId)) as unknown as {
78 noteBlocked(host: string): Promise<void>;
79 };
80 await sandbox.noteBlocked(host);
81 } catch (error) {
82 console.log("blocked host not reported", host, String(error));
83 }
84 return refusal(host);
85}
86
87/**
88 * A sandbox's report that it stopped itself for mining (crates/runner
89 * abuse.rs), handed to its Durable Object. Reached through `egress` for a
90 * guarded sandbox and as the handler for `ABUSE_HOST` for any other.
91 */
92export async function abuse(
93 request: Request,
94 env: { SANDBOX: DurableObjectNamespace },
95 ctx: OutboundHandlerContext<unknown>,
96): Promise<Response> {
97 let verdict: unknown = null;
98 try {
99 verdict = ((await request.json()) as { verdict?: unknown }).verdict ?? null;
100 } catch {
101 // A report without its metrics still stops the run.
102 }
103 try {
104 const sandbox = env.SANDBOX.get(env.SANDBOX.idFromString(ctx.containerId)) as unknown as {
105 flagAbuse(verdict: unknown): Promise<void>;
106 };
107 await sandbox.flagAbuse(verdict);
108 } catch (error) {
109 console.log("abuse report not handled", String(error));
110 }
111 return new Response("noted\n");
112}
113
114/** Adds a step to a run, with its token. Never fails the caller. */
115export async function reportRun(
116 work: ServiceBinding,
117 tracked: { runId: string; token: string },
118 report: { steps?: string[]; halt?: "budget" | "time" | "abuse"; error?: string },
119): Promise<void> {
120 await work
121 .fetch("https://service/rpc/report_run", {
122 method: "POST",
123 headers: { "content-type": "application/json" },
124 body: JSON.stringify({ runId: tracked.runId, token: tracked.token, ...report }),
125 })
126 .catch((error: unknown) => console.log("run report failed", tracked.runId, String(error)));
127}
128