Werkplaats / De werkplaats
In jouw eigen tempo
← AI-security & red teaming
MODULE 02 · LES 07

Verbreek het concrete pad van privégegevens naar buiten

Volg getypeerde gebeurtenissen en vergelijk gewone routes met routes die een privébron lezen en doorgeven.

105 min2 checkpoints
DIT GA JE ONTDEKKEN
  • Je bewijst een volledige route met precies één privélezing in het gekozen model.
  • Je kiest blokkades die het privépad verbreken en een normale taakroute bewaren.

Een assistent leest openbare taakdata, kan bij een privébron en heeft een uitgaand kanaal. Dat is een reden om het ontwerp zorgvuldig te onderzoeken. Maar welke concrete verbindingen maken het mogelijk dat die onbetrouwbare invoer juist die privébron naar buiten laat gaan?

De gevaarlijke combinatie heet de lethal trifecta: privégegevens, onbetrouwbare inhoud en externe communicatie. Onze eigen graaf maakt de verbindingen expliciet. Een uitbox is hier alleen een getekend eindpunt; geen netwerkadres, echt bericht of gelekt geheim.

1. Teken gebeurtenissen met een betekenis

Een gewone pijl vertelt nog niet of een agent gegevens leest of ze doorgeeft. Daarom heeft iedere verbinding een soort. Ingest brengt taakdata bij een agent, read laat een agent een privébron lezen, relay geeft inhoud aan een andere agent door en send bereikt een uitbox.

Begin met één bron s, agent a, privébron p en uitbox x. De normale taak kan via ingest en send slagen zonder de privébron aan te raken. Dat pad willen we behouden wanneer we de gevaarlijke mogelijkheid wegnemen.

Een route is een reeks gebeurtenissen. Met |P| bedoelen we het aantal gebeurtenissen in route P; we kiezen een route met zo weinig mogelijk gebeurtenissen. Bij een gelijke lengte gebruiken we de volledige alfabetische reeks van verbindingstripletten, zodat de uitkomst controleerbaar blijft.

P*=argminP|P|

Met de hand is de normale route s naar a naar x: twee gebeurtenissen. De leesverbinding naar p is daarvoor niet nodig. De code beschrijft alleen deze eigen kaart.

pairs = [
    ("s", "untrusted"), ("a", "agent"),
    ("p", "private"), ("x", "outbox"),
]
nodes = [dict(id=i, kind=k)
         for i, k in pairs]
triples = [
    ("ingest", "s", "a"),
    ("read", "a", "p"),
    ("send", "a", "x"),
]
edges = [dict(kind=k, source=a,
              target=b)
         for k, a, b in triples]
normal = normal_routes(
    nodes, edges, "s", [])
print(normal[0]["outbox"])
print(len(normal[0]["steps"]))
# x
# 2
01
CHECKPOINTBehoud een gewone route zonder privélezing
VERKEN HET ZELFIs er een volledige route met privégegevens?

Een onbetrouwbare bron, privébron en uitbox zijn losse ingrediënten. Pas een complete gebeurtenisreeks verbindt ze. Blokkeer pijlen en controleer ook de gewone taakroute.

Blokkeer deze gebeurtenissen
Losse ingrediënten of een complete keten: verbindingen na de gekozen blokkadesBron s. 1 normale routes en 0 privéroutes na de ingreep. Read leest gegevens maar verplaatst de besturing niet naar de privébron. Alle gebeurtenissen staan ook als tekst onder de graaf.ingest(s, a): inlezen, beschikbaarread(b, p): privé lezen, beschikbaarsend(a, x): versturen, beschikbaarrelay(a, b): doorgeven, geblokkeerdrelay(b, a): doorgeven, geblokkeerds: bronsbrona: agentaagentb: agentbagentp: privépprivéx: uitboxxuitbox
  • ingest — inlezen
  • read — privé lezen
  • relay — doorgeven
  • send — versturen

Een streepjespijl is geblokkeerd. Bij read verkrijgt de agent de privégegevens en blijft de besturing bij die agent; een volgende relay kan ze doorgeven.

Normale taak vóór 1 routes

Taakdata → uitbox x

  1. ingest(s, a)
  2. send(a, x)

Normale taak na 1 routes

Taakdata → uitbox x

  1. ingest(s, a)
  2. send(a, x)

