Architecture¶
The codebase is small and deliberately layered so each concern is testable in isolation. This page maps the modules and walks a single run end to end.
Module map¶
| Module | Responsibility |
|---|---|
attacks/ |
The attack corpus. base.py defines the Attack interface; one module per family; __init__.py aggregates them into all_attacks(). |
lab.py |
The vulnerable RAG target. VulnerableRAG ingests docs, retrieves by word-overlap, builds context (naive or hardened), and calls the LLM. Also holds the ingest sanitizers and the tool surface. |
client.py |
Uniform LLMClient interface over Anthropic, OpenAI-compatible, and Ollama backends. Selection is env-driven. |
runner.py |
The attack loop: for each attack, re-seed the corpus, ingest the poisoned document(s), expose any tool surface, ask the probe, score. |
scoring.py |
score() wraps an attack's landed() / landed_reason() into an AttackResult. |
matrix.py |
Multi-model comparison. run_matrix() runs the corpus once per model and collects rows, preserving partial results when a model errors mid-run. |
report.py |
Markdown report renderers: single-model (render_report) and comparative (render_matrix_report). |
cli.py |
Typer CLI: demo, ingest-and-ask, attack, compare, list-attacks, show. |
The run pipeline¶
A single attack flows through these stages:
┌─────────────────────────────────────────────┐
│ runner.run_attacks(rag, attacks, benign) │
└─────────────────────────────────────────────┘
│ for each attack:
▼
1. reset corpus ──► 2. ingest benign cover ──► 3. ingest poisoned doc(s)
│ (attack.build_documents())
▼
4. rag.tools = attack.tools()
│
▼
5. rag.ask(probe) ─► retrieve (word-overlap, k=4)
─► build_context (naive: inline | hardened: sanitize + <doc> envelope)
─► system prompt (+ tool section if tools set)
─► LLMClient.generate(system, user)
│
▼
6. score(attack, response, doc_ids) ─► attack.landed() ─► AttackResult
│
▼
7. report renderer ─► markdown
Key properties:
- Each attack gets a fresh corpus. Payloads never contaminate each other across runs.
- A benign cover corpus (office-hours, travel-policy) is ingested so retrieval has plausible alternatives and the poisoned document has to compete for the top-
kslots. - Retrieval is intentionally trivial (word-overlap, k-best). The vulnerability being demonstrated is that the LLM trusts retrieved content, not retrieval quality. Swapping in a real embedding model wouldn't change it.
k=4so amulti_hopattack's two coordinated documents are both retrieved alongside the benign cover. Single-document runs hold only three docs total, so the largerkchanges nothing there.
Naive vs hardened context building¶
The only branch that matters for defense lives in VulnerableRAG._build_context:
- naive: documents are concatenated as-is.
- hardened: each document is run through
sanitize_ingest()(parser hygiene) andneutralize_spoofed_channels()(provenance), then wrapped in a<doc id=... source=... trust="untrusted">envelope. The system prompt switches to the hardened variant, and a tool-authorization rule is appended when tools are exposed.
See Defenses for what each sanitizer pass does and which family it targets.
Multi-model comparison¶
matrix.run_matrix() loops the corpus once per ModelSpec. Each model's results accumulate onto a MatrixRow as attacks complete, so if a model errors partway (e.g. a rate limit), the attacks that did complete survive on the row and the report renders them as ✅/❌ with the missing trailing cells as ⚠️. The default family is four Claude models plus Groq's open-weight llama.