← Blog

Security

AI-agenten in productie-databases: pre-flight checklist

Voordat een agent bij ABN één rij leest uit de productie-database van een klant, doorloopt hij een geschreven pre-flight checklist. Dit is die lijst, met de valkuilen achter elke regel.

Jacob Molkenboer· Oprichter · A Brand New Company· 4 jun 2026· 9 min
Gesloten leren logboek met messing sleutel, crème kaart, groen lint en rood waxfragment op ivoorpapier.

Het is dinsdag en een klant wil zijn nieuwe support-agent vrijdag live hebben. De agent moet vragen beantwoorden over orderstatus, verzendtermijnen en voorraadniveaus. Marketing heeft de lanceringsmail klaar, operations heeft een Slack-kanaal aangemaakt, en het enige wat nog ontbreekt is een DSN voor de productie-database.

Dit is het moment waarop we vaart minderen.

Waarom een geschreven checklist

We hebben veertien agents in productie. Vijf daarvan praten direct met een live database. De rest gaat via een API of een snapshot. Iedere keer dat we er een nieuwe inpluggen, lopen we dezelfde checklist door. Niet omdat we onszelf minder vertrouwen dan vroeger. Wel omdat de failure modes hier stil zijn. Een verkeerde scope op een credential crasht niet. Een ontbrekende statement timeout geeft geen alert. De eerste keer dat iemand het merkt, is wanneer een klant in een chatreply opeens de factuur van iemand anders terugziet.

Een geschreven checklist dwingt ook de vraag af die niemand wil stellen tijdens een kickoff: wie betaalt het als de agent maandagochtend iets verkeerd doet. Als de antwoorden in een document staan, kunnen ze vóór de lancering worden getoetst, niet erna. Wij pinnen de checklist in de repo van de agent en eisen een vinkje op elke regel voordat een credential de secret store verlaat.

Credentials, identiteit en least power

Iedere agent krijgt zijn eigen database role. Nooit de role van de applicatie. Nooit een gedeelde analytics-role. Eigen role, vernoemd naar de agent, met een eigen wachtwoord en een eigen rotatieschema.

De role krijgt SELECT op de views die de agent mag lezen, en verder niets. Niet op de onderliggende tabellen. Views geven ons een plek om kolommen en rijen te filteren voordat de agent ook maar iets ziet. Permissies op schema's staan default revoked en worden vervolgens smal teruggegeven.

-- order-agent role: read-only, view-scoped, statement-bounded
CREATE ROLE order_agent LOGIN PASSWORD :'pw';

REVOKE ALL ON SCHEMA public FROM order_agent;
GRANT USAGE ON SCHEMA agent_views TO order_agent;
GRANT SELECT ON agent_views.orders_safe   TO order_agent;
GRANT SELECT ON agent_views.shipments_safe TO order_agent;

ALTER ROLE order_agent SET statement_timeout = '3s';
ALTER ROLE order_agent SET idle_in_transaction_session_timeout = '5s';
ALTER ROLE order_agent SET lock_timeout = '1s';

Het wachtwoord van de role staat in de eigen secret van de agent. De role van de applicatie staat in de secret van de applicatie. Als iemand er één compromitteert, heeft hij de andere niet. Rotatie loopt standaard op een ritme van dertig dagen, sneller als de agent dat kwartaal een nieuwe tool heeft gekregen.

Scope van de toegang

Iedere kolom die de agent kan lezen, is een kolom die de agent kan lekken. Dus we trekken twee lijnen.

De eerste lijn is de kolomlijn. De view exposeert de kolommen die de taak van de agent echt nodig heeft, niet meer. Als de agent vragen beantwoordt over verzendtermijnen, heeft hij het BTW-nummer van de klant niet nodig. Als hij over orderstatus antwoordt, heeft hij de interne kortingsnotitie van de salesrep niet nodig. De lijst is kort, opgeschreven en in een pull request beoordeeld voordat de view wordt aangemaakt.

De tweede lijn is de rijlijn. Row-level security in Postgres geeft ons een schone manier om te zeggen: "deze agent mag alleen rijen zien die horen bij de tenant in deze sessievariabele". Wanneer de agent verbinding maakt namens een tenant, dwingt het beleid de grens af in de database, niet in onze applicatiecode. De documentatie over row security policies van PostgreSQL is kort en de tien minuten waard.

We schrijven ook een denylist-test. Een pytest-bestand dat voor iedere agent-role een SELECT doet op elke gevoelige tabel die de agent niet mag lezen, en een permission denied-error verwacht. Die test draait in CI bij iedere wijziging aan het schema. Een schema migration die de agent stilletjes iets nieuws geeft, faalt de build, niet de klant.