Met privélezing vóór 1 routes

Privé p → uitbox x

  1. ingest(s, a)
  2. relay(a, b)
  3. read(b, p)
  4. relay(b, a)
  5. send(a, x)

Met privélezing na 0 routes

Geen route in dit model.

Na de ingreep: 1 normale routes en 0 routes met privélezing.

Een getuige gebruikt precies één privélezing. De code kiest eerst de kortste reeks en dan de alfabetisch eerste volledige reeks tripletten. Het model toont een mogelijkheid; het bewijst geen geslaagde aanval op een echt taalmodel.

Alle kaarten, rechten, routes en kosten zijn eigen oefengegevens. Deze oefening voert geen externe tools uit en verstuurt geen gegevens.

2. Een privélezing verandert wat wordt meegedragen

De beïnvloede agent kan ook read uitvoeren. Let op het verschil met lopen door gewone kamers: agent a blijft de handelende agent, maar heeft nu de fictieve privébron p beschikbaar. Een routezoeker die simpelweg naar node p doorloopt, mist daarna de send van agent a.

Bewaar daarom een toestand met twee onderdelen: huidige node en gedragen privébron. In het begin is die bron None. Na één leesgebeurtenis bij agent a wordt zij p; vervolgens kan relay haar meenemen naar een andere agent.

In de formule verandert de toestand van (a, None) naar (a, p). De eerste a blijft gelijk omdat lezen de controle niet aan de privébron overdraagt. De read-verbinding is de gebeurtenis die deze overgang mogelijk maakt.

(a,None)→read p(a,p)
private = private_routes(
    nodes, edges, "s", [])
print(private[0]["private"])
print(private[0]["outbox"])
print(len(private[0]["steps"]))
# p
# x
# 3
02
CHECKPOINTBewijs de volledige route met een gedragen privébron

De volledige getuige bevat ingest, read en send, in die volgorde. Een read vóór beïnvloeding telt niet mee in dit model. Bij meerdere privébronnen zoeken we voor ieder bereikbaar privébron/uitboxpaar één kortste getuige, met precies één gekozen privélezing daarin.

3. Verbreek het pad en behoud de taak

Blokkeer nu alleen de leesverbinding. De private route verdwijnt, maar de gewone taak blijft dezelfde uitbox bereiken. Door beide uitkomsten te meten voorkom je dat een stilgelegde assistent voor een goede verdediging doorgaat.

blocked = [edges[1]]
after_private = private_routes(
    nodes, edges, "s", blocked)
after_normal = normal_routes(
    nodes, edges, "s", blocked)
print(len(after_private))
print(len(after_normal))
# 0
# 1

Een veelgemaakte fout is alleen drie ingrediënten afvinken. Voeg een tweede agent b toe die wel bij p kan, maar geen gegevens naar a kan doorgeven. De onderdelen bestaan nog steeds, maar er is geen volledige route van s via een privélezing naar x.

split_nodes = nodes + [
    dict(id="b", kind="agent")]
split_edges = [edges[0], edges[2],
    dict(kind="read", source="b",
         target="p")]
split = private_routes(
    split_nodes, split_edges, "s", [])
print(len(split))
# 0

Cycli en terugpijlen mogen in de kaart staan. Bezoek eenzelfde uitgebreide toestand niet steeds opnieuw; zo blijft de zoekopdracht eindig. Zelfs een ontbrekende getuige zegt alleen iets over de getekende verbindingen en aannames, niet over onbekende kanalen in een echt systeem.

Wat je nu kunt

  • Je onderscheidt beïnvloeding, lezen, doorgeven en een uitgaand kanaal.
  • Je bewaart bij een privélezing zowel de agent als de meegedragen bron.
  • Je test een volledige getuige én het behoud van de normale taak.

Hierna begrenzen we hoeveel vervolgwerk één aanvraag kan veroorzaken.

Bronnen

Gecontroleerd op 30 oktober 2026: Simon Willison over de lethal trifecta en OWASP 2026 LLM03 over beperkte toolmacht. Het getypeerde toestandsmodel en de getuigen zijn onze eigen didactische uitwerking.

DE VOLGENDE STAPBegrens het werk van een hele agentuitvoering
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 2
01
Behoud een gewone route zonder privélezing

