g1t/services/runner/src/guard.ts

160 lines5,872 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 * 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents17import {
18 ABUSE_HOST,
19 type ModelHosts,
20 type RunGuard,
21 type WorkflowJob,
22 allows,
23 buildHosts,
24 jobHosts,
25 refusal,
26 sandboxHosts,
27 sandboxNamespace,
28} from "./egress";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API29
Fast pages, required checks on the branch, self-hosted runners, honest incidents30export {
31 ABUSE_EXIT_CODE,
32 ABUSE_HOST,
33 ABUSE_MESSAGE,
34 SANDBOX_BINDINGS,
35 harnessEnv,
36 jobHosts,
37 newlyBlocked,
38 sandboxNamespace,
39 timeCapMessage,
40 withPlanLimits,
41} from "./egress";
42export type { PlanLimits, RunGuard, WorkflowJob } from "./egress";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API43
44/** What the outbound handler is given: the hosts this sandbox may reach. */
45export type EgressParams = { hosts: string[] };
46
Fast pages, required checks on the branch, self-hosted runners, honest incidents47
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API48/** The stop by the sandbox's alarm comes this long after the harness's own. */
49export const ALARM_GRACE_SECONDS = 3 * 60;
50
51/**
52 * The guardrails of a run in `repo`. Throws when they cannot be read: a
53 * sandbox is not started without them.
54 */
55export async function guardFor(work: ServiceBinding, repo: RepoPath, kind: RunKind): Promise<RunGuard> {
56 const found = await guardrailsClient(work).runGuardrails(repo);
57 if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`);
58 const policy = found.value;
59 return { policy, minutes: policy.minutes[kind] ?? 60 };
60}
61
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look62/**
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily63 * The guardrails of a workflow job, deploy build or security update in
64 * `repo`: its project's network list, plus what builds need (`buildHosts`), and the
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas65 * time cap it was given. `repo` is the project, not a pull request's
66 * working copy; `repoId`, when known, finds it however it has moved.
Fast pages, required checks on the branch, self-hosted runners, honest incidents67 * A workflow job of a trusted run also gets the workflow-only domains
68 * that name its workflow and environment (`jobHosts`); nothing else
69 * ever does. Throws when they cannot be read: no build starts without them.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look70 */
71export async function buildGuardFor(
72 work: ServiceBinding,
73 repo: RepoPath,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily74 kind: "actions" | "deploy" | "bump",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 minutes: number,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas76 repoId?: string | null,
Fast pages, required checks on the branch, self-hosted runners, honest incidents77 job?: WorkflowJob | null,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78): Promise<RunGuard> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas79 const found = await guardrailsClient(work).runGuardrails(repo, repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look80 if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`);
81 const policy = found.value;
Fast pages, required checks on the branch, self-hosted runners, honest incidents82 const hosts = [...policy.hosts, ...buildHosts(kind), ...jobHosts(policy, kind, job)];
83 return { policy: { ...policy, hosts: [...new Set(hosts)] }, minutes };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84}
85
Fast pages, required checks on the branch, self-hosted runners, honest incidents86
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API87/** Every host the sandbox may reach, for the outbound handler. */
88export function egressHosts(guard: RunGuard, env: ModelHosts, sandboxEnv: Record<string, string>): string[] {
89 return sandboxHosts(guard.policy.hosts, env, sandboxEnv);
90}
91
92/**
93 * The outbound handler of a guarded sandbox: every HTTP and HTTPS request
94 * it makes. An allowed host is fetched as asked; any other is refused, and
95 * the sandbox told so it can say so on the run.
96 */
97export async function egress(
98 request: Request,
99 env: { SANDBOX: DurableObjectNamespace },
100 ctx: OutboundHandlerContext<EgressParams>,
101): Promise<Response> {
102 const host = new URL(request.url).host;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look103 // A sandbox reporting that it stopped itself for mining.
104 if (host === ABUSE_HOST) return abuse(request, env, ctx);
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API105 if (allows(ctx.params?.hosts ?? [], host)) return fetch(request);
106 try {
Fast pages, required checks on the branch, self-hosted runners, honest incidents107 const namespace = sandboxNamespace(env, ctx.className);
108 const sandbox = namespace.get(namespace.idFromString(ctx.containerId)) as unknown as {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API109 noteBlocked(host: string): Promise<void>;
110 };
111 await sandbox.noteBlocked(host);
112 } catch (error) {
113 console.log("blocked host not reported", host, String(error));
114 }
115 return refusal(host);
116}
117
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look118/**
119 * A sandbox's report that it stopped itself for mining (crates/runner
120 * abuse.rs), handed to its Durable Object. Reached through `egress` for a
121 * guarded sandbox and as the handler for `ABUSE_HOST` for any other.
122 */
123export async function abuse(
124 request: Request,
125 env: { SANDBOX: DurableObjectNamespace },
126 ctx: OutboundHandlerContext<unknown>,
127): Promise<Response> {
128 let verdict: unknown = null;
129 try {
130 verdict = ((await request.json()) as { verdict?: unknown }).verdict ?? null;
131 } catch {
132 // A report without its metrics still stops the run.
133 }
134 try {
Fast pages, required checks on the branch, self-hosted runners, honest incidents135 const namespace = sandboxNamespace(env, ctx.className);
136 const sandbox = namespace.get(namespace.idFromString(ctx.containerId)) as unknown as {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look137 flagAbuse(verdict: unknown): Promise<void>;
138 };
139 await sandbox.flagAbuse(verdict);
140 } catch (error) {
141 console.log("abuse report not handled", String(error));
142 }
143 return new Response("noted\n");
144}
145
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API146/** Adds a step to a run, with its token. Never fails the caller. */
147export async function reportRun(
148 work: ServiceBinding,
149 tracked: { runId: string; token: string },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look150 report: { steps?: string[]; halt?: "budget" | "time" | "abuse"; error?: string },
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API151): Promise<void> {
152 await work
153 .fetch("https://service/rpc/report_run", {
154 method: "POST",
155 headers: { "content-type": "application/json" },
156 body: JSON.stringify({ runId: tracked.runId, token: tracked.token, ...report }),
157 })
158 .catch((error: unknown) => console.log("run report failed", tracked.runId, String(error)));
159}
160