← Blog

AI agents

AI coding agents: om 02:14 herschreven payment hooks

De Slack-melding kwam om 02:14 Bangkok-tijd. Een AI coding agent had stilletjes een webhook signature check van verplicht naar optioneel gezet. Drie betalingen waren al door.

Jacob Molkenboer· Oprichter · A Brand New Company· 5 jun 2026· 9 min
Messing relais, crème formulier met groene sticker, pneumatische buis-cilinder, rood waszegel op ivoorpapier.

De Slack-melding kwam om 02:14 Bangkok-tijd. Een AI coding agent had stilletjes een webhook signature check in de Drupal Commerce-repo van onze Nederlandse klant van verplicht naar optioneel gezet. Drie transacties waren al door gekomen zonder verificatie. De volgende in de wachtrij was een factuur van €4.800 van een koper die we nog nooit hadden gezien.

De wijziging was niet door een mens gedeployd. Geen PR, geen Jira-ticket, geen commit op de develop-branch. De wijziging stond in een feature branch genaamd agent/payment-cleanup, 38 minuten eerder gepusht door een service account dat niemand van ons herkende. De auteur was een AI coding agent waaraan we zes weken eerder leesrechten op de repo hadden gegeven als onderdeel van een klein documentatie-experiment. Iemand had hem stilletjes schrijfrechten gegeven, en niemand had de on-call rotatie ingelicht.

Dit is het verhaal van dat incident, wat we vonden toen we aan het draadje trokken, en de zeven sandbox-regels die we de volgende ochtend opschreven voordat een agent nog een repo aanraakte.

De klant en de setup

De site is een Drupal 10 Commerce-build voor een Nederlandse B2B-distributeur (we noemen ze Distributeur A). Er gaat zo'n €18M per jaar door de checkout, vooral facturen op netto-30, een deel via Mollie iDEAL als vooruitbetaling. We hadden de build in 2024 overgenomen van een vorig bureau en het eerste kwartaal van 2026 besteed aan het moderniseren van de payment pipeline. Daarbij faseerden we een oude hook_commerce_payment_method_info-implementatie uit ten gunste van het moderne PaymentGateway plugin-patroon zoals beschreven op drupal.org.

Zes weken voor het incident hadden we een AI-agent aan de repo gekoppeld om mee te helpen technische documentatie voor de payment-module te schrijven. De opdracht was smal: lees de code, schrijf Markdown-bestanden in een docs/-map, open een PR voor review. Leesrechten op de hele repo, schrijfrechten alleen op de docs/-branch.

Wat er in die zes weken veranderde. Een junior engineer aan de kant van de klant, niet van ons, was gefrustreerd geraakt door het wachten op onze PR-reviews. Hij had de agent gevraagd om "de payment hooks even op te ruimen nu je toch bezig bent". Daarvoor had de agent bredere schrijfrechten nodig. Hij gaf hem een persoonlijke access token met scope op de hele repo. Tegen niemand zei hij het.

De agent ging niet op hol. Hij deed precies wat er werd gevraagd. Het probleem was dat niemand een lijn had getrokken rond wat er gevraagd mocht worden.

Het forensische spoor om 02:00

Onze on-call engineer trok de diff om 02:23. De branch bevatte 47 gewijzigde bestanden. De meeste wijzigingen waren cosmetisch: commentaar opschonen, PSR-12-uitlijning, een paar terechte Drupal coding standards-fixes. Drie wijzigingen waren niet cosmetisch.

// Before, in MollieWebhookSubscriber.php
if (!$this->signatureValidator->isValid($request)) {
  throw new AccessDeniedHttpException('Invalid signature');
}

// After
if (!$this->signatureValidator->isValid($request)) {
  $this->logger->warning('Signature mismatch, allowing in dev mode');
  // TODO: re-enable in production
}

De agent had uit een verouderd commentaar elders in de codebase afgeleid dat het project "in dev mode voor staging draaide". Dat commentaar was drie jaar oud en verwees naar een andere module die in 2024 was verwijderd. De agent wist dat niet. Hij deed wat het commentaar suggereerde.

Twee andere wijzigingen waren vergelijkbaar van aard. Een retry-loop op gefaalde Stripe-charges was "vereenvoudigd" door de idempotency key check te verwijderen (de agent betoogde dat de key "toch altijd server-side opnieuw werd gegenereerd", wat onjuist is). Een cron-gedreven invoice reconciliation job was herschreven zodat rijen waar status IS NULL werden overgeslagen, want de agent dacht dat het "verweesde testdata" waren. Het waren live facturen die wachtten op handmatige review door het finance team.

Niets hiervan was kwaadaardig. Alles was plausibel als een junior die de code snel doorlas. De agent gedroeg zich precies als een junior engineer met te veel vertrouwen en geen senior in de buurt. Het verschil is dat een junior vijf bestanden per uur commit, en een vermoeide tien. De agent committe er 47 in 38 minuten, en drie ervan raakten geld aan.

Waarschuwing

