agenttwo-tools/README.md
Jeuner dd0ee30165 feat: Bestätigungspflicht für Werkzeuge mit Außenwirkung, DNS-Pinning
Ergebnis eines Security-Reviews. Die schwerwiegendste Kette war:
Fremdinhalt aus read_webpage weist das Modell an, remember aufzurufen —
der Anker landet gepinnt und sessionübergreifend im Gedächtnis und geht
danach als "verlässliches Wissen" in jeden System-Prompt. Beide Werkzeuge
liefen ungefragt, requiresConfirmation war nur ein Feld ohne Wirkung.

Bestätigung:
- runTool wertet requiresConfirmation über ToolContext.confirm aus; ohne
  Rückkanal (Skript, Test) gilt abgelehnt statt ungefragt ausführen
- Handshake über den WebSocket: tool-confirm mit ungekürzten Argumenten,
  Antwort allow/always/deny. Ablehnung auch bei Timeout (2 min),
  Verbindungsabbruch und Stop; "always" gilt pro Verbindung
- read_webpage und remember sind bestätigungspflichtig; remember pinnt
  nicht mehr automatisch, damit Modell-Anker normal verfallen
- ToolConfirm-Komponente zeigt Werkzeug und vollständige Argumente

SSRF-Guard (DNS-Rebinding):
- fetch gegen node:http/https mit eigenem lookup-Hook getauscht: geprüft
  wird genau die Adresse, die dann auch verbunden wird. Vorher löste fetch
  ein zweites Mal auf — öffentlich beim Prüfen, 127.0.0.1 beim Verbinden
- IPv6-Literale werden entklammert, gzip/deflate/br werden ausgepackt

Nebenbei:
- ctx.signal kombiniert Abbruch und Zeitlimit und wirkt bis in den Abruf
- activeAborts als Set: Stop erwischt alle laufenden Antworten
- Chat-Rate-Limit 30/min je Verbindung, Rate-Limiter räumt Buckets ab
- web/dist untracked

Nicht umgesetzt: Auth/Origin-Härtung — der Server läuft bewusst lokal,
Anfragen ohne Origin-Header bleiben erlaubt (im README dokumentiert).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
2026-08-28 14:30:18 +02:00

14 KiB
Raw Blame History

agenttwo-tools

Fork von agenttwo. Die Basis bleibt dort unverändert; hier kommen Vision und Werkzeuge (Tool-Calling) dazu. Läuft auf eigenen Ports (Backend 8788, Frontend 5174), damit beide Projekte parallel laufen können. Fixes aus der Basis lassen sich per git cherry-pick aus dem Remote upstream übernehmen.

Voice-Chat-Oberfläche für lokale und Cloud-LLMs: lokales Qwen3 über Ollama, optionaler Fallback auf OpenRouter, Spracheingabe via whisper.cpp und deutsche Sprachausgabe via Piper (Stimme: Thorsten).

Aufbau

npm-Workspace mit zwei Paketen:

Pfad Inhalt
server/ Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite
web/ React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording

Voraussetzungen

  • macOS mit Homebrew
  • Node.js 22+server/src/db.ts nutzt das eingebaute node:sqlite, das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start eine ExperimentalWarning; ab Node 24 ist das Modul stabil.
  • Ollama mit einem Qwen3-Modell (ollama pull qwen3.5)
  • ffmpeg im PATH
  • whisper-cli im PATH (whisper.cpp) inklusive Modell
  • Piper als Python-Modul — der Server ruft python3 -m piper auf, nicht das gleichnamige Homebrew-Binary

RAM-Hinweis (Apple Silicon): Qwen3.5 belegt ~7 GB im Speicher — mit weniger als 16 GB RAM wird es eng. Dann lieber das kleinere qwen3:8b (ollama pull qwen3:8b, ~5 GB) laden und in den Einstellungen unter „Lokales Modell“ wählen bzw. MODEL=qwen3:8b in die .env schreiben.

Schnellstart (Mac)

Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper, Ollama, Modell, Sprachdateien) zur Installation an:

git clone https://github.com/Jeuners/agenttwo-tools.git
cd agenttwo-tools
./setup.sh          # oder: npm run setup
npm run dev         # → http://localhost:5174

Nur prüfen ohne etwas zu ändern: ./setup.sh --check · Ohne Nachfragen: ./setup.sh --yes

Unter macOS:

brew install ffmpeg whisper-cpp
pip3 install piper-tts

Prüfen, ob alles bereitsteht:

ffmpeg -version >/dev/null 2>&1 && echo "ffmpeg OK"
whisper-cli --help >/dev/null 2>&1 && echo "whisper-cli OK"
python3 -m piper --help >/dev/null 2>&1 && echo "piper OK"
node -e "require('node:sqlite')" 2>/dev/null && echo "node:sqlite OK"

Whisper-Modell

Wird per Default unter ~/whisper-models/ggml-large-v3-turbo.bin erwartet:

mkdir -p ~/whisper-models
curl -L -o ~/whisper-models/ggml-large-v3-turbo.bin \
  https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin

Piper-Stimme

Die Stimmdatei ist nicht im Repo (109 MB, über GitHubs Dateilimit). Einmalig herunterladen:

mkdir -p server/voices
curl -L -o server/voices/de_DE-thorsten-high.onnx \
  https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx

Die zugehörige de_DE-thorsten-high.onnx.json liegt bereits im Repo.

Konfiguration

.env im Projekt-Root (wird vom Server eingelesen, ist gitignored):

OPENROUTER_API_KEY=sk-or-...

Weitere optionale Variablen mit ihren Defaults:

Variable Default
PORT 8788
OLLAMA_URL http://localhost:11434
MODEL qwen3.5:latest
WHISPER_MODEL ~/whisper-models/ggml-large-v3-turbo.bin
WHISPER_LANG de

MODEL ist nur der Default: In den Einstellungen lässt sich das lokale Ollama-Modell pro Chat über das Dropdown „Lokales Modell“ wechseln (die Liste kommt live von GET /api/ollama/models). | PIPER_MODEL | server/voices/de_DE-thorsten-high.onnx | | ALLOWED_ORIGINS | (leer — siehe Sicherheit) |

Anhänge (Bilder & Dateien)

qwen3.5 bringt die Fähigkeit vision mit, deshalb versteht der Chat Bilder. Anhängen geht auf drei Wegen: Button 📎 im Composer, Einfügen aus der Zwischenablage (⌘V) oder Drag & Drop auf die Eingabezeile. Eine Nachricht darf auch nur aus einem Anhang bestehen.

Bilder werden zusammen mit der Nachricht in der SQLite-Datei abgelegt und bei Folgefragen erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. Für Ollama gehen sie als images: [base64] raus, für OpenRouter im OpenAI-Format als image_url mit data-URL — der MIME-Typ wird dabei aus den Magic Bytes bestimmt.

Grenzen (server/src/images.ts): maximal 4 Bilder pro Nachricht, je 6 MB, nur PNG, JPEG, GIF und WebP. Der Typ wird an den Magic Bytes geprüft, nicht am angegebenen Dateinamen; der WebSocket hat dafür ein Payload-Limit von 32 MB.

Text- und PDF-Dateien (server/src/files.ts) landen als formatierter Block im Kontext und werden ebenfalls gespeichert — Rückfragen zur Datei funktionieren also auch in der nächsten Nachricht. Unterstützt: .txt .json .md .csv .yaml .xml .sql und gängige Code-Endungen, plus .pdf (Text wird serverseitig mit pdf-parse extrahiert). Grenzen: maximal 4 Dateien pro Nachricht, je 100 kB extrahierter Text (bei PDFs gekürzt, nicht abgelehnt), 10 MB PDF-Größe; Binärdateien werden über Steuerzeichen erkannt und abgelehnt. Gescannte PDFs ohne Textebene werden abgewiesen (keine OCR).

Ob das eingestellte Modell Bilder kann, verrät:

curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])"

Werkzeuge (Tool-Calling)

