#!/usr/bin/env python3 """Einmal-Cleanup: entfernt "vergiftete" Hauptthread-Turns aus conversation.jsonl. Hintergrund ----------- Solange ARIAs Persona nur via --append-system-prompt kam (statt --system-prompt, voller Replace), fiel das Modell im Hauptchat aus der Rolle und antwortete als "Claude Code" ("das ist injizierter Kontext, ich adoptiere die Persona nicht"). Jede dieser Antworten wurde per conversation.add("assistant", ...) in die History geschrieben. Beim naechsten Request landet sie als im stdin-Prompt — das Modell sieht seine EIGENEN Ablehnungs-Turns und setzt die Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst zieht die History das Modell weiter aus der Rolle. Zweite Giftquelle: Anthropics Sicherheitsfilter ---------------------------------------------- Dasselbe Muster, anderer Auslöser. Der Filter ("... safeguards flagged this message", `Details: [cyber]`) antwortet nicht mit einem HTTP-Fehler: die CLI liefert ein normales Ergebnis mit is_error=true und dem Fehlertext im content. Vor dem safeguard.py-Fix wurde dieser Text als ARIAs Antwort persistiert — und die ausloesende Frage steht direkt davor. Beides geht bei jedem Folge-Turn wieder mit, also greift der Filter wieder: das Projekt ist tot. Neue Vorfaelle faengt das Brain jetzt selbst ab (Modellwechsel, im Notfall Quarantaene). Was VORHER schon in der Datei steht, muss einmalig raus — dafuer ist dieses Script da. Diese Sorte wird in ALLEN Kontexten gesucht, nicht nur im Hauptthread: Projekt-Chats sind genau die, die daran sterben. Was das Script tut ------------------ - Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker (injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten). - Findet in JEDEM Kontext Assistant-Turns, die der Fehlertext des Sicherheitsfilters sind. - Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog. - Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker, legitime Hauptchat-Turns. - Standard = DRY-RUN (zeigt nur was raus wuerde). Mit --apply wird geschrieben, vorher ein Backup .pre-cleanup.bak angelegt. Idempotent. Aufruf (auf der VM, Host-Pfad des Bind-Mounts): python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl --apply Danach Brain neu starten, damit die bereinigte History geladen wird: docker compose restart aria-brain """ from __future__ import annotations import json import re import shutil import sys from pathlib import Path # 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, ) # Der Fehlertext des Sicherheitsfilters. Gleiche Marker wie safeguard.py; hier # dupliziert, damit das Script ohne Brain-Imports auf dem Host laeuft. # Verlangt wird ZWEIERLEI: der Text muss mit "API Error" beginnen UND einen # Filter-Marker enthalten. Sonst traefe es auch Turns, in denen ARIA ueber den # Filter *spricht* — und eine Erklaerung ist kein Gift. _FILTER_MARKERS = ( "safeguards flagged this message", "anthropic.com/legal/aup", "can't respond to this message with", "cannot respond to this message with", "try rephrasing the request in a new session", ) def is_poison(content: str) -> bool: return bool(_BREAK.search(content or "")) def is_filter_error(content: str) -> bool: t = (content or "").strip().lower() if not t.startswith("api error"): return False return any(m in t for m in _FILTER_MARKERS) def project_of(obj: dict) -> str: """Projekt-Tag eines Turns. Brain nutzt 'project_id', UI/Bridge 'projectId'.""" pid = obj.get("project_id") if pid is None: pid = obj.get("projectId") return str(pid or "").strip() def get_content(obj: dict) -> str: """conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'.""" v = obj.get("content") if not isinstance(v, str): v = obj.get("text") return v if isinstance(v, str) else "" def is_main_thread(obj: dict) -> bool: """Hauptthread = kein Projekt-Tag. Brain nutzt 'project_id', UI/Bridge 'projectId'.""" pid = obj.get("project_id") if pid is None: pid = obj.get("projectId") return not (str(pid or "").strip()) def main() -> int: args = [a for a in sys.argv[1:] if not a.startswith("--")] apply = "--apply" in sys.argv[1:] path = Path(args[0]) if args else Path("/data/conversation.jsonl") if not path.exists(): print(f"FEHLER: {path} existiert nicht.", file=sys.stderr) return 2 raw_lines = path.read_text(encoding="utf-8").splitlines() # Parse zu (raw, obj|None). Nicht-JSON / leere Zeilen bleiben unangetastet. parsed: list[tuple[str, dict | None]] = [] for line in raw_lines: s = line.strip() if not s: parsed.append((line, None)) continue try: parsed.append((line, json.loads(s))) except Exception: parsed.append((line, None)) drop = [False] * len(parsed) poisoned_pairs = [] # (assistant_idx, user_idx|None) fuer's Log for i, (_, obj) in enumerate(parsed): if not isinstance(obj, dict): continue if obj.get("op") == "distill": continue if obj.get("role") != "assistant": continue content = get_content(obj) if not content: continue # Zwei Giftsorten mit unterschiedlichem Geltungsbereich: # Rollen-Ablehnung — nur Hauptthread (so war es immer; in Projekten # waere das Risiko eines False Positive hoeher als der Nutzen). # Sicherheitsfilter — ueberall, denn genau Projekt-Chats sterben daran. if is_filter_error(content): reason = "Sicherheitsfilter" elif is_main_thread(obj) and is_poison(content): reason = "Rollen-Ablehnung" else: continue # Gift-Assistant-Turn -> droppen drop[i] = True user_idx = None pid = project_of(obj) # Unmittelbar davor stehenden User-Turn DESSELBEN Kontexts (die # ausloesende Frage) mit weg — sonst bleibt der Ausloeser im Fenster. for j in range(i - 1, -1, -1): pj = parsed[j][1] if not isinstance(pj, dict) or pj.get("op") == "distill": continue if project_of(pj) != pid: continue # fremder Kontext — ueberspringen, nicht abbrechen if pj.get("role") == "user": drop[j] = True user_idx = j break # nur der direkt vorangehende Turn dieses Kontexts poisoned_pairs.append((i, user_idx, reason, pid)) n_drop = sum(drop) if n_drop == 0: print("Keine Gift-Turns gefunden — History ist sauber. Nichts zu tun.") return 0 print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} " f"(insgesamt {n_drop} Zeilen zu entfernen)\n") for a_idx, u_idx, reason, pid in poisoned_pairs: print(f" [{reason}] Kontext: {pid or 'Hauptchat'}") if u_idx is not None: uq = get_content(parsed[u_idx][1] or {}) print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}") ac = get_content(parsed[a_idx][1] or {}) print(f" Antwort (Zeile {a_idx + 1}): {ac[:90]!r}") print() if not apply: print("DRY-RUN — nichts geschrieben. Zum Anwenden erneut mit --apply aufrufen.") return 0 backup = path.with_suffix(path.suffix + ".pre-cleanup.bak") shutil.copy2(path, backup) kept = [raw for idx, (raw, _) in enumerate(parsed) if not drop[idx]] path.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8") print(f"OK — {n_drop} Zeilen entfernt. Backup: {backup}") print("Jetzt Brain neu starten: docker compose restart aria-brain") return 0 if __name__ == "__main__": raise SystemExit(main())