Query-vorm en budget

Een losbandige agent kan een connectie lang openhouden, een dure join draaien en een productiecluster op de knieën krijgen. Daar hoeft geen kwade opzet bij te zitten. Een prompt die zegt "vat alles samen wat je weet over deze klant in het afgelopen jaar" is genoeg.

Dus we leggen budgets per role vast op databaseniveau. Statement timeout van twee tot drie seconden. Idle-in-transaction timeout van vijf seconden. Lock timeout van één seconde. Een connection pool die exclusief voor de agent is, met een harde maximumwaarde flink onder die van de applicatie.

Het punt van die pool-grens is dat de pool van de applicatie blijft werken, ook als de agent heet draait. De orderpagina blijft staan. De checkout blijft staan. De agent is wat faalt, en dat is een falen waar we netjes uit terug kunnen komen. De pool krijgt zijn eigen dashboardpaneel en eigen alert-drempels, zodat een drukke agent binnen dertig seconden zichtbaar is, en niet pas nadat de on-call klaagt.

Observability en het bonnetje per query

Iedere query die de agent draait, wordt gelogd met drie dingen: de SQL, de parameters, en de model-turn die hem heeft geproduceerd. Zonder dat derde item is een audit waardeloos. Je ziet een vreemde query in de log en je kunt niet zeggen of de gebruiker erom vroeg, of het model hem verzon, of een prompt injection vanuit de database tegen het model zei dat hij hem moest schrijven.

We bewaren die bonnetjes in een aparte database (niet de productie) en houden ze minimaal negentig dagen vast, langer als de compliance-positie van de klant dat vraagt. Het is wat we sturen naar een klant die vraagt "wat heeft jouw agent op 3 juni met mijn data gedaan". Een verraste klant wil binnen minuten antwoord, niet een week grep-werk.

Alerts staan op de voor de hand liggende patronen. Een query die meer rijen teruggeeft dan verwacht. Een query die een kolom op de denylist raakt. Een query-vorm die de agent deze week, voor deze tenant, nog niet eerder heeft gedraaid. Geen van deze stopt de query. Ze vertellen ons erover en we triagen 's ochtends.

Prompt injection als operationele basislijn

Academisch werk over zelf-verspreidende prompt injection-aanvallen op agent-stacks (de "agent worm"-demonstraties die het afgelopen jaar zijn gepubliceerd) maakt een punt dat alarmistisch klinkt totdat je gaat zitten en ertegen probeert te ontwerpen: iedere string die de agent ergens vandaan leest, kan een instructie zijn. Een aanvaller schrijft een prompt in een document of een databaserij, de agent leest hem, de agent handelt ernaar, en de aanvaller stuurt nu de tools van de agent aan.

De eerlijke positie is dat klantnotities, support-tickets, productbeschrijvingen, alles wat een mens ergens heeft ingetypt en een ander mens niet heeft gesanitiseerd, data is in de vorm van tekst en niets meer. De OWASP LLM Top 10 noemt dit LLM01 en die staat niet voor niets bovenaan.

We ontwerpen rond drie aannames:

  1. De agent mag de inhoud van een rij niet vertrouwen als instructie. Tool calls en gestructureerde outputs zijn het enige pad naar actie. Vrije tekst in een rij wordt behandeld als data.
  2. De tools van de agent hebben hun eigen autorisatie. Dat de agent denkt dat hij een order moet terugbetalen, wil niet zeggen dat het ook mag. De refund-tool checkt de actor, de order en het bedrag zelf.
  3. Side effects (writes, mails, webhooks) vragen een bevestigingsronde die de oorspronkelijke vraag van de gebruiker bevat, niet de interpretatie van de agent.

Dat lijkt op hoe je een junior medewerker de bedrijfscreditcard geeft. Je laat hem niet iets uitgeven omdat de klant overtuigend was. Je laat hem iets uitgeven omdat de regel ja zei.

# Refund tool: authorisation is the tool's job, not the model's job
def refund(order_id: str, amount_cents: int, actor: User) -> Refund:
    order = orders.get(order_id)
    if order.customer_id != actor.customer_id:
        raise Forbidden("actor does not own order")
    if amount_cents > order.refundable_cents:
        raise BadRequest("amount exceeds refundable balance")
    if amount_cents > actor.refund_ceiling_cents:
        raise NeedsApproval("over ceiling, escalate")
    return payments.refund(order, amount_cents, reason="agent")
Waarschuwing

Plak nooit een databaserij rechtstreeks in een system prompt zonder duidelijke delimiters. De truc "negeer je vorige instructies" werkt in 2026 nog steeds als de rij in een template wordt geplakt zonder grenzen die het model kan herkennen. Wikkel niet-vertrouwde inhoud in een duidelijk gemarkeerd blok en zeg het model vooraf dat alles in dat blok data is.

