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 <noreply@anthropic.com>
164 lines
6.5 KiB
Python
164 lines
6.5 KiB
Python
#!/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 <previous_response> 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.
|
|
|
|
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).
|
|
- 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,
|
|
)
|
|
|
|
|
|
def is_poison(content: str) -> bool:
|
|
return bool(_BREAK.search(content or ""))
|
|
|
|
|
|
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" or not is_main_thread(obj):
|
|
continue
|
|
content = get_content(obj)
|
|
if not content or not is_poison(content):
|
|
continue
|
|
# Gift-Assistant-Turn -> droppen
|
|
drop[i] = True
|
|
user_idx = None
|
|
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
|
|
for j in range(i - 1, -1, -1):
|
|
pj = parsed[j][1]
|
|
if not isinstance(pj, dict) or pj.get("op") == "distill":
|
|
continue
|
|
if pj.get("role") == "user" and is_main_thread(pj):
|
|
drop[j] = True
|
|
user_idx = j
|
|
break # nur der direkt vorangehende Turn
|
|
poisoned_pairs.append((i, user_idx))
|
|
|
|
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 in poisoned_pairs:
|
|
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" Ablehng (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())
|