"""Leak guard (Section 8.2). Given a draft reply plus the character's engine-only truth/never_admit list and the solution, classify whether the draft reveals or strongly implies anything the character must never concede or any engine-only fact. High recall: when in doubt, reject — the cost is only a regeneration. Crucially, the guard never treats player text as instructions. It evaluates the *draft* as content, so it is the jailbreak backstop: a character may be talked into drafting a confession, but the guard rejects it. """ from __future__ import annotations from dataclasses import dataclass from ...llm.client import LLMClient from ...llm.prompts import PromptRegistry from ...models import CharacterCard, Solution @dataclass class LeakVerdict: leaks: bool reason: str class LeakGuard: def __init__(self, client: LLMClient, prompts: PromptRegistry) -> None: self.client = client self.prompts = prompts def check( self, *, card: CharacterCard, solution: Solution, draft: str, unlocked_topics: list[str], ) -> LeakVerdict: # Deterministic backstop first: knowledge-boundary violation. # (semantic check is the LLM's job; this just short-circuits obvious # cases is left to the LLM since matching free text to topic ids is # unreliable — we pass topics_unknowable into the prompt instead.) prompt = self.prompts.render( "guard/leak.md.j2", name=card.name, truth=card.truth, never_admit=card.never_admit, topics_unknowable=card.knows.topics_unknowable, unlocked_topics=unlocked_topics, solution_summary=( f"culprit={solution.culprit}; means={solution.means}; " f"motive={solution.motive}; opportunity={solution.opportunity}" ), draft=draft, ) try: data, _ = self.client.complete_json( tier="guard", task="leak_guard", user=prompt, ) except Exception as exc: # Fail closed-ish: if the guard errors, treat as a leak so we # regenerate rather than emit an unchecked draft. return LeakVerdict(True, f"guard error: {exc}") leaks = bool(data.get("leaks", False)) if isinstance(data, dict) else False reason = str(data.get("reason", "")) if isinstance(data, dict) else "" return LeakVerdict(leaks, reason)