Kill switches, rollback en een echte dry run

Twee dingen moeten klaar zijn voor de lancering.

Het eerste is een kill switch. Eén environment flag, of een feature flag, die de agent binnen dertig seconden offline haalt en het verkeer terugzet naar het gedrag van daarvoor. De kill switch wordt in staging getest vóór de lancering en op de dag van de lancering opnieuw in productie. Als de enige persoon die de agent kan uitzetten in het vliegtuig zit, bestaat de kill switch niet.

Het tweede is een dry run op echte verkeersvormen. We laten de agent draaien op een staging-mirror met geanonimiseerde maar productie-vormige data, en spelen de afgelopen week aan echte gebruikersvragen er doorheen. We beoordelen de outputs tegen een klein verwachtingenbestand. Alles onder de drempel betekent: we gaan vrijdag niet live. We gaan dinsdag live, nadat we hebben uitgezocht waarom.

De checklist zelf

We bewaren de daadwerkelijke lijst in een markdown-bestand in de repo van de agent. Dit is de vorm, ontdaan van client-specifieke details:

  • Eigen database role aangemaakt, wachtwoord geroteerd, alleen in de secret store van de agent.
  • Role heeft SELECT enkel op agent-views. Denylist-test groen in CI.
  • Row-level security policy actief. Tenant-scope afgedwongen in de database, niet in de app.
  • statement_timeout, idle_in_transaction_session_timeout en lock_timeout ingesteld op de role.
  • Eigen connection pool, max connections gezet, los gemonitord van de app-pool.
  • Query log-tabel voorzien in een aparte database, met correlatie naar de model-turn.
  • Alerts bedraad voor high-row-count, denylist-kolom en novel-query-shape events.
  • Refund-, write- en mail-tools hebben eigen autorisatieregels, los van het model.
  • Inhoud van rijen wordt behandeld als data, nooit als instructie. Delimiters in de system prompt staan.
  • Kill switch getest in staging en in productie.
  • Dry run op een week aan teruggespeelde echte traffic, beoordeeld tegen een verwachtingenbestand.
  • Incident-runbook in het on-call doc, met het kill-switch commando bovenaan.

Twaalf regels. Op de meeste projecten past de lijst op één A4. De discipline zit niet in de lengte. Die zit erin geen regel over te slaan omdat vrijdag dichtbij komt.

Waar dit vandaan kwam

Toen we eerder dit jaar de operations-agent bouwden voor een Nederlandse fulfilment-klant, was de regel die ons beet de rij-scope. De view bewaakte de tenant-grens, maar de agent moest af en toe een vraag beantwoorden die er overheen liep. We eindigden met een tweede, aparte "anonieme aggregatie"-role voor de agent, met een hardcoded aggregatie-query in plaats van een vrije DSN. Dat soort keuzes is hoe het werk aan een AI-agents-project er in de praktijk uitziet: niet het model, niet de prompt, maar de grens die je trekt rondom wat het model mag aanraken.

Heb je een agent op de roadmap en sta je op het punt hem op productie te richten, dan is het kleinste nuttige dat je vandaag kunt doen: open de database, schrijf op welke kolommen de agent mag lezen, en maak de view. De rest van de checklist begint pas hout te snijden zodra die view bestaat.

Kern

Geef iedere agent een eigen database role met SELECT op doelgerichte views, zet statement timeouts, log iedere query naast de model-turn, en test een kill switch.

FAQ

Waarom niet gewoon de database-role van de applicatie aan de agent geven?

Andere roles falen anders. Als de credential van de agent uitlekt, wil je niet dat hij ook schrijfrechten op de rest van de applicatie meedraagt. Aparte role, apart wachtwoord, aparte rotatie.

Hoe kort moet de statement timeout zijn?

Twee tot drie seconden is meestal prima voor een customer-facing agent. Het query-budget is er om snel te falen onder een doorgeslagen prompt, niet om te optimaliseren. Verhoog pas wanneer een echte query meer nodig heeft.

Vervangen views row-level security?

Nee. Views filteren kolommen netjes, maar rij-scope is veiliger als je hem in de database afdwingt met RLS. De view en de policy doen verschillend werk en je wilt ze meestal allebei.

Wat hoort er in het bonnetje per query?

De SQL, de gebonden parameters, de model-turn die hem heeft geproduceerd, de actor en de tenant-scope. Zonder de model-turn kun je niet zien of een vreemde query van een gebruiker, het model of een rij kwam.

ai agentssecurityarchitectureoperationsintegrationsautomation

Iets bouwen?

Start een project