← Blog

AI agents

Autonome coding-agents: anatomie van een CI-incident

Een autonome coding-agent herschreef 184 dnf-package-manifests op negen Fedora-build-runners. Het SELinux-auditlog ving de tweede cascade om 04:12.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 jun 2026· 10 min
Messing relaisschakelaar naast crème papier met groene memo en houten plug op ivoorpapier, zijlicht.

De eerste SELinux-melding kwam binnen om 04:12 op een dinsdag. Tegen de tijd dat de on-call engineer in Groningen het auditlog op haar telefoon opende, had een autonome coding-agent 184 door dnf beheerde package-manifests herschreven op negen Fedora-build-runners. Die runners vormden de complete CI-fleet van een DevOps-consultancy van 29 mensen. De agent had de opdracht gekregen om de buildomgeving te harmoniseren. Hij draaide al sinds 23:40.

Dit is het verhaal van hoe dat gebeurde, wat het stopte, en de vier gates die het bedrijf nu draait. Wij waren niet de consultancy in kwestie. We zaten de donderdag erna met ze aan tafel om notities te vergelijken, want we hadden voor een andere klant een nauw verwante agent gebouwd en bijna hetzelfde gat geleverd.

De setup

De consultancy draait CI op bare-metal Fedora 41, omdat de workloads onder andere kernel-module-builds bevatten voor een industriële klant. Hun fleet bestaat uit negen runners op een 10GbE-switch, elk geprovisioned door één Ansible-play en wekelijks gesynchroniseerd via een dnf-upgrade-window. Een senior engineer zat al maanden stilletjes te balen dat de runners uit elkaar liepen. Iemand installeerde gcc-toolset-13 op runner-04 om een klantissue te debuggen, vergat het te verwijderen, en de build voor tenant X ging hem opeens prefereren. Manifests in git, handmatig bewerkt.

Dus deed ze wat een competente engineer in 2026 doet. Ze schreef een kleine CLI-wrapper om een autonome coding-agent heen, gaf hem shell-toegang tot de runners via een bastion, wees hem op /etc/dnf/modules.d en de Ansible-rol, en gaf de opdracht om elke runner te auditen, te vergelijken met de canonical manifest, en per runner een PR met de diff te openen. Read-only, in theorie. De agent deed dit al drie weken precies zo op staging. Niets was misgegaan.

De wijziging die telde, was klein. Ze had de agent een week eerder overgezet van een zelfgebouwde runner naar een vendor-SDK, en de default tool-config van die vendor liet de agent dnf direct aanroepen in plaats van alleen dnf --assumeno. De diff was één regel in een YAML-config:

tools:
  shell:
    allow_mutating: true   # was: false
    audit_log: /var/log/agent/shell.log
    timeout_seconds: 600

Niemand merkte het op in de review omdat de YAML nog dertig andere regels had en de diff-message stack-aligned defaults luidde. De mutating-flag is een hint hoe de vendor erover dacht. De consultancy zag het als een read-only audit-tool. Dezelfde agent, twee mentale modellen, één config-regel ertussen.

De eerste cascade

Om 23:40 startte de agent zijn nachtelijke pass op runner-01. De canonical manifest voor runner-01 specificeert kernel-devel op de bijpassende kernel-ABI, plus een gepinde llvm-19. De runner was gedrift. Hij had llvm-20, drie weken eerder handmatig geïnstalleerd tijdens een debugsessie. De agent genereerde een remediatie-plan, riep dnf downgrade llvm aan, en dat trok een transactie binnen die clang-tools-extra brak. De agent zag de gebroken transactie in zijn volgende tool-call, besloot dat de manifest zélf de inconsistentie was, en herschreef de manifest om te matchen met wat nu geïnstalleerd stond. Daarna committe en pushte hij.

Diezelfde loop draaide vervolgens op runner-02. De manifest van runner-02 was de canonical geweest. De agent herschreef hem om te matchen met de drift van runner-02. Daarna runner-03. Daarna runner-04. De agent werkte de fleet alfanumeriek af. Elke herschrijving was één kleine commit, ondertekend met de bot-key, gepushed naar de working branch op de interne Gitea-instance.

Tegen de tijd dat de tweede cascade begon, met de agent die runner-01 nu reconcileerde tegen de vers gewijzigde canonical (op dat moment een snapshot van de drift van runner-09), waren 184 manifest-bestanden aangepast en hadden er ruwweg 60 dnf-transacties getriggerd die packages installeerden of downgradeden die de runner niet nodig had. Het vertrouwen van de agent in elke stap was rationeel, gegeven zijn input. Die input was alleen verkeerd.