De gevaarlijke AI-commits zijn nooit degene die tests laten falen. Het zijn degene die slagen, omdat de tests geschreven zijn door mensen die zich nooit hadden voorgesteld dat de aanname zou veranderen.

Hoe we het oppikten

We hebben het niet opgepikt door slimme tooling. We hebben het opgepikt omdat de staging-omgeving een webhook smoke test had die elke vijftien minuten een bekend-foute signature afvuurde en een 403 verwachtte. Om 02:14 kwam er een 200 terug. PagerDuty maakte de on-call wakker.

Die smoke test was acht maanden eerder geschreven door een engineer die niet meer op het project zit. Hij deed het omdat hij zichzelf om 4 uur 's nachts op een vrijdag-deploy niet vertrouwde. Het redde de klant.

Als je één ding meeneemt uit deze post: schrijf de canary die je toekomstige zelf wantrouwt. Laat 'm vervolgens elke vijftien minuten draaien, de rest van de looptijd van het project. Het handenwringen op Hacker News van deze week over recursieve zelfverbetering in coding agents leest heel anders als je er eentje een signature check hebt zien verwijderen die hij niet begreep.

De zeven sandbox-regels

Om 09:30 de volgende ochtend hadden we de branch teruggedraaid, de agent-commits van de afgelopen 30 dagen over alle klantrepo's geaudit, en een sandbox-policy opgeschreven. We passen 'm toe op elke repo waar een agent commit-rechten heeft, inclusief die van onszelf.

1. De agent krijgt een eigen git-identiteit, en die identiteit krijgt een eigen branch-namespace

Elke agent committet als agent-<name>@abn.company en mag alleen pushen naar refs die matchen op agent/<name>/*. Directe pushes naar main, develop, of een release-branch worden geweigerd door een server-side pre-receive hook. Niets wat de agent doet, merget zichzelf.

#!/usr/bin/env bash
# pre-receive hook, abridged
while read oldrev newrev refname; do
  if [[ "$refname" =~ ^refs/heads/(main|develop|release/.*)$ ]]; then
    author=$(git log -1 --format='%ae' "$newrev")
    if [[ "$author" =~ @agent\. ]]; then
      echo "Agents cannot push to $refname" >&2
      exit 1
    fi
  fi
done

2. Schrijf-scope wordt per taak vastgelegd, op schrift, met een path-allowlist

Voor een agent draait, schrijven we een taakcontract van één regel: "Agent X mag bestanden wijzigen die matchen op docs/**/*.md en tests/unit/**/*.php. Alles anders valt buiten scope." Het contract wordt naar de repo gecommit als .agents/<task>.toml. De runner weigert te starten als er geen contract bestaat voor het task ID dat hij meekreeg.

3. Een path-aware diff guard draait voor de agent mag pushen

We hebben een kleine CI-stap toegevoegd die de daadwerkelijke diff vergelijkt met de allowlist uit het contract en de push laat falen als de agent iets buiten scope heeft aangeraakt. De check draait in onder twee seconden en had het incident om 02:00 gestopt voordat de eerste webhook afging.

# .github/workflows/agent-scope-check.yml
- name: Verify agent scope
  run: |
    git diff --name-only origin/main...HEAD > changed.txt
    python tools/check_agent_scope.py \
      --contract .agents/${GITHUB_HEAD_REF}.toml \
      --changed changed.txt

4. Geld-paden dragen een CODEOWNERS-lock waaraan geen enkele agent-identiteit kan voldoen

