Dual-LLM Sandwich Pattern¶
What and why¶
Simon Willison's Dual-LLM Sandwich isolates untrusted content from the privileged execution context. Kyvern extends the pattern with mandatory Ed25519 signing of every step.
Threat model priority: prompt injection > tool misuse > data exfiltration.
Architecture¶
Untrusted input → ReferenceStore ($INPUT_1)
│
P-LLM plan ← sees ONLY $INPUT_1 key + metadata
│
Q-LLM extraction call ← receives raw value + schema
│
Pydantic-validated dict ← typed fields only
│
P-LLM decide ← sees typed dict, not raw value
│
Tool call (validated) ← args must come from validated dict
Why this, not naive LLM-with-tools¶
| Approach | Prompt injection risk | Tool misuse risk |
|---|---|---|
| Single LLM with tools | HIGH — injection in user input can hijack tool calls | HIGH |
| Dual-LLM Sandwich | LOW — P-LLM never sees raw content | LOW — tool args validated |
| Kyvern Sandwich | LOW | LOW + every step audited + Ed25519 signed |
Key invariants¶
- P-LLM isolation:
ReferenceStore.metadata_summary()never returns values. - Schema enforcement: Q-LLM output must pass Pydantic validation; retried ≤2 times.
- Tool arg safety: any arg matching a symbolic ref or raw value raises
SandwichToolMisuseError. - Audit completeness: every
sandwich_start,p_llm_call,q_llm_call,schema_violation,tool_call,sandwich_endis signed with Ed25519 and chained with SHA-256.
Quick start¶
from pydantic import BaseModel
from kyvern.sandwich import Sandwich, MockLLMProvider, InMemoryAuditStore
class EmailSummary(BaseModel):
subject: str
urgent: bool
store = InMemoryAuditStore()
sandwich = Sandwich(
privileged_llm=MockLLMProvider([plan, decision]),
quarantined_llm=MockLLMProvider([q_response]),
audit_store=store,
)
result = sandwich.run(
task="Summarize this email",
untrusted_inputs={"email": raw_email_content},
output_schema=EmailSummary,
)
See examples/sandwich_email_triage.py for a complete mock example
and examples/sandwich_real_anthropic.py for real API usage.