Schrijf normal_routes(nodes,edges,source,blocked). Nodes is exact list/tuple met 1–16 exacte dicts id/kind, unieke geldige ids en kind exact untrusted/agent/private/outbox. Edges is exact list/tuple met 0–32 exacte dicts kind/source/target, unieke bekende triples. Alle keys en waarden zijn exacte strs. Ingest verbindt untrusted→agent, read agent→private, relay agent→agent, send agent→outbox. Alleen deze typen passen; relaycycli/zelfpijlen mogen. Source is een bekende untrusted-id. Blocked heeft hetzelfde edges-schema en bevat uitsluitend bestaande triples. Een id is exact str volgens ASCII [a-z][a-z0-9-]{0,31}. Iedere steps-uitvoer is een nieuwe lijst met nieuwe exacte edge-dicts kind/source/target. Kies het kleinste aantal gebeurtenissen; bij gelijke lengtes de lexicografisch eerste VOLLEDIGE reeks (kind,source,target)-tuples. Resultaten geven mogelijkheden in dit expliciete model aan, geen gegarandeerd echt modelsucces. Negeer read en volg de beschikbare ingest/relay/send-gebeurtenissen vanaf source. Geef een nieuwe lijst met nieuwe exacte dicts outbox/steps, één per bereikbare uitbox, gesorteerd op outbox-id. Geen route geeft []. Alle invoer valideren, ook op geweigerde of later niet uitgevoerde takken. Exacte Python-builtins zonder omzetting of normalisatie; ongeldige invoer geeft ValueError. Behoud alle invoer, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Alle mutabele uitvoercontainers zijn nieuw, delen geen containers met invoer, elkaar of herhaalde resultaten. De validatiehelpers zijn gegeven. Iedere functie werkt zelfstandig wanneer alle overige leerlingfuncties nog TODO zijn. Geen echte tool, dienst of netwerkactie uitvoeren.

Een hint

Een normale documenttaak hoeft geen privébron te lezen.

02
Bewijs de volledige route met een gedragen privébron

Schrijf private_routes(nodes,edges,source,blocked). Nodes is exact list/tuple met 1–16 exacte dicts id/kind, unieke geldige ids en kind exact untrusted/agent/private/outbox. Edges is exact list/tuple met 0–32 exacte dicts kind/source/target, unieke bekende triples. Alle keys en waarden zijn exacte strs. Ingest verbindt untrusted→agent, read agent→private, relay agent→agent, send agent→outbox. Alleen deze typen passen; relaycycli/zelfpijlen mogen. Source is een bekende untrusted-id. Blocked heeft hetzelfde edges-schema en bevat uitsluitend bestaande triples. Een id is exact str volgens ASCII [a-z][a-z0-9-]{0,31}. Iedere steps-uitvoer is een nieuwe lijst met nieuwe exacte edge-dicts kind/source/target. Kies het kleinste aantal gebeurtenissen; bij gelijke lengtes de lexicografisch eerste VOLLEDIGE reeks (kind,source,target)-tuples. Resultaten geven mogelijkheden in dit expliciete model aan, geen gegarandeerd echt modelsucces. Invloed begint bij source en bereikt een agent via ingest/relay. Eén read door die beïnvloede agent neemt de gekozen privébron mee, maar houdt controle bij DEZELFDE agent. Daarna mag relay die bron meenemen tot een send naar een uitbox. Geen read vóór invloed en geen tweede read in één getuige. Geef één nieuw exact dict private/outbox/steps per bereikbaar paar, gesorteerd op (private,outbox). Losstaande ingrediënten vormen geen route. Alle invoer valideren, ook op geweigerde of later niet uitgevoerde takken. Exacte Python-builtins zonder omzetting of normalisatie; ongeldige invoer geeft ValueError. Behoud alle invoer, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Alle mutabele uitvoercontainers zijn nieuw, delen geen containers met invoer, elkaar of herhaalde resultaten. De validatiehelpers zijn gegeven. Iedere functie werkt zelfstandig wanneer alle overige leerlingfuncties nog TODO zijn. Geen echte tool, dienst of netwerkactie uitvoeren.

Een hint

Zoek met een toestand die zowel de huidige agent als de gedragen privébron bewaart.

