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.
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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"; | |
| A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in. | 29 | import { type BrokeredParams, brokered } from "./broker-egress.ts"; |
| A sandbox's request to an allowed host that fails upstream no longer resets the sandbox's connection. The runner Worker sent each one on with a bare fetch, so a reset or a failure upstream (as under npm ci's many tarball fetches) was thrown out of the outbound handler, and npm saw ECONNRESET and gave up. Forwarding is now one function (forward.ts) that egress() and the brokered-secret helper both use: a GET or HEAD with no body is tried twice more, after 250 ms and then a second, and what still fails comes back as a 502 whose plain-text body names the host and the error, with x-g1t-egress: upstream-failed, which npm and most tools retry. A request with a body is never sent twice. Each failure is one line of the Worker's log, with the host, the method and the error. | 30 | import { forward } from "./forward.ts"; |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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, |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 38 | harnessEnv, |
| 39 | jobHosts, | |
| 40 | newlyBlocked, | |
| 41 | sandboxNamespace, | |
| 42 | timeCapMessage, | |
| 43 | withPlanLimits, | |
| 44 | } from "./egress"; | |
| 45 | export type { PlanLimits, RunGuard, WorkflowJob } from "./egress"; | |
| 46 | ||
| A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in. | 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; | |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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[] = [], |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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]; |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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, | |
| A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in. | 106 | env: { SANDBOX: DurableObjectNamespace; ACTIONS?: ServiceBinding }, |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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); | |
| A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in. | 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. | |
| A sandbox's request to an allowed host that fails upstream no longer resets the sandbox's connection. The runner Worker sent each one on with a bare fetch, so a reset or a failure upstream (as under npm ci's many tarball fetches) was thrown out of the outbound handler, and npm saw ECONNRESET and gave up. Forwarding is now one function (forward.ts) that egress() and the brokered-secret helper both use: a GET or HEAD with no body is tried twice more, after 250 ms and then a second, and what still fails comes back as a 502 whose plain-text body names the host and the error, with x-g1t-egress: upstream-failed, which npm and most tools retry. A request with a body is never sent twice. Each failure is one line of the Worker's log, with the host, the method and the error. | 114 | return (await brokered(request, env, ctx)) ?? forward(request); |
| A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in. | 115 | } |
| g1t push: images with layers of any size, uploaded in chunks under the edge's limit | 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.