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:
2026-10-08 10:16:24 +02:00
co-authored by Claude Opus 5
parent d53dd83df4
commit d221c1a320
8 changed files with 759 additions and 20 deletions
+173
View File
@@ -663,6 +663,28 @@
<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-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;">
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
@@ -679,6 +701,33 @@
</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">
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
<div class="card" style="max-width:620px;">
@@ -2187,6 +2236,8 @@
try { loadBrainStatus(); } catch {}
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
try { loadModelList(); } catch {}
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
try { loadSafeguard(); } catch {}
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
@@ -2880,6 +2931,11 @@
const has = Array.from(sel.options).some(o => o.value === msg.model);
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) {
st.textContent = msg.info || msg.error || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
@@ -8167,6 +8223,14 @@
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
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();
// Aktuelles Model vom Brain holen → markiert die richtige Option
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 ──────────────────────────────────────────
function loadPrivacyFlags() {
const st = document.getElementById('privacy-flags-status');
+30
View File
@@ -268,6 +268,10 @@ const RUNTIME_CONFIG_FIELDS = [
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
"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
// aus derselben Datei — Aenderung greift ohne Neustart.
"memoryAttachmentMaxMb",
@@ -3274,6 +3278,8 @@ wss.on("connection", (ws) => {
handleSetModel(ws, msg.model);
} else if (msg.action === "test_model") {
handleTestModel(ws, msg.model);
} else if (msg.action === "set_fallback_model") {
handleSetFallbackModel(ws, msg.model);
} else if (msg.action === "check_proxy_patches") {
handleCheckProxyPatches(ws);
} else if (msg.action === "get_privacy_flags") {
@@ -3680,6 +3686,7 @@ function handleGetModel(clientWs) {
clientWs.send(JSON.stringify({
type: "model_info",
model,
fallbackModel: (cfg.brainFallbackModel || "").trim(),
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
}));
} 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) {
if (!model || typeof model !== "string") {
clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));