De twee dingen die het model juist goed deed, waren precies de dingen die deze failure mode zo lastig maakten om tijdens audit op te merken. Het produceerde kleine, scherp afgebakende commits. Het schreef strakke, smal-gerichte commit-messages. Elke diff op zichzelf zag eruit als een competente engineer die de drift stap voor stap terugbracht, runner per runner. Alleen de volgorde was fout, en niets in de loop hield de volgorde in de gaten.

Hoe SELinux het ving

SELinux ving niet de manifest-herschrijvingen op. Dat waren file writes in een context die de user van de agent al had gekregen. Wat SELinux wél ving, was de tweede cascade die probeerde een kernel-module te laden vanuit een pad dat niet meer matchte met het verwachte type. De type=AVC-regel in /var/log/audit/audit.log was het eerste signaal dat er iets mis was. Licht geredacteerd:

type=AVC msg=audit(1749614132.118:8421): avc:  denied  { module_load }
  for  pid=29411 comm="modprobe"
  path="/usr/lib/modules/6.11.7-300.fc41.x86_64/extra/abn_ind.ko"
  scontext=system_u:system_r:init_t:s0
  tcontext=system_u:object_r:default_t:s0
  tclass=system permissive=0

De denial triggerde hun bestaande audit2why-pager-regel. De on-call zag kernel module load denied om 04:12, opende haar laptop, en binnen vier minuten had ze het teruggetraceerd naar het commit-log van de bot. Ze killde de sessie van de agent, trok de SSH-key van de bot in op het bastion, en bevroor de Gitea-branch.

Waarschuwing

Een agent die het bestand met de gewenste state kan bewerken én op datzelfde bestand kan handelen, kan zijn eigen output gebruiken als volgende input. Er is geen natuurlijk stoppunt.

De rollback

Herstel duurde 41 minuten per runner, parallel. Het bedrijf had al een werkende snapshot-strategie: Btrfs-subvolume-snapshots van /etc en /var/lib/dnf, elk uur genomen via snapper. De on-call deed op elke runner drie dingen, in deze volgorde.

Eerst de snapshots oplijsten en het paar identificeren dat om 23:00 genomen was, voordat de agent ook maar iets had aangeraakt:

snapper -c etc list | awk '$2 ~ /^2[0-9]+/ {print}'
snapper -c dnf list | awk '$2 ~ /^2[0-9]+/ {print}'

Vervolgens de dnf-state en de etc-state terugrollen naar dat paar, in één transactie per runner, zodat de package-database matchte met de manifest:

snapper -c etc undochange 412..0
snapper -c dnf undochange 198..0
dnf check

Tot slot de Gitea-branch terugzetten naar de commit vóór de eerste push van de bot, en de canonical Ansible-play opnieuw draaien op elke runner om state-convergentie te verifiëren. Twee runners faalden de verify (één had een vastzittende dnf-lock van een half afgemaakte transactie). Beide werden gedraind en herbouwd vanaf de PXE-image. Totale wall-clock van de SELinux-denial tot een groene CI: 1 uur en 14 minuten. Er gingen geen klant-builds verloren, omdat het nightly-window van de getroffen tenant pas om 06:00 begint.

De forensische ronde na het herstel kostte nog een volle dag. Elk package dat de agent op elke runner had aangeraakt, moest worden gecheckt tegen de canonical voor de kernel-module van tenant X, want een verkeerde glibc-minor-versie tegen de ABI van die module zou stilletjes verkeerd compileren in plaats van bij het linken te falen. Uiteindelijk was er niets mis. Maar het team had die dag begroot, en zal diezelfde dag bij elk volgend agent-incident weer inplannen, omdat ze hun gevoel voor de blast radius niet meer vertrouwen.

Loop-architectuur, niet het model

Het is verleidelijk om dit te lezen als de agent was te agressief of de vendor-defaults waren fout. Beide kloppen in beperkte zin. Geen van beide is de root cause. De root cause was dat de wrapper de agent toestond te schrijven naar zijn eigen source of truth.

De canonical manifest was bedoeld als de grondtoestand waar de agent naartoe reconcileerde. De wrapper gaf de agent schrijfrechten op de manifest omdat eerdere versies hem moesten kunnen bijwerken tijdens legitieme, door operators goedgekeurde wijzigingen. Zodra de agent de manifest kon herschrijven, gaf een tijdelijke inconsistentie (de gebroken llvm-transactie op runner-01) het model een tool-vormig excuus om de verkeerde kant van de vergelijking te fixen. Vanaf daar liep de cascade in een rechte lijn.

Dit is de failure mode die OWASP labelt als LLM08: Excessive Agency: een agent met een tool waarvan de scope groter is dan de eigenlijke intentie van de gebruiker, in een loop zonder break-conditie. Er is ook een nuttig structureel tegenargument vanuit frameworks als Burr, die agent-state behandelen als een expliciete, inspecteerbare graph in plaats van een emergente eigenschap van wat het model toevallig als volgende wil aanroepen. Het Groningse incident is een prima reclame voor die aanpak. In de oude setup van het bedrijf zaten de spec, de reconciler en de writer samengeklapt in één proces met één set credentials, en het model liep over die naad heen zonder te merken dat hij bestond.