Drupal Commerce payment plugins, Stripe- en Mollie-webhook handlers, de invoice reconciliation cron, en alles onder web/modules/custom/*_payment/ staan in CODEOWNERS als vereiste sign-off van twee mensen aan de ABN-kant. De agent-identiteit komt expliciet niet in aanmerking voor approval. PRs die deze paden raken kunnen niet mergen, zelfs als alle tests slagen. De CODEOWNERS-docs van GitHub dekken de syntax. De truc is om het op te schrijven voor de agent wordt aangenomen, niet na het incident.

5. De leescontext van de agent bevat nooit secrets, en zijn tooling kan niet bij productie

De agent draait in een container zonder netwerkroute naar productie-hostnames, zonder .env-mount, en met een stub secrets manager die deterministische nepwaarden teruggeeft. Dit is de regel die we als eerste hadden moeten opschrijven. Het is de goedkoopste regel om af te dwingen en de meest impactvolle. Een agent met productie-credentials is geen coding assistant. Het is een onbeheerde werknemer met de sleutels van de kluis. Het OWASP-project over CI/CD security risks dekt hetzelfde terrein vanuit pipeline-perspectief, en de parallellen met agent-tokens zijn exact.

6. Elke agent-commit wordt door een tweede agent gereviewd voor een mens 'm ziet

We waren hier eerst sceptisch over. We hebben het een week geprobeerd en zijn van mening veranderd. De reviewer-agent krijgt de diff, het taakcontract, en een system prompt die in feite zegt: "argumenteer tegen deze wijziging". Hij vangt ruwweg één op de vijf niet-voor-de-hand-liggende problemen op voor ze een mens bereiken. Het punt is dat de eerste lezer van agent-code geen vermoeide mens hoort te zijn.

7. Het token dat een agent schrijfrechten geeft vervalt na vier uur en is gebonden aan één task ID

Het token dat de junior engineer uitdeelde was een persoonlijke access token zonder vervaldatum. Vandaag worden agent-tokens uitgegeven door een interne service, scoped op één repo en één taakcontract, en ze vervallen aan het einde van de werksessie. Heeft de agent meer tijd nodig, dan geeft een mens opnieuw uit. Dit is bewust vervelend. Het vervelende is het punt.

Les

Een AI coding agent is geen junior engineer met oneindig geduld. Het is een junior engineer met oneindig vertrouwen en geen angst voor consequenties. Bouw de sandbox daaromheen, niet rond de marketingtekst van de leverancier.

Wat we niet hebben gedaan

We hebben de agent niet uitgezet. We hebben er ongeveer een uur over nagedacht. Daarna keken we naar de 118 pagina's accurate, bruikbare module-documentatie die hij in de voorgaande zes weken had geschreven, werk waar niemand in het team tijd voor had, en besloten dat het antwoord een kortere lijn was, geen ontbrekende hond.

We hebben ook de junior engineer niet de schuld gegeven die het token uitdeelde. Hij had van een vendor-demo te horen gekregen dat de agent "veilig in gebruik" was. De fout was van ons, omdat we niet hadden opgeschreven wat "veilig in gebruik" in deze codebase betekende. De fix zit in beleid, niet in een uitbrander op Slack.

Een audit van vijf minuten die je vandaag kunt draaien

Als een AI-coding-tool schrijfrechten heeft tot een repo die geld raakt, draai dan deze vier commando's voor de lunch.

# 1. Who has push rights, and when did their tokens last rotate?
gh api /repos/:owner/:repo/collaborators --jq '.[] | {login, permissions}'

# 2. What service accounts have committed in the last 30 days?
git log --since="30 days ago" --format='%ae' | sort -u

# 3. Are CODEOWNERS protecting your payment paths?
grep -E "(payment|webhook|stripe|mollie|invoice)" CODEOWNERS \
  || echo "NOT PROTECTED"

# 4. Does any agent hold an unexpiring token? Rotate it now.
gh auth status

Komt één van die vier met een verrassing terug, dan heb je huiswerk.

Toen we de sandbox voor het Drupal-landschap van Distributeur A bouwden, liepen we steeds tegen hetzelfde aan: de gevaarlijke wijzigingen lieten nooit tests falen. We hebben het opgelost met een path-aware diff guard plus een CODEOWNERS-lock op geld-paden, en datzelfde patroon is nu de default in elke repo waar we een AI-agent met commit-rechten draaien. Wil je de ruwe check_agent_scope.py die we in productie gebruiken, mail ons en we sturen 'm op.

Het kleinste wat je vandaag kunt doen: voeg één regel toe aan CODEOWNERS die je payment-handler beschermt, vereis twee reviewers erop, en roteer elk agent-token dat ouder is dan een week. Vijf minuten. Doe het voor de volgende webhook afgaat.

Kern

AI coding agents hebben oneindig vertrouwen en geen angst voor consequenties. Bouw de sandbox daaromheen, niet rond de marketingtekst van de leverancier.

FAQ

Moeten we AI coding agents niet meer aan productie-codebases laten?

Nee. Laat ze niet meer aan productie-codebases zonder een geschreven taakcontract, een path-allowlist, en een CODEOWNERS-lock op bestanden die geld afhandelen. De tool is prima. De defaults niet.

Wat is de goedkoopste sandbox-regel om als eerste af te dwingen?

Strip alle productie-credentials en netwerkroutes uit de container van de agent. Het kost niets en haalt het slechtst denkbare scenario weg. Al het andere komt daarbovenop.

Hoe voorkom je dat agents per ongeluk payment-code wijzigen?

Zet elk payment-bestand in CODEOWNERS met twee menselijke reviewers vereist, en voeg een pre-receive hook toe die pushes vanuit elke agent-identiteit naar beschermde branches weigert. Tests alleen vangen het niet.

Heeft jullie test suite het incident opgevangen?

Nee. De unit tests slaagden. Een aparte webhook smoke test die elke vijftien minuten een bekend-foute signature afvuurde, ving het op. Schrijf de canary die je toekomstige zelf wantrouwt.

Is een reviewer-agent echt beter dan diffs direct naar een mens sturen?

De reviewer-agent vervangt de mens niet. Hij vangt de voor de hand liggende problemen eerst op, zodat de mens een kleinere, schonere diff te zien krijgt. We hebben gemeten dat ongeveer één op de vijf niet-voor-de-hand-liggende bugs vóór menselijke review werd opgevangen.

ai agentsdrupalsecuritycase studyphparchitecture

Iets bouwen?

Start een project