Files
ARIA-AGENT/aria-brain/clean_poisoned_turns.py
T
duffyduckandClaude Opus 5 d221c1a320 feat(brain): Sicherheitsfilter blockiert nicht mehr das ganze Projekt
Anthropics Filter ("safeguards flagged this message", Details: [cyber]) trifft
genau unsere Themen — Ports, Tunnel, Zugangsdaten. Bisher war danach der
Projekt-Chat tot, und zwar aus einem Grund, den man der Meldung nicht ansieht:

Der Filter meldet sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig
normales Ergebnis mit is_error=true und dem Fehlertext im content — also dort,
wo sonst ARIAs Antwort steht. proxy_client.py warf das Flag weg, der Agent
konnte Fehler und Antwort nicht unterscheiden und schrieb den Text per
conversation.add("assistant", ...) in den Verlauf. Weil der Verlauf bei jedem
Turn mitgeht, scheiterte ab da JEDE weitere Frage an derselben Stelle.

Zwei Stufen dagegen (aria-brain/safeguard.py):

1. Modellwechsel. Der Filter haengt am Modell ("Opus 5's safeguards ..."),
   Anthropic raet selbst zu "change your model". Derselbe Turn wird automatisch
   mit dem Ausweich-Modell neu gefragt; laeuft der Rest des Turns (Tool-Schritte)
   gleich mit darauf weiter. Der ausloesende Turn wird vermerkt, damit die
   naechsten Turns nicht erst wieder gegen die Wand laufen. Der Vermerk
   verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist —
   nichts muss aufgeraeumt werden, und das Projekt faellt von allein aufs
   Hauptmodell zurueck.

2. Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf selbst.
   Dann faellt der ausloesende Turn aus dem Prompt-Fenster. NICHT geloescht: er
   bleibt in conversation.jsonl, steht mit Text in der Diagnostic und ist per
   Klick zurueckholbar. Der rohe Fehlertext wird nie persistiert — ARIA sagt in
   eigenen Worten, was passiert ist.

Der Zustand liegt in /data/safeguard.json, bewusst nicht in conversation.jsonl:
ein Markieren dort erzwaenge ein Rewrite, und das schreibt nur das In-Memory-
Window zurueck — alles schon Destillierte waere weg.

Erkennung bewusst zweistufig: mit is_error genuegt ein Marker, ohne muss der
Text mit "API Error" beginnen. Sonst wuerde eine Antwort, in der ARIA ueber den
Filter *spricht*, einen Modellwechsel ausloesen.

Mitgenommen:
- Das Memory-Destillat schickt denselben Verlauf nochmal durch und wurde
  genauso blockiert — nur fiel es dort als "unparsbares JSON" auf und
  wiederholte sich bei jedem Lauf. Weicht jetzt ebenfalls aus.
- clean_poisoned_turns.py raeumt die Altlasten: Fehlertexte, die vor diesem Fix
  schon im Verlauf stehen. Neu auch in Projekten (dort sterben sie ja), mit dem
  ausloesenden User-Turn desselben Kontexts, Dry-Run per Default.
- Diagnostic: Ausweich-Modell einstellbar (runtime.json, ohne Neustart, weil
  safeguard.py erst im Ernstfall liest) und ein Panel, das zeigt was blockiert
  wurde und ob es NOCH wirkt.

Tests: 35/35 fuer die Rettungskette gegen den echten agent.chat(), 16/16 fuer
das Aufraeum-Script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:24 +02:00

224 lines
9.1 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.
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())