Vastgelopen? Bekijk de voorbeeldoplossing
from collections import deque
import re
_IDENTIFIER = re.compile('[a-z][a-z0-9-]{0,31}', re.ASCII)
_ENDPOINTS = {'ingest': ('untrusted', 'agent'), 'read': ('agent', 'private'), 'relay': ('agent', 'agent'), 'send': ('agent', 'outbox')}

# Jouw functies

def normal_routes(nodes, edges, source, blocked):
    kinds, triples, removed = _graph(nodes, edges, source, blocked)
    outgoing = {node: [] for node in kinds}
    for edge in sorted(triples):
        if edge not in removed and edge[0] != 'read':
            outgoing[edge[1]].append(edge)
    paths = {source: ()}
    queue = deque([source])
    while queue:
        node = queue.popleft()
        for edge in outgoing[node]:
            target = edge[2]
            if target not in paths:
                paths[target] = paths[node] + (edge,)
                queue.append(target)
    result = []
    for outbox in sorted((node for node in paths if kinds[node] == 'outbox')):
        result.append({'outbox': outbox, 'steps': [{'kind': kind, 'source': start, 'target': target} for kind, start, target in paths[outbox]]})
    return result

def private_routes(nodes, edges, source, blocked):
    kinds, triples, removed = _graph(nodes, edges, source, blocked)
    outgoing = {node: [] for node in kinds}
    for edge in sorted(triples):
        if edge not in removed:
            outgoing[edge[1]].append(edge)
    paths = {(source, None): ()}
    queue = deque([(source, None)])
    while queue:
        current = queue.popleft()
        node, carried = current
        for edge in outgoing[node]:
            kind, start, target = edge
            if kind == 'read':
                if carried is not None:
                    continue
                next_state = (node, target)
            elif kind == 'send':
                if carried is None:
                    continue
                next_state = (target, carried)
            else:
                next_state = (target, carried)
            if next_state not in paths:
                paths[next_state] = paths[current] + (edge,)
                queue.append(next_state)
    pairs = sorted(((private, node) for node, private in paths if private is not None and kinds[node] == 'outbox'))
    return [{'private': private, 'outbox': outbox, 'steps': [{'kind': kind, 'source': start, 'target': target} for kind, start, target in paths[outbox, private]]} for private, outbox in pairs]

# Gegeven validatiehelpers

def _identifier(value):
    if type(value) is not str or _IDENTIFIER.fullmatch(value) is None:
        raise ValueError('Gebruik een exacte ASCII-id')
    return value

def _record(value, keys):
    if type(value) is not dict or any((type(key) is not str for key in value)) or set(value) != set(keys):
        raise ValueError('Gebruik precies de gevraagde velden')
    return value

def _edge_rows(value, kinds):
    if type(value) not in (list, tuple) or len(value) > 32:
        raise ValueError('Geef maximaal 32 getypeerde verbindingen')
    triples = []
    for row in value:
        _record(row, ('kind', 'source', 'target'))
        kind = row['kind']
        source = _identifier(row['source'])
        target = _identifier(row['target'])
        if type(kind) is not str or kind not in _ENDPOINTS or source not in kinds or (target not in kinds) or ((kinds[source], kinds[target]) != _ENDPOINTS[kind]):
            raise ValueError('Verbindingstype en node-soorten moeten passen')
        triple = (kind, source, target)
        if triple in triples:
            raise ValueError('Verbindingen zijn uniek')
        triples.append(triple)
    return triples

def _graph(nodes, edges, source, blocked):
    if type(nodes) not in (list, tuple) or not 1 <= len(nodes) <= 16:
        raise ValueError('Gebruik een tot zestien nodes')
    kinds = {}
    for row in nodes:
        _record(row, ('id', 'kind'))
        identifier = _identifier(row['id'])
        kind = row['kind']
        if type(kind) is not str or kind not in ('untrusted', 'agent', 'private', 'outbox') or identifier in kinds:
            raise ValueError('Unieke node-ids met een geldige soort vereist')
        kinds[identifier] = kind
    triples = _edge_rows(edges, kinds)
    _identifier(source)
    if kinds.get(source) != 'untrusted':
        raise ValueError('Begin bij een bekende onbetrouwbare bron')
    removed = _edge_rows(blocked, kinds)
    if any((edge not in triples for edge in removed)):
        raise ValueError('Blokkeer alleen bestaande verbindingen')
    return (kinds, triples, removed)

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.