Merge branch 'main' into ask-integration
59 files+353−120711 files not counted0/59 viewed
| 19 | 19 | # makes no deployment. | |
| 20 | 20 | # | |
| 21 | 21 | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with | |
| 22 | − | # Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and | |
| 23 | − | # api.cloudflare.com among the project's workflow-only domains for | |
| 24 | − | # deploy.yml in production (Settings, Guardrails), and | |
| 25 | − | # registry.cloudflare.com there too, to find the runner's image and make | |
| 26 | − | # it. A job that must make that image (the `runner-image` group) builds the | |
| 27 | − | # runner's binary on a larger machine and makes the image in the registry | |
| 28 | − | # with no Docker: one layer with the binary on the base's layers, pulling | |
| 29 | − | # none of the base (scripts/deploy/registry.mjs). Optionally, the | |
| 30 | − | # secret STATUS_DEPLOY_TOKEN (the status Worker's secret of the same name) | |
| 31 | − | # and status.g1t.sh among the same workflow-only domains, so status.g1t.sh | |
| 32 | − | # hears each deploy start and finish. | |
| 22 | + | # Containers write) and the variable CLOUDFLARE_ACCOUNT_ID. A job that | |
| 23 | + | # must make the runner's image (the `runner-image` group) builds the | |
| 24 | + | # runner's binary on a larger machine and makes the image in | |
| 25 | + | # registry.cloudflare.com with no Docker: one layer with the binary on the | |
| 26 | + | # base's layers, pulling none of the base (scripts/deploy/registry.mjs). | |
| 27 | + | # Optionally, the secret STATUS_DEPLOY_TOKEN (the status Worker's secret of | |
| 28 | + | # the same name), so status.g1t.sh hears each deploy start and finish. | |
| 33 | 29 | name: Deploy | |
| 34 | 30 | ||
| 35 | 31 | on: |
| 19 | 19 | # | |
| 20 | 20 | # DESKTOP_WINDOWS_RUNNER=true a self-hosted runner on Windows, labels `self-hosted` and `windows`, | |
| 21 | 21 | # with Rust, Node and the Visual Studio build tools (the NSIS installer is fetched) | |
| 22 | − | # DESKTOP_WINDOWS_FROM_LINUX=true no Windows machine: cross-build on g1t's Linux machine with cargo-xwin and NSIS. | |
| 23 | − | # Needs aka.ms and download.visualstudio.microsoft.com among the project's | |
| 24 | − | # workflow-only domains for desktop-release.yml in production (Settings, Guardrails), | |
| 25 | − | # where cargo-xwin fetches the MSVC CRT from. | |
| 22 | + | # DESKTOP_WINDOWS_FROM_LINUX=true no Windows machine: cross-build on g1t's Linux machine with cargo-xwin and NSIS | |
| 23 | + | # (cargo-xwin fetches the MSVC CRT from aka.ms and download.visualstudio.microsoft.com). | |
| 26 | 24 | # DESKTOP_MACOS_RUNNER=true a self-hosted runner on a Mac, labels `self-hosted` and `macos`. | |
| 27 | 25 | name: Desktop release | |
| 28 | 26 |
| 12 | 12 | # the app's code from `npx expo start` on your computer. | |
| 13 | 13 | # | |
| 14 | 14 | # Needs the repository secret EXPO_TOKEN (an Expo access token with access | |
| 15 | − | # to @flagon-io/g1t), and Expo's hosts allowed for these workflows alone in | |
| 16 | − | # the project's Settings, Guardrails, Workflow-only domains: | |
| 17 | − | # | |
| 18 | − | # api.expo.dev | mobile-build.yml, mobile-release.yml | |
| 19 | − | # expo.dev | mobile-build.yml, mobile-release.yml | |
| 20 | − | # storage.googleapis.com | mobile-build.yml, mobile-release.yml | |
| 21 | − | # *.eascdn.net | mobile-build.yml, mobile-release.yml | |
| 22 | − | # | |
| 23 | − | # (the API, its artifact links, where EAS uploads the project's archive, | |
| 24 | − | # and where an artifact link sends the APK's download). | |
| 15 | + | # to @flagon-io/g1t). | |
| 25 | 16 | name: Mobile build | |
| 26 | 17 | ||
| 27 | 18 | on: | |
| ⋯ | |||
| 72 | 63 | run: | | |
| 73 | 64 | # A token pasted with a newline breaks the request's header. | |
| 74 | 65 | export EXPO_TOKEN="$(printf %s "$EXPO_TOKEN" | tr -d '[:space:]')" | |
| 75 | − | # Without a token Expo answers 200; a bare 403 is the sandbox's egress refusing it. | |
| 76 | − | code="$(curl -s -o /dev/null -w '%{http_code}' -X POST https://api.expo.dev/graphql \ | |
| 77 | − | -H 'Content-Type: application/json' -d '{"query":"{ meUserActor { id } }"}')" | |
| 78 | − | echo "api.expo.dev answers this runner with $code" | |
| 79 | − | if [ "$code" = 403 ]; then echo "::error::api.expo.dev is not allowed: add the workflow-only domains above in the project's Guardrails"; exit 1; fi | |
| 80 | 66 | npx --yes eas-cli@24.12.1 whoami | |
| 81 | 67 | npx --yes eas-cli@24.12.1 build --platform android --profile "$PROFILE" \ | |
| 82 | 68 | --non-interactive --wait --json > build.json | |
| 15 | 15 | # | |
| 16 | 16 | # Needs the repository secret EXPO_TOKEN (an Expo access token with access | |
| 17 | 17 | # to the g1t project), CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as the | |
| 18 | − | # deploy has them, and Expo's hosts as workflow-only domains (mobile-build.yml | |
| 19 | − | # lists them). | |
| 18 | + | # deploy has them. | |
| 20 | 19 | name: Mobile release | |
| 21 | 20 | ||
| 22 | 21 | on: |
| 145 | 145 | `apps/desktop/src-tauri/Cargo.toml`, merge, and push a | |
| 146 | 146 | `desktop-v<version>` tag. `.g1t/workflows/desktop-release.yml` builds | |
| 147 | 147 | the installers (Linux on g1t's machines; Windows and macOS on machines | |
| 148 | − | registered for them, or Windows cross-built on Linux once its download | |
| 149 | − | hosts are allowed, each switched on by a repository variable the | |
| 148 | + | registered for them, or Windows cross-built on Linux, each switched on | |
| 149 | + | by a repository variable the | |
| 150 | 150 | workflow's header names; a platform with no machine is left out of that | |
| 151 | 151 | release), signs the updater's files and publishes them, and installed | |
| 152 | 152 | apps update themselves. `scripts/desktop-release.mjs` does the |
| 194 | 194 | ||
| 195 | 195 | - **Why.** Jobs run in Cloudflare Containers, which let a container run | |
| 196 | 196 | Docker but not route a container network of its own out, or change its | |
| 197 | − | packet filter. Sharing the job's network is also what keeps the job's | |
| 198 | − | guardrails on every container. | |
| 197 | + | packet filter. | |
| 199 | 198 | - **Instead.** Give containers that would clash different ports. | |
| 200 | 199 | - **Status.** Not scheduled. | |
| 201 | 200 |
| 574 | 574 | ||
| 575 | 575 | ### What a job can reach | |
| 576 | 576 | ||
| 577 | − | A job's network is restricted by its project's | |
| 578 | − | [guardrails](/guides/guardrails/): it reaches the hosts its project's | |
| 579 | − | guardrails allow, g1t itself, and what builds need, and nothing else. | |
| 580 | − | What builds need is the package registries (npm, PyPI, crates.io, the Go | |
| 581 | − | proxy, RubyGems, Packagist, NuGet, Maven and Gradle, Debian's mirrors), | |
| 582 | − | GitHub, where `uses:` actions and the setup actions' downloads come from, | |
| 583 | − | the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 584 | − | `static.rust-lang.org`), and the public container registries (Docker Hub, | |
| 585 | − | GitHub's, Quay, and `mirror.gcr.io`, the mirror of Docker Hub that a job's | |
| 586 | − | Engine asks first). A request anywhere else gets `403`, and its status | |
| 587 | − | line names the host (`403 Forbidden by g1t guardrails: example.com is not | |
| 588 | − | an allowed domain`), so even a tool that reports only the status says which | |
| 589 | − | host to allow. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or | |
| 590 | − | higher adds it to the project's | |
| 591 | − | allowed domains under **Settings → Guardrails**; a project whose guardrails | |
| 592 | − | turn the network restriction off runs its jobs with an open network. | |
| 577 | + | A job reaches the internet, as on GitHub's machines: package registries, | |
| 578 | + | GitHub, toolchains' download sites, container registries, your cloud's | |
| 579 | + | APIs and any other host. There is no list of hosts to allow. A job that | |
| 580 | + | may be given [brokered secrets](#brokered-secrets) sends its HTTP and | |
| 581 | + | HTTPS through g1t's runner Worker, which fills them in and sends every | |
| 582 | + | request on; every other job's requests go straight out. See | |
| 583 | + | [network](/guides/guardrails/#network). | |
| 593 | 584 | ||
| 594 | − | A request to an allowed host that fails on the way is handled for the job. | |
| 595 | − | A `GET` or `HEAD` with no body is sent again, twice, after a short wait; one | |
| 596 | − | that still fails gets `502` with `x-g1t-egress: upstream-failed` and a body | |
| 597 | − | naming the host and the error, which npm, pip and most tools retry. A | |
| 598 | − | request with a body is never sent twice. An answer up to 8 MB that breaks | |
| 599 | − | off partway is fetched again whole, and a larger one, such as a 50 MB | |
| 600 | − | package tarball, is resumed from where it stopped with a `Range` request | |
| 601 | − | when its host takes ranges, so the job receives one unbroken answer. | |
| 602 | − | ||
| 603 | − | A host only workflows should reach, such as the API a deploy uploads to, | |
| 604 | − | goes in **Workflow-only domains** instead, limited to the workflows and | |
| 605 | − | environments that need it: `api.cloudflare.com | deploy.yml | production` | |
| 606 | − | lets only `deploy.yml`'s jobs with `environment: production` reach it. | |
| 607 | − | Runs of pull requests from forks never reach those hosts. See [workflow-only domains](/guides/guardrails/#workflow-only-domains). | |
| 585 | + | A request that passes the Worker and fails on the way is handled for the | |
| 586 | + | job. A `GET` or `HEAD` with no body is sent again, twice, after a short | |
| 587 | + | wait; one that still fails gets `502` with `x-g1t-egress: upstream-failed` | |
| 588 | + | and a body naming the host and the error, which npm, pip and most tools | |
| 589 | + | retry. A request with a body is never sent twice. An answer up to 8 MB | |
| 590 | + | that breaks off partway is fetched again whole, and a larger one, such as | |
| 591 | + | a 50 MB package tarball, is resumed from where it stopped with a `Range` | |
| 592 | + | request when its host takes ranges, so the job receives one unbroken | |
| 593 | + | answer. | |
| 608 | 594 | ||
| 609 | 595 | ### Brokered secrets | |
| 610 | 596 | ||
| ⋯ | |||
| 614 | 600 | leave its sandbox, in headers (Basic auth included) and the query string. | |
| 615 | 601 | The value never enters the job, so neither its steps nor a compromised | |
| 616 | 602 | dependency can read it. A stand-in sent to any other host goes as it is, | |
| 617 | − | and the log says so once. This works on g1t's machines, whether the | |
| 618 | − | project's network is restricted or open, and on self-hosted runners whose | |
| 603 | + | and the log says so once. This works on g1t's machines, and on self-hosted runners whose | |
| 619 | 604 | jobs run on Linux or macOS, which pass the job's requests to those hosts | |
| 620 | 605 | through g1t so the value never reaches the runner's computer. A job on | |
| 621 | 606 | Windows gets the stand-in too, never the value, and its log warns that it | |
| ⋯ | |||
| 769 | 754 | - **One Engine per job.** It runs inside the job's own sandbox, a virtual | |
| 770 | 755 | machine of its own, and is gone with it. No Docker socket of g1t's, or of | |
| 771 | 756 | any machine, is shared with a job. | |
| 772 | − | - **The job's guardrails hold.** Containers use the job's network, so a | |
| 773 | − | container, a build step or an image pull reaches only what the job may | |
| 774 | − | reach. A host off the list gets `403` with the reason, as any step does. | |
| 775 | − | - **HTTPS keeps working.** In a job whose network is restricted, every | |
| 776 | − | container and build step is given the certificate the job's HTTPS is | |
| 777 | − | checked with, in `/dev/g1t-egress`, and `SSL_CERT_FILE`, | |
| 757 | + | - **The job's network.** Containers use the job's network, so a | |
| 758 | + | container, a build step or an image pull reaches what the job reaches. | |
| 759 | + | - **HTTPS keeps working.** In a job whose requests pass g1t's runner | |
| 760 | + | Worker for its brokered secrets, every container and build step is | |
| 761 | + | given the certificate the job's HTTPS is re-signed with, in | |
| 762 | + | `/dev/g1t-egress`, and `SSL_CERT_FILE`, | |
| 778 | 763 | `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, | |
| 779 | 764 | `GIT_SSL_CAINFO` and `CARGO_HTTP_CAINFO` pointing at it, unless the | |
| 780 | 765 | container sets them itself. None of it is written into an image's layers. | |
| ⋯ | |||
| 1117 | 1102 | ||
| 1118 | 1103 | ### Reaching your cloud | |
| 1119 | 1104 | ||
| 1120 | − | Asking for the token goes to `api.g1t.sh`, which every job reaches. Trading | |
| 1121 | − | it for credentials goes to your cloud's token service, which a job reaches | |
| 1122 | − | only when its project allows it (see [what a job can reach](#what-a-job-can-reach)). | |
| 1123 | − | Add the hosts under **Settings → Guardrails → Workflow-only domains**, | |
| 1124 | − | limited to the workflow and environment that deploy, so other workflows | |
| 1125 | − | never reach them: | |
| 1126 | − | ||
| 1127 | − | ```text | |
| 1128 | − | sts.us-east-1.amazonaws.com | deploy.yml | production | |
| 1129 | − | s3.us-east-1.amazonaws.com | deploy.yml | production | |
| 1130 | − | sts.googleapis.com | deploy.yml | production | |
| 1131 | − | iamcredentials.googleapis.com | deploy.yml | production | |
| 1132 | − | login.microsoftonline.com | deploy.yml | production | |
| 1133 | − | management.azure.com | deploy.yml | production | |
| 1134 | − | ``` | |
| 1105 | + | Asking for the token goes to `api.g1t.sh`, and trading it for credentials | |
| 1106 | + | goes to your cloud's token service. A job reaches both, as it reaches any | |
| 1107 | + | host (see [what a job can reach](#what-a-job-can-reach)); there is nothing | |
| 1108 | + | to allow on g1t's side. Which repositories, branches and environments may | |
| 1109 | + | take on a role is up to the trust policy you write on your cloud's side. | |
| 1135 | 1110 | ||
| 1136 | 1111 | | Cloud | Token exchange | Then | | |
| 1137 | 1112 | | --- | --- | --- | | |
| 1138 | − | | AWS | `sts.<region>.amazonaws.com`, the region `aws-region` names | Each service the job uses, such as `s3.<region>.amazonaws.com`, or `*.amazonaws.com` for all of them | | |
| 1113 | + | | AWS | `sts.<region>.amazonaws.com`, the region `aws-region` names | Each service the job uses, such as `s3.<region>.amazonaws.com` | | |
| 1139 | 1114 | | Google Cloud | `sts.googleapis.com`, `iamcredentials.googleapis.com` | Each API the job uses, such as `run.googleapis.com` | | |
| 1140 | 1115 | | Azure | `login.microsoftonline.com` | `management.azure.com`, and each service the job uses | | |
| 1141 | 1116 | ||
| 1142 | − | Without them, the login step fails with `403` and a line naming the host | |
| 1143 | − | that is not allowed. | |
| 1144 | − | ||
| 1145 | 1117 | ### Claims | |
| 1146 | 1118 | ||
| 1147 | 1119 | Each token carries the claims GitHub's do, so trust policies written for | |
| ⋯ | |||
| 1195 | 1167 | } | |
| 1196 | 1168 | ``` | |
| 1197 | 1169 | ||
| 1198 | − | 3. Allow `sts.<region>.amazonaws.com`, and what the job deploys to, as | |
| 1199 | − | [workflow-only domains](#reaching-your-cloud). | |
| 1200 | − | 4. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`, | |
| 1170 | + | 3. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`, | |
| 1201 | 1171 | as above. | |
| 1202 | 1172 | ||
| 1203 | 1173 | ### Google Cloud | |
| ⋯ | |||
| 1221 | 1191 | --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/g1t/attribute.repository/acme/web" | |
| 1222 | 1192 | ``` | |
| 1223 | 1193 | ||
| 1224 | − | 3. Allow `sts.googleapis.com` and `iamcredentials.googleapis.com`, and | |
| 1225 | − | the APIs the job uses, as [workflow-only domains](#reaching-your-cloud). | |
| 1226 | − | 4. Use `google-github-actions/auth@v2` with | |
| 1194 | + | 3. Use `google-github-actions/auth@v2` with | |
| 1227 | 1195 | `workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/g1t/providers/g1t` | |
| 1228 | 1196 | and `service_account`. It asks for the provider's own name as the | |
| 1229 | 1197 | audience, which the provider accepts unless you change its allowed | |
| ⋯ | |||
| 1247 | 1215 | ``` | |
| 1248 | 1216 | ||
| 1249 | 1217 | 2. Give it a role on what it deploys to, as for any identity. | |
| 1250 | − | 3. Allow `login.microsoftonline.com` and `management.azure.com` as | |
| 1251 | − | [workflow-only domains](#reaching-your-cloud). | |
| 1252 | − | 4. Use `azure/login@v2` with `client-id`, `tenant-id` and | |
| 1218 | + | 3. Use `azure/login@v2` with `client-id`, `tenant-id` and | |
| 1253 | 1219 | `subscription-id`, and no secret. | |
| 1254 | 1220 | ||
| 1255 | 1221 | A federated credential matches the subject exactly: add one per | |
| ⋯ | |||
| 1258 | 1224 | ### Cloudflare | |
| 1259 | 1225 | ||
| 1260 | 1226 | Cloudflare's API takes API tokens, not OIDC tokens. Keep a token scoped | |
| 1261 | − | to what the workflow deploys in a secret, available to that workflow only | |
| 1262 | − | (see [workflow-only domains](/guides/guardrails/#workflow-only-domains) | |
| 1263 | − | for limiting where it can be sent). | |
| 1227 | + | to what the workflow deploys in a secret, available to that workflow only, | |
| 1228 | + | and broker it to `api.cloudflare.com` so the job never holds its value | |
| 1229 | + | (see [brokered secrets](#brokered-secrets)). | |
| 1264 | 1230 | ||
| 1265 | 1231 | A Worker of your own can trust g1t's jobs directly, by checking the token | |
| 1266 | 1232 | a job sends it against g1t's keys: | |
| 141 | 141 | sccache kept in the Actions cache. | |
| 142 | 142 | ||
| 143 | 143 | The deploy jobs use `environment: production`, so only they can read the | |
| 144 | − | Cloudflare token. They reach Cloudflare through the project's | |
| 145 | − | [workflow-only domains](/guides/guardrails/), under **Settings → | |
| 146 | − | Guardrails**: | |
| 147 | − | ||
| 148 | − | ```text | |
| 149 | − | api.cloudflare.com | deploy.yml | production | |
| 150 | − | registry.cloudflare.com | deploy.yml, runner-base.yml | production | |
| 151 | − | ``` | |
| 144 | + | Cloudflare token. Jobs on g1t's machines reach Cloudflare's API and | |
| 145 | + | registry as they reach any host; there is nothing to allow. | |
| 152 | 146 | ||
| 153 | 147 | Every deploy shows on the repository's Deployments page. A deploy run by | |
| 154 | 148 | hand reports one too when `G1T_DEPLOY_TOKEN` holds a g1t token with | |
| ⋯ | |||
| 338 | 332 | `pull` (Docker checks for layers before it uploads them). If it needs | |
| 339 | 333 | `pull`, do not add it: a pull login reads every app's image. Find | |
| 340 | 334 | another way first, such as Cloudflare scoping logins to a repository. | |
| 341 | − | 3. **The registry's hosts.** A push from a guarded sandbox reaches only | |
| 342 | − | `registry.cloudflare.com` (on the deploy builds' list in | |
| 343 | − | `services/runner/src/egress.ts`). If its layers upload to another host, | |
| 344 | − | add that host there. | |
| 345 | − | 4. **Checking a digest.** g1t asks | |
| 335 | + | 3. **Checking a digest.** g1t asks | |
| 346 | 336 | `HEAD https://registry.cloudflare.com/v2/<account>/g1t-<app>/manifests/sha256:…` | |
| 347 | 337 | with a pull login of its own, following a `WWW-Authenticate: Bearer` | |
| 348 | 338 | challenge. It answers 200 for an image that was pushed. | |
| 349 | − | 5. **The app's Worker.** A script uploaded to the dispatch namespace with | |
| 339 | + | 4. **The app's Worker.** A script uploaded to the dispatch namespace with | |
| 350 | 340 | `exports: { "G1tApp": { "type": "durable-object", "storage": "sqlite" } }` | |
| 351 | 341 | and `containers: [{ "class_name": "G1tApp" }]` in its metadata is taken, | |
| 352 | 342 | and taken again on the next deploy with nothing changed about the class. | |
| 353 | − | 6. **Its Durable Object namespace.** | |
| 343 | + | 5. **Its Durable Object namespace.** | |
| 354 | 344 | `GET /accounts/{id}/workers/durable_objects/namespaces` lists it with | |
| 355 | 345 | `class` `G1tApp`, `script` the app's name and `dispatch_namespace` | |
| 356 | 346 | `g1t-deployments`. | |
| 357 | − | 7. **The Container application.** `POST /accounts/{id}/containers/applications` | |
| 347 | + | 6. **The Container application.** `POST /accounts/{id}/containers/applications` | |
| 358 | 348 | with `scheduling_policy` `default`, the image by digest, `instance_type`, | |
| 359 | 349 | `max_instances` 1 and `durable_objects.namespace_id` creates it; a | |
| 360 | 350 | rollout (`…/rollouts`, `strategy` `rolling`, `step_percentage` 100, | |
| 361 | 351 | `kind` `full_auto`) moves it to a new image; `DELETE` removes it. | |
| 362 | − | 8. **Starting.** `ctx.container.start({ env, enableInternet: true })` | |
| 352 | + | 7. **Starting.** `ctx.container.start({ env, enableInternet: true })` | |
| 363 | 353 | works under the default policy from a Worker in a dispatch namespace, | |
| 364 | 354 | the container gets its environment, and `getTcpPort(port).fetch()` | |
| 365 | 355 | reaches it; the dispatcher's limits (50 ms of CPU, 50 subrequests) leave | |
| 366 | 356 | room for a page that waits on a container starting. | |
| 367 | − | 9. **Sleeping and billing.** Five minutes after the last request the | |
| 357 | + | 8. **Sleeping and billing.** Five minutes after the last request the | |
| 368 | 358 | container stops (the app's object stops it itself), Cloudflare's billable | |
| 369 | 359 | usage for Containers stops growing, and `container_meters` in the | |
| 370 | 360 | deployments database has the minutes the app's Worker reported, which | |
| 371 | 361 | should be at least what Cloudflare billed for the same time. | |
| 372 | − | 10. **Pausing and taking down.** A workspace over its limit gets its | |
| 362 | + | 9. **Pausing and taking down.** A workspace over its limit gets its | |
| 373 | 363 | container app's Worker put back paused, which stops the container, and | |
| 374 | 364 | taking an app down deletes its application before its Worker. | |
| 375 | 365 | ||
| ⋯ | |||
| 418 | 408 | Restarts during a deploy can look like an outage to the status page's | |
| 419 | 409 | checks. Set the same value as the status Worker's secret | |
| 420 | 410 | `STATUS_DEPLOY_TOKEN` and as the repository's Actions secret | |
| 421 | − | `STATUS_DEPLOY_TOKEN`, and add the status page's host to the workflow-only | |
| 422 | − | domains. Deploys then announce their start and finish, and the status page | |
| 411 | + | `STATUS_DEPLOY_TOKEN`. Deploys then announce their start and finish, and the status page | |
| 423 | 412 | drafts no incident during them or for 3 minutes after. | |
| 424 | 413 | ||
| 425 | 414 | ### Browser push | |
| 767 | 767 | reason and what to do, and **Redeploy** shows the same reason. | |
| 768 | 768 | 2. It starts a build in a sandbox of its own, the same machines that run | |
| 769 | 769 | [GitHub Actions](/guides/actions/). The sandbox checks out | |
| 770 | − | the commit with a read-only token that expires in 30 minutes. Its | |
| 771 | − | network is restricted to the project's allowed domains, the package | |
| 772 | − | registries, GitHub and Cloudflare's API, as | |
| 773 | − | [workflow jobs'](/guides/actions/#what-a-job-can-reach) are, and it | |
| 770 | + | the commit with a read-only token that expires in 30 minutes. It | |
| 771 | + | reaches the internet, as | |
| 772 | + | [workflow jobs](/guides/actions/#what-a-job-can-reach) do, and it | |
| 774 | 773 | stops at 30 minutes. | |
| 775 | 774 | When it stops, what it cost is settled against what was reserved. | |
| 776 | 775 | 3. The sandbox builds, then lists its files. g1t opens an upload with | |
| ⋯ | |||
| 864 | 863 | | "Deployments come with the g1t plan … and `<workspace>` does not have it" | An owner starts the g1t plan under Billing. | | |
| 865 | 864 | | A deployment **Skipped**, saying it reached its spend limit or that new compute is paused | The build was refused before it started. Follow the link in the message: raise the spend limit, prepay, or answer the spike, on Billing. | | |
| 866 | 865 | | "Stopped: unusual CPU use" | The build looked like it was mining. Contact support if it was a real build; see [abuse and mining](/guides/guardrails/#abuse-and-mining). | | |
| 867 | − | | "403" from a host in the build log | The build's network is restricted. Add the host to the project's allowed domains under **Settings → Guardrails**. | | |
| 868 | 866 | | "found nothing to serve" | Add a `Dockerfile`, a `build` script, an `index.html`, or a Workers config; or set **Output directory**. | | |
| 869 | 867 | | "Docker deployments are not switched on for g1t.page yet" | The project is a container, which g1t.page does not run yet. Set **Build as** to Static site or Workers if it can be built as one. See [Docker deployments](#docker-deployments). | | |
| 870 | 868 | | "**Build as** is Docker, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. | | |
| 533 | 533 | | The runner's release key | The app trusts a `g1t-runner` release only when its manifest is signed with g1t's runner release key, whose public half (the repository variable `RUNNER_RELEASE_PUBLIC_KEY`) is built into the app, in `src-tauri/src/runner/native.rs`. To build the app for a g1t of your own, whose runner releases you sign with your own key, set `G1T_RUNNER_RELEASE_KEY` to its public key, base64, when you build it. | | |
| 534 | 534 | | The updater's signing key | `node scripts/desktop-release.mjs keygen`, once. The private key is the repository secret `DESKTOP_SIGNING_KEY`; the public key is `plugins.updater.pubkey` in `tauri.conf.json`. Losing the private key means every installed app stops updating: keep it. | | |
| 535 | 535 | | Linux builds | g1t's own Linux machines, in every release. | | |
| 536 | − | | Windows builds | Either a Windows machine registered as a [self-hosted runner](/guides/self-hosted-runners/) with the labels `self-hosted` and `windows` (Rust, Node and the Visual Studio build tools on it) and the repository variable `DESKTOP_WINDOWS_RUNNER` set to `true`; or, with no Windows machine, `DESKTOP_WINDOWS_FROM_LINUX` set to `true`, which cross-builds on Linux with `cargo-xwin` and NSIS and needs `aka.ms` and `download.visualstudio.microsoft.com` among the project's [workflow-only domains](/guides/guardrails/#workflow-only-domains) for `desktop-release.yml` in `production`, where the MSVC runtime is fetched from. | | |
| 536 | + | | Windows builds | Either a Windows machine registered as a [self-hosted runner](/guides/self-hosted-runners/) with the labels `self-hosted` and `windows` (Rust, Node and the Visual Studio build tools on it) and the repository variable `DESKTOP_WINDOWS_RUNNER` set to `true`; or, with no Windows machine, `DESKTOP_WINDOWS_FROM_LINUX` set to `true`, which cross-builds on Linux with `cargo-xwin` and NSIS, fetching the MSVC runtime from `aka.ms` and `download.visualstudio.microsoft.com`. | | |
| 537 | 537 | | macOS builds | A Mac, registered as a self-hosted runner with the labels `self-hosted` and `macos`, and the repository variable `DESKTOP_MACOS_RUNNER` set to `true`. | | |
| 538 | 538 | | A platform with no machine | Its job is skipped and the release goes out with the rest; the download page says which platforms a release lacks, and the next release fills them in. | | |
| 539 | 539 | | Where releases are served | `g1t.sh/downloads/desktop/<version>/<file>`, from the same bucket as the runner's and the CLI's releases. | |
| 1 | 1 | --- | |
| 2 | 2 | title: Guardrails | |
| 3 | − | description: Which hosts a project's jobs, checks, merge queue and deploy builds may reach on g1t's machines, how long a run may take, and how each rule is enforced. | |
| 3 | + | description: How long a project's checks, merge queue and merge checks may run on g1t's machines, how a sandbox reaches the internet, and how g1t watches for mining. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | Guardrails decide what a project's work may do in g1t's sandboxes: which | |
| 7 | − | hosts a sandbox can reach, and how long a run may take. A workspace sets | |
| 8 | − | defaults, and each project can override them. | |
| 6 | + | Guardrails decide how long a project's work may run in g1t's sandboxes. A | |
| 7 | + | workspace sets defaults, and each project can override them. | |
| 9 | 8 | ||
| 10 | − | They apply to every sandbox g1t starts for a project: its Actions jobs, | |
| 11 | − | its checks, its merge queue and its deploy builds. Jobs and deploy builds | |
| 12 | − | get the network list with what builds need added (see [builds](#builds)), | |
| 13 | − | and their own time limit. Merge checks are not covered; see | |
| 14 | − | [what is not covered](#what-is-not-covered). Every sandbox, whatever it | |
| 15 | − | runs, is watched for [mining](#abuse-and-mining). | |
| 9 | + | They apply to the runs g1t starts for a project: its checks, its merge | |
| 10 | + | queue and its merge checks. Actions jobs and deploy builds keep their own | |
| 11 | + | time limits. Every sandbox, whatever it runs, is watched for | |
| 12 | + | [mining](#abuse-and-mining). | |
| 16 | 13 | ||
| 17 | 14 | ## Where to set them | |
| 18 | 15 | ||
| ⋯ | |||
| 23 | 20 | its repository can see and change them; the page is not shown to anyone | |
| 24 | 21 | else. | |
| 25 | 22 | ||
| 26 | − | Every setting on a project's page starts as "As the workspace", which | |
| 27 | − | follows the workspace's default, whatever it is now. Choose a value to | |
| 28 | − | override it for that project only. Allowed domains and workflow-only | |
| 29 | − | domains add up: a project's are added to the workspace's, never | |
| 30 | − | instead of them. | |
| 23 | + | A time left empty on a project's page follows the workspace's default, | |
| 24 | + | whatever it is now, shown faded. Enter a number to override it for that | |
| 25 | + | project only. | |
| 31 | 26 | ||
| 32 | 27 | Changes apply to runs that start after you save. A run that is under way | |
| 33 | − | keeps the guardrails it started with. | |
| 34 | − | ||
| 35 | − | ## Network | |
| 36 | − | ||
| 37 | − | With **Only allowed hosts** restricted (the default), a sandbox can reach: | |
| 38 | − | ||
| 39 | − | - g1t's own hosts, always: `g1t.sh`, `api.g1t.sh` and `mcp.g1t.sh`, for | |
| 40 | − | cloning, pushing and reporting; | |
| 41 | − | - the package registries that are on (all of them by default): | |
| 42 | − | ||
| 43 | − | | Registry | Hosts | | |
| 44 | − | | --- | --- | | |
| 45 | − | | npm and Yarn | `registry.npmjs.org`, `registry.yarnpkg.com`, `repo.yarnpkg.com` | | |
| 46 | − | | PyPI | `pypi.org`, `files.pythonhosted.org` | | |
| 47 | − | | crates.io and Rust toolchains | `crates.io`, `index.crates.io`, `static.crates.io`, `static.rust-lang.org` | | |
| 48 | − | | Go module proxy | `proxy.golang.org`, `sum.golang.org` | | |
| 49 | − | | GitHub downloads | `codeload.github.com`, `raw.githubusercontent.com`, `objects.githubusercontent.com` | | |
| 50 | − | ||
| 51 | − | - the domains you list: `api.stripe.com` allows exactly that host, and | |
| 52 | − | `*.example.com` allows every subdomain of `example.com` (not | |
| 53 | − | `example.com` itself; list both if you need both). | |
| 54 | − | ||
| 55 | − | `github.com` itself is not on the default list. A project with | |
| 56 | − | dependencies fetched with git from GitHub, rather than as archives, needs it | |
| 57 | − | listed. | |
| 58 | − | ||
| 59 | − | ### How it is enforced | |
| 60 | − | ||
| 61 | − | This is enforced outside the sandbox, by Cloudflare Containers' outbound | |
| 62 | − | interception: | |
| 63 | − | ||
| 64 | − | - A restricted sandbox starts with no internet connection at all. Its DNS | |
| 65 | − | resolves nothing on its own, and no protocol or port other than HTTP (80) | |
| 66 | − | and HTTPS (443) has any route out: SSH, raw TCP and UDP connections fail. | |
| 67 | − | - Every HTTP and HTTPS request it makes is handed to g1t's runner Worker | |
| 68 | − | before it leaves. The Worker forwards requests to allowed hosts and | |
| 69 | − | answers every other with `403`. The refusal names the host three times | |
| 70 | − | over, since tools differ in what they show of a failed request: in its | |
| 71 | − | status line (`403 Forbidden by g1t guardrails: example.com is not an | |
| 72 | − | allowed domain`, which is what a tool that prints only the status shows), | |
| 73 | − | in an `x-g1t-blocked-host` header beside `x-g1t-guardrails: blocked`, and | |
| 74 | − | in a plain-text body saying where to allow it: **Allowed domains** for | |
| 75 | − | every sandbox, or **Workflow-only domains** for the jobs of one workflow | |
| 76 | − | and environment. | |
| 77 | − | - To see the host of an HTTPS request, the connection is re-encrypted with | |
| 78 | − | a certificate the sandbox is given when it starts, which g1t adds to the | |
| 79 | − | sandbox's trusted certificates (and points Node.js, Python, curl, Cargo | |
| 80 | − | and git at). A program that brings its own fixed list of certificates and | |
| 81 | − | ignores the system's cannot connect anywhere, allowed or not. | |
| 28 | + | keeps the guardrails it started with. Each change is recorded in the | |
| 29 | + | workspace's [audit log](/guides/audit-log/) as `update_guardrails`, saying | |
| 30 | + | which time caps were set, changed or cleared. | |
| 82 | 31 | ||
| 83 | − | Nothing running in the sandbox, root included, can change this: the rule | |
| 84 | − | is applied where the sandbox's traffic leaves it, not inside it. | |
| 32 | + | ## Time per run | |
| 85 | 33 | ||
| 86 | − | Each refused host appears once as a step of the run, such as | |
| 87 | − | `Blocked: example.com (not an allowed domain)`. It is a note, not the | |
| 88 | − | run's state: the run carries on. If a run needs a host, allow it and run | |
| 89 | − | it again. | |
| 34 | + | How long each kind of run may take, in minutes. By default: | |
| 90 | 35 | ||
| 91 | − | Tools that send usage reports home are told not to in a sandbox with only | |
| 92 | − | allowed hosts (`DO_NOT_TRACK=1`, and each common tool's own switch, such | |
| 93 | − | as `WRANGLER_SEND_METRICS=false`), so those reports are not refused and | |
| 94 | − | noted on every run. | |
| 36 | + | | Kind of run | Minutes | | |
| 37 | + | | --- | --- | | |
| 38 | + | | Checks | 45 | | |
| 39 | + | | Merge queue | 45 | | |
| 40 | + | | Merge check | 10 | | |
| 95 | 41 | ||
| 96 | − | Setting **Only allowed hosts** to Open gives that project's sandboxes the | |
| 97 | − | whole internet, as before guardrails. | |
| 42 | + | At most 240 minutes. A run that reaches its time cap fails, with | |
| 43 | + | "Stopped: it reached its time cap of 45 minutes." The sandbox is stopped | |
| 44 | + | whatever is running in it. Actions jobs keep their own limit: at most 60 | |
| 45 | + | minutes on g1t's machines, or less with `timeout-minutes` (see | |
| 46 | + | [Actions](/guides/actions/)). Your plan can cap a run's time lower still. | |
| 98 | 47 | ||
| 99 | − | ### Builds | |
| 48 | + | ## Network | |
| 100 | 49 | ||
| 101 | − | GitHub Actions jobs and deploy builds reach the project's list, plus what | |
| 102 | − | real builds need, which no setting removes: | |
| 50 | + | Every sandbox reaches the internet, as a CI machine does: package | |
| 51 | + | registries, GitHub, container registries, your cloud's APIs and anything | |
| 52 | + | else a job or build fetches from or sends to. No setting limits the hosts | |
| 53 | + | a sandbox may reach. | |
| 103 | 54 | ||
| 104 | − | - GitHub, where `uses:` actions, `actions/checkout`'s helpers and the | |
| 105 | − | setup actions' downloads come from: `github.com`, `api.github.com`, | |
| 106 | − | `codeload.github.com`, `objects.githubusercontent.com`, | |
| 107 | − | `raw.githubusercontent.com`, `release-assets.githubusercontent.com`, | |
| 108 | − | `ghcr.io`; | |
| 109 | − | - toolchains: `nodejs.org`, `go.dev`, `dl.google.com`, | |
| 110 | − | `static.rust-lang.org`, `sh.rustup.rs`; | |
| 111 | − | - every package registry above, whatever the project turned on, and | |
| 112 | − | RubyGems, Packagist, NuGet, Maven Central, Gradle and | |
| 113 | − | Debian's mirrors; | |
| 114 | − | - container registries, for a job's own Docker Engine: Docker Hub | |
| 115 | − | (`registry-1.docker.io`, `auth.docker.io` and the CDNs its layers come | |
| 116 | − | from), `mirror.gcr.io`, Quay (`quay.io` and its CDNs), and Docker's | |
| 117 | − | package repository, `download.docker.com`; | |
| 118 | − | - for deploy builds, Cloudflare's API, which the build uploads its app to. | |
| 55 | + | A sandbox's requests go straight out. Two kinds of request pass g1t's | |
| 56 | + | runner Worker on the way: | |
| 119 | 57 | ||
| 120 | − | The repository itself is cloned from g1t, which is always reachable. A | |
| 121 | − | project whose guardrails set **Only allowed hosts** to Open runs its jobs | |
| 122 | − | and builds with an open network too. | |
| 58 | + | - **A sandbox's report that it stopped itself for mining**, to | |
| 59 | + | `sandbox.g1t.internal`, which never leaves g1t. | |
| 60 | + | - **Every HTTP and HTTPS request of a workflow job that may be given | |
| 61 | + | [brokered secrets](#brokered-secrets)**, so they can be filled in. The | |
| 62 | + | Worker sends each one on, to any host, and tries a download that fails | |
| 63 | + | midway again. To see a request's host and headers over HTTPS, the | |
| 64 | + | connection is re-encrypted with a certificate the sandbox is given when | |
| 65 | + | it starts, which g1t adds to the sandbox's trusted certificates (and | |
| 66 | + | points Node.js, Python, curl, Cargo and git at). The job's other | |
| 67 | + | protocols go straight out. | |
| 123 | 68 | ||
| 124 | 69 | The containers a job starts with [Docker](/guides/actions/#docker), its | |
| 125 | − | services and its build steps share the job's network, so this list is | |
| 126 | − | theirs too: an image from another registry, or a build step that | |
| 127 | − | downloads from another host, needs that host allowed, as a step would. | |
| 128 | − | ||
| 129 | − | ### Workflow-only domains | |
| 130 | − | ||
| 131 | − | Some hosts only a workflow should reach: the API a deploy uploads to, a | |
| 132 | − | release server, a package registry you publish to. Listing them under | |
| 133 | − | allowed domains would open them to every sandbox as well. List them under | |
| 134 | − | **Workflow-only domains** instead, one per line: | |
| 135 | − | ||
| 136 | − | ``` | |
| 137 | − | api.cloudflare.com | deploy.yml | production | |
| 138 | − | uploads.example.com | release.yml, nightly.yml | |
| 139 | − | *.internal.example.com | |
| 140 | − | ``` | |
| 141 | − | ||
| 142 | − | | Part | | | |
| 143 | − | | --- | --- | | |
| 144 | − | | The domain | As for allowed domains: `example.com`, or `*.example.com` for its subdomains. | | |
| 145 | − | | Workflows | Workflow files by name, comma-separated, as they are in `.g1t/workflows/`. Left out: any workflow. | | |
| 146 | − | | Environments | The environments a job must name with `environment:`, comma-separated. Left out: any job. | | |
| 147 | − | ||
| 148 | − | A domain is reached only by: | |
| 149 | − | ||
| 150 | − | - jobs of the workflows and environments its line names; | |
| 151 | − | - in a run that is not of a pull request from a fork, which runs code | |
| 152 | − | anyone could write. | |
| 153 | − | ||
| 154 | − | Checks, the merge queue and deploy builds never reach these hosts, whatever the line says. A job that names its environment with an | |
| 155 | − | expression (`environment: ${{ inputs.target }}`) matches only lines with | |
| 156 | − | no environments. | |
| 157 | − | ||
| 158 | − | Workflow-only domains are set by the same people as the rest of the page: | |
| 159 | − | owners for the workspace's, Admin for a project's. Each change | |
| 160 | − | is recorded in the workspace's [audit log](/guides/audit-log/) as | |
| 161 | − | `update_guardrails`, saying which domains were added or removed and what | |
| 162 | − | they were limited to. | |
| 163 | − | ||
| 164 | − | A deploy that signs in to AWS, Google Cloud or Azure with the job's | |
| 165 | − | [OIDC token](/guides/actions/#oidc-tokens), rather than a key kept in a | |
| 166 | − | secret, needs its cloud's token service listed here, such as | |
| 167 | − | `sts.us-east-1.amazonaws.com | deploy.yml | production`. See | |
| 168 | − | [reaching your cloud](/guides/actions/#reaching-your-cloud) for each | |
| 169 | − | cloud's hosts. | |
| 70 | + | services and its build steps share the job's network. | |
| 170 | 71 | ||
| 171 | 72 | ### Brokered secrets | |
| 172 | 73 | ||
| 173 | − | The same interception keeps a workflow's API keys out of its jobs. A | |
| 74 | + | The Worker keeps a workflow's API keys out of its jobs. A | |
| 174 | 75 | [brokered secret](/guides/secrets-and-variables/#brokered-secrets) names | |
| 175 | 76 | the hosts it is for, and a job is given a stand-in in place of its value. | |
| 176 | 77 | As a request to one of those hosts passes the runner Worker, the Worker | |
| ⋯ | |||
| 181 | 82 | stand-ins with that job's own token and keeps them only while the job | |
| 182 | 83 | runs. | |
| 183 | 84 | ||
| 184 | − | Brokering does not widen the network: a secret's hosts must still be ones | |
| 185 | − | the job may reach. A job whose project's network is open, but which may be | |
| 186 | − | given a brokered secret, still sends its HTTP and HTTPS through the | |
| 187 | − | Worker, which reaches every host for it, and trusts the certificate as a | |
| 188 | − | restricted sandbox does; its other protocols go straight out, as before. | |
| 189 | 85 | Self-hosted runners' traffic does not pass through g1t, so a runner whose | |
| 190 | 86 | jobs run on Linux or macOS sends the requests that carry a stand-in to g1t | |
| 191 | 87 | itself, through a proxy it runs for the job, and g1t fills them in the same | |
| 192 | 88 | way; see | |
| 193 | 89 | [on a self-hosted runner](/guides/secrets-and-variables/#on-a-self-hosted-runner). | |
| 194 | 90 | On Windows, brokered secrets stay stand-ins. | |
| 195 | − | ||
| 196 | − | ## Time per run | |
| 197 | − | ||
| 198 | − | How long each kind of run may take, in minutes. By default: | |
| 199 | − | ||
| 200 | − | | Kind of run | Minutes | | |
| 201 | − | | --- | --- | | |
| 202 | − | | Checks | 45 | | |
| 203 | − | | Merge queue | 45 | | |
| 204 | − | | Merge check | 10 | | |
| 205 | − | ||
| 206 | − | At most 240 minutes. A run that reaches its time cap fails, with | |
| 207 | − | "Stopped: it reached its time cap of 45 minutes." The sandbox is stopped | |
| 208 | − | whatever is running in it. Actions jobs keep their own limit: at most 60 | |
| 209 | − | minutes on g1t's machines, or less with `timeout-minutes` (see | |
| 210 | − | [Actions](/guides/actions/)). | |
| 211 | 91 | ||
| 212 | 92 | ## Abuse and mining | |
| 213 | 93 | ||
| 214 | − | g1t does not run cryptocurrency miners, on any plan. Mining needs a mining | |
| 215 | − | pool and hours of CPU; g1t's sandboxes withhold the first and watch for the | |
| 216 | − | second: | |
| 94 | + | g1t does not run cryptocurrency miners, on any plan. Mining needs hours of | |
| 95 | + | CPU, and g1t's sandboxes watch for it: | |
| 217 | 96 | ||
| 218 | − | - **No pool to reach.** No mining pool is on any allowed list, so a | |
| 219 | − | restricted sandbox's miner has nowhere to send its work. | |
| 220 | 97 | - **Miners by name.** A check, a build command, a workflow step or a container a job starts whose image or | |
| 221 | 98 | command names a known miner (`xmrig`, `cpuminer`, `t-rex` and others), a | |
| 222 | 99 | pool address (`stratum+tcp://`) or a miner's flags (`--donate-level`, | |
| ⋯ | |||
| 242 | 119 | staff are told, with the measurements, and look at what ran. If it was a | |
| 243 | 120 | real job, write to hey@flagon.io and say which run. | |
| 244 | 121 | ||
| 122 | + | Beyond that, what a sandbox may do is bounded by its time cap, your | |
| 123 | + | plan's limits on how much may run and how long, the short-lived | |
| 124 | + | credentials each run is given, and branch protection, which g1t enforces | |
| 125 | + | on every push whatever happened in the sandbox. | |
| 126 | + | ||
| 245 | 127 | ## What is not covered | |
| 246 | 128 | ||
| 247 | − | - **Merge checks** only merge two commits; they get no network list. | |
| 248 | − | - **Self-hosted runners** run on your machines, whose network g1t does not | |
| 249 | − | see: the network list is not enforced there. See | |
| 129 | + | - **Self-hosted runners** run on your machines, and reach whatever those | |
| 130 | + | machines can. See | |
| 250 | 131 | [self-hosted runners](/guides/self-hosted-runners/#security). | |
| 232 | 232 | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 233 | 233 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 234 | 234 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 235 | − | | **Guardrails** | What g1t's sandboxes may reach while they work here. See [guardrails](/guides/guardrails/). | | |
| 235 | + | | **Guardrails** | How long g1t's checks, merge queue and merge checks may run here. See [guardrails](/guides/guardrails/). | | |
| 236 | 236 | | **Repository** | The repository's name, description, website, [topics](/guides/search/#what-is-indexed) and default branch, and its danger zone: visibility, archive, transfer and delete. See [Managing a repository](/guides/managing-repositories/). | | |
| 237 | 237 | | **Access** | Who has a [role](/guides/access-and-roles/) on the repository, and invitations. | | |
| 238 | 238 | | **Branches and merging** | Auto-merge, the CODEOWNERS file's errors, and what the rules hold for the default branch. | |
| 112 | 112 | ||
| 113 | 113 | 1. Open **Settings → Secrets and variables**, and add or edit the secret. | |
| 114 | 114 | 2. Under **Brokered**, tick **Only for these hosts**. | |
| 115 | − | 3. List the hosts it is for, one per line, as for | |
| 116 | − | [allowed domains](/guides/guardrails/#network): `registry.npmjs.org`, | |
| 117 | − | or `*.example.com` for every subdomain of `example.com`. | |
| 115 | + | 3. List the hosts it is for, one per line: `registry.npmjs.org`, or | |
| 116 | + | `*.example.com` for every subdomain of `example.com` (not | |
| 117 | + | `example.com` itself; list both if you need both). | |
| 118 | 118 | 4. Save. | |
| 119 | 119 | ||
| 120 | 120 | Workflows use it as they would any secret: `${{ secrets.NPM_TOKEN }}` in an | |
| ⋯ | |||
| 138 | 138 | A request to a host the secret is not for goes out with the stand-in, | |
| 139 | 139 | which is useless to whoever receives it, and the job's log says so once: | |
| 140 | 140 | `NPM_TOKEN was sent to example.com, which it is not for, so g1t did not | |
| 141 | − | fill it in.` A host must also be one the job may reach (its project's | |
| 142 | − | allowed domains, what builds need, or a | |
| 143 | − | [workflow-only domain](/guides/guardrails/#workflow-only-domains)): | |
| 144 | − | brokering a secret for a host does not open the network to it. | |
| 141 | + | fill it in.` | |
| 145 | 142 | ||
| 146 | 143 | If an answer echoes the value back, it is swapped for the stand-in again | |
| 147 | 144 | in the answer's headers, and in its body when that is plain text (JSON, | |
| ⋯ | |||
| 151 | 148 | ||
| 152 | 149 | ### Where it works | |
| 153 | 150 | ||
| 154 | − | - **Workflow jobs on g1t's machines.** Brokering works whether the | |
| 155 | − | project's network is restricted or open: a job that may be given a | |
| 156 | − | brokered secret sends its HTTP and HTTPS through g1t either way, and | |
| 157 | − | trusts the certificate HTTPS is re-signed with, as a restricted one does. | |
| 158 | − | Other protocols are not affected. | |
| 151 | + | - **Workflow jobs on g1t's machines.** A job that may be given a | |
| 152 | + | brokered secret sends its HTTP and HTTPS through g1t's runner Worker, | |
| 153 | + | which fills the value in and sends every request on, to any host, and | |
| 154 | + | the job trusts the certificate its HTTPS is re-signed with. Other jobs' | |
| 155 | + | requests go straight out. Other protocols are not affected. | |
| 159 | 156 | - **Self-hosted runners on Linux and macOS**, in Docker, in the desktop | |
| 160 | 157 | app's sandbox, or directly on the computer: the job's requests to the | |
| 161 | 158 | secret's hosts go through g1t, which fills the value in, so it never | |
| 666 | 666 | short-lived credential that can do only what that kind of run may, in | |
| 667 | 667 | that repository, revoked when it ends. | |
| 668 | 668 | - Its machine time is free, as for every job on your runners. | |
| 669 | − | - [Guardrails](/guides/guardrails/) cannot enforce their network list on | |
| 670 | − | your machine. | |
| 671 | 669 | ||
| 672 | 670 | A project can have its own setting, or follow the workspace's. | |
| 673 | 671 | ||
| ⋯ | |||
| 707 | 705 | runner's user and can reach whatever that user can; host mode is | |
| 708 | 706 | deprecated and refuses public repositories' work. Use ephemeral runners | |
| 709 | 707 | for code you do not trust. See [isolation](#isolation). | |
| 710 | − | - **Network**: g1t's network guardrails are not enforced on your machines. | |
| 711 | − | A job reaches whatever the machine can. | |
| 708 | + | - **Network**: a job reaches whatever the machine can. | |
| 712 | 709 | - **Audit**: making a registration token, registering, removing, and every | |
| 713 | 710 | job a runner takes are in the [audit log](/guides/audit-log/), with the | |
| 714 | 711 | runner as the actor. | |
| 733 | 733 | | | **Emoji** | Members | The workspace's own emoji. `-/emoji` | | |
| 734 | 734 | | **Artifacts** | **Spaces and sharing** | | Soon: which spaces a workspace starts with, and how a new artifact is shared by default. | | |
| 735 | 735 | | **Security** | **Security settings** | Members | The workspace's security policies. `-/security/settings` | | |
| 736 | − | | | [**Guardrails**](/guides/guardrails/) | Members | What Actions jobs on g1t's machines may reach. `-/guardrails` | | |
| 736 | + | | | [**Guardrails**](/guides/guardrails/) | Members | How long checks, the merge queue and merge checks may run. `-/guardrails` | | |
| 737 | 737 | | | [**Rules**](/guides/rules/) | Members | Rulesets across projects. `-/rules` | | |
| 738 | 738 | | | [**Audit log**](/guides/audit-log/) | Members | Every change, by whom, to what. `-/audit` | | |
| 739 | 739 | | **Integrations** | [**Integrations**](/guides/integrations/) | Members | The tools connected for everyone. `-/integrations` | |
| 1 | 1 | /** | |
| 2 | 2 | * Guardrails: the form a workspace sets its defaults with and a project its | |
| 3 | − | * overrides: the network a sandbox may reach, and how long its runs may take. | |
| 3 | + | * overrides: how long its runs may take. | |
| 4 | 4 | */ | |
| 5 | − | import { type ReactNode, useState } from "react"; | |
| 5 | + | import type { ReactNode } from "react"; | |
| 6 | 6 | import { Form } from "react-router"; | |
| 7 | 7 | ||
| 8 | 8 | import { type GuardrailSettings, type Guardrails, type GuardrailsView, RUN_KIND_LABEL } from "@g1t/contracts"; | |
| 9 | 9 | ||
| 10 | − | import { SANDBOX_RUN_KINDS, tri, workflowDomainLine } from "../lib/guardrails"; | |
| 10 | + | import { SANDBOX_RUN_KINDS } from "../lib/guardrails"; | |
| 11 | 11 | import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 12 | 12 | import { Card } from "./ui/card"; | |
| 13 | − | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | − | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 15 | − | import { Textarea } from "./ui/textarea"; | |
| 16 | 13 | ||
| 17 | 14 | function Section({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) { | |
| 18 | 15 | return ( | |
| ⋯ | |||
| 26 | 23 | ); | |
| 27 | 24 | } | |
| 28 | 25 | ||
| 29 | − | /** A choice of inheriting, or on or off, for one setting. */ | |
| 30 | − | function TriSelect({ | |
| 31 | − | name, | |
| 32 | − | value, | |
| 33 | − | inherited, | |
| 34 | − | parent, | |
| 35 | − | labels, | |
| 36 | − | title, | |
| 37 | − | children, | |
| 38 | − | }: { | |
| 39 | − | name: string; | |
| 40 | − | value: boolean | null | undefined; | |
| 41 | − | inherited: boolean; | |
| 42 | − | parent: string; | |
| 43 | − | labels: [on: string, off: string]; | |
| 44 | − | title: string; | |
| 45 | − | children: ReactNode; | |
| 46 | − | }) { | |
| 47 | − | return ( | |
| 48 | − | <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start"> | |
| 49 | − | <div className="min-w-0 grow"> | |
| 50 | − | <p className="text-sm font-medium">{title}</p> | |
| 51 | − | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 52 | − | </div> | |
| 53 | − | <Select name={name} defaultValue={tri(value)}> | |
| 54 | − | <SelectTrigger size="sm" aria-label={title} className="w-full shrink-0 sm:w-auto sm:min-w-44"> | |
| 55 | − | <SelectValue /> | |
| 56 | − | </SelectTrigger> | |
| 57 | − | <SelectContent align="end"> | |
| 58 | − | <SelectItem value="inherit"> | |
| 59 | − | As {parent} ({(inherited ? labels[0] : labels[1]).toLowerCase()}) | |
| 60 | − | </SelectItem> | |
| 61 | − | <SelectItem value="on">{labels[0]}</SelectItem> | |
| 62 | − | <SelectItem value="off">{labels[1]}</SelectItem> | |
| 63 | − | </SelectContent> | |
| 64 | − | </Select> | |
| 65 | − | </Card> | |
| 66 | − | ); | |
| 67 | − | } | |
| 68 | − | ||
| 69 | − | /** Hosts in a compact list. */ | |
| 70 | − | function Hosts({ hosts }: { hosts: string[] }) { | |
| 71 | − | return ( | |
| 72 | − | <span className="font-mono text-xs wrap-break-word text-faint">{hosts.length ? hosts.join(", ") : "none"}</span> | |
| 73 | − | ); | |
| 74 | − | } | |
| 75 | − | ||
| 76 | 26 | /** | |
| 77 | 27 | * One level's guardrails. `level` says which: the workspace's defaults, | |
| 78 | 28 | * over g1t's, or a project's overrides, over its workspace's. | |
| ⋯ | |||
| 97 | 47 | // What this level inherits, and what it is called. | |
| 98 | 48 | const base: Guardrails = level === "project" ? view.inherited : view.defaults; | |
| 99 | 49 | const parent = level === "project" ? "the workspace" : "g1t's default"; | |
| 100 | − | const [registryMode, setRegistryMode] = useState(own.registries ? "custom" : "inherit"); | |
| 101 | − | const shownRegistries = own.registries ?? base.registries; | |
| 102 | − | const inheritedDomains = level === "project" ? view.inherited.domains : []; | |
| 103 | − | const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : []; | |
| 104 | 50 | return ( | |
| 105 | 51 | // Keyed to the last change, so after a save the fields show what was kept, as it was tidied. | |
| 106 | 52 | <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8"> | |
| 107 | 53 | <fieldset disabled={!editable} className="min-w-0 space-y-8"> | |
| 108 | − | <Section | |
| 109 | − | title="Network" | |
| 110 | − | about={ | |
| 111 | − | <> | |
| 112 | − | Which hosts a sandbox may reach. Requests anywhere else are refused at the sandbox's edge, and | |
| 113 | − | each refused host shows on the run as a step. | |
| 114 | − | </> | |
| 115 | − | } | |
| 116 | − | > | |
| 117 | − | <TriSelect | |
| 118 | − | name="restrictNetwork" | |
| 119 | − | value={own.restrictNetwork} | |
| 120 | − | inherited={base.restrictNetwork} | |
| 121 | − | parent={parent} | |
| 122 | − | labels={["Restricted", "Open"]} | |
| 123 | − | title="Only allowed hosts" | |
| 124 | − | > | |
| 125 | − | Restricted, a sandbox reaches g1t, the registries below and the domains you list, over HTTP and | |
| 126 | − | HTTPS only. Open, it reaches the whole internet. | |
| 127 | − | </TriSelect> | |
| 128 | − | <Card className="p-4"> | |
| 129 | − | <p className="text-sm font-medium">Always allowed</p> | |
| 130 | − | <p className="mt-1 text-sm text-muted"> | |
| 131 | − | g1t's own hosts, for cloning, pushing and reporting: <Hosts hosts={view.g1tHosts} /> | |
| 132 | − | </p> | |
| 133 | − | </Card> | |
| 134 | − | <Card className="p-4"> | |
| 135 | − | <div className="flex flex-col gap-3 sm:flex-row sm:items-start"> | |
| 136 | − | <div className="min-w-0 grow"> | |
| 137 | − | <p className="text-sm font-medium">Package registries</p> | |
| 138 | − | <p className="mt-1 text-sm text-muted">Where installs fetch dependencies from.</p> | |
| 139 | − | </div> | |
| 140 | − | <Select name="registries" value={registryMode} onValueChange={setRegistryMode}> | |
| 141 | − | <SelectTrigger size="sm" aria-label="Package registries" className="w-full shrink-0 sm:w-auto sm:min-w-44"> | |
| 142 | − | <SelectValue /> | |
| 143 | − | </SelectTrigger> | |
| 144 | − | <SelectContent align="end"> | |
| 145 | − | <SelectItem value="inherit">As {parent}</SelectItem> | |
| 146 | − | <SelectItem value="custom">Choose</SelectItem> | |
| 147 | − | </SelectContent> | |
| 148 | − | </Select> | |
| 149 | − | </div> | |
| 150 | − | <div className="mt-4 grid gap-3 sm:grid-cols-2"> | |
| 151 | − | {view.registries.map((registry) => ( | |
| 152 | − | <CheckboxOption | |
| 153 | − | key={`${registry.id}-${registryMode}`} | |
| 154 | − | name={`registry:${registry.id}`} | |
| 155 | − | disabled={registryMode !== "custom"} | |
| 156 | − | defaultChecked={(registryMode === "custom" ? shownRegistries : base.registries).includes(registry.id)} | |
| 157 | − | label={registry.name} | |
| 158 | − | description={registry.hosts.join(", ")} | |
| 159 | − | /> | |
| 160 | − | ))} | |
| 161 | − | </div> | |
| 162 | − | </Card> | |
| 163 | − | <Card className="p-4"> | |
| 164 | − | <label htmlFor="guardrail-domains" className="text-sm font-medium"> | |
| 165 | − | Allowed domains | |
| 166 | − | </label> | |
| 167 | − | <p className="mt-1 text-sm text-muted"> | |
| 168 | − | One per line: <span className="font-mono text-xs">api.stripe.com</span>, or{" "} | |
| 169 | − | <span className="font-mono text-xs">*.example.com</span> for its subdomains. | |
| 170 | − | {level === "project" && " These add to the workspace's."} | |
| 171 | − | </p> | |
| 172 | − | <Textarea | |
| 173 | − | id="guardrail-domains" | |
| 174 | − | name="domains" | |
| 175 | − | className="mt-3 font-mono text-xs" | |
| 176 | − | rows={4} | |
| 177 | − | defaultValue={(own.domains ?? []).join("\n")} | |
| 178 | − | placeholder="api.stripe.com" | |
| 179 | − | /> | |
| 180 | − | {inheritedDomains.length > 0 && ( | |
| 181 | − | <p className="mt-2 text-xs text-faint"> | |
| 182 | − | From the workspace: <Hosts hosts={inheritedDomains} /> | |
| 183 | − | </p> | |
| 184 | − | )} | |
| 185 | − | </Card> | |
| 186 | − | <Card className="p-4"> | |
| 187 | − | <label htmlFor="guardrail-workflow-domains" className="text-sm font-medium"> | |
| 188 | − | Workflow-only domains | |
| 189 | − | </label> | |
| 190 | − | <p className="mt-1 text-sm text-muted"> | |
| 191 | − | Hosts that only workflow jobs may reach, such as the API a deploy uploads to. One | |
| 192 | − | per line: the domain, then the workflows and the environments it is for, each comma-separated; | |
| 193 | − | leave either out for any. Only jobs of runs that are not pull requests from forks get them. | |
| 194 | − | {level === "project" && " These add to the workspace's."} | |
| 195 | − | </p> | |
| 196 | − | <Textarea | |
| 197 | − | id="guardrail-workflow-domains" | |
| 198 | − | name="workflowDomains" | |
| 199 | − | className="mt-3 font-mono text-xs" | |
| 200 | − | rows={3} | |
| 201 | − | defaultValue={(own.workflowDomains ?? []).map(workflowDomainLine).join("\n")} | |
| 202 | − | placeholder="api.cloudflare.com | deploy.yml | production" | |
| 203 | − | /> | |
| 204 | − | {inheritedWorkflowDomains.length > 0 && ( | |
| 205 | − | <p className="mt-2 text-xs text-faint"> | |
| 206 | − | From the workspace:{" "} | |
| 207 | − | <span className="font-mono wrap-break-word">{inheritedWorkflowDomains.map(workflowDomainLine).join("; ")}</span> | |
| 208 | − | </p> | |
| 209 | − | )} | |
| 210 | − | </Card> | |
| 211 | − | </Section> | |
| 212 | − | ||
| 213 | 54 | <Section title="Time limits" about="How long one run may take. A run that reaches it is stopped."> | |
| 214 | 55 | <Card className="p-4"> | |
| 215 | 56 | <p className="text-sm font-medium">Time per run, in minutes</p> | |
| 277 | 277 | icon: <Eye size={18} />, | |
| 278 | 278 | title: "Guardrails", | |
| 279 | 279 | about: | |
| 280 | − | "Which hosts a project's jobs, checks and deploy builds may reach, and how long they may run, enforced in g1t's sandboxes.", | |
| 280 | + | "How long a project's checks and merge queue may run, enforced in g1t's sandboxes.", | |
| 281 | 281 | to: `${DOCS}/guides/guardrails/`, | |
| 282 | 282 | }, | |
| 283 | 283 | { |
| 5 | 5 | "": { title: "General", about: "The project's name, where its code lives, and what it is for." }, | |
| 6 | 6 | deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." }, | |
| 7 | 7 | domains: { title: "Domains", about: "Addresses of your own for the production deployment." }, | |
| 8 | − | guardrails: { title: "Guardrails", about: "What this project's sandboxes may reach, and how long their runs may take." }, | |
| 8 | + | guardrails: { title: "Guardrails", about: "How long this project's runs may take." }, | |
| 9 | 9 | repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." }, | |
| 10 | 10 | access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." }, | |
| 11 | 11 | keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." }, |
| 439 | 439 | </span> | |
| 440 | 440 | <Switch name="forks" checked={forks} onCheckedChange={setForks} disabled={!manage} /> | |
| 441 | 441 | </label> | |
| 442 | − | <p className="text-xs text-faint"> | |
| 443 | − | g1t's network guardrails are not enforced on your own machines: a job there reaches whatever the machine can. | |
| 444 | − | </p> | |
| 445 | 442 | {manage && ( | |
| 446 | 443 | <div className="flex flex-wrap gap-3"> | |
| 447 | 444 | <SubmitButton name="intent" value="settings" pending="Saving…"> |
| 20 | 20 | ||
| 21 | 21 | - **Integrations**, such as GitHub, Linear, Jira, Sentry and Datadog. | |
| 22 | 22 | - **Webhooks** you add, which send events to the addresses you choose. | |
| 23 | − | - **Hosts your sandboxes reach**, such as package registries and anything you allow in your guardrails. | |
| 23 | + | - **Hosts your sandboxes reach**, such as package registries and anything your jobs and builds fetch from or send to. | |
| 24 | 24 | - **Custom domains** you point at your deployments. | |
| 25 | 25 | ||
| 26 | 26 | Questions about subprocessors: [hey@flagon.io](mailto:hey@flagon.io). |
| 53 | 53 | ||
| 54 | 54 | - **An agent acts on behalf of the person who started its work**, within the limits of that person's access and the workspace's guardrails. Its actions are recorded as such in the audit log. | |
| 55 | 55 | - **You're responsible for what you ask agents to do**, and for checking what they produce before you rely on it, merge it or deploy it, as you would for a teammate's work. Agents make mistakes. Their output can be wrong, insecure, or similar to code that exists elsewhere, and you're responsible for making sure your use of it complies with the licenses and laws that apply. | |
| 56 | − | - **Guardrails** (network limits, command rules and caps) reduce risk. They're a safety net, not a guarantee that an agent won't do something you didn't intend. | |
| 56 | + | - **Guardrails** (time caps on runs) reduce risk. They're a safety net, not a guarantee that an agent won't do something you didn't intend. | |
| 57 | 57 | - **What agents produce for you is your content.** As between you and us, Flagon claims no rights in it. | |
| 58 | 58 | - **Agents cost money.** Runs, sandbox time and model use are charged to the workspace that owns the repository, as described on [pricing](/pricing). | |
| 59 | 59 |
| 24 | 24 | ||
| 25 | 25 | Each workspace decides what its agents may do, and each project can tighten it: | |
| 26 | 26 | ||
| 27 | − | - **A network allowlist.** By default a sandbox can reach only g1t itself, the package registries you turn on, and the hosts you list. It's enforced outside the sandbox, where its traffic leaves it: a restricted sandbox has no other route to the internet, and nothing running inside it, root included, can change that. | |
| 28 | 27 | - **A command hook.** The agent's harness checks every tool call before it runs, and refuses force-pushes, rewriting the default branch, reading files outside the project, printing the environment, `sudo`, and any rules you add. With **No sudo** on, the sandbox gives up root before the agent starts. | |
| 29 | 28 | - **Caps** on what one run may spend and how long it may take, enforced by the harness and, for time, by stopping the sandbox itself. | |
| 30 | 29 | - **Branch protection**, enforced by g1t when a push arrives, whatever happened in the sandbox. | |
| 31 | 30 | ||
| 32 | − | Command rules guard against an agent's mistakes; the network allowlist, the run's credentials and branch protection are the hard boundaries. The [guardrails guide](https://docs.g1t.sh/guides/guardrails/) explains each one and how it's enforced. | |
| 31 | + | Command rules guard against an agent's mistakes; the run's credentials and branch protection are the hard boundaries. The [guardrails guide](https://docs.g1t.sh/guides/guardrails/) explains each one and how it's enforced. | |
| 33 | 32 | ||
| 34 | 33 | ## Isolated sandboxes | |
| 35 | 34 | ||
| ⋯ | |||
| 75 | 74 | ||
| 76 | 75 | - g1t.sh, api.g1t.sh, mcp.g1t.sh, og.g1t.sh, docs.g1t.sh and status.g1t.sh; | |
| 77 | 76 | - the g1t.page platform itself: routing, isolation between apps, custom domains; | |
| 78 | − | - the sandboxes: escaping one, reaching another run or workspace, or getting around guardrails, run credentials or the network allowlist; | |
| 77 | + | - the sandboxes: escaping one, reaching another run or workspace, or getting around guardrails or run credentials; | |
| 79 | 78 | - authentication, authorization, the API, the MCP server and git over HTTPS; | |
| 80 | 79 | - the [open-source code](/flagon-io/g1t), as deployed. | |
| 81 | 80 | ||
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { capShare, formatCap, lines, settingsFromForm, tri, workflowDomainLine, workflowDomains } from "./guardrails.ts"; | |
| 4 | + | import { capShare, formatCap, settingsFromForm } from "./guardrails.ts"; | |
| 5 | 5 | ||
| 6 | − | const catalog = { | |
| 7 | − | registries: ["npm", "pypi", "crates"], | |
| 8 | − | kinds: ["implement", "review"] as const, | |
| 9 | − | }; | |
| 6 | + | const catalog = { kinds: ["implement", "review"] as const }; | |
| 10 | 7 | ||
| 11 | 8 | function form(entries: Record<string, string>): FormData { | |
| 12 | 9 | const data = new FormData(); | |
| ⋯ | |||
| 14 | 11 | return data; | |
| 15 | 12 | } | |
| 16 | 13 | ||
| 17 | − | test("workflow-only domains are read one per line, and written back the same way", () => { | |
| 18 | − | const read = workflowDomains( | |
| 19 | − | " api.cloudflare.com | deploy.yml | production \n\n*.example.com\nhooks.example.com | | staging, production\nci.example.com | ci.yml, release.yml\n | deploy.yml", | |
| 20 | − | ); | |
| 21 | − | assert.deepEqual(read, [ | |
| 22 | − | { domain: "api.cloudflare.com", workflows: ["deploy.yml"], environments: ["production"] }, | |
| 23 | − | { domain: "*.example.com", workflows: [], environments: [] }, | |
| 24 | − | { domain: "hooks.example.com", workflows: [], environments: ["staging", "production"] }, | |
| 25 | − | { domain: "ci.example.com", workflows: ["ci.yml", "release.yml"], environments: [] }, | |
| 26 | − | ]); | |
| 27 | − | assert.deepEqual(read.map(workflowDomainLine), [ | |
| 28 | − | "api.cloudflare.com | deploy.yml | production", | |
| 29 | − | "*.example.com", | |
| 30 | − | "hooks.example.com | | staging, production", | |
| 31 | − | "ci.example.com | ci.yml, release.yml", | |
| 32 | − | ]); | |
| 33 | − | const settings = settingsFromForm(form({ workflowDomains: "api.cloudflare.com | deploy.yml | production" }), catalog); | |
| 34 | − | assert.deepEqual(settings.workflowDomains, [{ domain: "api.cloudflare.com", workflows: ["deploy.yml"], environments: ["production"] }]); | |
| 35 | − | }); | |
| 36 | − | ||
| 37 | 14 | test("an untouched form inherits everything", () => { | |
| 38 | − | const settings = settingsFromForm(form({ restrictNetwork: "inherit", registries: "inherit" }), catalog); | |
| 39 | − | assert.equal(settings.restrictNetwork, null); | |
| 40 | − | assert.equal(settings.registries, null); | |
| 41 | − | assert.deepEqual(settings.minutes, {}); | |
| 42 | − | assert.deepEqual(settings.domains, []); | |
| 15 | + | assert.deepEqual(settingsFromForm(form({}), catalog), { minutes: {} }); | |
| 43 | 16 | }); | |
| 44 | 17 | ||
| 45 | − | test("choices of a level's own are kept", () => { | |
| 18 | + | test("time caps of a level's own are kept, and nothing else is sent", () => { | |
| 46 | 19 | const settings = settingsFromForm( | |
| 47 | 20 | form({ | |
| 48 | − | restrictNetwork: "off", | |
| 49 | − | registries: "custom", | |
| 50 | − | "registry:npm": "on", | |
| 51 | − | "registry:crates": "on", | |
| 52 | 21 | "minutes:implement": "45", | |
| 53 | 22 | "minutes:review": "", | |
| 54 | − | domains: "api.stripe.com\n\n api.stripe.com \r\n*.example.com", | |
| 23 | + | // Fields of a page from before network settings were removed. | |
| 24 | + | restrictNetwork: "on", | |
| 25 | + | domains: "api.stripe.com", | |
| 55 | 26 | }), | |
| 56 | 27 | catalog, | |
| 57 | 28 | ); | |
| 58 | − | assert.equal(settings.restrictNetwork, false); | |
| 59 | − | assert.deepEqual(settings.registries, ["npm", "crates"]); | |
| 60 | − | assert.deepEqual(settings.minutes, { implement: 45 }); | |
| 61 | − | assert.deepEqual(settings.domains, ["api.stripe.com", "*.example.com"]); | |
| 29 | + | assert.deepEqual(settings, { minutes: { implement: 45 } }); | |
| 62 | 30 | }); | |
| 63 | 31 | ||
| 64 | 32 | test("nonsense is passed on for the service to refuse", () => { | |
| ⋯ | |||
| 72 | 40 | }); | |
| 73 | 41 | ||
| 74 | 42 | test("helpers", () => { | |
| 75 | − | assert.equal(tri(undefined), "inherit"); | |
| 76 | − | assert.equal(tri(false), "off"); | |
| 77 | − | assert.deepEqual(lines(" a \nb\na"), ["a", "b"]); | |
| 78 | 43 | assert.equal(capShare(1, 4), 0.25); | |
| 79 | 44 | assert.equal(capShare(9, 4), 1); | |
| 80 | 45 | assert.equal(capShare(1, null), null); | |
| 1 | − | import type { GuardrailSettings, RunKind, WorkflowDomain } from "@g1t/contracts"; | |
| 1 | + | import type { GuardrailSettings, RunKind } from "@g1t/contracts"; | |
| 2 | 2 | import { microsOf, money } from "./money.ts"; | |
| 3 | − | ||
| 4 | − | /** What a level's form field means: inherit, or a choice of its own. */ | |
| 5 | − | export type Tri = "inherit" | "on" | "off"; | |
| 6 | − | ||
| 7 | − | export function tri(value: boolean | null | undefined): Tri { | |
| 8 | − | return value == null ? "inherit" : value ? "on" : "off"; | |
| 9 | − | } | |
| 10 | − | ||
| 11 | − | /** Lines of a textarea, trimmed, without blanks or repeats. */ | |
| 12 | − | export function lines(value: FormDataEntryValue | null): string[] { | |
| 13 | − | const seen = new Set<string>(); | |
| 14 | − | for (const line of String(value ?? "").split(/\r?\n/)) { | |
| 15 | − | const trimmed = line.trim(); | |
| 16 | − | if (trimmed) seen.add(trimmed); | |
| 17 | − | } | |
| 18 | − | return [...seen]; | |
| 19 | − | } | |
| 20 | − | ||
| 21 | − | /** | |
| 22 | − | * Workflow-only domains, one per line, as | |
| 23 | − | * `domain | workflows | environments`: the last two comma-separated, and | |
| 24 | − | * either left out or empty for any. `api.cloudflare.com | deploy.yml | | |
| 25 | − | * production` lets only deploy.yml's jobs in production reach it. | |
| 26 | − | */ | |
| 27 | − | export function workflowDomains(value: FormDataEntryValue | null): WorkflowDomain[] { | |
| 28 | − | const list = (text: string | undefined) => | |
| 29 | − | (text ?? "") | |
| 30 | − | .split(",") | |
| 31 | − | .map((part) => part.trim()) | |
| 32 | − | .filter(Boolean); | |
| 33 | − | const out: WorkflowDomain[] = []; | |
| 34 | − | for (const line of lines(value)) { | |
| 35 | − | const [domain, workflows, environments] = line.split("|").map((part) => part.trim()); | |
| 36 | − | if (!domain) continue; | |
| 37 | − | out.push({ domain, workflows: list(workflows), environments: list(environments) }); | |
| 38 | − | } | |
| 39 | − | return out; | |
| 40 | − | } | |
| 41 | − | ||
| 42 | − | /** A workflow-only domain as a line of the form's field. */ | |
| 43 | − | export function workflowDomainLine(entry: WorkflowDomain): string { | |
| 44 | − | const parts = [entry.domain, entry.workflows.join(", "), entry.environments.join(", ")]; | |
| 45 | − | while (parts.length > 1 && !parts[parts.length - 1]) parts.pop(); | |
| 46 | − | return parts.join(" | "); | |
| 47 | − | } | |
| 48 | 3 | ||
| 49 | 4 | /** A number from a field; empty means inherit. Not a number is kept, to be refused. */ | |
| 50 | 5 | function amount(value: FormDataEntryValue | null): number | null { | |
| ⋯ | |||
| 57 | 12 | /** The kinds of run that still use a sandbox: checks, the merge queue and merge checks. */ | |
| 58 | 13 | export const SANDBOX_RUN_KINDS = ["checks", "queue", "mergecheck"] as const satisfies readonly RunKind[]; | |
| 59 | 14 | ||
| 60 | − | /** What one level's form says, as the settings that level keeps. */ | |
| 61 | − | export function settingsFromForm( | |
| 62 | − | form: FormData, | |
| 63 | − | catalog: { registries: string[]; kinds: readonly RunKind[] }, | |
| 64 | − | ): GuardrailSettings { | |
| 65 | − | const choice = (name: string): boolean | null => { | |
| 66 | − | const value = form.get(name); | |
| 67 | − | return value === "on" ? true : value === "off" ? false : null; | |
| 68 | − | }; | |
| 15 | + | /** | |
| 16 | + | * What one level's form says, as the settings that level keeps: a time cap | |
| 17 | + | * for each kind of run it sets, the rest inherited. | |
| 18 | + | */ | |
| 19 | + | export function settingsFromForm(form: FormData, catalog: { kinds: readonly RunKind[] }): GuardrailSettings { | |
| 69 | 20 | const minutes: Record<string, number> = {}; | |
| 70 | 21 | for (const kind of catalog.kinds) { | |
| 71 | 22 | const cap = amount(form.get(`minutes:${kind}`)); | |
| 72 | 23 | if (cap != null) minutes[kind] = Number.isNaN(cap) ? 0 : Math.trunc(cap); | |
| 73 | 24 | } | |
| 74 | − | return { | |
| 75 | − | restrictNetwork: choice("restrictNetwork"), | |
| 76 | − | registries: | |
| 77 | − | form.get("registries") === "custom" | |
| 78 | − | ? catalog.registries.filter((id) => form.get(`registry:${id}`) === "on") | |
| 79 | − | : null, | |
| 80 | − | domains: lines(form.get("domains")), | |
| 81 | − | workflowDomains: workflowDomains(form.get("workflowDomains")), | |
| 82 | − | minutes, | |
| 83 | − | }; | |
| 25 | + | return { minutes }; | |
| 84 | 26 | } | |
| 85 | 27 | ||
| 86 | 28 | /** A cost in dollars, or "no cap". */ | |
| 144 | 144 | <span className="min-w-0 grow"> | |
| 145 | 145 | <span className="block text-sm font-medium">Guardrails</span> | |
| 146 | 146 | <span className="mt-0.5 block text-sm text-muted"> | |
| 147 | − | What this repository's sandboxes may reach, and how long their runs may take. | |
| 147 | + | How long this repository's runs may take. | |
| 148 | 148 | </span> | |
| 149 | 149 | </span> | |
| 150 | 150 | <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> |
| 26 | 26 | const user = requireUser(context, request); | |
| 27 | 27 | await requireCapability(context, params, "manage_protection"); | |
| 28 | 28 | const form = await request.formData(); | |
| 29 | − | const current = await guardrails.getGuardrails(user, params.owner); | |
| 30 | − | if (!current.ok) return { saved: false, error: current.error.message }; | |
| 31 | − | const settings = settingsFromForm(form, { | |
| 32 | − | registries: current.value.registries.map((registry) => registry.id), | |
| 33 | − | kinds: SANDBOX_RUN_KINDS, | |
| 34 | − | }); | |
| 29 | + | const settings = settingsFromForm(form, { kinds: SANDBOX_RUN_KINDS }); | |
| 35 | 30 | const saved = await guardrails.updateGuardrails(user, params.owner, { namespace: params.owner, name: params.repo }, settings); | |
| 36 | 31 | return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message }; | |
| 37 | 32 | } | |
| ⋯ | |||
| 42 | 37 | <> | |
| 43 | 38 | <RepoSettingsHeading base={base} /> | |
| 44 | 39 | <p className="mb-8 max-w-3xl text-sm text-muted"> | |
| 45 | − | What g1t, its checks and the merge queue may do in this project's sandboxes. Anything left as the | |
| 40 | + | How long this project's checks, merge queue and merge checks may run. Anything left as the | |
| 46 | 41 | workspace's follows the{" "} | |
| 47 | 42 | <Link to={`/${params.owner}/-/guardrails`} className="text-fg underline-offset-2 hover:underline"> | |
| 48 | 43 | workspace's defaults | |
| 24 | 24 | assertSameOrigin(request); | |
| 25 | 25 | const user = requireUser(context, request); | |
| 26 | 26 | const form = await request.formData(); | |
| 27 | − | const current = await guardrails.getGuardrails(user, params.owner); | |
| 28 | − | if (!current.ok) return { saved: false, error: current.error.message }; | |
| 29 | − | const settings = settingsFromForm(form, { | |
| 30 | − | registries: current.value.registries.map((registry) => registry.id), | |
| 31 | − | kinds: SANDBOX_RUN_KINDS, | |
| 32 | − | }); | |
| 27 | + | const settings = settingsFromForm(form, { kinds: SANDBOX_RUN_KINDS }); | |
| 33 | 28 | const saved = await guardrails.updateGuardrails(user, params.owner, null, settings); | |
| 34 | 29 | return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message }; | |
| 35 | 30 | } |
| 103 | 103 | }, | |
| 104 | 104 | guardrails: { | |
| 105 | 105 | title: "Guardrails", | |
| 106 | − | about: "What every project's sandboxes may reach, and how long their runs may take. Each project can override these under its settings.", | |
| 106 | + | about: "How long every project's runs may take. Each project can override these under its settings.", | |
| 107 | 107 | }, | |
| 108 | 108 | webhooks: { | |
| 109 | 109 | title: "Webhooks", |
| 90 | 90 | Policies | |
| 91 | 91 | </h2> | |
| 92 | 92 | <p className="mt-1.5 text-sm text-muted"> | |
| 93 | − | What sandboxes may reach, the rules every repository follows, and how the workspace's security is set up. | |
| 93 | + | How long runs may take, the rules every repository follows, and how the workspace's security is set up. | |
| 94 | 94 | </p> | |
| 95 | 95 | <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm"> | |
| 96 | 96 | <Link to={`/${slug}/-/guardrails`} className="text-accent hover:underline"> |
| 1048 | 1048 | pub repo: RepoPath, | |
| 1049 | 1049 | /// Minutes before the job is stopped. | |
| 1050 | 1050 | pub timeout_minutes: u32, | |
| 1051 | − | /// The workflow file the job is in (`.g1t/workflows/deploy.yml`), for | |
| 1052 | − | /// the guardrails' workflow-only domains. | |
| 1053 | − | #[serde(default)] | |
| 1054 | − | pub workflow: Option<String>, | |
| 1055 | − | /// The environment the job names with `environment:`, when it names | |
| 1056 | − | /// one plainly (not with an expression). | |
| 1057 | − | #[serde(default)] | |
| 1058 | − | pub environment: Option<String>, | |
| 1059 | − | /// Whether its run is trusted: not a pull request from a fork. Only a | |
| 1060 | − | /// trusted run's jobs reach workflow-only domains. | |
| 1061 | − | #[serde(default)] | |
| 1062 | − | pub trusted: bool, | |
| 1063 | 1051 | /// The machine its `runs-on` asked for, by label (`instance_for`): | |
| 1064 | 1052 | /// `g1t-2core` or `g1t-4core`; absent, the standard one. | |
| 1065 | 1053 | #[serde(default)] | |
| 1066 | 1054 | pub instance: Option<String>, | |
| 1067 | 1055 | /// Whether the job may be given brokered secrets, so the runner sends | |
| 1068 | − | /// its HTTP and HTTPS through the Worker even when its network is open. | |
| 1056 | + | /// its HTTP and HTTPS through the Worker, which fills them in. | |
| 1069 | 1057 | #[serde(default)] | |
| 1070 | 1058 | pub brokered: bool, | |
| 1071 | 1059 | } | |
| ⋯ | |||
| 1789 | 1777 | "job": "job_1", "token": "t", "repo": { "namespace": "acme", "name": "web" }, "timeoutMinutes": 30 | |
| 1790 | 1778 | })) | |
| 1791 | 1779 | .unwrap(); | |
| 1792 | − | assert!(args.workflow.is_none() && args.environment.is_none() && !args.trusted && args.instance.is_none()); | |
| 1780 | + | assert!(args.instance.is_none() && !args.brokered); | |
| 1781 | + | // Callers from when workflow-only domains existed still read. | |
| 1782 | + | let older: StartJobArgs = serde_json::from_value(serde_json::json!({ | |
| 1783 | + | "job": "job_1", "token": "t", "repo": { "namespace": "acme", "name": "web" }, "timeoutMinutes": 30, | |
| 1784 | + | "workflow": ".g1t/workflows/deploy.yml", "environment": "production", "trusted": true | |
| 1785 | + | })) | |
| 1786 | + | .unwrap(); | |
| 1787 | + | assert_eq!(older.timeout_minutes, 30); | |
| 1793 | 1788 | } | |
| 1794 | 1789 | } | |
| 1795 | 1790 | ||
| 1 | − | //! Guardrails: what a workspace lets its sandboxes do. Kept by the work | |
| 1 | + | //! Guardrails: how long a workspace's runs may take. Kept by the work | |
| 2 | 2 | //! service. | |
| 3 | 3 | //! | |
| 4 | − | //! A workspace sets defaults and each project may override them. Two kinds | |
| 5 | − | //! of rule come out of the two: | |
| 6 | − | //! | |
| 7 | − | //! - **Network**: which hosts a sandbox may reach. g1t's own hosts always, | |
| 8 | − | //! the package registries the project needs, and any domains listed. | |
| 9 | − | //! Everything else is refused at the sandbox's edge. Some domains can be | |
| 10 | − | //! opened to workflow jobs only. | |
| 11 | − | //! - **Time**: how long each kind of run may take before g1t stops it. | |
| 4 | + | //! A workspace sets defaults and each project may override them: a time | |
| 5 | + | //! cap for each kind of run, after which g1t stops it. Sandboxes reach the | |
| 6 | + | //! internet as any CI machine does; guardrails say nothing about the | |
| 7 | + | //! network. | |
| 12 | 8 | //! | |
| 13 | − | //! Levels saved when guardrails also had command rules, deny patterns and | |
| 14 | − | //! a cost cap (for g1t's own agents, since removed) still read: those | |
| 15 | − | //! fields are ignored. | |
| 9 | + | //! Levels saved when guardrails also had network rules (restricting the | |
| 10 | + | //! network, package registries, allowed domains and workflow-only | |
| 11 | + | //! domains), command rules, deny patterns and a cost cap still read: those | |
| 12 | + | //! fields are ignored, and dropped when the level is saved again. | |
| 16 | 13 | //! | |
| 17 | 14 | //! Each `*Args` struct is the argument of the method of the same name, | |
| 18 | 15 | //! served at `POST /rpc/<method>`. | |
| ⋯ | |||
| 24 | 21 | use crate::agents::RunKind; | |
| 25 | 22 | use crate::repos::RepoPath; | |
| 26 | 23 | use crate::{User, Viewer}; | |
| 27 | − | ||
| 28 | − | /// g1t's own hosts. Always reachable: without them a sandbox could not | |
| 29 | − | /// clone, push or report. | |
| 30 | − | pub const G1T_HOSTS: &[&str] = &["g1t.sh", "api.g1t.sh", "mcp.g1t.sh"]; | |
| 31 | − | ||
| 32 | − | /// A package registry, which a project turns on or off as one. | |
| 33 | − | #[derive(Clone, Copy, Debug)] | |
| 34 | − | pub struct Registry { | |
| 35 | − | pub id: &'static str, | |
| 36 | − | pub name: &'static str, | |
| 37 | − | pub hosts: &'static [&'static str], | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | /// The registries a sandbox can be given, all on by default. | |
| 41 | − | pub const REGISTRIES: &[Registry] = &[ | |
| 42 | − | Registry { | |
| 43 | − | id: "npm", | |
| 44 | − | name: "npm and Yarn", | |
| 45 | − | hosts: &["registry.npmjs.org", "registry.yarnpkg.com", "repo.yarnpkg.com"], | |
| 46 | − | }, | |
| 47 | − | Registry { | |
| 48 | − | id: "pypi", | |
| 49 | − | name: "PyPI", | |
| 50 | − | hosts: &["pypi.org", "files.pythonhosted.org"], | |
| 51 | − | }, | |
| 52 | − | Registry { | |
| 53 | − | id: "crates", | |
| 54 | − | name: "crates.io and Rust toolchains", | |
| 55 | − | hosts: &["crates.io", "index.crates.io", "static.crates.io", "static.rust-lang.org"], | |
| 56 | − | }, | |
| 57 | − | Registry { | |
| 58 | − | id: "go", | |
| 59 | − | name: "Go module proxy", | |
| 60 | − | hosts: &["proxy.golang.org", "sum.golang.org"], | |
| 61 | − | }, | |
| 62 | − | Registry { | |
| 63 | − | id: "github", | |
| 64 | − | name: "GitHub downloads", | |
| 65 | − | hosts: &[ | |
| 66 | − | "codeload.github.com", | |
| 67 | − | "raw.githubusercontent.com", | |
| 68 | − | "objects.githubusercontent.com", | |
| 69 | − | ], | |
| 70 | − | }, | |
| 71 | − | ]; | |
| 72 | 24 | ||
| 73 | − | /// The most domains one level keeps. | |
| 74 | − | pub const MAX_DOMAINS: usize = 100; | |
| 75 | − | /// The most workflow-only domains one level keeps. | |
| 76 | − | pub const MAX_WORKFLOW_DOMAINS: usize = 50; | |
| 77 | − | const MAX_NAME_CHARS: usize = 255; | |
| 78 | 25 | /// The longest any run may be allowed to take, in minutes. | |
| 79 | 26 | pub const MAX_MINUTES: u32 = 240; | |
| 80 | 27 | ||
| ⋯ | |||
| 99 | 46 | ||
| 100 | 47 | /// What one level, the workspace or a project, sets. Anything left unset | |
| 101 | 48 | /// is inherited: a project from its workspace, a workspace from g1t's | |
| 102 | − | /// defaults. Domains add up across the two levels. | |
| 49 | + | /// defaults. | |
| 103 | 50 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 104 | 51 | #[serde(rename_all = "camelCase", default)] | |
| 105 | 52 | pub struct GuardrailSettings { | |
| 106 | − | /// Whether sandboxes may reach only the allowed hosts. | |
| 107 | − | pub restrict_network: Option<bool>, | |
| 108 | − | /// The registries that are on, by id. Replaces the inherited list. | |
| 109 | − | pub registries: Option<Vec<String>>, | |
| 110 | − | /// More hosts to allow: `example.com`, or `*.example.com` for its | |
| 111 | − | /// subdomains. | |
| 112 | − | pub domains: Vec<String>, | |
| 113 | − | /// Hosts only workflow jobs may reach, never agents: a deploy's API, | |
| 114 | − | /// say. Each can be limited to some workflows and environments. They | |
| 115 | − | /// add to the other level's. | |
| 116 | − | pub workflow_domains: Vec<WorkflowDomain>, | |
| 117 | 53 | /// How long a run may take, in minutes, by kind of run. | |
| 118 | 54 | pub minutes: BTreeMap<String, u32>, | |
| 119 | 55 | /// Username of whoever last changed this level. | |
| 120 | 56 | pub updated_by: Option<String>, | |
| 121 | 57 | /// RFC 3339. | |
| 122 | 58 | pub updated_at: Option<String>, | |
| 123 | − | } | |
| 124 | − | ||
| 125 | − | /// A host that only workflow jobs may reach: jobs of a trusted run (not a | |
| 126 | − | /// pull request from a fork), of the workflows named, in the environments | |
| 127 | − | /// named. Agents, checks, the merge queue and g1t.page builds never do. | |
| 128 | − | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 129 | − | #[serde(rename_all = "camelCase", default)] | |
| 130 | − | pub struct WorkflowDomain { | |
| 131 | − | /// `api.example.com`, or `*.example.com` for its subdomains. | |
| 132 | − | pub domain: String, | |
| 133 | − | /// Workflow files by name, such as `deploy.yml`. Empty: any workflow. | |
| 134 | − | pub workflows: Vec<String>, | |
| 135 | − | /// The environments a job must name with `environment:`, such as | |
| 136 | − | /// `production`. Empty: any job, whether it names one or not. | |
| 137 | − | pub environments: Vec<String>, | |
| 138 | − | } | |
| 139 | − | ||
| 140 | − | impl WorkflowDomain { | |
| 141 | − | /// Whether a job of `workflow` (its path or file name) in `environment` | |
| 142 | − | /// may reach this domain. Names compare without regard to case. | |
| 143 | − | pub fn applies_to(&self, workflow: &str, environment: Option<&str>) -> bool { | |
| 144 | − | let file = workflow_file(workflow); | |
| 145 | − | let workflow_ok = self.workflows.is_empty() || self.workflows.iter().any(|w| workflow_file(w).eq_ignore_ascii_case(file)); | |
| 146 | − | let environment_ok = self.environments.is_empty() | |
| 147 | − | || environment.is_some_and(|env| self.environments.iter().any(|e| e.eq_ignore_ascii_case(env.trim()))); | |
| 148 | − | workflow_ok && environment_ok | |
| 149 | − | } | |
| 150 | − | } | |
| 151 | − | ||
| 152 | − | /// A workflow's file name: `.g1t/workflows/deploy.yml` is `deploy.yml`. | |
| 153 | − | fn workflow_file(path: &str) -> &str { | |
| 154 | − | let path = path.trim(); | |
| 155 | − | path.rsplit(['/', '\\']).next().unwrap_or(path) | |
| 156 | 59 | } | |
| 157 | 60 | ||
| 158 | 61 | /// The guardrails a run actually gets: g1t's defaults, then the | |
| ⋯ | |||
| 160 | 63 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 161 | 64 | #[serde(rename_all = "camelCase")] | |
| 162 | 65 | pub struct Guardrails { | |
| 163 | − | pub restrict_network: bool, | |
| 164 | − | pub registries: Vec<String>, | |
| 165 | − | /// The domains listed at either level, workspace first. | |
| 166 | − | pub domains: Vec<String>, | |
| 167 | − | /// Every host a sandbox may reach: g1t's, the registries', the domains. | |
| 168 | − | pub hosts: Vec<String>, | |
| 169 | − | /// Hosts only some workflow jobs may reach, from both levels, | |
| 170 | − | /// workspace first. Never in `hosts`. | |
| 171 | − | #[serde(default)] | |
| 172 | − | pub workflow_domains: Vec<WorkflowDomain>, | |
| 173 | 66 | /// Every kind of run. | |
| 174 | 67 | pub minutes: BTreeMap<String, u32>, | |
| 68 | + | /// Always false, and `hosts` always empty: sent for a runner deployed | |
| 69 | + | /// before network rules were removed, which reads them to start its | |
| 70 | + | /// sandboxes with an open network rather than failing to start them. | |
| 71 | + | /// Never read. Remove once the runner after this change is deployed. | |
| 72 | + | #[serde(skip_deserializing)] | |
| 73 | + | pub restrict_network: bool, | |
| 74 | + | #[serde(skip_deserializing)] | |
| 75 | + | pub hosts: Vec<String>, | |
| 175 | 76 | } | |
| 176 | 77 | ||
| 177 | 78 | impl Guardrails { | |
| 178 | − | /// g1t's defaults: network restricted to g1t and every registry, and a | |
| 179 | − | /// time cap for each kind of run. | |
| 79 | + | /// g1t's defaults: a time cap for each kind of run. | |
| 180 | 80 | pub fn defaults() -> Self { | |
| 181 | − | let mut defaults = Guardrails { | |
| 182 | − | restrict_network: true, | |
| 183 | − | registries: REGISTRIES.iter().map(|registry| registry.id.to_owned()).collect(), | |
| 184 | − | domains: Vec::new(), | |
| 185 | − | hosts: Vec::new(), | |
| 186 | − | workflow_domains: Vec::new(), | |
| 81 | + | Guardrails { | |
| 187 | 82 | minutes: RunKind::ALL | |
| 188 | 83 | .into_iter() | |
| 189 | 84 | .map(|kind| (kind.as_str().to_owned(), default_minutes(kind))) | |
| 190 | 85 | .collect(), | |
| 191 | − | }; | |
| 192 | − | defaults.hosts = defaults.allowed_hosts(); | |
| 193 | − | defaults | |
| 86 | + | restrict_network: false, | |
| 87 | + | hosts: Vec::new(), | |
| 88 | + | } | |
| 194 | 89 | } | |
| 195 | 90 | ||
| 196 | 91 | /// One level laid over what it inherits. | |
| 197 | 92 | pub fn apply(mut self, level: &GuardrailSettings) -> Self { | |
| 198 | − | if let Some(restrict) = level.restrict_network { | |
| 199 | − | self.restrict_network = restrict; | |
| 200 | − | } | |
| 201 | − | if let Some(registries) = &level.registries { | |
| 202 | − | self.registries = REGISTRIES | |
| 203 | − | .iter() | |
| 204 | − | .filter(|registry| registries.iter().any(|id| id == registry.id)) | |
| 205 | − | .map(|registry| registry.id.to_owned()) | |
| 206 | − | .collect(); | |
| 207 | − | } | |
| 208 | − | for domain in &level.domains { | |
| 209 | − | if !self.domains.contains(domain) { | |
| 210 | − | self.domains.push(domain.clone()); | |
| 211 | − | } | |
| 212 | − | } | |
| 213 | − | for entry in &level.workflow_domains { | |
| 214 | − | if !self.workflow_domains.contains(entry) { | |
| 215 | − | self.workflow_domains.push(entry.clone()); | |
| 216 | − | } | |
| 217 | − | } | |
| 218 | 93 | for (kind, minutes) in &level.minutes { | |
| 219 | 94 | if let Some(cap) = self.minutes.get_mut(kind) { | |
| 220 | 95 | *cap = *minutes; | |
| 221 | 96 | } | |
| 222 | 97 | } | |
| 223 | − | self.hosts = self.allowed_hosts(); | |
| 224 | 98 | self | |
| 225 | 99 | } | |
| 226 | 100 | ||
| ⋯ | |||
| 230 | 104 | match project { | |
| 231 | 105 | Some(project) => inherited.apply(project), | |
| 232 | 106 | None => inherited, | |
| 233 | − | } | |
| 234 | − | } | |
| 235 | − | ||
| 236 | − | fn allowed_hosts(&self) -> Vec<String> { | |
| 237 | − | let mut hosts: Vec<String> = G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect(); | |
| 238 | − | for registry in REGISTRIES { | |
| 239 | − | if self.registries.iter().any(|id| id == registry.id) { | |
| 240 | − | hosts.extend(registry.hosts.iter().map(|host| (*host).to_owned())); | |
| 241 | − | } | |
| 242 | − | } | |
| 243 | − | for domain in &self.domains { | |
| 244 | − | if !hosts.contains(domain) { | |
| 245 | − | hosts.push(domain.clone()); | |
| 246 | − | } | |
| 247 | − | } | |
| 248 | − | hosts | |
| 249 | − | } | |
| 250 | − | ||
| 251 | − | /// The hosts a job of `workflow` (its path) in `environment` may | |
| 252 | − | /// reach on top of `hosts`: the workflow-only domains that apply to | |
| 253 | − | /// it. For workflow jobs of trusted runs only; the runner never adds | |
| 254 | − | /// them for anything else. | |
| 255 | − | pub fn workflow_hosts(&self, workflow: &str, environment: Option<&str>) -> Vec<String> { | |
| 256 | − | let mut hosts: Vec<String> = Vec::new(); | |
| 257 | − | for entry in self.workflow_domains.iter().filter(|entry| entry.applies_to(workflow, environment)) { | |
| 258 | − | if !hosts.contains(&entry.domain) { | |
| 259 | − | hosts.push(entry.domain.clone()); | |
| 260 | − | } | |
| 261 | 107 | } | |
| 262 | − | hosts | |
| 263 | 108 | } | |
| 264 | 109 | ||
| 265 | 110 | /// The time cap of a kind of run, in minutes. | |
| ⋯ | |||
| 271 | 116 | } | |
| 272 | 117 | } | |
| 273 | 118 | ||
| 274 | − | /// A domain as it is kept: lower case, no scheme, path or port, optionally | |
| 275 | − | /// `*.` for its subdomains. Refused if it is not a host name. | |
| 119 | + | /// A host name as it is kept: lower case, no scheme, path or port, | |
| 120 | + | /// optionally `*.` for its subdomains. Refused if it is not a host name. | |
| 121 | + | /// The hosts a brokered secret is for are kept this way. | |
| 276 | 122 | pub fn normalize_domain(input: &str) -> Result<String, String> { | |
| 277 | 123 | let mut domain = input.trim().to_lowercase(); | |
| 278 | 124 | for scheme in ["https://", "http://"] { | |
| ⋯ | |||
| 304 | 150 | ||
| 305 | 151 | /// One level's settings, checked and tidied before they are kept. | |
| 306 | 152 | pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> { | |
| 307 | − | let mut domains = Vec::new(); | |
| 308 | − | for domain in &settings.domains { | |
| 309 | − | if domain.trim().is_empty() { | |
| 310 | − | continue; | |
| 311 | − | } | |
| 312 | − | let domain = normalize_domain(domain)?; | |
| 313 | − | if !domains.contains(&domain) { | |
| 314 | − | domains.push(domain); | |
| 315 | − | } | |
| 316 | − | } | |
| 317 | − | if domains.len() > MAX_DOMAINS { | |
| 318 | − | return Err(format!("At most {MAX_DOMAINS} domains can be listed.")); | |
| 319 | − | } | |
| 320 | − | let mut workflow_domains: Vec<WorkflowDomain> = Vec::new(); | |
| 321 | − | for entry in &settings.workflow_domains { | |
| 322 | − | if entry.domain.trim().is_empty() { | |
| 323 | − | continue; | |
| 324 | − | } | |
| 325 | − | let entry = validate_workflow_domain(entry)?; | |
| 326 | − | if !workflow_domains.contains(&entry) { | |
| 327 | − | workflow_domains.push(entry); | |
| 328 | − | } | |
| 329 | − | } | |
| 330 | − | if workflow_domains.len() > MAX_WORKFLOW_DOMAINS { | |
| 331 | − | return Err(format!("At most {MAX_WORKFLOW_DOMAINS} workflow-only domains can be listed.")); | |
| 332 | − | } | |
| 333 | 153 | let mut minutes = BTreeMap::new(); | |
| 334 | 154 | for (kind, cap) in settings.minutes { | |
| 335 | 155 | if RunKind::parse(&kind).is_none() { | |
| ⋯ | |||
| 340 | 160 | } | |
| 341 | 161 | minutes.insert(kind, cap); | |
| 342 | 162 | } | |
| 343 | − | let registries = settings.registries.map(|ids| { | |
| 344 | − | REGISTRIES | |
| 345 | − | .iter() | |
| 346 | − | .filter(|registry| ids.iter().any(|id| id == registry.id)) | |
| 347 | − | .map(|registry| registry.id.to_owned()) | |
| 348 | − | .collect() | |
| 349 | − | }); | |
| 350 | 163 | Ok(GuardrailSettings { | |
| 351 | − | restrict_network: settings.restrict_network, | |
| 352 | − | registries, | |
| 353 | − | domains, | |
| 354 | − | workflow_domains, | |
| 355 | 164 | minutes, | |
| 356 | 165 | updated_by: settings.updated_by, | |
| 357 | 166 | updated_at: settings.updated_at, | |
| 358 | 167 | }) | |
| 359 | − | } | |
| 360 | − | ||
| 361 | − | /// A workflow-only domain, tidied: its domain as `normalize_domain` keeps | |
| 362 | − | /// it, workflows as file names ending in `.yml` or `.yaml`, and | |
| 363 | − | /// environments as given, each list without repeats. | |
| 364 | − | pub fn validate_workflow_domain(entry: &WorkflowDomain) -> Result<WorkflowDomain, String> { | |
| 365 | − | let domain = normalize_domain(&entry.domain)?; | |
| 366 | − | let mut workflows: Vec<String> = Vec::new(); | |
| 367 | − | for workflow in entry.workflows.iter().map(|w| workflow_file(w).to_owned()).filter(|w| !w.is_empty()) { | |
| 368 | − | let lower = workflow.to_lowercase(); | |
| 369 | − | let valid = (lower.ends_with(".yml") || lower.ends_with(".yaml")) | |
| 370 | − | && workflow.chars().count() <= MAX_NAME_CHARS | |
| 371 | − | && workflow.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); | |
| 372 | − | if !valid { | |
| 373 | − | return Err(format!("{workflow} is not a workflow file. Name it as it is in .g1t/workflows, such as deploy.yml.")); | |
| 374 | − | } | |
| 375 | − | if !workflows.iter().any(|w| w.eq_ignore_ascii_case(&workflow)) { | |
| 376 | − | workflows.push(workflow); | |
| 377 | − | } | |
| 378 | − | } | |
| 379 | − | let mut environments: Vec<String> = Vec::new(); | |
| 380 | − | for environment in entry.environments.iter().map(|e| e.trim().to_owned()).filter(|e| !e.is_empty()) { | |
| 381 | − | if environment.chars().count() > MAX_NAME_CHARS || environment.contains(['\n', '\r']) || environment.contains("${{") { | |
| 382 | − | return Err(format!("{environment} is not an environment name.")); | |
| 383 | − | } | |
| 384 | − | if !environments.iter().any(|e| e.eq_ignore_ascii_case(&environment)) { | |
| 385 | − | environments.push(environment); | |
| 386 | − | } | |
| 387 | − | } | |
| 388 | − | Ok(WorkflowDomain { domain, workflows, environments }) | |
| 389 | − | } | |
| 390 | − | ||
| 391 | − | /// A registry as the settings page shows it. | |
| 392 | − | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 393 | − | pub struct RegistryInfo { | |
| 394 | − | pub id: String, | |
| 395 | − | pub name: String, | |
| 396 | − | pub hosts: Vec<String>, | |
| 397 | 168 | } | |
| 398 | 169 | ||
| 399 | 170 | /// Everything the settings pages show: each level as it was set, what | |
| ⋯ | |||
| 409 | 180 | pub inherited: Guardrails, | |
| 410 | 181 | /// What runs get: the project's, or with no project, the workspace's. | |
| 411 | 182 | pub effective: Guardrails, | |
| 412 | − | pub g1t_hosts: Vec<String>, | |
| 413 | − | pub registries: Vec<RegistryInfo>, | |
| 414 | 183 | } | |
| 415 | 184 | ||
| 416 | 185 | impl GuardrailsView { | |
| ⋯ | |||
| 423 | 192 | defaults: Guardrails::defaults(), | |
| 424 | 193 | inherited, | |
| 425 | 194 | effective, | |
| 426 | − | g1t_hosts: G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect(), | |
| 427 | − | registries: REGISTRIES | |
| 428 | − | .iter() | |
| 429 | − | .map(|registry| RegistryInfo { | |
| 430 | − | id: registry.id.to_owned(), | |
| 431 | − | name: registry.name.to_owned(), | |
| 432 | − | hosts: registry.hosts.iter().map(|host| (*host).to_owned()).collect(), | |
| 433 | − | }) | |
| 434 | − | .collect(), | |
| 435 | 195 | } | |
| 436 | 196 | } | |
| 437 | 197 | } | |
| ⋯ | |||
| 508 | 268 | } | |
| 509 | 269 | ||
| 510 | 270 | #[test] | |
| 511 | − | fn defaults_restrict_to_g1t_and_every_registry() { | |
| 271 | + | fn defaults_cap_every_kind_of_run() { | |
| 512 | 272 | let defaults = Guardrails::defaults(); | |
| 513 | − | assert!(defaults.restrict_network); | |
| 514 | − | assert!(defaults.hosts.iter().any(|host| host == "api.g1t.sh")); | |
| 515 | − | assert!(defaults.hosts.iter().any(|host| host == "registry.npmjs.org")); | |
| 516 | − | assert!(defaults.hosts.iter().any(|host| host == "codeload.github.com")); | |
| 517 | − | assert!(!defaults.hosts.iter().any(|host| host == "github.com")); | |
| 518 | − | assert!(!defaults.hosts.iter().any(|host| host == "models.g1t.sh")); | |
| 519 | 273 | assert_eq!(defaults.minutes_for(RunKind::Implement), 90); | |
| 274 | + | assert_eq!(defaults.minutes.len(), RunKind::ALL.len()); | |
| 520 | 275 | } | |
| 521 | 276 | ||
| 522 | 277 | #[test] | |
| ⋯ | |||
| 527 | 282 | #[test] | |
| 528 | 283 | fn a_project_overrides_its_workspace() { | |
| 529 | 284 | let workspace = GuardrailSettings { | |
| 530 | − | registries: Some(vec!["npm".into(), "pypi".into()]), | |
| 531 | − | minutes: BTreeMap::from([("implement".to_owned(), 30)]), | |
| 285 | + | minutes: BTreeMap::from([("implement".to_owned(), 30), ("review".to_owned(), 20)]), | |
| 532 | 286 | ..level() | |
| 533 | 287 | }; | |
| 534 | 288 | let project = GuardrailSettings { | |
| 535 | − | registries: Some(vec!["crates".into()]), | |
| 289 | + | minutes: BTreeMap::from([("review".to_owned(), 15)]), | |
| 536 | 290 | ..level() | |
| 537 | 291 | }; | |
| 538 | 292 | let inherited = Guardrails::merge(&workspace, None); | |
| 539 | − | assert_eq!(inherited.registries, vec!["npm", "pypi"]); | |
| 540 | − | assert!(inherited.hosts.iter().any(|host| host == "pypi.org")); | |
| 541 | − | assert!(!inherited.hosts.iter().any(|host| host == "crates.io")); | |
| 542 | − | ||
| 293 | + | assert_eq!(inherited.minutes_for(RunKind::Review), 20); | |
| 543 | 294 | let effective = Guardrails::merge(&workspace, Some(&project)); | |
| 544 | − | assert_eq!(effective.registries, vec!["crates"]); | |
| 545 | − | assert!(effective.hosts.iter().any(|host| host == "crates.io")); | |
| 546 | − | assert!(!effective.hosts.iter().any(|host| host == "pypi.org")); | |
| 547 | 295 | // Inherited where the project says nothing. | |
| 548 | 296 | assert_eq!(effective.minutes_for(RunKind::Implement), 30); | |
| 549 | − | assert_eq!(effective.minutes_for(RunKind::Review), 30); | |
| 550 | − | // g1t's own hosts can never be turned off. | |
| 551 | − | assert!(effective.hosts.iter().any(|host| host == "g1t.sh")); | |
| 297 | + | assert_eq!(effective.minutes_for(RunKind::Review), 15); | |
| 552 | 298 | } | |
| 553 | 299 | ||
| 554 | 300 | #[test] | |
| 555 | − | fn domains_add_up() { | |
| 556 | − | let workspace = GuardrailSettings { domains: vec!["api.stripe.com".into()], ..level() }; | |
| 557 | − | let project = GuardrailSettings { domains: vec!["*.example.com".into(), "api.stripe.com".into()], ..level() }; | |
| 558 | − | let effective = Guardrails::merge(&workspace, Some(&project)); | |
| 559 | − | assert_eq!(effective.domains, vec!["api.stripe.com", "*.example.com"]); | |
| 560 | − | assert!(effective.hosts.iter().any(|host| host == "*.example.com")); | |
| 561 | − | } | |
| 562 | − | ||
| 563 | − | fn workflow_domain(domain: &str, workflows: &[&str], environments: &[&str]) -> WorkflowDomain { | |
| 564 | − | WorkflowDomain { | |
| 565 | − | domain: domain.into(), | |
| 566 | − | workflows: workflows.iter().map(|w| (*w).to_owned()).collect(), | |
| 567 | − | environments: environments.iter().map(|e| (*e).to_owned()).collect(), | |
| 568 | − | } | |
| 569 | − | } | |
| 570 | − | ||
| 571 | − | #[test] | |
| 572 | − | fn workflow_domains_are_never_hosts_and_reach_only_the_jobs_named() { | |
| 573 | − | let workspace = GuardrailSettings { | |
| 574 | − | workflow_domains: vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])], | |
| 575 | − | ..level() | |
| 576 | − | }; | |
| 577 | − | let project = GuardrailSettings { | |
| 578 | − | workflow_domains: vec![ | |
| 579 | − | workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]), | |
| 580 | − | workflow_domain("*.example.com", &[], &[]), | |
| 581 | − | ], | |
| 582 | − | ..level() | |
| 583 | − | }; | |
| 584 | − | let effective = Guardrails::merge(&workspace, Some(&project)); | |
| 585 | − | // Added up across the levels, once each, and never for agents. | |
| 586 | − | assert_eq!(effective.workflow_domains.len(), 2); | |
| 587 | − | assert!(!effective.hosts.iter().any(|host| host == "api.cloudflare.com" || host == "*.example.com")); | |
| 588 | − | // deploy.yml's jobs in production, by path or name, in any case. | |
| 589 | − | assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("production")), ["api.cloudflare.com", "*.example.com"]); | |
| 590 | − | assert_eq!(effective.workflow_hosts("DEPLOY.yml", Some("Production")), ["api.cloudflare.com", "*.example.com"]); | |
| 591 | − | // Another environment, none, or another workflow: only the open one. | |
| 592 | − | assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("staging")), ["*.example.com"]); | |
| 593 | − | assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", None), ["*.example.com"]); | |
| 594 | − | assert_eq!(effective.workflow_hosts(".g1t/workflows/ci.yml", Some("production")), ["*.example.com"]); | |
| 595 | − | } | |
| 596 | − | ||
| 597 | − | #[test] | |
| 598 | − | fn workflow_domains_are_tidied_or_refused() { | |
| 599 | − | let settings = validate(GuardrailSettings { | |
| 600 | − | workflow_domains: vec![ | |
| 601 | − | workflow_domain(" HTTPS://API.Cloudflare.com/client/v4 ", &[".g1t/workflows/deploy.yml", "deploy.yml"], &[" production ", "Production"]), | |
| 602 | − | workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]), | |
| 603 | − | workflow_domain(" ", &[], &[]), | |
| 604 | − | ], | |
| 605 | − | ..level() | |
| 606 | − | }) | |
| 301 | + | fn levels_saved_with_network_rules_still_read_and_drop_them() { | |
| 302 | + | // Network rules, command rules, deny patterns and a cost cap, from | |
| 303 | + | // before, are ignored, and gone once the level is saved again. | |
| 304 | + | let old: GuardrailSettings = serde_json::from_str( | |
| 305 | + | r#"{"restrictNetwork":true,"registries":["npm"],"domains":["example.com"], | |
| 306 | + | "workflowDomains":[{"domain":"api.cloudflare.com","workflows":["deploy.yml"],"environments":[]}], | |
| 307 | + | "rules":{"sudo":false},"deny":["Bash(rm:*)"],"budgetUsd":2.5,"minutes":{"implement":45}}"#, | |
| 308 | + | ) | |
| 607 | 309 | .unwrap(); | |
| 608 | − | assert_eq!(settings.workflow_domains, vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])]); | |
| 609 | − | let refused = |entry: WorkflowDomain| validate(GuardrailSettings { workflow_domains: vec![entry], ..level() }).is_err(); | |
| 610 | − | assert!(refused(workflow_domain("localhost", &[], &[]))); | |
| 611 | − | assert!(refused(workflow_domain("api.example.com", &["deploy"], &[]))); | |
| 612 | − | assert!(refused(workflow_domain("api.example.com", &["de ploy.yml"], &[]))); | |
| 613 | − | assert!(refused(workflow_domain("api.example.com", &[], &["${{ inputs.env }}"]))); | |
| 614 | − | let many = (0..=MAX_WORKFLOW_DOMAINS).map(|i| workflow_domain(&format!("h{i}.example.com"), &[], &[])).collect(); | |
| 615 | − | assert!(validate(GuardrailSettings { workflow_domains: many, ..level() }).is_err()); | |
| 616 | − | } | |
| 617 | − | ||
| 618 | − | #[test] | |
| 619 | − | fn levels_saved_before_workflow_domains_still_read() { | |
| 620 | − | let old: GuardrailSettings = serde_json::from_str(r#"{"domains":["example.com"]}"#).unwrap(); | |
| 621 | − | assert!(old.workflow_domains.is_empty()); | |
| 310 | + | assert_eq!(old.minutes, BTreeMap::from([("implement".to_owned(), 45)])); | |
| 311 | + | let saved = serde_json::to_value(validate(old).unwrap()).unwrap(); | |
| 312 | + | assert_eq!(saved, serde_json::json!({ "minutes": { "implement": 45 }, "updatedBy": null, "updatedAt": null })); | |
| 313 | + | // What a run got before reads as its time caps. | |
| 622 | 314 | let old: Guardrails = serde_json::from_value(serde_json::json!({ | |
| 623 | − | "restrictNetwork": true, "registries": [], "domains": [], "hosts": [], "rules": {}, "deny": [], "budgetUsd": null, "minutes": {} | |
| 315 | + | "restrictNetwork": true, "registries": [], "domains": [], "hosts": ["g1t.sh"], "workflowDomains": [], "minutes": { "implement": 90 } | |
| 624 | 316 | })) | |
| 625 | 317 | .unwrap(); | |
| 626 | − | assert!(old.workflow_hosts("deploy.yml", Some("production")).is_empty()); | |
| 627 | − | // Command rules, deny patterns and a cost cap, from when g1t's own | |
| 628 | − | // agents had them, are ignored. | |
| 629 | − | let old: GuardrailSettings = | |
| 630 | − | serde_json::from_str(r#"{"rules":{"sudo":false},"deny":["Bash(rm:*)"],"budgetUsd":2.5,"domains":["example.com"]}"#).unwrap(); | |
| 631 | − | assert_eq!(old.domains, vec!["example.com"]); | |
| 632 | − | assert!(validate(old).is_ok()); | |
| 318 | + | assert_eq!(old.minutes_for(RunKind::Implement), 90); | |
| 319 | + | assert!(!old.restrict_network && old.hosts.is_empty()); | |
| 633 | 320 | } | |
| 634 | 321 | ||
| 635 | 322 | #[test] | |
| 636 | − | fn unrestricted_is_kept() { | |
| 637 | − | let project = GuardrailSettings { restrict_network: Some(false), ..level() }; | |
| 638 | − | let effective = Guardrails::merge(&level(), Some(&project)); | |
| 639 | − | assert!(!effective.restrict_network); | |
| 323 | + | fn a_runner_from_before_reads_an_open_network() { | |
| 324 | + | let sent = serde_json::to_value(Guardrails::defaults()).unwrap(); | |
| 325 | + | assert_eq!(sent["restrictNetwork"], false); | |
| 326 | + | assert_eq!(sent["hosts"], serde_json::json!([])); | |
| 640 | 327 | } | |
| 641 | 328 | ||
| 642 | 329 | #[test] | |
| ⋯ | |||
| 651 | 338 | } | |
| 652 | 339 | ||
| 653 | 340 | #[test] | |
| 654 | − | fn validation_clamps_and_drops_unknowns() { | |
| 655 | − | let settings = validate(GuardrailSettings { | |
| 656 | − | registries: Some(vec!["npm".into(), "nonsense".into()]), | |
| 657 | − | domains: vec!["Example.com".into(), "example.com".into(), " ".into()], | |
| 658 | − | ..level() | |
| 659 | − | }) | |
| 660 | − | .unwrap(); | |
| 661 | − | assert_eq!(settings.registries, Some(vec!["npm".to_owned()])); | |
| 662 | − | assert_eq!(settings.domains, vec!["example.com"]); | |
| 341 | + | fn validation_refuses_bad_caps() { | |
| 663 | 342 | assert!( | |
| 664 | 343 | validate(GuardrailSettings { | |
| 665 | 344 | minutes: BTreeMap::from([("implement".to_owned(), 0)]), | |
| ⋯ | |||
| 674 | 353 | }) | |
| 675 | 354 | .is_err() | |
| 676 | 355 | ); | |
| 356 | + | assert!( | |
| 357 | + | validate(GuardrailSettings { | |
| 358 | + | minutes: BTreeMap::from([("implement".to_owned(), MAX_MINUTES)]), | |
| 359 | + | ..level() | |
| 360 | + | }) | |
| 361 | + | .is_ok() | |
| 362 | + | ); | |
| 677 | 363 | } | |
| 678 | 364 | } | |
| 674 | 674 | return (false, BTreeMap::new()); | |
| 675 | 675 | } | |
| 676 | 676 | if let Some(driver) = with.get("driver").filter(|d| !d.is_empty() && *d != "docker") { | |
| 677 | − | self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network and guardrails.")); | |
| 677 | + | self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network.")); | |
| 678 | 678 | } | |
| 679 | 679 | for input in ["driver-opts", "buildkitd-flags", "buildkitd-config", "buildkitd-config-inline", "endpoint"] { | |
| 680 | 680 | if with.get(input).is_some_and(|v| !v.trim().is_empty()) { |
| 6 | 6 | //! - **A container's network.** A sandbox cannot route a container's | |
| 7 | 7 | //! bridge network out (Cloudflare Containers allow no iptables and no IP | |
| 8 | 8 | //! forwarding), so containers join the job's own network (`host`), the | |
| 9 | − | //! one its guardrails apply to. The names a container would have had on | |
| 9 | + | //! one its requests leave by. The names a container would have had on | |
| 10 | 10 | //! its network (its name, its aliases, its Compose service) resolve to | |
| 11 | 11 | //! 127.0.0.1 in later containers and in the job's own steps, and a port | |
| 12 | 12 | //! published under another number (`-p 8080:80`) is forwarded to it. |
| 194 | 194 | let result = start(); | |
| 195 | 195 | match &result { | |
| 196 | 196 | Ok(version) => super::super::note(format!( | |
| 197 | − | "Docker: started this job's own Docker Engine {version} in {:.1}s. Its containers run in this job's sandbox, on the job's network and under its guardrails, and end with the job.", | |
| 197 | + | "Docker: started this job's own Docker Engine {version} in {:.1}s. Its containers run in this job's sandbox, on the job's network, and end with the job.", | |
| 198 | 198 | begun.elapsed().as_secs_f64() | |
| 199 | 199 | )), | |
| 200 | 200 | Err(problem) => super::super::note(format!("##[error]Docker could not start: {problem}")), | |
| ⋯ | |||
| 217 | 217 | let _ = std::fs::remove_file(&shim); | |
| 218 | 218 | std::os::unix::fs::symlink(&exe, &shim).map_err(|e| format!("could not link runc: {e}"))?; | |
| 219 | 219 | ||
| 220 | − | // A guarded job's certificate, for its containers. | |
| 220 | + | // The certificate of a job whose HTTPS passes the Worker, for its containers. | |
| 221 | 221 | let mut ca_dir = None; | |
| 222 | 222 | if let Ok(ca) = std::env::var("G1T_EGRESS_CA") | |
| 223 | 223 | && Path::new(&ca).exists() | |
| 6 | 6 | //! Containers run Docker), with no iptables and no IP forwarding, which | |
| 7 | 7 | //! a sandbox does not have. | |
| 8 | 8 | //! - `api` is `/var/run/docker.sock`: the Engine's API, with containers | |
| 9 | − | //! moved to the job's own network, where its guardrails apply. | |
| 9 | + | //! moved to the job's own network. | |
| 10 | 10 | //! - `oci` is the `runc` the Engine runs containers with: build steps on | |
| 11 | − | //! the job's network, and a guarded job's egress certificate in every | |
| 12 | − | //! container. | |
| 11 | + | //! the job's network, and, in a job whose HTTPS passes the Worker, its | |
| 12 | + | //! egress certificate in every container. | |
| 13 | 13 | //! - `http` is the HTTP/1.1 the proxy reads. | |
| 14 | 14 | //! | |
| 15 | 15 | //! Nothing here is shared with another job: each job has its own sandbox, |
| 8 | 8 | //! job's own network instead (their network namespace and libnetwork's | |
| 9 | 9 | //! hook are taken out of the container's config), because a sandbox has | |
| 10 | 10 | //! no route out of a bridge. `RUN --network=none` stays without one. | |
| 11 | − | //! - **In a guarded job**, every container (`docker run`, services, build | |
| 11 | + | //! - **In a job whose HTTPS passes the Worker** (one that may be given brokered secrets), every container (`docker run`, services, build | |
| 12 | 12 | //! steps, `docker exec`) gets the certificate the job's HTTPS is | |
| 13 | 13 | //! re-signed with: the folder of certificates at `/dev/g1t-egress`, and | |
| 14 | 14 | //! the variables that point common tools at it, unless the container | |
| ⋯ | |||
| 27 | 27 | /// The egress certificate alone. | |
| 28 | 28 | pub(crate) const EGRESS: &str = "egress-ca.crt"; | |
| 29 | 29 | ||
| 30 | − | /// The variables a container is given in a guarded job, as the sandbox's | |
| 30 | + | /// The variables a container is given in a job whose HTTPS passes the Worker, as the sandbox's | |
| 31 | 31 | /// own (services/runner egress.ts `EGRESS_ENV`) point its tools. | |
| 32 | 32 | pub(crate) fn ca_variables() -> Vec<(&'static str, String)> { | |
| 33 | 33 | let bundle = format!("{CA_MOUNT}/{BUNDLE}"); | |
| ⋯ | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | /// Changes a container's `config.json`. `ca_dir`: the folder of | |
| 106 | − | /// certificates to give it, in a guarded job. Returns whether it changed. | |
| 106 | + | /// certificates to give it, in a job whose HTTPS passes the Worker. Returns whether it changed. | |
| 107 | 107 | pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool { | |
| 108 | 108 | let mut changed = false; | |
| 109 | 109 | // A BuildKit step bound for a bridge network: the job's network instead. | |
| 1 | 1 | //! Trusting the certificate a sandbox's HTTPS is re-signed with. | |
| 2 | 2 | //! | |
| 3 | − | //! When a sandbox's network is restricted by its project's guardrails (or a | |
| 4 | − | //! workflow job may be given brokered secrets), its HTTP and HTTPS pass the | |
| 5 | − | //! runner Worker, which re-signs HTTPS with a certificate Cloudflare puts in | |
| 6 | − | //! the sandbox. The network list, the credentials a sandbox holds, and | |
| 7 | − | //! branch protection on g1t's side are what bound a sandbox's work. | |
| 3 | + | //! When a workflow job may be given brokered secrets, its HTTP and HTTPS | |
| 4 | + | //! pass the runner Worker, which fills them in and re-signs HTTPS with a | |
| 5 | + | //! certificate Cloudflare puts in the sandbox. Other sandboxes reach the | |
| 6 | + | //! internet directly. The credentials a sandbox holds and branch | |
| 7 | + | //! protection on g1t's side are what bound a sandbox's work. | |
| 8 | 8 | ||
| 9 | 9 | use std::path::Path; | |
| 10 | 10 | use std::process::{Command, Stdio}; | |
| 11 | 11 | ||
| 12 | − | /// The certificate the sandbox's HTTPS is re-signed with, when it is guarded. | |
| 12 | + | /// The certificate the sandbox's HTTPS is re-signed with, when it passes | |
| 13 | + | /// the Worker. | |
| 13 | 14 | const EGRESS_CA: &str = "/etc/cloudflare/certs/cloudflare-containers-ca.crt"; | |
| 14 | 15 | ||
| 15 | 16 | /// Runs a command as root without asking. False where that is not allowed. | |
| ⋯ | |||
| 24 | 25 | .is_ok_and(|status| status.success()) | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | − | /// Trusts the certificate a guarded sandbox's HTTPS is re-signed with, so | |
| 28 | − | /// that git, package managers and this program can reach allowed hosts. | |
| 29 | − | /// Does nothing in a sandbox whose network is open. Called first thing, | |
| 30 | − | /// before any request is made. | |
| 28 | + | /// Trusts the certificate a sandbox's HTTPS is re-signed with, so that | |
| 29 | + | /// git, package managers and this program can reach the hosts it | |
| 30 | + | /// intercepts. Does nothing where Cloudflare put no certificate. Called | |
| 31 | + | /// first thing, before any request is made. | |
| 31 | 32 | pub fn trust_egress_ca() { | |
| 32 | 33 | const TRUSTED: &str = "/usr/local/share/ca-certificates/cloudflare-containers-ca.crt"; | |
| 33 | 34 | // Once per sandbox. | |
| 93 | 93 | if selfhosted::is_command(&args) { | |
| 94 | 94 | std::process::exit(selfhosted::main(args)); | |
| 95 | 95 | } | |
| 96 | − | // A guarded sandbox's HTTPS is re-signed on its way out: trust that | |
| 96 | + | // A sandbox whose HTTPS passes the Worker has it re-signed: trust that | |
| 97 | 97 | // before anything is fetched. | |
| 98 | 98 | guard::trust_egress_ca(); | |
| 99 | 99 | // Watches for mining for as long as the sandbox runs (abuse.rs). |
| 1 | 1 | /** | |
| 2 | − | * Guardrails: which hosts a workspace's sandboxes may reach and how long a | |
| 3 | − | * run may take, kept by the work service. Mirrors `g1t_contracts::guardrails`. | |
| 2 | + | * Guardrails: how long a workspace's runs may take, kept by the work | |
| 3 | + | * service. Mirrors `g1t_contracts::guardrails`. Sandboxes reach the | |
| 4 | + | * internet as any CI machine does; guardrails say nothing about the | |
| 5 | + | * network. | |
| 4 | 6 | */ | |
| 5 | 7 | import type { ServiceBinding } from "./clients"; | |
| 6 | 8 | import type { User, Viewer } from "./identity"; | |
| ⋯ | |||
| 9 | 11 | ||
| 10 | 12 | /** What one level, the workspace or a project, sets. Unset is inherited. */ | |
| 11 | 13 | export type GuardrailSettings = { | |
| 12 | − | restrictNetwork?: boolean | null; | |
| 13 | − | /** The registries that are on, by id. Replaces the inherited list. */ | |
| 14 | − | registries?: string[] | null; | |
| 15 | − | /** More hosts to allow; they add to the other level's. */ | |
| 16 | − | domains?: string[]; | |
| 17 | − | /** Hosts only workflow jobs may reach, never agents; they add to the other level's. */ | |
| 18 | − | workflowDomains?: WorkflowDomain[]; | |
| 19 | 14 | /** How long each kind of run may take, in minutes. */ | |
| 20 | 15 | minutes?: Record<string, number>; | |
| 21 | 16 | updatedBy?: string | null; | |
| ⋯ | |||
| 24 | 19 | ||
| 25 | 20 | /** What a run actually gets. */ | |
| 26 | 21 | export type Guardrails = { | |
| 27 | − | restrictNetwork: boolean; | |
| 28 | − | registries: string[]; | |
| 29 | − | domains: string[]; | |
| 30 | − | /** Every host a sandbox may reach. `*.example.com` covers subdomains. */ | |
| 31 | − | hosts: string[]; | |
| 32 | − | /** Hosts only some workflow jobs may reach (never in `hosts`). */ | |
| 33 | − | workflowDomains?: WorkflowDomain[]; | |
| 34 | 22 | /** | |
| 35 | 23 | * No longer sent: command rules, deny patterns and the cost cap were for | |
| 36 | 24 | * g1t's own agents. Kept optional for the runner, which still passes | |
| ⋯ | |||
| 42 | 30 | minutes: Record<string, number>; | |
| 43 | 31 | }; | |
| 44 | 32 | ||
| 45 | − | /** | |
| 46 | − | * A host only workflow jobs may reach: jobs of a trusted run (not a pull | |
| 47 | − | * request from a fork), of the workflows named, in the environments | |
| 48 | − | * named. Agents, checks, the merge queue and g1t.page builds never do. | |
| 49 | − | */ | |
| 50 | − | export type WorkflowDomain = { | |
| 51 | − | /** `api.example.com`, or `*.example.com` for its subdomains. */ | |
| 52 | − | domain: string; | |
| 53 | − | /** Workflow files by name, such as `deploy.yml`. Empty: any workflow. */ | |
| 54 | − | workflows: string[]; | |
| 55 | − | /** Environments a job must name with `environment:`. Empty: any job. */ | |
| 56 | − | environments: string[]; | |
| 57 | − | }; | |
| 58 | − | ||
| 59 | − | export type RegistryInfo = { id: string; name: string; hosts: string[] }; | |
| 60 | − | ||
| 61 | 33 | export type GuardrailsView = { | |
| 62 | 34 | workspace: GuardrailSettings; | |
| 63 | 35 | project: GuardrailSettings | null; | |
| ⋯ | |||
| 65 | 37 | /** g1t's defaults with the workspace's: what a project inherits. */ | |
| 66 | 38 | inherited: Guardrails; | |
| 67 | 39 | effective: Guardrails; | |
| 68 | − | g1tHosts: string[]; | |
| 69 | − | registries: RegistryInfo[]; | |
| 70 | 40 | }; | |
| 71 | 41 | ||
| 72 | 42 | export interface GuardrailsApi { | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…