qwen3.5 meldet die Fähigkeit tools. Der Server schickt bei jeder Anfrage eine Werkzeugliste mit; will das Modell eines benutzen, wird es ausgeführt und das Ergebnis zurückgereicht, bis eine Antwort ohne Werkzeugwunsch entsteht (maximal MAX_TOOL_ROUNDS = 5 Runden, je 15 s Zeitlimit). Abschalten lässt sich das in den Einstellungen.

Werkzeug Zweck
get_time Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone
calculate Arithmetik mit eigenem Parser
read_file Textdatei unterhalb des Projektverzeichnisses lesen
list_files Verzeichnis auflisten
remember Wichtigen Punkt als Ankerpunkt ins Gedächtnis schreiben — Rückfrage
recall Gedächtnis (Ankerpunkte) durchsuchen
read_webpage Öffentliche Website laden, Hauptinhalt als Markdown — Rückfrage

Aktuelle Liste: curl -s http://localhost:8788/api/tools

Rückfrage vor Werkzeugen mit Außenwirkung

Werkzeuge mit requiresConfirmation laufen erst nach Freigabe. Der Server schickt eine Rückfrage über den WebSocket, die Oberfläche zeigt Werkzeugname und die vollständigen Argumente, und erst die Antwort löst die Ausführung aus:

Antwort Wirkung
Ablehnen Werkzeug läuft nicht; das Modell bekommt "vom Nutzer abgelehnt" als Ergebnis und macht ohne weiter
Einmal zulassen Nur dieser eine Aufruf
Immer zulassen Dieses Werkzeug bis zum Neuladen der Seite, pro WebSocket-Verbindung

Ohne Antwort gilt nach 2 Minuten „abgelehnt"; dasselbe bei Verbindungsabbruch und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal (Skript, Test), wird es abgelehnt statt ungefragt ausgeführt — die Bestätigung soll sich nicht dadurch umgehen lassen, dass niemand zum Fragen da ist.

Gedächtnis (Chat-Memory)

Das Gedächtnis hat drei Schichten:

Schicht Speicher Zweck
Arbeitsgedächtnis Nachrichtenfenster Einstellbar (Default 10 Schritte), geht ans Modell
Episodisch memory_events Append-Only-Log: nur INSERT, nie UPDATE/DELETE
Semantisch anchors Ankerpunkte, destilliert in der Traumphase

Das Fenster ist in den Einstellungen per Slider einstellbar (2100 Schritte).

Traumphase

Die Traumphase konsolidiert neue Log-Einträge in Ankerpunkte — bevorzugt per LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als Fallback. Auslöser:

  • automatisch nach 3 Minuten Inaktivität oder wenn 10 Schritte unkonsolidiert sind
  • manuell über 🧠 Gedächtnis → „Traumphase jetzt"

