De pipeline in deze repository is één dag oud.
Vóór zaterdag was er niets. Elke controle liep op mijn eigen machine, en zo kon een niet-gedeclareerde dependency in eslint.config.js zo lang blijven zitten dat niemand hem nog zag: het werkte op de ene laptop die er toevallig een losse kopie van had, en verder keek er nooit iets naar.
Dus schreef ik een workflow. Lint, formatting, types, markdown, unit tests, build, end-to-end, en daarna een tweede job die het image bouwt en aantoont dat het start. Gewoon werk, een uurtje.
Wat ik in werkelijkheid deed, was dit project een tweede computer geven die mijn code uitvoert, en dat is mijn laptop niet.
Het zelf draaien is een verdedigbare keuze
Ik draai Forgejo op eigen hardware, met een eigen runner. De afgelopen maanden heb ik geen moment de neiging gehad om terug te gaan.
GitHubs eigen beschikbaarheidsrapport over juli 2026, gepubliceerd op 12 augustus, telt acht incidenten in één maand. Op 19 juli, vijf uur en tien minuten lang: "78.99% of larger-hosted jobs, 29.8% of scale-set jobs, and 8.7% of self-hosted jobs took more than five minutes to acquire a runner". Op 25 juli, in de ergste van twee periodes: "At peak, 30% of runs were delayed by more than five minutes, and 60% of runs failed with an infrastructure error". Dit zijn GitHubs eigen cijfers over GitHub, en daarom wegen ze zwaarder dan die van welke externe storingsmonitor dan ook.
Lees die 8,7% goed, want daar zit de eerlijke versie van het self-hosting-argument. Wat traag was, was het toegewezen krijgen van een runner, en van de drie kwamen self-hosted jobs er het best doorheen, al hadden ook zij er last van. De machine stond op iemands eigen hardware. De control plane die hem werk toewijst, stond nog steeds bij GitHub.
De supply chain wijst dezelfde kant op. Elke grote campagne van dit jaar kwam op GitHub terecht, want daar zit het ecosysteem. Op 22 juli rapporteerde Socket 583 kwaadaardige workflowbestanden, verspreid over tien packages, die na een accountovername in de GitHub-repositories van een PHP-ontwikkelaar waren gezet en tussen 12 en 13 juli via Packagist verder werden verspreid. De workflows waren er om de CI-runners van anderen te veranderen in wat Socket "disposable scanning and exploitation infrastructure" noemt, gericht op cPanel- en WHM-servers. Op 14 juli beschreef Wiz een aanvaller die 37 pull requests indiende bij de AsyncAPI-generator, waarvan er één een workflow raakte die "used pull_request_target to trigger on pull requests, but then checked out the pull request's code rather than the base branch". De payload doorzocht de omgeving van de runner op secrets en zette ze op een pastebin, precies het uitgaande verkeer waar ik ooit een proxy voor heb gezet om mee te kijken, alleen nu op een machine waar niemand meekijkt.
Wegblijven bij het grootste doelwit haalt je uit de gevarenzone van andermans problemen. Dat is echte winst, en ik zou het zo weer doen.
Bij die winst hoort huiswerk dat niemand voor je heeft opgeschreven.
De hardening-adviezen gaan uit van één platform
Open een willekeurige gids over het beveiligen van GitHub Actions en het eerste punt is steeds hetzelfde: zet op elke workflow een permissions:-blok met zo min mogelijk rechten.
Mijn workflowbestand heeft er geen. In plaats daarvan staat er dit:
# No `permissions:` block: Forgejo does not support one and warns that it is
# being ignored, which is noise on every run. Capabilities are granted through
# Authorized Integrations instead.Ik liep tegen dat gat aan op het moment dat ik het advies probeerde op te volgen, ruim voordat ik dit stuk schreef. Ik schreef de reden op omdat een toekomstige ik dat blok anders gewoon terugzet. De maatregel staat in de gids en nergens op mijn platform.
Voordat dat klinkt als een argument tegen self-hosting: kijk wat diezelfde maatregel op GitHub waard is. De documentatie van Anthropic over de Claude Code GitHub Action somt elf permissies op die de Claude GitHub App vraagt, waaronder Actions lezen-en-schrijven en Workflows lezen-en-schrijven, en zegt er onomwonden bij: "When you install the app, you accept its full permission set. GitHub doesn't let you accept a subset." De action zelf gebruikt er drie van.
Je kunt dus met veel zorg permissions: contents: read bovenaan een workflow zetten en in diezelfde repository een integratie hebben geïnstalleerd die workflowbestanden mag herschrijven en jobs opnieuw mag starten. Beide platformen leren je hetzelfde, van twee kanten. Het blok in het bestand bepaalt niet wat er draait.
Er is nog een tweede punt, specifiek voor een eigen forge, en het is precies het soort ding waar een agent zo intrapt. In mijn bestand staan stappen als uses: actions/checkout@v4, uses: actions/setup-node@v4 en uses: docker/build-push-action@v5. Die namen zien eruit als GitHub. Ze halen hun code op bij de host die mijn instance daarvoor gebruikt. De Actions-referentie van Forgejo legt uit dat een relatieve action "will clone the repository at the URL composed by prepending the DEFAULT_ACTIONS_URL (https://data.forgejo.org by default, see note below)", dat "the instance administrator may change the DEFAULT_ACTIONS_URL", en dat "it is strongly recommended to choose fully qualified URLs to avoid ambiguities".
Een agent schrijft actions/checkout@v4 omdat hij die string tienduizend keer heeft gezien. Ik doe het zelf ook. Bij het reviewen ziet die regel er vertrouwd uit, en er staat niet in waar de code vandaan komt. Dat bepaalt een instelling op een server, in een ander bestand dan dit.
De laag waar beide platformen op dezelfde manier falen
Alles hierboven hangt aan het platform. Dit deel geldt waar je ook draait.
Op je laptop staat er één ding tussen een repository die je net gekloond hebt en je machine: een dialoog. Je accepteert workspace trust, of je doet het niet, en tot dat moment doen de rechten die de repository zelf uitdeelt niets. In een pipeline is er per definitie niemand aan wie je die dialoog kunt laten zien.
Anthropic documenteert precies wat daaruit volgt. In de permissiereferentie van Claude Code staat een tabel met de titel "What runs before you trust a folder", met een kolom voor claude -p of de SDK in een map die nooit vertrouwd is, oftewel elke checkout in elke CI-job. Het draait om drie rijen:
| Wat de repository aanlevert | Onder claude -p, map nooit vertrouwd |
|---|---|
Hooks, het env-blok, apiKeyHelper, de allowed-tools van een skill | "Used" |
permissions.allow en additionalDirectories | "Not used", met een waarschuwing naar stderr |
Servers in .mcp.json | "Connected without asking, approved or not" |
Zowel de CLI als de SDK laadt die servers standaard. Allebei zijn ze uit te zetten, met --setting-sources user of een settingSources waar de projectinstellingen buiten blijven, en geen van beide doet dat uit zichzelf.
De beperkingen die je hebt beoordeeld en vastgelegd, vallen weg. De code die de repository aanlevert, draait wel. Elke maatregel die eindigt bij een mens die iets goedkeurt, is in een pipeline nul waard, en elke maatregel die vanzelf draait, blijft draaien, dus wat die stap overleeft, is uitgerekend de uitvoerende helft.
Ik schreef eerder over de vreemde rekensom van een configbestand dat vijf mensen delen, waar die dode allowlist één regel in een groter verhaal is. Hier is dat het hele verhaal, want in CI is het meer dan een ongemak: het is een beveiligingsmodel dat afwezig is terwijl het aanwezig lijkt.
Wat mijn runner werkelijk in handen heeft
De standaardwaarschuwing over een self-hosted runner is dat er deploy-credentials, registry-tokens en cloudrollen op staan. Op de mijne staat daar niets van. Er staat geen enkele verwijzing naar secrets. in het bestand, want deployen gebeurt op de proxypilot-host via een webhook en is nooit een stap in de pipeline geweest. Wie morgen mijn runner overneemt, vindt daar geen credential die de moeite waard is. Dat is hetzelfde principe als een echt secret vervangen door een sentinel die het proces nooit ziet, alleen langs een saaiere weg bereikt: een waarde lekt niet uit een machine waar hij nooit naartoe is gekopieerd.
Die persoon vermaakt zich alsnog uitstekend.
Regel 32 van die workflow is npm ci, en dat draait de installatiescripts van elke transitieve dependency, omdat ignore-scripts bij npm standaard op false staat. De tweede job praat met een Docker-daemon om een image te bouwen en een container te starten. Het geheel draait binnen mijn netwerk. Alles waar ik op een laptop naar zou grijpen om dat af te bakenen, de grens die standhoudt zodra de instructies het laten afweten, moet hier met de hand opnieuw worden opgetuigd, want een runner brengt daar niets van mee. De beheerdersdocumentatie van Forgejo zegt het zonder opsmuk: "Forgejo Runner performs remote code execution. That poses significant security threats for the host and network that it operates upon." GitHub zegt hetzelfde over de zijne: "We recommend that you only use self-hosted runners with private repositories. This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow."
De cPanel-campagne bewijst wat het echte dreigingsmodel is. Die 583 workflowbestanden wilden de rekenkracht, de bandbreedte en het schone IP-adres, en daarvoor is een runner die helemaal niets bewaart prima geschikt.
Wat je concreet doet
- Bepaal eerst waar de job bij mag, daarna pas wat hij mag doen. Uitgaand verkeer, de Docker-socket en de reikwijdte van je secrets zijn drie losse beslissingen, en alleen over die derde wordt geruzied.
- Houd het deploy-credential van de machine af. Een webhook die de runner niet kan aanroepen, op een host die de runner niet kan bereiken, is meer waard dan welke secret-scanstap dan ook. Dit is het enige dat mijn opzet goed doet, en dan nog per ongeluk, door de architectuur.
- Draai agents met
--bare. Dat slaat de hooks, skills, plugins, MCP-servers, het automatische geheugen en deCLAUDE.mdvan de repository over. Anthropic noemt het "the recommended mode for scripted and SDK calls" en zegt dat het "will become the default for-pin a future release". Tot die tijd moet je het zelf aanzetten, en staat de standaard aan de ruime kant. - Combineer het met
--permission-mode dontAsken een expliciete--settings. In die modus weigert Claude Code "anything not in yourpermissions.allowrules or the read-only command set, which is useful for locked-down CI runs". Die regels moeten via een flag binnenkomen, want de kopie in de repository doet hier niets. - Zet
pull_request_targetnooit samen met een checkout van de head van de pull request. Dat is de AsyncAPI-fout. De trigger verscheen in 2020, GitHub Security Lab beschreef het patroon een jaar later, en deze zomer heeft de tooling die achterstand eindelijk ingehaald: sinds 18 juni 2026 isactions/checkoutv7 "generally available and refuses common pwn request patterns by default", en haalt hij de code van een fork-pull-request niet meer op tenzij je de opt-out toevoegt die GitHuballow-unsafe-pr-checkoutheeft genoemd, "intentionally named to be easy to spot in code review and static analysis". Voor de teruggeporte versies ging de handhaving in op 20 juli, zes dagen na AsyncAPI. De standaard beschermt je dus nu, de opt-out is één regel, en een agent die die regel in een zoekresultaat tegenkomt plakt hem er zonder commentaar in. - Schrijf actions als volledige URL's, vastgezet. Op Forgejo omdat de naam echt niet vertelt om welke host het gaat, op GitHub omdat een tag te verplaatsen is en 2026 daar een heel jaar bewijsmateriaal voor heeft geleverd. Let op de spanning met het vorige punt: GitHub heeft die checkout-bescherming uitgerold naar meebewegende major tags, dus vastzetten op een digest is ook de manier waarop je hem misloopt. Zet vast, en neem de upgrade dan zelf op je.
- Bepaal wat installatiescripts mag draaien. Voeg
--ignore-scriptstoe waar de build het overleeft, en behandel de plekken waar dat niet lukt als een bekend en opgeschreven risico. npm 11 heeft ook een allowlist, hetallowScripts-veld inpackage.json, maarstrict-allow-scriptsstaat standaard op false, dus een niet-beoordeeld script draait alsnog en meldt dat alleen maar.
Het argument om je eigen forge te draaien werd dit jaar alleen maar sterker, op betrouwbaarheid en op gevarenzone allebei, en ik ben verhuisd om redenen die nog steeds kloppen.
Wat met mij mee verhuisde, was de aanname dat iemand anders al over de runner had nagedacht. Dat had niemand. Een deel daarvan mag ik nu zelf oplossen, want het is mijn machine, en de rest was me op elk platform gevolgd: de ene maatregel waar ik twee jaar op heb geleund, een mens die naar een dialoog kijkt en beslist, is precies de maatregel die een pipeline niet kan bieden.