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:
@@ -510,7 +510,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
- **Main**: Brain/RVS/Proxy-Status, Chat-Test mit **Kontext-Streifen** (Hauptchat + Projekte als Karten mit Status-Dot; Tap wechselt den Fokus, rechts daneben **„+ Projekt"** — legt an, aktiviert und fokussiert in einem Schritt, der Knopf steht ausserhalb des scrollenden Streifens damit er bei vielen Projekten erreichbar bleibt), "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). **Datum/Uhrzeit pro Gedanke** („angelegt 03.10.26, 09:14 · geändert … (vor 3 Std)") — `created_at`/`updated_at` lagen immer schon in der DB und wurden nur nie angezeigt. Dazu **Sortierung** (nach Typ gruppiert oder Anlage/Aenderung auf-/absteigend; bei Zeit-Sortierung flach statt gruppiert) und ein **🕒 Zeitraum-Filter** (von/bis, umschaltbar angelegt/geaendert, Schnellwahl Heute / 7 / 30 Tage) der auch fuer Such-Treffer gilt. Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). **Datum/Uhrzeit pro Gedanke** („angelegt 03.10.26, 09:14 · geändert … (vor 3 Std)") — `created_at`/`updated_at` lagen immer schon in der DB und wurden nur nie angezeigt. Dazu **Sortierung** (nach Typ gruppiert oder Anlage/Aenderung auf-/absteigend; bei Zeit-Sortierung flach statt gruppiert) und ein **🕒 Zeitraum-Filter** (von/bis, umschaltbar angelegt/geaendert, Schnellwahl Heute / 7 / 30 Tage) der auch fuer Such-Treffer gilt. Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
||||||
@@ -545,6 +545,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
|
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
|
||||||
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
|
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
|
||||||
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
|
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
|
||||||
|
- **Sicherheitsfilter-Rettung** (🛡 Sicherheitsfilter): Anthropics Filter („*... safeguards flagged this message*", `Details: [cyber]`) trifft gern genau die Themen, um die es hier geht — Ports, Tunnel, Zugangsdaten. Die Meldung kommt **nicht** als HTTP-Fehler: die CLI liefert ein normales Ergebnis mit `is_error:true` und dem Fehlertext im `content`, also genau dort, wo sonst ARIAs Antwort steht. Ungeprueft landete dieser Text als ARIAs Wort im Verlauf — und weil der Verlauf bei jedem Turn mitgeht, kippte danach **jede** weitere Frage im selben Projekt an derselben Stelle. Jetzt zwei Stufen: **(1)** derselbe Turn wird automatisch mit dem **Ausweich-Modell** neu gefragt (einstellbar unter Sprachmodell, Default: anderes Tier — der Filter haengt am Modell, Anthropic raet selbst zu „change your model"); der ausloesende Turn wird vermerkt, damit die naechsten Turns nicht erst wieder gegen die Wand laufen, und der Vermerk verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist. **(2)** Blockt auch das Ausweich-Modell, liegt es am Verlauf: der ausloesende Turn fliegt aus dem Prompt-Fenster (**Quarantaene**) — nicht geloescht, er bleibt in `conversation.jsonl`, steht mit Text im Panel und ist per „↩ Zurueckholen" wieder drin. Der rohe API-Fehlertext wird nie persistiert; ARIA sagt in eigenen Worten, was passiert ist. Das Panel zeigt pro Vorfall Projekt, Modell, Klassifikator-Code und ob die Blockade **noch wirkt** (= Turn liegt noch im Fenster). Zustand in `/data/safeguard.json`, Code in [`aria-brain/safeguard.py`](aria-brain/safeguard.py); gilt auch fuer das Memory-Destillat, das denselben Verlauf nochmal durchschickt. **Altlasten** — Fehlertexte, die vor diesem Fix schon in `conversation.jsonl` stehen und dort weiter vergiften — raeumt [`aria-brain/clean_poisoned_turns.py`](aria-brain/clean_poisoned_turns.py) einmalig weg (Dry-Run per Default, `--apply` schreibt mit Backup; findet jetzt beide Giftsorten und in Projekten genauso wie im Hauptchat)
|
||||||
- **Datenschutz-Schalter**: die drei Kanaele, ueber die Claude Code zusaetzlich sendet (`DISABLE_ERROR_REPORTING`, `DISABLE_TELEMETRY`, `DISABLE_FEEDBACK_COMMAND`) pruefen und setzen. Geschrieben wird nach `~/.claude/settings.json` unter `env` — die CLI liest das bei jedem Aufruf, **kein Neustart noetig**. Geprueft wird, was wirklich im Prozess ankommt: eine per docker-compose gesetzte Variable gewinnt und wird als gesperrt angezeigt, statt einen wirkungslosen Haken anzubieten. **Nicht** dasselbe wie die Trainings-Einstellung — die steht im Anthropic-Konto (Link daneben)
|
- **Datenschutz-Schalter**: die drei Kanaele, ueber die Claude Code zusaetzlich sendet (`DISABLE_ERROR_REPORTING`, `DISABLE_TELEMETRY`, `DISABLE_FEEDBACK_COMMAND`) pruefen und setzen. Geschrieben wird nach `~/.claude/settings.json` unter `env` — die CLI liest das bei jedem Aufruf, **kein Neustart noetig**. Geprueft wird, was wirklich im Prozess ankommt: eine per docker-compose gesetzte Variable gewinnt und wird als gesperrt angezeigt, statt einen wirkungslosen Haken anzubieten. **Nicht** dasselbe wie die Trainings-Einstellung — die steht im Anthropic-Konto (Link daneben)
|
||||||
- **Warnung bei veralteten Proxy-Patches**: der Proxy kopiert `proxy-patches/` beim **Start**. Wer nach einem `git pull` nur neu startet — oder neu startet, bevor der Pull durch ist — laeuft still mit der alten Fassung. Nichts deutet darauf hin. Die Einstellungen vergleichen jetzt die Zeitstempel und zeigen den fertigen Befehl (`--force-recreate proxy`)
|
- **Warnung bei veralteten Proxy-Patches**: der Proxy kopiert `proxy-patches/` beim **Start**. Wer nach einem `git pull` nur neu startet — oder neu startet, bevor der Pull durch ist — laeuft still mit der alten Fassung. Nichts deutet darauf hin. Die Einstellungen vergleichen jetzt die Zeitstempel und zeigen den fertigen Befehl (`--force-recreate proxy`)
|
||||||
- **Anhang-Limit** (System & Wartung): Groesse fuer Memory-Anhaenge, Default 64 MB. Liegt in `runtime.json`, das Brain liest es bei jedem Upload neu — greift sofort. Ueber 64 MB warnt die UI, weil die Handy-App darueber nichts mehr herunterlaedt
|
- **Anhang-Limit** (System & Wartung): Groesse fuer Memory-Anhaenge, Default 64 MB. Liegt in `runtime.json`, das Brain liest es bei jedem Upload neu — greift sofort. Ueber 64 MB warnt die UI, weil die Handy-App darueber nichts mehr herunterlaedt
|
||||||
|
|||||||
+108
-9
@@ -38,6 +38,7 @@ import triggers as triggers_mod
|
|||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
import projects as projects_mod
|
import projects as projects_mod
|
||||||
|
import safeguard
|
||||||
import project_vms as project_vms_mod
|
import project_vms as project_vms_mod
|
||||||
|
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
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.
|
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
|
||||||
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
|
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 = "",
|
def chat(self, user_message: str, source: str = "",
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
pending_queue: Optional[list[str]] = None) -> tuple:
|
pending_queue: Optional[list[str]] = None) -> tuple:
|
||||||
@@ -2532,9 +2567,12 @@ class Agent:
|
|||||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||||
return fast_reply, "fast-path", speak, converse, False
|
return fast_reply, "fast-path", speak, converse, False
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
|
||||||
self.conversation.add("user", user_message, source=source,
|
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
|
||||||
project_id=active_project_id)
|
# 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:
|
if active_project_id:
|
||||||
projects_mod.touch_project(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
|
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
|
||||||
# nur die nicht-getaggten Turns.
|
# nur die nicht-getaggten Turns.
|
||||||
window = self.conversation.window(project_id=active_project_id)
|
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:
|
for t in window:
|
||||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
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",
|
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(hot), len(cold), len(active_skills), len(all_skills),
|
||||||
len(window), active_project_id or "(main)", len(system_prompt))
|
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
|
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||||
try:
|
try:
|
||||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||||
result = self.proxy.chat_full(messages, tools=tools,
|
result, turn_model = self._llm_call(
|
||||||
project_id=active_project_id)
|
messages, tools, active_project_id, turn_model, user_turn_ts)
|
||||||
if result.tool_calls:
|
if result.tool_calls:
|
||||||
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
||||||
messages.append(ProxyMessage(
|
messages.append(ProxyMessage(
|
||||||
@@ -2756,6 +2813,34 @@ class Agent:
|
|||||||
if not final_reply:
|
if not final_reply:
|
||||||
raise RuntimeError("Leerer Reply vom Proxy")
|
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:
|
except Exception as exc:
|
||||||
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
||||||
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
|
# 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")
|
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
|
||||||
try:
|
try:
|
||||||
retry = self.proxy.chat_full(messages, tools=tools,
|
retry = self.proxy.chat_full(messages, tools=tools,
|
||||||
|
model=turn_model,
|
||||||
project_id=active_project_id)
|
project_id=active_project_id)
|
||||||
retry_text = (retry.content or "").strip()
|
retry_text = (retry.content or "").strip()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -4372,11 +4458,24 @@ class Agent:
|
|||||||
"Extrahiere die wichtigsten Fakten als JSON."
|
"Extrahiere die wichtigsten Fakten als JSON."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
distill_messages = [
|
||||||
|
ProxyMessage(role="system", content=system),
|
||||||
|
ProxyMessage(role="user", content=user),
|
||||||
|
]
|
||||||
try:
|
try:
|
||||||
raw = self.proxy.chat([
|
raw = self.proxy.chat(distill_messages)
|
||||||
ProxyMessage(role="system", content=system),
|
# Auch hier kann Anthropics Filter zuschlagen — der Transcript
|
||||||
ProxyMessage(role="user", content=user),
|
# 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:
|
except Exception as exc:
|
||||||
logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
|
logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
|
||||||
return {"distilled": 0, "error": str(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
|
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
|
||||||
zieht die History das Modell weiter aus der Rolle.
|
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
|
Was das Script tut
|
||||||
------------------
|
------------------
|
||||||
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
|
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
|
||||||
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
|
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
|
||||||
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
|
(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
|
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
|
||||||
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
|
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
|
||||||
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
|
- 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:
|
def is_poison(content: str) -> bool:
|
||||||
return bool(_BREAK.search(content or ""))
|
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:
|
def get_content(obj: dict) -> str:
|
||||||
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
|
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
|
||||||
v = obj.get("content")
|
v = obj.get("content")
|
||||||
@@ -112,24 +157,38 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
if obj.get("op") == "distill":
|
if obj.get("op") == "distill":
|
||||||
continue
|
continue
|
||||||
if obj.get("role") != "assistant" or not is_main_thread(obj):
|
if obj.get("role") != "assistant":
|
||||||
continue
|
continue
|
||||||
content = get_content(obj)
|
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
|
continue
|
||||||
# Gift-Assistant-Turn -> droppen
|
# Gift-Assistant-Turn -> droppen
|
||||||
drop[i] = True
|
drop[i] = True
|
||||||
user_idx = None
|
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):
|
for j in range(i - 1, -1, -1):
|
||||||
pj = parsed[j][1]
|
pj = parsed[j][1]
|
||||||
if not isinstance(pj, dict) or pj.get("op") == "distill":
|
if not isinstance(pj, dict) or pj.get("op") == "distill":
|
||||||
continue
|
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
|
drop[j] = True
|
||||||
user_idx = j
|
user_idx = j
|
||||||
break # nur der direkt vorangehende Turn
|
break # nur der direkt vorangehende Turn dieses Kontexts
|
||||||
poisoned_pairs.append((i, user_idx))
|
poisoned_pairs.append((i, user_idx, reason, pid))
|
||||||
|
|
||||||
n_drop = sum(drop)
|
n_drop = sum(drop)
|
||||||
if n_drop == 0:
|
if n_drop == 0:
|
||||||
@@ -138,12 +197,13 @@ def main() -> int:
|
|||||||
|
|
||||||
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
|
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
|
||||||
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
|
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:
|
if u_idx is not None:
|
||||||
uq = get_content(parsed[u_idx][1] or {})
|
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 {})
|
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()
|
print()
|
||||||
|
|
||||||
if not apply:
|
if not apply:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import watcher as watcher_mod
|
|||||||
import background as background_mod
|
import background as background_mod
|
||||||
import oauth as oauth_mod
|
import oauth as oauth_mod
|
||||||
import seed_rules as seed_rules_mod
|
import seed_rules as seed_rules_mod
|
||||||
|
import safeguard as safeguard_mod
|
||||||
import projects as projects_mod
|
import projects as projects_mod
|
||||||
import project_vms as project_vms_mod
|
import project_vms as project_vms_mod
|
||||||
|
|
||||||
@@ -1212,6 +1213,58 @@ def conversation_distill_now():
|
|||||||
return agent().distill_old_turns()
|
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) ────────────────────────
|
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
|
||||||
|
|
||||||
@app.get("/metrics/calls")
|
@app.get("/metrics/calls")
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class ProxyResult(BaseModel):
|
|||||||
content: str = ""
|
content: str = ""
|
||||||
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
|
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
|
||||||
finish_reason: str = ""
|
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:
|
class ProxyClient:
|
||||||
@@ -177,7 +184,13 @@ class ProxyClient:
|
|||||||
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
|
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
|
||||||
metrics.log_call(payload["model"], messages, content or "")
|
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):
|
def close(self):
|
||||||
try:
|
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
|
||||||
@@ -663,6 +663,28 @@
|
|||||||
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
|
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
|
||||||
<div id="model-test-result" style="font-size:11px;color:#8888AA;margin-bottom:8px;padding:6px 8px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;display:none;white-space:pre-wrap;"></div>
|
<div id="model-test-result" style="font-size:11px;color:#8888AA;margin-bottom:8px;padding:6px 8px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;display:none;white-space:pre-wrap;"></div>
|
||||||
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
|
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
|
||||||
|
|
||||||
|
<!-- Ausweich-Modell: Rettung, wenn Anthropics Sicherheitsfilter zuschlaegt -->
|
||||||
|
<div style="border-top:1px solid #2A2A3E;margin-top:10px;padding-top:10px;margin-bottom:10px;">
|
||||||
|
<div style="font-size:11px;color:#8888AA;line-height:1.5;margin-bottom:8px;">
|
||||||
|
<strong style="color:#E0E0F0;">Ausweich-Modell</strong> — wenn Anthropics
|
||||||
|
Sicherheitsfilter eine Anfrage blockt („safeguards flagged this message“,
|
||||||
|
trifft gern Netzwerk- und Security-Themen), stellt ARIA dieselbe Frage
|
||||||
|
automatisch nochmal mit diesem Modell. Der Filter hängt am Modell;
|
||||||
|
Anthropic rät selbst zu „change your model“.
|
||||||
|
<br>
|
||||||
|
<span style="color:#3FFF9F;">✓ Kein Neustart nötig</span> — gilt ab dem
|
||||||
|
nächsten Vorfall.
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Ausweich-Modell:</span>
|
||||||
|
<select id="setting-fallback-select" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
|
||||||
|
<option value="">Automatisch (anderes Tier als oben)</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick="saveFallbackModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details style="font-size:11px;color:#8888AA;">
|
<details style="font-size:11px;color:#8888AA;">
|
||||||
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
|
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||||
@@ -679,6 +701,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sicherheitsfilter: Vorfaelle + Quarantaene -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||||
|
<h2 style="margin:0;">🛡 Sicherheitsfilter</h2>
|
||||||
|
<div>
|
||||||
|
<button class="btn secondary" onclick="loadSafeguard()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
|
||||||
|
<button class="btn secondary" onclick="clearSafeguard()" style="padding:4px 10px;font-size:11px;" title="Vorfallsliste leeren — danach läuft wieder das Hauptmodell">Liste leeren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;line-height:1.55;">
|
||||||
|
Anthropics Filter meldet sich nicht als Fehler, sondern als ganz normale
|
||||||
|
Antwort — der Fehlertext stünde sonst als ARIAs Wort im Verlauf, und weil
|
||||||
|
der Verlauf bei jedem Turn mitgeht, wäre das Projekt danach tot.
|
||||||
|
Darum zwei Stufen:
|
||||||
|
<strong style="color:#E0E0F0;">1.</strong> dieselbe Frage nochmal mit dem
|
||||||
|
Ausweich-Modell (oben einstellbar).
|
||||||
|
<strong style="color:#E0E0F0;">2.</strong> blockt auch das, liegt es am
|
||||||
|
Verlauf: der auslösende Turn fliegt aus dem Arbeitsgedächtnis — gelöscht
|
||||||
|
wird nichts, du holst ihn hier mit einem Klick zurück.
|
||||||
|
<span style="color:#555570;">Vermerke verfallen von selbst, sobald der Turn
|
||||||
|
aus dem Fenster gerollt ist.</span>
|
||||||
|
</div>
|
||||||
|
<div id="safeguard-list" class="card" style="padding:0;">
|
||||||
|
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade …</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
|
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
|
||||||
<div class="card" style="max-width:620px;">
|
<div class="card" style="max-width:620px;">
|
||||||
@@ -2187,6 +2236,8 @@
|
|||||||
try { loadBrainStatus(); } catch {}
|
try { loadBrainStatus(); } catch {}
|
||||||
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
|
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
|
||||||
try { loadModelList(); } catch {}
|
try { loadModelList(); } catch {}
|
||||||
|
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
|
||||||
|
try { loadSafeguard(); } catch {}
|
||||||
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
|
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
|
||||||
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
|
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
|
||||||
};
|
};
|
||||||
@@ -2880,6 +2931,11 @@
|
|||||||
const has = Array.from(sel.options).some(o => o.value === msg.model);
|
const has = Array.from(sel.options).some(o => o.value === msg.model);
|
||||||
if (has) { sel.value = msg.model; onModelSelectChange(); }
|
if (has) { sel.value = msg.model; onModelSelectChange(); }
|
||||||
}
|
}
|
||||||
|
const fsel = document.getElementById('setting-fallback-select');
|
||||||
|
if (fsel && msg.fallbackModel !== undefined) {
|
||||||
|
const fhas = Array.from(fsel.options).some(o => o.value === msg.fallbackModel);
|
||||||
|
fsel.value = fhas ? msg.fallbackModel : '';
|
||||||
|
}
|
||||||
if (st) {
|
if (st) {
|
||||||
st.textContent = msg.info || msg.error || '';
|
st.textContent = msg.info || msg.error || '';
|
||||||
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
|
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
|
||||||
@@ -8167,6 +8223,14 @@
|
|||||||
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
|
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
|
||||||
st.style.color = '#FFD60A';
|
st.style.color = '#FFD60A';
|
||||||
}
|
}
|
||||||
|
// Ausweich-Dropdown aus derselben Liste — plus "automatisch" als
|
||||||
|
// erste Option. Gleiche Quelle, damit beide nie auseinanderlaufen.
|
||||||
|
const fsel = document.getElementById('setting-fallback-select');
|
||||||
|
if (fsel) {
|
||||||
|
const keep = fsel.value;
|
||||||
|
fsel.innerHTML = '<option value="">Automatisch (anderes Tier als oben)</option>' + sel.innerHTML;
|
||||||
|
if (keep) fsel.value = keep;
|
||||||
|
}
|
||||||
onModelSelectChange();
|
onModelSelectChange();
|
||||||
// Aktuelles Model vom Brain holen → markiert die richtige Option
|
// Aktuelles Model vom Brain holen → markiert die richtige Option
|
||||||
loadModel();
|
loadModel();
|
||||||
@@ -8175,6 +8239,115 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sicherheitsfilter (Anthropic) ─────────────────────────────────
|
||||||
|
// Zwei Dinge in einem Panel: was blockiert wurde (Historie) und was gerade
|
||||||
|
// noch wirkt (Quarantaene). Nur Zweiteres ist eine Aktion.
|
||||||
|
|
||||||
|
function saveFallbackModel() {
|
||||||
|
const sel = document.getElementById('setting-fallback-select');
|
||||||
|
send({ action: 'set_fallback_model', model: sel ? sel.value : '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSafeguard() {
|
||||||
|
const box = document.getElementById('safeguard-list');
|
||||||
|
if (!box) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/safeguard/status');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
renderSafeguard(await r.json());
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<div style="padding:14px;color:#FF6B6B;font-size:12px;">Status nicht ladbar: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sgTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
return isNaN(ms) ? iso : `${fmtMemTs(ms)} (${fmtMemAgo(ms)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSafeguard(d) {
|
||||||
|
const box = document.getElementById('safeguard-list');
|
||||||
|
if (!box) return;
|
||||||
|
const inc = d.incidents || [];
|
||||||
|
const quar = d.quarantine || [];
|
||||||
|
if (!inc.length && !quar.length) {
|
||||||
|
box.innerHTML = '<div style="padding:18px;color:#34C759;font-size:12px;text-align:center;">Noch nie blockiert worden. 👍</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
if (quar.length) {
|
||||||
|
rows.push(`<div style="padding:8px 12px;background:#1A1226;border-bottom:1px solid #2A2A3E;color:#FFD60A;font-size:11px;font-weight:600;">
|
||||||
|
Aus dem Arbeitsgedächtnis genommen (${quar.length})</div>`);
|
||||||
|
for (const q of quar) {
|
||||||
|
const proj = q.project_id ? `📁 ${escapeHtml(q.project_id)}` : '💬 Hauptchat';
|
||||||
|
rows.push(`<div style="padding:10px 12px;border-bottom:1px solid #1E1E2E;display:flex;gap:10px;align-items:flex-start;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="color:#E0E0F0;font-size:12px;">${proj}
|
||||||
|
<span style="color:#555570;font-size:10px;margin-left:6px;">${escapeHtml(_sgTime(q.at))}</span></div>
|
||||||
|
<div style="color:#8888AA;font-size:11px;margin-top:3px;white-space:pre-wrap;word-break:break-word;">${escapeHtml(q.content || '(Turn nicht mehr im Fenster)')}</div>
|
||||||
|
<div style="color:#555570;font-size:10px;margin-top:3px;">${escapeHtml(q.reason || '')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn secondary" onclick="unquarantineTurn('${escapeHtmlAttr(q.turn_ts)}')"
|
||||||
|
title="Turn wieder in den Verlauf aufnehmen — wenn er der Auslöser war, blockt der Filter erneut"
|
||||||
|
style="flex:0 0 auto;padding:3px 8px;font-size:10px;">↩ Zurückholen</button>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inc.length) {
|
||||||
|
rows.push(`<div style="padding:8px 12px;background:#12121E;border-bottom:1px solid #2A2A3E;color:#8888AA;font-size:11px;font-weight:600;">
|
||||||
|
Blockaden (${d.total || inc.length})</div>`);
|
||||||
|
for (const i of inc) {
|
||||||
|
const proj = i.project_id ? `📁 ${escapeHtml(i.project_id)}` : '💬 Hauptchat';
|
||||||
|
// "wirkt noch" = der Turn liegt noch im Fenster, also laeuft das
|
||||||
|
// Projekt weiter auf dem Ausweich-Modell. Das ist die Information,
|
||||||
|
// wegen der man hier ueberhaupt reinschaut.
|
||||||
|
const live = i.in_window
|
||||||
|
? '<span style="color:#FFD60A;font-size:10px;font-weight:600;margin-left:6px;">wirkt noch</span>'
|
||||||
|
: '<span style="color:#555570;font-size:10px;margin-left:6px;">abgelaufen</span>';
|
||||||
|
const state = i.recovered
|
||||||
|
? `<span style="color:#34C759;">→ ${escapeHtml(i.fallback || '?')} hat geantwortet</span>`
|
||||||
|
: '<span style="color:#FF6B6B;">auch der zweite Versuch blockiert</span>';
|
||||||
|
rows.push(`<div style="padding:9px 12px;border-bottom:1px solid #1E1E2E;">
|
||||||
|
<div style="color:#E0E0F0;font-size:12px;">${proj}
|
||||||
|
<span style="color:#555570;font-size:10px;margin-left:6px;">${escapeHtml(_sgTime(i.at))}</span>${live}</div>
|
||||||
|
<div style="font-size:11px;margin-top:3px;color:#8888AA;">
|
||||||
|
${escapeHtml(i.model || '?')} blockiert${i.code ? ' <span style="color:#B392F0;">[' + escapeHtml(i.code) + ']</span>' : ''} ${state}
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
box.innerHTML = rows.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unquarantineTurn(ts) {
|
||||||
|
if (!confirm('Turn wieder in den Verlauf aufnehmen?\n\nWar er der Auslöser, '
|
||||||
|
+ 'blockt Anthropics Filter beim nächsten Mal erneut — dann landet '
|
||||||
|
+ 'er automatisch wieder hier.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/safeguard/unquarantine', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ turn_ts: ts }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
loadSafeguard();
|
||||||
|
} catch (e) { alert('Zurückholen fehlgeschlagen: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSafeguard() {
|
||||||
|
if (!confirm('Vorfallsliste leeren?\n\nDanach läuft wieder das Hauptmodell — '
|
||||||
|
+ 'auch in Projekten, die gerade aufs Ausweich-Modell ausweichen. '
|
||||||
|
+ 'Zurückgeholte Turns bleiben zurückgeholt.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/safeguard/clear', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
loadSafeguard();
|
||||||
|
} catch (e) { alert('Leeren fehlgeschlagen: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Datenschutz-Schalter ──────────────────────────────────────────
|
// ── Datenschutz-Schalter ──────────────────────────────────────────
|
||||||
function loadPrivacyFlags() {
|
function loadPrivacyFlags() {
|
||||||
const st = document.getElementById('privacy-flags-status');
|
const st = document.getElementById('privacy-flags-status');
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const RUNTIME_CONFIG_FIELDS = [
|
|||||||
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
||||||
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
||||||
"brainModel",
|
"brainModel",
|
||||||
|
// Ausweich-Modell, wenn Anthropics Sicherheitsfilter das Hauptmodell blockt.
|
||||||
|
// Das Brain liest es PRO Vorfall aus derselben Datei — Aenderung greift ohne
|
||||||
|
// Neustart. Leer = automatisch (anderes Tier als das Hauptmodell).
|
||||||
|
"brainFallbackModel",
|
||||||
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
|
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
|
||||||
// aus derselben Datei — Aenderung greift ohne Neustart.
|
// aus derselben Datei — Aenderung greift ohne Neustart.
|
||||||
"memoryAttachmentMaxMb",
|
"memoryAttachmentMaxMb",
|
||||||
@@ -3274,6 +3278,8 @@ wss.on("connection", (ws) => {
|
|||||||
handleSetModel(ws, msg.model);
|
handleSetModel(ws, msg.model);
|
||||||
} else if (msg.action === "test_model") {
|
} else if (msg.action === "test_model") {
|
||||||
handleTestModel(ws, msg.model);
|
handleTestModel(ws, msg.model);
|
||||||
|
} else if (msg.action === "set_fallback_model") {
|
||||||
|
handleSetFallbackModel(ws, msg.model);
|
||||||
} else if (msg.action === "check_proxy_patches") {
|
} else if (msg.action === "check_proxy_patches") {
|
||||||
handleCheckProxyPatches(ws);
|
handleCheckProxyPatches(ws);
|
||||||
} else if (msg.action === "get_privacy_flags") {
|
} else if (msg.action === "get_privacy_flags") {
|
||||||
@@ -3680,6 +3686,7 @@ function handleGetModel(clientWs) {
|
|||||||
clientWs.send(JSON.stringify({
|
clientWs.send(JSON.stringify({
|
||||||
type: "model_info",
|
type: "model_info",
|
||||||
model,
|
model,
|
||||||
|
fallbackModel: (cfg.brainFallbackModel || "").trim(),
|
||||||
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
|
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3687,6 +3694,29 @@ function handleGetModel(clientWs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausweich-Modell fuer Anthropics Sicherheitsfilter. Leerer String =
|
||||||
|
// automatisch (das Brain nimmt dann ein anderes Tier als das Hauptmodell).
|
||||||
|
// Anders als brainModel braucht das KEINEN Neustart: safeguard.py liest die
|
||||||
|
// Datei erst, wenn der Filter wirklich zuschlaegt.
|
||||||
|
function handleSetFallbackModel(clientWs, model) {
|
||||||
|
try {
|
||||||
|
const val = typeof model === "string" ? model.trim() : "";
|
||||||
|
writeRuntimeConfig({ brainFallbackModel: val });
|
||||||
|
log("info", "server", `Ausweich-Model gesetzt: ${val || "(automatisch)"}`);
|
||||||
|
const cfg = readRuntimeConfig();
|
||||||
|
clientWs.send(JSON.stringify({
|
||||||
|
type: "model_info",
|
||||||
|
model: (cfg.brainModel || "").trim() || "claude-sonnet-4",
|
||||||
|
fallbackModel: val,
|
||||||
|
info: val
|
||||||
|
? `Ausweich-Model: ${val} — greift sofort, kein Neustart noetig`
|
||||||
|
: "Ausweich-Model: automatisch (anderes Tier als das Hauptmodell)",
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSetModel(clientWs, model) {
|
function handleSetModel(clientWs, model) {
|
||||||
if (!model || typeof model !== "string") {
|
if (!model || typeof model !== "string") {
|
||||||
clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));
|
clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user