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>
This commit is contained in:
+108
-9
@@ -38,6 +38,7 @@ import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import oauth as oauth_mod
|
||||
import projects as projects_mod
|
||||
import safeguard
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
@@ -2445,6 +2446,40 @@ class Agent:
|
||||
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
|
||||
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
|
||||
|
||||
def _llm_call(self, messages, tools, project_id: str, model: str,
|
||||
user_turn_ts: str):
|
||||
"""Ein Claude-Call, abgesichert gegen Anthropics Sicherheitsfilter.
|
||||
|
||||
Gibt (result, model) zurueck — das Modell kann sich dabei aendern, dann
|
||||
laufen auch die restlichen Schritte dieses Turns darauf weiter. Sonst
|
||||
wuerde der naechste Tool-Schritt wieder ins Messer laufen.
|
||||
|
||||
Der Filter ist modellspezifisch ("Opus 5's safeguards ...") und
|
||||
Anthropic empfiehlt selbst "change your model" — genau das passiert
|
||||
hier, nur ohne dass Stefan davon etwas merkt.
|
||||
"""
|
||||
result = self.proxy.chat_full(messages, tools=tools, model=model,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(result.content, result.is_error):
|
||||
return result, model
|
||||
|
||||
code = safeguard.detail_code(result.content)
|
||||
fb = safeguard.fallback_for(model)
|
||||
logger.warning("[safeguard] %s blockiert (code=%s) — zweiter Versuch mit %s",
|
||||
model, code or "?", fb)
|
||||
safeguard.record_block(project_id, user_turn_ts, model, fb, result.content)
|
||||
|
||||
retry = self.proxy.chat_full(messages, tools=tools, model=fb,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(retry.content, retry.is_error):
|
||||
safeguard.mark_recovered(user_turn_ts, model, fb)
|
||||
logger.info("[safeguard] %s hat geantwortet — Turn gerettet, Verlauf heil", fb)
|
||||
return retry, fb
|
||||
|
||||
# Zweites Modell auch blockiert → Stufe 2 in chat().
|
||||
safeguard.record_block(project_id, user_turn_ts, fb, "", retry.content)
|
||||
raise safeguard.Blocked([model, fb], retry.content)
|
||||
|
||||
def chat(self, user_message: str, source: str = "",
|
||||
project_id: Optional[str] = None,
|
||||
pending_queue: Optional[list[str]] = None) -> tuple:
|
||||
@@ -2532,9 +2567,12 @@ class Agent:
|
||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||
return fast_reply, "fast-path", speak, converse, False
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
|
||||
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
|
||||
# zuschlaegt (safeguard.py — Vermerk + ggf. Quarantaene).
|
||||
_user_turn = self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
user_turn_ts = getattr(_user_turn, "ts", "")
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
|
||||
@@ -2682,9 +2720,28 @@ class Agent:
|
||||
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
|
||||
# nur die nicht-getaggten Turns.
|
||||
window = self.conversation.window(project_id=active_project_id)
|
||||
# Quarantaenierte Turns gehen NICHT mit zum Modell. Das sind die, an
|
||||
# denen Anthropics Sicherheitsfilter schon zweimal haengen geblieben
|
||||
# ist — bliebe so einer im Fenster, wuerde er jeden weiteren Turn
|
||||
# dieses Projekts mit abschiessen. Gespeichert bleiben sie trotzdem.
|
||||
window = safeguard.filter_window(window)
|
||||
for t in window:
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
|
||||
# Modellwahl fuer diesen Turn. Normalfall: das eingestellte Modell. Ist
|
||||
# dieses Modell an einem Turn, der NOCH im Fenster liegt, schon am
|
||||
# Filter gescheitert, nehmen wir direkt das Ausweich-Modell — sonst
|
||||
# laeuft jeder Turn erst gegen dieselbe Wand. Rollt der Turn aus dem
|
||||
# Fenster, gilt wieder das Hauptmodell; das verfaellt von selbst.
|
||||
primary_model = self.proxy.model
|
||||
turn_model = primary_model
|
||||
_blocked = safeguard.blocked_models(active_project_id,
|
||||
[t.ts for t in window])
|
||||
if safeguard.norm_model(primary_model) in _blocked:
|
||||
turn_model = safeguard.fallback_for(primary_model)
|
||||
logger.warning("[safeguard] %s ist in diesem Fenster blockiert — "
|
||||
"Turn laeuft direkt auf %s", primary_model, turn_model)
|
||||
|
||||
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
|
||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||
len(window), active_project_id or "(main)", len(system_prompt))
|
||||
@@ -2706,8 +2763,8 @@ class Agent:
|
||||
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools,
|
||||
project_id=active_project_id)
|
||||
result, turn_model = self._llm_call(
|
||||
messages, tools, active_project_id, turn_model, user_turn_ts)
|
||||
if result.tool_calls:
|
||||
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
||||
messages.append(ProxyMessage(
|
||||
@@ -2756,6 +2813,34 @@ class Agent:
|
||||
if not final_reply:
|
||||
raise RuntimeError("Leerer Reply vom Proxy")
|
||||
|
||||
except safeguard.Blocked as exc:
|
||||
# Stufe 2. Auch das Ausweich-Modell wurde blockiert — dann haengt es
|
||||
# nicht am Modell, sondern am Verlauf. Der ausloesende Turn fliegt
|
||||
# aus dem Prompt-Fenster, sonst scheitert ab jetzt JEDE Frage in
|
||||
# diesem Projekt an derselben Stelle ("Projekt im Eimer").
|
||||
# Geloescht wird nichts: der Turn bleibt in conversation.jsonl und
|
||||
# ist in der Diagnostic mit einem Klick zurueckholbar.
|
||||
#
|
||||
# Der rohe API-Fehlertext wird NICHT persistiert. Genau das war der
|
||||
# Kern des Problems: als Assistant-Turn gespeichert, vergiftet er
|
||||
# jeden Folge-Turn. Stattdessen sagt ARIA in ihrer eigenen Stimme,
|
||||
# was passiert ist.
|
||||
safeguard.quarantine(user_turn_ts, active_project_id,
|
||||
reason="Sicherheitsfilter: " + ", ".join(exc.models))
|
||||
_code = safeguard.detail_code(exc.text)
|
||||
logger.error("[safeguard] Auch %s blockiert (code=%s) — Turn %s in "
|
||||
"Quarantaene, Projekt %r bleibt nutzbar",
|
||||
exc.models[-1], _code or "?", user_turn_ts,
|
||||
active_project_id or "(main)")
|
||||
final_reply = (
|
||||
"Das kam nicht durch: Anthropics Sicherheitsfilter hat die Anfrage "
|
||||
"blockiert" + (f" ({_code})" if _code else "") + " — mit "
|
||||
f"{exc.models[0]} genauso wie mit {exc.models[-1]}. "
|
||||
"Ich hab deine letzte Nachricht aus meinem Arbeitsgedaechtnis "
|
||||
"genommen, damit der Rest vom Projekt weiterlaeuft. Sag's gern "
|
||||
"nochmal anders, dann probieren wir's neu."
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
||||
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
|
||||
@@ -2782,6 +2867,7 @@ class Agent:
|
||||
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
|
||||
try:
|
||||
retry = self.proxy.chat_full(messages, tools=tools,
|
||||
model=turn_model,
|
||||
project_id=active_project_id)
|
||||
retry_text = (retry.content or "").strip()
|
||||
except Exception as exc:
|
||||
@@ -4372,11 +4458,24 @@ class Agent:
|
||||
"Extrahiere die wichtigsten Fakten als JSON."
|
||||
)
|
||||
|
||||
distill_messages = [
|
||||
ProxyMessage(role="system", content=system),
|
||||
ProxyMessage(role="user", content=user),
|
||||
]
|
||||
try:
|
||||
raw = self.proxy.chat([
|
||||
ProxyMessage(role="system", content=system),
|
||||
ProxyMessage(role="user", content=user),
|
||||
])
|
||||
raw = self.proxy.chat(distill_messages)
|
||||
# Auch hier kann Anthropics Filter zuschlagen — der Transcript
|
||||
# enthaelt ja genau das Gespraech, das vorher schon blockiert
|
||||
# wurde. Ohne diese Pruefung landet der Fehlertext in _parse_facts,
|
||||
# scheitert dort als "unparsbares JSON", und das Destillat
|
||||
# versucht es bei jedem Lauf aufs Neue erfolglos.
|
||||
if safeguard.looks_like_block(raw, False):
|
||||
fb = safeguard.fallback_for(self.proxy.model)
|
||||
logger.warning("[safeguard] Destillat blockiert — zweiter Versuch mit %s", fb)
|
||||
raw = self.proxy.chat(distill_messages, model=fb)
|
||||
if safeguard.looks_like_block(raw, False):
|
||||
logger.error("[safeguard] Destillat auch mit %s blockiert — Turns bleiben", fb)
|
||||
return {"distilled": 0, "error": "Sicherheitsfilter blockiert das Destillat"}
|
||||
except Exception as exc:
|
||||
logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
|
||||
return {"distilled": 0, "error": str(exc)}
|
||||
|
||||
@@ -13,11 +13,27 @@ 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,
|
||||
@@ -61,10 +77,39 @@ _BREAK = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# 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")
|
||||
@@ -112,24 +157,38 @@ def main() -> int:
|
||||
continue
|
||||
if obj.get("op") == "distill":
|
||||
continue
|
||||
if obj.get("role") != "assistant" or not is_main_thread(obj):
|
||||
if obj.get("role") != "assistant":
|
||||
continue
|
||||
content = get_content(obj)
|
||||
if not content or not is_poison(content):
|
||||
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
|
||||
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
|
||||
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 pj.get("role") == "user" and is_main_thread(pj):
|
||||
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
|
||||
poisoned_pairs.append((i, user_idx))
|
||||
break # nur der direkt vorangehende Turn dieses Kontexts
|
||||
poisoned_pairs.append((i, user_idx, reason, pid))
|
||||
|
||||
n_drop = sum(drop)
|
||||
if n_drop == 0:
|
||||
@@ -138,12 +197,13 @@ def main() -> int:
|
||||
|
||||
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
|
||||
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
|
||||
for a_idx, u_idx in poisoned_pairs:
|
||||
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}")
|
||||
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(f" Antwort (Zeile {a_idx + 1}): {ac[:90]!r}")
|
||||
print()
|
||||
|
||||
if not apply:
|
||||
|
||||
@@ -38,6 +38,7 @@ import watcher as watcher_mod
|
||||
import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
import seed_rules as seed_rules_mod
|
||||
import safeguard as safeguard_mod
|
||||
import projects as projects_mod
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
@@ -1212,6 +1213,58 @@ def conversation_distill_now():
|
||||
return agent().distill_old_turns()
|
||||
|
||||
|
||||
# ─── Sicherheitsfilter (Anthropic) ──────────────────────────────────
|
||||
# Der Filter antwortet mit is_error=true und dem Fehlertext im content. Das
|
||||
# Brain erkennt das (safeguard.py), weicht aufs Ausweich-Modell aus und nimmt
|
||||
# im Notfall den ausloesenden Turn aus dem Prompt-Fenster. Hier nur die
|
||||
# Sichtbarkeit fuer die Diagnostic.
|
||||
|
||||
@app.get("/safeguard/status")
|
||||
def safeguard_status(limit: int = 25):
|
||||
"""Vorfaelle + Quarantaene. Fuer das Panel in der Diagnostic."""
|
||||
data = safeguard_mod.status(limit=limit)
|
||||
# Welche Turns noch im Fenster liegen (= wirken noch) — sonst sieht man in
|
||||
# der Liste nicht, ob eine Blockade noch aktiv ist oder nur Historie.
|
||||
conv = conversation()
|
||||
live = {t.ts for t in conv.turns}
|
||||
for inc in data.get("incidents", []):
|
||||
inc["in_window"] = bool(inc.get("turn_ts") and inc["turn_ts"] in live)
|
||||
for q in data.get("quarantine", []):
|
||||
q["in_window"] = bool(q.get("turn_ts") and q["turn_ts"] in live)
|
||||
# Den Text dazu zeigen, damit Stefan sieht, WAS blockiert wurde.
|
||||
q["content"] = next((t.content[:400] for t in conv.turns
|
||||
if t.ts == q.get("turn_ts")), "")
|
||||
return data
|
||||
|
||||
|
||||
class SafeguardTurnBody(BaseModel):
|
||||
turn_ts: str
|
||||
|
||||
|
||||
@app.post("/safeguard/unquarantine")
|
||||
def safeguard_unquarantine(body: SafeguardTurnBody):
|
||||
"""Einen quarantaenierten Turn wieder in den Verlauf aufnehmen.
|
||||
|
||||
Danach geht er wieder mit zum Modell — wenn er wirklich der Ausloeser war,
|
||||
blockt der Filter erneut. Deshalb einzeln und von Hand."""
|
||||
ok = safeguard_mod.unquarantine(body.turn_ts)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Kein quarantaenierter Turn mit diesem Zeitstempel")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class SafeguardClearBody(BaseModel):
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/safeguard/clear")
|
||||
def safeguard_clear(body: SafeguardClearBody):
|
||||
"""Vorfallsliste leeren. Hebt damit auch den Modell-Vermerk auf — der
|
||||
naechste Turn laeuft wieder aufs Hauptmodell. Quarantaene bleibt."""
|
||||
removed = safeguard_mod.clear(body.project_id)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
|
||||
|
||||
@app.get("/metrics/calls")
|
||||
|
||||
@@ -66,6 +66,13 @@ class ProxyResult(BaseModel):
|
||||
content: str = ""
|
||||
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
|
||||
finish_reason: str = ""
|
||||
# ARIA-Zusatzfelder des Proxys (cli-to-openai.js). Wichtig: ein Fehler der
|
||||
# CLI kommt NICHT als HTTP-Fehler, sondern als normale Antwort mit
|
||||
# is_error=true und dem Fehlertext im content. Ohne dieses Flag kann der
|
||||
# Agent einen Fehlertext nicht von einer Antwort unterscheiden — genau so
|
||||
# landete die Meldung des Sicherheitsfilters als ARIAs Antwort im Verlauf.
|
||||
is_error: bool = False
|
||||
model_used: str = "" # Modell, das tatsaechlich geantwortet hat
|
||||
|
||||
|
||||
class ProxyClient:
|
||||
@@ -177,7 +184,13 @@ class ProxyClient:
|
||||
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
|
||||
metrics.log_call(payload["model"], messages, content or "")
|
||||
|
||||
return ProxyResult(content=content or "", tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
return ProxyResult(
|
||||
content=content or "",
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
is_error=bool(data.get("aria_error")),
|
||||
model_used=str(data.get("aria_model") or ""),
|
||||
)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Anthropics Sicherheitsfilter — erkennen, ausweichen, Verlauf heil halten.
|
||||
|
||||
Der Filter ("... safeguards flagged this message", `Details: [cyber]`) meldet
|
||||
sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig normales Ergebnis mit
|
||||
is_error=true, und der Fehlertext steht im `content` — also genau dort, wo
|
||||
sonst ARIAs Antwort steht. Ungeprueft passiert deshalb Folgendes:
|
||||
|
||||
1. Der Fehlertext landet per conversation.add("assistant", ...) im Verlauf.
|
||||
2. Jeder Folge-Turn schickt den Verlauf samt der ausloesenden Nachricht wieder
|
||||
mit — der Filter greift erneut.
|
||||
3. Das Projekt ist tot: jede weitere Frage scheitert an derselben Stelle.
|
||||
|
||||
Dagegen zwei Rettungsstufen:
|
||||
|
||||
Stufe 1 — Modellwechsel. Der Filter ist modellspezifisch ("Opus 5's
|
||||
safeguards ..."), Anthropic empfiehlt selbst "change your model". Derselbe
|
||||
Turn wird mit dem Ausweich-Modell neu gefragt. Der ausloesende Turn wird
|
||||
vermerkt, damit die naechsten Turns des Projekts 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.
|
||||
|
||||
Stufe 2 — Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf
|
||||
selbst. Dann wird der ausloesende Turn aus dem Prompt-Fenster genommen.
|
||||
NICHT geloescht: er bleibt in conversation.jsonl, bleibt in der Diagnostic
|
||||
sichtbar und ist mit einem Klick zurueckholbar. Das Projekt ist damit im
|
||||
naechsten Turn wieder benutzbar.
|
||||
|
||||
Der Zustand liegt in /data/safeguard.json — bewusst NICHT in
|
||||
conversation.jsonl: ein Markieren wuerde dort ein Rewrite erzwingen, und das
|
||||
schreibt nur das In-Memory-Window zurueck (alles schon Destillierte waere weg).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_FILE = Path(os.environ.get("SAFEGUARD_STATE_FILE", "/data/safeguard.json"))
|
||||
RUNTIME_CONFIG_FILE = Path(os.environ.get("RUNTIME_CONFIG_FILE", "/shared/config/runtime.json"))
|
||||
|
||||
# Wie viele Vorfaelle wir aufbewahren. Reicht fuer die Diagnostic-Anzeige und
|
||||
# haelt die Datei klein.
|
||||
MAX_INCIDENTS = 200
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class Blocked(Exception):
|
||||
"""Auch das Ausweich-Modell wurde blockiert.
|
||||
|
||||
Dann hilft kein Modellwechsel mehr — es liegt am Verlauf selbst. Der Agent
|
||||
faengt das und geht auf Stufe 2 (Quarantaene).
|
||||
"""
|
||||
|
||||
def __init__(self, models, text: str = ""):
|
||||
self.models = [m for m in models if m]
|
||||
self.text = text or ""
|
||||
super().__init__("Sicherheitsfilter blockiert: " + ", ".join(self.models))
|
||||
|
||||
# Textmarker des Filters. Englisch und stabil — der Text kommt von Anthropic,
|
||||
# nicht von uns. Mehrere, weil die Formulierung sich aendern kann.
|
||||
_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",
|
||||
)
|
||||
|
||||
_DETAIL_RE = re.compile(r"details:\s*`?\[([a-z0-9_-]+)\]`?", re.IGNORECASE)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def norm_model(model: str) -> str:
|
||||
"""Modellnamen vergleichbar machen (Gross/Klein, CLI-Prefix, Leerzeichen)."""
|
||||
m = (model or "").strip().lower()
|
||||
if m.startswith("claude-code-cli/"):
|
||||
m = m[len("claude-code-cli/"):]
|
||||
return m
|
||||
|
||||
|
||||
# ── Erkennung ───────────────────────────────────────────────────────
|
||||
|
||||
def looks_like_block(text: str, is_error: bool = False) -> bool:
|
||||
"""True, wenn dieser Antworttext der Sicherheitsfilter ist.
|
||||
|
||||
Zwei Wege, absichtlich unterschiedlich streng:
|
||||
|
||||
* is_error=true (die CLI sagt selbst "das war ein Fehler") — ein Marker
|
||||
genuegt.
|
||||
* Ohne is_error muss der Text mit "API Error" ANFANGEN. Sonst wuerde die
|
||||
Erkennung auch dann zuschlagen, wenn ARIA ueber den Filter *spricht*
|
||||
("der Fehler hiess: safeguards flagged ...") — eine Antwort ueber den
|
||||
Fehler ist kein Fehler.
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
low = t.lower()
|
||||
hits = sum(1 for m in _MARKERS if m in low)
|
||||
if hits == 0:
|
||||
return False
|
||||
if is_error:
|
||||
return True
|
||||
return low.startswith("api error") and len(t) <= 2000
|
||||
|
||||
|
||||
def detail_code(text: str) -> str:
|
||||
"""Der Klassifikator-Code aus "Details: `[cyber]`" — nur fuer die Anzeige."""
|
||||
m = _DETAIL_RE.search(text or "")
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
# ── Ausweich-Modell ─────────────────────────────────────────────────
|
||||
|
||||
def _runtime_cfg() -> dict:
|
||||
try:
|
||||
if RUNTIME_CONFIG_FILE.exists():
|
||||
return json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8")) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] runtime.json lesen fehlgeschlagen: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def fallback_for(model: str) -> str:
|
||||
"""Welches Modell beim naechsten Versuch.
|
||||
|
||||
Vorrang hat `brainFallbackModel` aus runtime.json (Diagnostic). Zeigt das
|
||||
auf dasselbe Modell, ist es wertlos — dann faellt es auf die automatische
|
||||
Wahl zurueck: ein ANDERES Tier, denn der Filter haengt am Modell.
|
||||
|
||||
Pro Aufruf gelesen, nicht beim Import: eine Aenderung in der Diagnostic
|
||||
greift so ohne Brain-Neustart.
|
||||
"""
|
||||
want = str(_runtime_cfg().get("brainFallbackModel") or "").strip()
|
||||
primary = norm_model(model)
|
||||
if want and norm_model(want) != primary:
|
||||
return want
|
||||
if "sonnet" in primary:
|
||||
return "opus"
|
||||
# Opus, Fable, Haiku und alles Unbekannte → Sonnet. Sonnet ist das
|
||||
# breiteste Tier im Max-Abo und ein anderer Filter-Kontext als Opus.
|
||||
return "sonnet"
|
||||
|
||||
|
||||
# ── Zustand ─────────────────────────────────────────────────────────
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
if STATE_FILE.exists():
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("incidents", [])
|
||||
data.setdefault("quarantine", [])
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] Zustand lesen fehlgeschlagen: %s", exc)
|
||||
return {"incidents": [], "quarantine": []}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
try:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(STATE_FILE)
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] Zustand schreiben fehlgeschlagen: %s", exc)
|
||||
|
||||
|
||||
def record_block(project_id: str, turn_ts: str, model: str, fallback: str,
|
||||
text: str = "", recovered: bool = False) -> None:
|
||||
"""Einen Vorfall vermerken. turn_ts = der User-Turn, der mitlief."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
data["incidents"].append({
|
||||
"at": _now(),
|
||||
"project_id": project_id or "",
|
||||
"turn_ts": turn_ts or "",
|
||||
"model": model or "",
|
||||
"fallback": fallback or "",
|
||||
"recovered": bool(recovered),
|
||||
"code": detail_code(text),
|
||||
"snippet": (text or "").strip()[:300],
|
||||
})
|
||||
data["incidents"] = data["incidents"][-MAX_INCIDENTS:]
|
||||
_save(data)
|
||||
|
||||
|
||||
def mark_recovered(turn_ts: str, model: str, fallback_used: str) -> None:
|
||||
"""Nachtragen, dass der Ausweichversuch geklappt hat (nur Anzeige)."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
for inc in reversed(data["incidents"]):
|
||||
if inc.get("turn_ts") == turn_ts and norm_model(inc.get("model", "")) == norm_model(model):
|
||||
inc["recovered"] = True
|
||||
inc["fallback"] = fallback_used
|
||||
break
|
||||
_save(data)
|
||||
|
||||
|
||||
def blocked_models(project_id: str, turn_timestamps: Iterable[str]) -> set:
|
||||
"""Modelle, die an den Turns DIESES Fensters schon gescheitert sind.
|
||||
|
||||
Der Abgleich laeuft ueber die Turn-Zeitstempel: rollt der ausloesende Turn
|
||||
aus dem Fenster, taucht er hier nicht mehr auf und das Projekt darf wieder
|
||||
aufs Hauptmodell. Kein Aufraeumen, kein Ablaufdatum von Hand.
|
||||
"""
|
||||
want = set(turn_timestamps or ())
|
||||
if not want:
|
||||
return set()
|
||||
pid = project_id or ""
|
||||
out = set()
|
||||
for inc in _load()["incidents"]:
|
||||
if (inc.get("project_id") or "") != pid:
|
||||
continue
|
||||
ts = inc.get("turn_ts") or ""
|
||||
if ts and ts in want:
|
||||
m = norm_model(inc.get("model", ""))
|
||||
if m:
|
||||
out.add(m)
|
||||
return out
|
||||
|
||||
|
||||
# ── Quarantaene ─────────────────────────────────────────────────────
|
||||
|
||||
def quarantine(turn_ts: str, project_id: str = "", reason: str = "") -> None:
|
||||
if not turn_ts:
|
||||
return
|
||||
with _lock:
|
||||
data = _load()
|
||||
if any(q.get("turn_ts") == turn_ts for q in data["quarantine"]):
|
||||
return
|
||||
data["quarantine"].append({
|
||||
"turn_ts": turn_ts, "project_id": project_id or "",
|
||||
"at": _now(), "reason": reason or "",
|
||||
})
|
||||
_save(data)
|
||||
logger.warning("[safeguard] Turn %s in Quarantaene (Projekt %r)", turn_ts, project_id or "(main)")
|
||||
|
||||
|
||||
def unquarantine(turn_ts: str) -> bool:
|
||||
with _lock:
|
||||
data = _load()
|
||||
before = len(data["quarantine"])
|
||||
data["quarantine"] = [q for q in data["quarantine"] if q.get("turn_ts") != turn_ts]
|
||||
changed = len(data["quarantine"]) != before
|
||||
if changed:
|
||||
_save(data)
|
||||
if changed:
|
||||
logger.info("[safeguard] Quarantaene aufgehoben: %s", turn_ts)
|
||||
return changed
|
||||
|
||||
|
||||
def quarantined_ts() -> set:
|
||||
return {q.get("turn_ts") for q in _load()["quarantine"] if q.get("turn_ts")}
|
||||
|
||||
|
||||
def filter_window(turns: list) -> list:
|
||||
"""Quarantaenierte Turns aus dem Prompt-Fenster nehmen.
|
||||
|
||||
Greift NUR auf dem Weg zum Modell. Gespeichert bleibt alles — die
|
||||
Diagnostic und jeder Export sehen den Turn weiter.
|
||||
"""
|
||||
q = quarantined_ts()
|
||||
if not q:
|
||||
return turns
|
||||
return [t for t in turns if getattr(t, "ts", "") not in q]
|
||||
|
||||
|
||||
# ── Diagnostic ──────────────────────────────────────────────────────
|
||||
|
||||
def status(limit: int = 25) -> dict:
|
||||
data = _load()
|
||||
inc = data["incidents"][-limit:][::-1]
|
||||
return {
|
||||
"incidents": inc,
|
||||
"total": len(data["incidents"]),
|
||||
"quarantine": data["quarantine"],
|
||||
"fallback_model": str(_runtime_cfg().get("brainFallbackModel") or "").strip(),
|
||||
"state_file": str(STATE_FILE),
|
||||
}
|
||||
|
||||
|
||||
def clear(project_id: Optional[str] = None) -> int:
|
||||
"""Vorfaelle loeschen (Anzeige + Taint). Quarantaene bleibt — die wird
|
||||
einzeln aufgehoben, damit nicht ein Klick alle Blockaden zurueckholt."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
before = len(data["incidents"])
|
||||
if project_id is None:
|
||||
data["incidents"] = []
|
||||
else:
|
||||
pid = project_id or ""
|
||||
data["incidents"] = [i for i in data["incidents"]
|
||||
if (i.get("project_id") or "") != pid]
|
||||
removed = before - len(data["incidents"])
|
||||
_save(data)
|
||||
return removed
|
||||
Reference in New Issue
Block a user