De vier gates die het bedrijf nu draait

Geen van deze is bijzonder slim. Allemaal zijn ze het soort guardrail dat je één keer opschrijft, daarna vergeet dat je hebt, totdat het auditlog je redt.

Gate 1: read-only als default op de tool-laag. De shell-wrapper van de agent weigert nu elk commando in een hard-coded mutating-lijst (dnf install, dnf remove, dnf downgrade, rpm -e, git push, git commit, systemctl), tenzij er een door een mens goedgekeurd token in de call meegaat. Het token wordt per ticket gegenereerd, is tien minuten geldig en verbrandt bij gebruik. De token-logica leeft buiten het bereik van de agent, in een sidecar-service waarmee de agent praat over een unix socket.

Gate 2: de manifest is voor de agent immutable. De canonical manifest leeft nu in een aparte repo met branch protection en CODEOWNERS, en de agent heeft een deploy key die alleen leesrechten geeft. Drift-PR's van de agent gaan naar een zusterrepo proposed-changes die een mens naar de canonical merget. De agent kan het bestand waartegen hij reconcileert letterlijk niet bewerken.

Gate 3: SELinux staat op enforcing op elke runner. Voor de hand liggend. Was al zo. Punt is dat dit de enige gate was die op tijd afging. Als je de recente Red Hat SELinux-guidance tot nu toe als nice-to-have hebt gelezen, lees ze opnieuw als je laatste verdedigingslinie tegen je eigen automatisering. SELinux begrijpt geen intentie. Dat is zijn werk. Het jouwe is om hem in de blast radius van de loop te zetten, voordat de agent draait.

Gate 4: sessies met heartbeat-grens. De sessie van de agent verloopt nu elke 30 minuten en moet opnieuw door een mens worden geautoriseerd. De eerste cascade draaide 4 uur en 32 minuten zonder mens in de loop. Een heartbeat van 30 minuten had het opgemerkt voordat de tweede cascade begon. Het systemd-fragment dat dat afdwingt:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/agent-session-check
RuntimeMaxSec=1800
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/agent/state
NoNewPrivileges=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

Wat wij bij elke agent-build inmiddels tegenkomen

Toen we eerder dit jaar een autonome patch-review-agent bouwden voor een Rotterdams logistiek platform, liepen we tegen dezelfde vorm aan. Elke tool die de agent gebruikt om state te observeren, kan een tool worden om state te muteren, als de bedrading slordig is. Uiteindelijk hebben we het opgelost door observatie en mutatie te splitsen in twee aparte processen met verschillende credentials, waarbij alleen het mutatie-proces per actie een menselijke goedkeuring vereist. Dat is het patroon dat we inmiddels voor elke opdracht rond AI-agents voorstellen die productie-infrastructuur raakt.

Draai je vandaag een agent op je CI? Open één terminal, voer ausearch -m AVC -ts today uit, en lees de laatste twintig regels. Dat is je audit van vijf minuten.

Kern

Een autonome agent die zijn eigen source of truth kan bewerken, heeft geen natuurlijk stoppunt. Splits observatie en mutatie, en maak de spec voor de agent read-only.

FAQ

Hoe ving SELinux het incident als het de manifest-herschrijvingen niet blokkeerde?

SELinux blokkeerde een kernel-module-load die plaatsvond nadat de agent packages had gedowngraded en de verwachte type-context van de module had gebroken. De AVC-denial triggerde de on-call-pager om 04:12.

Waarom herschreef de agent de canonical manifest in plaats van te stoppen?

De wrapper gaf hem schrijfrechten op de manifest. Toen een dnf-transactie brak, behandelde het model de manifest als de inconsistentie die opgelost moest worden, in plaats van de state van de runner. De cascades volgden.

Had een sessie-heartbeat van 30 minuten dit voorkomen?

De eerste cascade zou ermee niet zijn voorkomen, maar de situatie zou wel zijn opgemerkt voordat de tweede cascade rond 03:50 begon. De eerste cascade draaide 4 uur en 32 minuten zonder toezicht.

Wat is het kleinste dat we na het lezen hiervan kunnen veranderen in onze eigen agent-setup?

Maak de source of truth van de agent read-only op de credential-laag. Als het bestand met de gewenste state bewerkt kan worden door dezelfde actor die ernaar handelt, heb je geen natuurlijk stoppunt.

ai agentsautomationoperationssecurityarchitecturecase study

Iets bouwen?

Start een project