Ankerpunkte haben eine Wichtigkeit (01), eine Art (fact, decision, preference, entity, open_question) und einen Ursprung (dream, model, heuristic, test). Ähnliche Anker werden zusammengeführt (Wort-Ähnlichkeit ≥ 0.5, hits steigt). Bei jedem Traumlauf verfallen ungepinnte Anker (Importance × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte (★) bleiben dauerhaft.

Die wichtigsten Anker werden als System-Kontext eingespielt (Budget ~1200 Zeichen) — das Modell beantwortet dann Fragen aus Inhalten, die nie im sichtbaren Chatverlauf standen.

Rekonstruktion

Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch aus Seq 0 neu falten: 🧠 Gedächtnis → „Aus Log rekonstruieren" (gepinnte Anker bleiben erhalten). Die Eval der Heuristik und des Dream-Parsers:

npm run memory:eval --workspace server

Endpunkte: GET /api/sessions/:id/memory, POST /api/sessions/:id/dream, POST /api/sessions/:id/memory/rebuild, PATCH|DELETE /api/anchors/:id.

Grenzen

Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem Außenwirkung und sind deshalb bestätigungspflichtig (siehe oben):

  • read_webpage verlässt den Rechner. Die URL selbst ist dabei der kritische Teil: Fremdinhalt kann das Modell anweisen, Gesprächsinhalte in eine Adresse zu packen und so nach außen zu geben. Deshalb bekommt der Nutzer die vollständige URL vor dem Abruf zu sehen.
  • remember schreibt dauerhaft und sessionübergreifend ins Gedächtnis. Modell-Anker werden nicht gepinnt — sie unterliegen dem normalen Verfall. Gepinnt wird nur, was der Nutzer im Gedächtnis-Panel selbst mit ★ markiert.

Der Abruf in read_webpage ist mehrfach geguardet: nur http/https, private Adressbereiche abgewiesen (auch über Weiterleitungen), 15 s Zeitlimit, 2 MB Fetch-Limit, 25 kB Output-Cap. Die DNS-Auflösung ist an die Verbindung gepinnt (lookup-Hook in tools/web.ts) — geprüft wird genau die Adresse, die dann auch verbunden wird. Ein getrennter Vorab-Check, wie ihn fetch erzwingt, ließe DNS-Rebinding zu: öffentlich beim Prüfen, 127.0.0.1 beim Verbinden. Der Inhalt wird dem Modell zusätzlich als nicht vertrauenswürdig markiert (Prompt-Injection aus Webseiten).

Ein Nutzer-Abbruch („Stop") beendet auch ein laufendes Werkzeug: ctx.signal kombiniert Abbruch und Zeitlimit und geht bis in den offenen Netzwerkabruf durch.

Der Dateizugriff liegt in einer Sandbox: Jeder Pfad wird über realpath aufgelöst (löst auch Symlinks auf) und muss danach unterhalb der Wurzel liegen, sonst wird abgelehnt. Die Wurzel ist standardmäßig das Projektverzeichnis und über TOOLS_ROOT einstellbar. .env, .git/ und Schlüsseldateien sind auch innerhalb der Wurzel gesperrt.

calculate benutzt bewusst kein eval oder new Function: der Ausdruck stammt aus einer Modellantwort, die von Nutzereingaben beeinflusst wird. Der Parser in tools/calculate.ts kennt nur Zahlen und Grundrechenarten.

Werkzeugaufrufe werden in der Oberfläche über der Antwort angezeigt. Sie leben nur im Browser-Zustand und sind nach einem Neuladen weg — im Gegensatz zu Bildern, die in der Datenbank landen.

Sicherheit

Der Server hat keine Authentifizierung und lauscht deshalb bewusst nur auf 127.0.0.1. Das allein genügt aber nicht: Eine beliebige Webseite, die im Browser geöffnet ist, kann localhost per fetch() oder WebSocket erreichen. Deshalb prüfen sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die Origin gegen eine Allowlist (server/src/security.ts) — Standard sind localhost/127.0.0.1 auf Port 5174 und 8788.

Läuft das Frontend woanders, die Origin ergänzen:

ALLOWED_ORIGINS=http://192.168.1.50:5174

Weitere Maßnahmen: Rate-Limits auf /api/stt (10/min), /api/tts (30/min), /dream (4/min) und auf Chats pro WebSocket-Verbindung (30/min), Format-Whitelist per Magic Bytes vor dem ffmpeg-Aufruf, und Fehlerdetails landen im Server-Log statt in der HTTP-Antwort.

Bewusst nicht abgesichert: Anfragen ohne Origin-Header werden durchgelassen (isOriginAllowed). Für Browser-Clients trägt die Prüfung, denn fetch und Formular-POSTs senden immer eine Origin — jedes lokale Programm kommt aber ungefragt an die API. Das ist für den Einzelplatzbetrieb so gewollt; für alles andere gilt der Absatz unten.

Für externen Zugriff reicht ein Reverse-Proxy nicht — davor gehört eine echte Authentifizierung.

Entwicklung

npm install
npm run dev          # Server (:8788) und Vite-Dev-Server (:5174) parallel

Einzeln:

npm run dev:server
npm run dev:web

Typecheck über beide Workspaces:

npm run typecheck

Produktion

npm start            # baut das Frontend und startet den Server

Der Server bindet nur an 127.0.0.1. Zum Aussetzen ins Netz siehe Sicherheit.