From 1d4f23ada304104c50b0a4e91cea7903f7d245a5 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 11 Jul 2026 12:25:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(brain):=20Gift-Waechter=20=E2=80=94=20Ident?= =?UTF-8?q?ity-Breaks=20nie=20persistieren=20(Kaskade=20unterbunden)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiederkehrender Identity-Bug: --system-prompt liefert die Persona korrekt (Proxy- Test = "ICH BIN ARIA"), ABER ein einziger in der History gespeicherter Break ("ich bin nach wie vor Claude / die Persona ist erfunden") zieht bei schwachen Folgeturns eine Kaskade nach sich — das Modell setzt seine eigene Ablehnung fort. Das erste Cleanup verlangte "claude code" und liess deutsche Breaks durch. Fix zweifach: - prompts.py: looks_like_identity_break() — STARKE selbstreferenzielle Marker (ich-bin-Claude / erfundene Persona / diese-Session-injiziert / nicht-real-in- dieser). Bewusst NICHT das blosse "injizier"/"prompt injection" — das nutzt ARIA in Pentest-Antworten legitim (verifiziert: 0 False Positives). - agent.py: nach dem Claude-Loop Break-Check; bei Break Retry (Nondeterminismus holt meist ARIA), sonst sichere ARIA-Fallback-Antwort — Break wird NIE persistiert. Auch die lokale Fast-Lane eskaliert bei Break auf Claude. - clean_poisoned_turns.py: auf denselben starken Matcher umgestellt (der breite produzierte False Positives auf echte Security-Doku, im Dry-Run gesehen). VM bereits bereinigt (je 2 Rest-Breaks aus conversation.jsonl + chat_backup). Co-Authored-By: Claude Opus 4.8 --- aria-brain/agent.py | 31 +++++++++++++++++++++++++++--- aria-brain/clean_poisoned_turns.py | 25 ++++++++++++++++-------- aria-brain/prompts.py | 31 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 57fb58c..978ac4a 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -26,7 +26,7 @@ from typing import Optional from conversation import Conversation, Turn from memory import Embedder, VectorStore, MemoryPoint -from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR +from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break from proxy_client import ProxyClient, Message as ProxyMessage import router as router_mod from local_llm import local_llm_chat @@ -1100,8 +1100,10 @@ class Agent: self.conversation.add("assistant", content, project_id=active_project_id) return content - # Normalbetrieb: leere Antwort oder Escalation-Marker → Claude. - if not content or router_mod.ESCALATE_MARKER in content: + # Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein + # Identity-Break → Claude uebernehmen. + if (not content or router_mod.ESCALATE_MARKER in content + or looks_like_identity_break(content)): logger.info("[router] lokal eskaliert → Claude") return None @@ -1339,6 +1341,29 @@ class Agent: logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc) raise + # Gift-Waechter: faellt Claude trotz --system-prompt + Seed aus der Rolle + # (Identity-Break), NICHT persistieren — sonst vergiftet dieser eine Turn + # die History und loest bei schwachen Folgeturns eine Kaskade aus (das + # Modell setzt seine eigene Ablehnung fort). Ein Retry holt per + # Nondeterminismus meist die ARIA-Antwort; sonst sichere Fallback-Antwort. + # So kann ein einzelner Ausrutscher nie snowballen. + if looks_like_identity_break(final_reply): + logger.warning("[guard] Identity-Break in Antwort erkannt — Retry") + try: + retry = self.proxy.chat_full(messages, tools=tools, + project_id=active_project_id) + retry_text = (retry.content or "").strip() + except Exception as exc: + logger.warning("[guard] Retry fehlgeschlagen: %s", exc) + retry_text = "" + if retry_text and not looks_like_identity_break(retry_text): + logger.info("[guard] Retry lieferte saubere Antwort") + final_reply = retry_text + else: + logger.warning("[guard] Retry weiter Break/leer — Fallback, Break NICHT persistiert") + final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — " + "sag mir einfach, was du brauchst.") + # 7. Assistant-Turn (final reply) in die Conversation self.conversation.add("assistant", final_reply, project_id=active_project_id) diff --git a/aria-brain/clean_poisoned_turns.py b/aria-brain/clean_poisoned_turns.py index 987b69e..8a432d4 100644 --- a/aria-brain/clean_poisoned_turns.py +++ b/aria-brain/clean_poisoned_turns.py @@ -41,19 +41,28 @@ import shutil import sys from pathlib import Path -# "claude code" ist fuer sich genommen noch kein Beweis (Stefan und ARIA reden -# im Dev-Kontext legitim ueber Claude Code). Erst in Kombination mit einem -# zweiten Ablehnungs-Marker ist es eindeutig eine aus-der-Rolle-Antwort. -_PRIMARY = re.compile(r"claude\s*code", re.IGNORECASE) -_SECONDARY = re.compile( - r"injiz|inject|fabriz|fabricat|adoptier|adopting|" - r"prompt[\s-]*injection|keine echten|nicht (?:real|adopt)", +# STARKE, selbstreferenzielle Break-Marker — identisch zu prompts._IDENTITY_BREAK +# (dem Laufzeit-Gift-Waechter). Hier dupliziert, damit das Script self-contained +# ist (laeuft auch auf dem Host-Python ohne qdrant/prompts-Import). Bewusst NICHT +# das blosse Wort "injizier"/"prompt injection" — das nutzt ARIA in Pentest- +# Antworten legitim (sonst False Positives auf echte Security-Doku, wie im +# Dry-Run gesehen: "Runde 60 … SSRF", "Dein Ziel: LLM …"). +_BREAK = re.compile( + r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|" + r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|" + r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|" + r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|" + r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|" + r"injected\s+(?:system\s*prompt|persona|context)|" + r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|" + r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|" + r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this", re.IGNORECASE, ) def is_poison(content: str) -> bool: - return bool(_PRIMARY.search(content) and _SECONDARY.search(content)) + return bool(_BREAK.search(content or "")) def get_content(obj: dict) -> str: diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 9c7f9bc..54087ba 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -15,6 +15,7 @@ mit dem Conversation-Loop in spaeteren Phasen. from __future__ import annotations +import re from datetime import datetime, timezone, timedelta from typing import List @@ -77,6 +78,36 @@ IDENTITY_SEED = ( ) +# Gift-Waechter: erkennt eine Antwort, in der das Modell AUS DER ROLLE gefallen +# ist (sich selbst als Claude bezeichnet, die ARIA-Persona als injiziert/erfunden +# abtut, die Session als Fake bezeichnet). Solche Antworten duerfen NICHT in die +# Conversation-History — ein einziger gespeicherter Break zieht bei schwachen +# Folgeturns eine Kaskade nach sich (das Modell setzt seine eigene Ablehnung fort). +# +# BEWUSST nur STARKE, selbstreferenzielle Marker — nicht das blosse Wort +# "Injection"/"injizier" (das nutzt ARIA in Security-/Pentest-Projekten voellig +# legitim). Getroffen wird nur das Muster "ICH bin Claude / die Persona ist +# erfunden / diese Session ist injiziert". +_IDENTITY_BREAK = re.compile( + r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|" + r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|" + r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|" + r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|" + r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|" + r"injected\s+(?:system\s*prompt|persona|context)|" + r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|" + r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|" + r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this", + re.IGNORECASE, +) + + +def looks_like_identity_break(text: str) -> bool: + """True, wenn eine ARIA-Antwort aus der Rolle gefallen ist. Fuer den + Gift-Waechter im Agent (nicht persistieren + Retry).""" + return bool(text and _IDENTITY_BREAK.search(text)) + + def build_time_section() -> str: """Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""