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.
| Topics sit in About on the files pages, under the description | 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 | */ | |
| 13 | import type { OutboundHandlerContext } from "@cloudflare/containers"; | |
| 14 | ||
| 15 | import { type RepoPath, type RunKind, type ServiceBinding, guardrailsClient } from "@g1t/contracts"; | |
| 16 | ||
| 17 | import { | |
| 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"; | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 29 | import { type BrokeredParams, brokered } from "./broker-egress.ts"; |
| 30 | import { forward } from "./forward.ts"; | |
| Topics sit in About on the files pages, under the description | 31 | |
| 32 | export { | |
| 33 | ABUSE_EXIT_CODE, | |
| 34 | ABUSE_HOST, | |
| 35 | ABUSE_MESSAGE, | |
| 36 | SANDBOX_BINDINGS, | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 37 | dockerFor, |
| Topics sit in About on the files pages, under the description | 38 | harnessEnv, |
| 39 | jobHosts, | |
| 40 | newlyBlocked, | |
| 41 | sandboxNamespace, | |
| 42 | timeCapMessage, | |
| 43 | withPlanLimits, | |
| 44 | } from "./egress"; | |
| 45 | export type { PlanLimits, RunGuard, WorkflowJob } from "./egress"; | |
| 46 | ||
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 47 | /** |
| 48 | * What the outbound handler is given: the hosts this sandbox may reach, | |
| 49 | * and what brokered secrets need (broker-egress.ts). | |
| 50 | */ | |
| 51 | export type EgressParams = { hosts: string[] } & BrokeredParams; | |
| Topics sit in About on the files pages, under the description | 52 | |
| 53 | ||
| 54 | /** The stop by the sandbox's alarm comes this long after the harness's own. */ | |
| 55 | export const ALARM_GRACE_SECONDS = 3 * 60; | |
| 56 | ||
| 57 | /** | |
| 58 | * The guardrails of a run in `repo`. Throws when they cannot be read: a | |
| 59 | * sandbox is not started without them. | |
| 60 | */ | |
| 61 | export async function guardFor(work: ServiceBinding, repo: RepoPath, kind: RunKind): Promise<RunGuard> { | |
| 62 | const found = await guardrailsClient(work).runGuardrails(repo); | |
| 63 | if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`); | |
| 64 | const policy = found.value; | |
| 65 | return { policy, minutes: policy.minutes[kind] ?? 60 }; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * The guardrails of a workflow job, deploy build or security update in | |
| 70 | * `repo`: its project's network list, plus what builds need (`buildHosts`), and the | |
| 71 | * time cap it was given. `repo` is the project, not a pull request's | |
| 72 | * working copy; `repoId`, when known, finds it however it has moved. | |
| 73 | * A workflow job of a trusted run also gets the workflow-only domains | |
| 74 | * that name its workflow and environment (`jobHosts`); nothing else | |
| 75 | * ever does. Throws when they cannot be read: no build starts without them. | |
| 76 | */ | |
| 77 | export async function buildGuardFor( | |
| 78 | work: ServiceBinding, | |
| 79 | repo: RepoPath, | |
| 80 | kind: "actions" | "deploy" | "bump", | |
| 81 | minutes: number, | |
| 82 | repoId?: string | null, | |
| 83 | job?: WorkflowJob | null, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 84 | extra: readonly string[] = [], |
| Topics sit in About on the files pages, under the description | 85 | ): Promise<RunGuard> { |
| 86 | const found = await guardrailsClient(work).runGuardrails(repo, repoId); | |
| 87 | if (!found.ok) throw new Error(`g1t could not read this project's guardrails: ${found.error.message}`); | |
| 88 | const policy = found.value; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 89 | const hosts = [...policy.hosts, ...buildHosts(kind), ...jobHosts(policy, kind, job), ...extra]; |
| Topics sit in About on the files pages, under the description | 90 | return { policy: { ...policy, hosts: [...new Set(hosts)] }, minutes }; |
| 91 | } | |
| 92 | ||
| 93 | ||
| 94 | /** Every host the sandbox may reach, for the outbound handler. */ | |
| 95 | export function egressHosts(guard: RunGuard, env: ModelHosts, sandboxEnv: Record<string, string>): string[] { | |
| 96 | return sandboxHosts(guard.policy.hosts, env, sandboxEnv); | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * The outbound handler of a guarded sandbox: every HTTP and HTTPS request | |
| 101 | * it makes. An allowed host is fetched as asked; any other is refused, and | |
| 102 | * the sandbox told so it can say so on the run. | |
| 103 | */ | |
| 104 | export async function egress( | |
| 105 | request: Request, | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 106 | env: { SANDBOX: DurableObjectNamespace; ACTIONS?: ServiceBinding }, |
| Topics sit in About on the files pages, under the description | 107 | ctx: OutboundHandlerContext<EgressParams>, |
| 108 | ): Promise<Response> { | |
| 109 | const host = new URL(request.url).host; | |
| 110 | // A sandbox reporting that it stopped itself for mining. | |
| 111 | if (host === ABUSE_HOST) return abuse(request, env, ctx); | |
| Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset. | 112 | if (ctx.params?.open || allows(ctx.params?.hosts ?? [], host)) { |
| 113 | // A workflow job's brokered secrets are filled in first, if it has any. | |
| 114 | return (await brokered(request, env, ctx)) ?? forward(request); | |
| 115 | } | |
| Topics sit in About on the files pages, under the description | 116 | try { |
| 117 | const namespace = sandboxNamespace(env, ctx.className); | |
| 118 | const sandbox = namespace.get(namespace.idFromString(ctx.containerId)) as unknown as { | |
| 119 | noteBlocked(host: string): Promise<void>; | |
| 120 | }; | |
| 121 | await sandbox.noteBlocked(host); | |
| 122 | } catch (error) { | |
| 123 | console.log("blocked host not reported", host, String(error)); | |
| 124 | } | |
| 125 | return refusal(host); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * A sandbox's report that it stopped itself for mining (crates/runner | |
| 130 | * abuse.rs), handed to its Durable Object. Reached through `egress` for a | |
| 131 | * guarded sandbox and as the handler for `ABUSE_HOST` for any other. | |
| 132 | */ | |
| 133 | export async function abuse( | |
| 134 | request: Request, | |
| 135 | env: { SANDBOX: DurableObjectNamespace }, | |
| 136 | ctx: OutboundHandlerContext<unknown>, | |
| 137 | ): Promise<Response> { | |
| 138 | let verdict: unknown = null; | |
| 139 | try { | |
| 140 | verdict = ((await request.json()) as { verdict?: unknown }).verdict ?? null; | |
| 141 | } catch { | |
| 142 | // A report without its metrics still stops the run. | |
| 143 | } | |
| 144 | try { | |
| 145 | const namespace = sandboxNamespace(env, ctx.className); | |
| 146 | const sandbox = namespace.get(namespace.idFromString(ctx.containerId)) as unknown as { | |
| 147 | flagAbuse(verdict: unknown): Promise<void>; | |
| 148 | }; | |
| 149 | await sandbox.flagAbuse(verdict); | |
| 150 | } catch (error) { | |
| 151 | console.log("abuse report not handled", String(error)); | |
| 152 | } | |
| 153 | return new Response("noted\n"); | |
| 154 | } | |
| 155 | ||
| 156 | /** Adds a step to a run, with its token. Never fails the caller. */ | |
| 157 | export async function reportRun( | |
| 158 | work: ServiceBinding, | |
| 159 | tracked: { runId: string; token: string }, | |
| 160 | report: { steps?: string[]; halt?: "budget" | "time" | "abuse"; error?: string }, | |
| 161 | ): Promise<void> { | |
| 162 | await work | |
| 163 | .fetch("https://service/rpc/report_run", { | |
| 164 | method: "POST", | |
| 165 | headers: { "content-type": "application/json" }, | |
| 166 | body: JSON.stringify({ runId: tracked.runId, token: tracked.token, ...report }), | |
| 167 | }) | |
| 168 | .catch((error: unknown) => console.log("run report failed", tracked.runId, String(error))); | |
| 169 | } | |
| 170 |
This file's history is long; its oldest lines are credited to the oldest commit read.