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
This commit is contained in:
Jeuner 2026-08-28 14:30:18 +02:00
parent 878acd9933
commit dd0ee30165
18 changed files with 570 additions and 181 deletions

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ data.sqlite*
# Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen
server/voices/*.onnx
# Build-Artefakt, wird aus web/src erzeugt
web/dist/

View file

@ -166,12 +166,30 @@ sich das in den Einstellungen.
| `calculate` | Arithmetik mit eigenem Parser |
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
| `list_files` | Verzeichnis auflisten |
| `remember` | Wichtigen Punkt als gepinnten Ankerpunkt ins Gedächtnis schreiben |
| `remember` | Wichtigen Punkt als Ankerpunkt ins Gedächtnis schreiben**Rückfrage** |
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen |
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown |
| `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:
@ -219,17 +237,29 @@ Endpunkte: `GET /api/sessions/:id/memory`, `POST /api/sessions/:id/dream`,
### Grenzen
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
löscht oder Befehle ausführt — entsprechend braucht es noch keine
Rückfrage pro Aufruf. Das Feld `requiresConfirmation` in
`tools/types.ts` ist bereits vorgesehen, damit die Bestätigungspflicht nicht
nachträglich eingezogen werden muss, sobald ein schreibendes Werkzeug dazukommt.
Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem
Außenwirkung und sind deshalb bestätigungspflichtig (siehe oben):
Ausnahme Netzwerk: `read_webpage` lädt öffentliche Websites. Der Abruf ist
geguardet — nur http/https, private Adressbereiche werden nach DNS-Auflösung
abgewiesen (SSRF-Schutz, auch über Weiterleitungen), 15 s Zeitlimit, 2 MB
Fetch-Limit, 25 kB Output-Cap. Der Inhalt wird dem Modell als nicht
vertrauenswürdig markiert (Prompt-Injection aus Webseiten).
- **`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,
@ -260,10 +290,17 @@ 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) und `/api/tts` (30/min),
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.

View file

@ -2,6 +2,7 @@ import Fastify from "fastify";
import cors from "@fastify/cors";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import * as dbmod from "./db.js";
@ -35,6 +36,15 @@ const PORT = Number(process.env.PORT ?? 8788);
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
const MODEL = process.env.MODEL ?? "qwen3.5:latest";
/**
* Wie lange auf die Freigabe eines bestätigungspflichtigen Werkzeugs gewartet
* wird. Danach gilt "abgelehnt" eine Antwort soll nicht ewig hängen, nur
* weil niemand am Rechner sitzt.
*/
const CONFIRM_TIMEOUT_MS = 120_000;
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
const CHAT_LIMIT_PER_MIN = 30;
const DREAM_IDLE_MS = 180_000;
const DREAM_BATCH = 10;
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -325,7 +335,54 @@ function broadcast(data: unknown) {
}
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
let activeAbort: AbortController | null = null;
// Mehrere Antworten können parallel laufen (zweite Nachricht bei laufendem
// Stream). Ein einzelnes Feld würde beim Abbruch nur die letzte erwischen.
const activeAborts = new Set<AbortController>();
const pendingConfirms = new Map<string, { name: string; decide(ok: boolean): void }>();
/** Werkzeuge, die der Nutzer für diese Verbindung generell freigegeben hat. */
const alwaysAllowed = new Set<string>();
const chatLimiter = createRateLimiter(CHAT_LIMIT_PER_MIN, 60_000);
function denyAllConfirms() {
for (const entry of [...pendingConfirms.values()]) entry.decide(false);
}
/**
* Fragt den Nutzer, bevor ein Werkzeug mit Außenwirkung läuft. Bricht die
* Verbindung weg oder bleibt die Antwort aus, gilt das als Ablehnung.
*/
function confirmTool(
messageId: string,
name: string,
args: Record<string, unknown>,
): Promise<boolean> {
if (alwaysAllowed.has(name)) return Promise.resolve(true);
if (socket.readyState !== WebSocket.OPEN) return Promise.resolve(false);
const id = randomUUID();
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => decide(false), CONFIRM_TIMEOUT_MS);
function decide(approved: boolean) {
clearTimeout(timer);
pendingConfirms.delete(id);
resolve(approved);
}
pendingConfirms.set(id, { name, decide });
socket.send(
JSON.stringify({
type: "tool-confirm",
id,
messageId,
name,
// Ungekürzt: der Nutzer muss genau sehen, was rausgeht — bei
// read_webpage ist die vollständige URL der eigentliche Punkt.
args: JSON.stringify(args),
}),
);
});
}
socket.on("close", denyAllConfirms);
socket.on("message", async (raw: Buffer) => {
let msg: Record<string, unknown>;
@ -336,13 +393,31 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
return;
}
if (msg.type === "tool-confirm-reply") {
const entry = pendingConfirms.get(String(msg.id ?? ""));
if (!entry) return;
// Der Werkzeugname kommt aus dem Server-Zustand, nicht aus der Antwort:
// sonst könnte eine Freigabe für ein Werkzeug ein anderes freischalten.
if (msg.decision === "always") alwaysAllowed.add(entry.name);
entry.decide(msg.decision === "allow" || msg.decision === "always");
return;
}
if (msg.type === "abort") {
activeAbort?.abort();
for (const controller of activeAborts) controller.abort();
denyAllConfirms();
return;
}
if (msg.type !== "chat") return;
if (!chatLimiter("chat")) {
socket.send(
JSON.stringify({ type: "error", error: "Zu viele Anfragen — kurz warten." }),
);
return;
}
const sessionId = String(msg.sessionId ?? "");
const content = String(msg.content ?? "").trim();
@ -423,7 +498,8 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
activeAbort = new AbortController();
const abort = new AbortController();
activeAborts.add(abort);
let full = "";
let thinking = "";
const callbacks = {
@ -463,6 +539,9 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
);
},
onToolConfirm(name: string, args: Record<string, unknown>) {
return confirmTool(assistantRow.id, name, args);
},
};
try {
@ -479,10 +558,10 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
},
apiKey,
callbacks,
activeAbort.signal,
abort.signal,
);
} else {
await streamChat(history, system, opts, callbacks, activeAbort.signal);
await streamChat(history, system, opts, callbacks, abort.signal);
}
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
mem.appendEvent(session.id, "message", {
@ -493,7 +572,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({
type: "done",
messageId: assistantRow.id,
aborted: activeAbort.signal.aborted,
aborted: abort.signal.aborted,
}),
);
broadcast({ type: "sessions-changed" });
@ -507,7 +586,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
}
} catch (err) {
const aborted =
activeAbort.signal.aborted ||
abort.signal.aborted ||
(err instanceof Error && err.name === "AbortError");
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
if (aborted) {
@ -522,7 +601,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
);
}
} finally {
activeAbort = null;
activeAborts.delete(abort);
}
});
});

View file

@ -24,6 +24,11 @@ export interface StreamCallbacks {
onToolCall?(name: string, args: Record<string, unknown>): void;
/** Werkzeug ist fertig. */
onToolResult?(name: string, ok: boolean, durationMs: number): void;
/**
* Holt die Freigabe des Nutzers für ein bestätigungspflichtiges Werkzeug.
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
*/
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
}
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
@ -200,7 +205,13 @@ export async function streamChat(
for (const call of toolCalls) {
cb.onToolCall?.(call.name, call.arguments);
const result = await runTool(call, { signal, sessionId: opts.sessionId });
const result = await runTool(call, {
signal,
sessionId: opts.sessionId,
confirm: cb.onToolConfirm
? (c) => cb.onToolConfirm!(c.name, c.arguments)
: undefined,
});
cb.onToolResult?.(result.name, result.ok, result.durationMs);
messages.push({ role: "tool", tool_name: result.name, content: result.content });
}

View file

@ -39,6 +39,9 @@ interface Bucket {
resetAt: number;
}
/** Ab dieser Größe wird aufgeräumt — reicht für jede realistische Nutzung. */
const MAX_BUCKETS = 1000;
/**
* Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte
* (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten.
@ -46,8 +49,16 @@ interface Bucket {
export function createRateLimiter(limit: number, windowMs: number) {
const buckets = new Map<string, Bucket>();
/** Abgelaufene Zähler wegräumen, damit die Map nicht unbegrenzt wächst. */
function sweep(now: number) {
for (const [key, bucket] of buckets) {
if (now >= bucket.resetAt) buckets.delete(key);
}
}
return function allow(key: string): boolean {
const now = Date.now();
if (buckets.size > MAX_BUCKETS) sweep(now);
const bucket = buckets.get(key);
if (!bucket || now >= bucket.resetAt) {

View file

@ -43,6 +43,11 @@ export function toolNames(): string[] {
return REGISTRY.map((t) => t.name);
}
/**
* Zeitlimit für das Ergebnis. Der `signal` unten bricht das Werkzeug zusätzlich
* ab beides zusammen, weil ein Werkzeug den Signal auch ignorieren kann und
* die Antwort dann trotzdem nicht ewig hängen darf.
*/
function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
@ -74,9 +79,26 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
};
}
if (tool.requiresConfirmation && !(await isApproved(call, ctx))) {
return {
name: tool.name,
content: JSON.stringify({
error:
"Vom Nutzer abgelehnt. Nicht erneut aufrufen — ohne dieses Werkzeug " +
"weitermachen und sagen, was dadurch fehlt.",
}),
ok: false,
durationMs: Date.now() - started,
};
}
// Zeitlimit und Nutzer-Abbruch als ein Signal, das an das Werkzeug geht:
// damit endet auch ein laufender Netzwerkabruf, statt weiterzulaufen.
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TOOL_TIMEOUT_MS)]);
try {
const value = await withTimeout(
tool.run(call.arguments, ctx),
tool.run(call.arguments, { ...ctx, signal }),
TOOL_TIMEOUT_MS,
tool.name,
);
@ -98,3 +120,17 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
};
}
}
/**
* Freigabe für ein bestätigungspflichtiges Werkzeug. Ohne Rückkanal (Skripte,
* Tests) gilt "abgelehnt" die Bestätigung soll sich nicht dadurch umgehen
* lassen, dass niemand zum Fragen da ist.
*/
async function isApproved(call: ToolCall, ctx: ToolContext): Promise<boolean> {
if (!ctx.confirm) return false;
try {
return await ctx.confirm(call);
} catch {
return false;
}
}

View file

@ -6,7 +6,7 @@ import type { Tool } from "./types.js";
export const rememberTool: Tool = {
name: "remember",
description:
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte.",
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte. Der Nutzer muss jeden Aufruf freigeben.",
requiresConfirmation: true,
parameters: {
type: "object",
@ -27,6 +27,10 @@ export const rememberTool: Tool = {
const text = String(args.text ?? "").trim();
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
if (text.length < 6) throw new ToolError("Text ist zu kurz, um ihn zu merken");
// Bewusst ungepinnt: ein vom Modell gesetzter Anker soll dem normalen
// Verfall unterliegen. Gepinnt wird nur, was der Nutzer im
// Gedächtnis-Panel selbst mit ★ markiert — sonst überlebt ein einmal
// untergeschobener "Fakt" jede Traumphase und jede Rekonstruktion.
const result = upsertAnchor(ctx.sessionId, {
text,
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
@ -34,7 +38,6 @@ export const rememberTool: Tool = {
: "fact",
importance: 0.9,
origin: "model",
pinned: true,
});
return { stored: result, text };
},

View file

@ -10,6 +10,12 @@ export interface ToolContext {
signal: AbortSignal;
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
sessionId?: string;
/**
* Holt die Freigabe des Nutzers für ein Werkzeug mit Außenwirkung.
* Fehlt der Rückkanal, werden bestätigungspflichtige Werkzeuge abgelehnt
* lieber nicht ausführen als ungefragt.
*/
confirm?(call: ToolCall): Promise<boolean>;
}
export interface Tool {
@ -17,9 +23,9 @@ export interface Tool {
description: string;
parameters: ToolSchema;
/**
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server).
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss.
* Kennzeichnet Werkzeuge mit Außenwirkung (Netzwerk, dauerhafter Speicher).
* `runTool` fragt vor der Ausführung über `ToolContext.confirm` beim Nutzer
* nach und lehnt ab, wenn keine Freigabe kommt.
*/
requiresConfirmation?: boolean;
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */

View file

@ -1,5 +1,8 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import dns from "node:dns";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP, type LookupFunction } from "node:net";
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
import { parseHTML } from "linkedom";
import { Defuddle } from "defuddle/node";
import { ToolError } from "./types.js";
@ -9,6 +12,8 @@ const TIMEOUT_MS = 15_000;
const MAX_HTML_BYTES = 2 * 1024 * 1024;
const MAX_CONTENT_CHARS = 25_000;
const MAX_REDIRECTS = 5;
const ALLOWED_TYPES =
/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/;
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 agenttwo-readweb/1.0";
@ -39,23 +44,53 @@ function isPrivateIP(ip: string): boolean {
first.startsWith("fe9") || first.startsWith("fea") || first.startsWith("feb");
}
/** `new URL().hostname` liefert IPv6-Literale in Klammern: [::1] -> ::1. */
function bareHost(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]")
? hostname.slice(1, -1)
: hostname;
}
/**
* SSRF-Schutz: Der Server löst den Host selbst auf und weist private Bereiche
* ab. Ohne das könnte das Modell http://localhost:8788/api/sessions lesen —
* der Origin-Check schützt nicht vor server-eigenem fetch.
* DNS-Auflösung, die private Adressen ablehnt eingehängt als `lookup` der
* HTTP-Verbindung.
*
* Entscheidend ist, dass genau diese Auflösung auch verbunden wird. Ein
* getrennter Vorab-Check (wie ihn `fetch` erzwingt, das selbst noch einmal
* auflöst) ließe DNS-Rebinding zu: öffentlich bei der Prüfung, 127.0.0.1 beim
* Verbinden.
*/
const guardedLookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err, "", 0);
const addresses = Array.isArray(address) ? address : [{ address, family }];
for (const a of addresses) {
if (isPrivateIP(a.address)) {
return callback(new ToolError("Zugriff auf private Adressen ist gesperrt"), "", 0);
}
}
callback(null, address as string, family);
});
};
/**
* Vorab-Prüfung, rein für die Fehlermeldung: so bekommt das Modell "private
* Adresse gesperrt" statt eines generischen Verbindungsfehlers. Die
* verbindliche Grenze ist `guardedLookup`.
*/
async function assertPublicHost(hostname: string): Promise<void> {
if (isIP(hostname)) {
if (isPrivateIP(hostname)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
const host = bareHost(hostname);
if (isIP(host)) {
if (isPrivateIP(host)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
return;
}
let addrs: { address: string }[];
try {
addrs = await lookup(hostname, { all: true, verbatim: true });
addrs = await dns.promises.lookup(host, { all: true, verbatim: true });
} catch {
throw new ToolError(`Host nicht auflösbar: ${hostname}`);
throw new ToolError(`Host nicht auflösbar: ${host}`);
}
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${hostname}`);
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${host}`);
for (const a of addrs) {
if (isPrivateIP(a.address)) {
throw new ToolError("Zugriff auf private Adressen ist gesperrt");
@ -76,49 +111,93 @@ function assertHttpUrl(raw: string): URL {
return url;
}
async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: string }> {
let url = assertHttpUrl(rawUrl).toString();
/** Ein GET mit gepinnter Auflösung. Weiterleitungen bleiben Sache des Aufrufers. */
function send(url: URL, signal: AbortSignal): Promise<IncomingMessage> {
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const req = request(
url,
{
method: "GET",
lookup: guardedLookup,
signal,
timeout: TIMEOUT_MS,
headers: {
"User-Agent": USER_AGENT,
Accept: "text/html, text/plain, application/xhtml+xml",
"Accept-Encoding": "gzip, deflate, br",
},
},
resolve,
);
req.on("timeout", () => req.destroy(new ToolError("Zeitlimit beim Abruf überschritten")));
req.on("error", (err) => {
if (err instanceof ToolError) return reject(err);
if (signal.aborted) return reject(new ToolError("Abruf abgebrochen"));
reject(new ToolError(`Abruf fehlgeschlagen: ${url.host}`));
});
req.end();
});
}
/** Antwortkörper bis MAX_HTML_BYTES lesen, komprimierte Antworten auspacken. */
async function readCapped(res: IncomingMessage): Promise<string> {
const encoding = String(res.headers["content-encoding"] ?? "").toLowerCase();
const stream =
encoding === "gzip" ? res.pipe(createGunzip())
: encoding === "deflate" ? res.pipe(createInflate())
: encoding === "br" ? res.pipe(createBrotliDecompress())
: res;
const decoder = new TextDecoder();
let html = "";
let bytes = 0;
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytes += chunk.byteLength;
html += decoder.decode(chunk, { stream: true });
if (bytes > MAX_HTML_BYTES) break;
}
} catch {
// Abbruch mitten im Strom: was schon da ist, reicht dem Extraktor meist.
if (!html) throw new ToolError("Antwort konnte nicht gelesen werden");
} finally {
res.destroy();
}
return html;
}
async function fetchWithGuards(
rawUrl: string,
signal: AbortSignal,
): Promise<{ url: string; body: string }> {
let url = assertHttpUrl(rawUrl);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertPublicHost(new URL(url).hostname);
await assertPublicHost(url.hostname);
const res = await fetch(url, {
redirect: "manual",
headers: { "User-Agent": USER_AGENT, Accept: "text/html, text/plain, application/xhtml+xml" },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const res = await send(url, signal);
const status = res.statusCode ?? 0;
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) break;
url = new URL(location, url).toString();
assertHttpUrl(url);
if (status >= 300 && status < 400) {
const location = res.headers.location;
res.destroy();
if (!location) throw new ToolError(`Weiterleitung ohne Ziel (HTTP ${status})`);
url = assertHttpUrl(new URL(location, url).toString());
continue;
}
if (!res.ok) throw new ToolError(`HTTP ${res.status} für ${url}`);
if (status < 200 || status >= 300) {
res.destroy();
throw new ToolError(`HTTP ${status} für ${url}`);
}
const type = (res.headers.get("content-type") ?? "").toLowerCase();
if (!/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/.test(type)) {
const type = String(res.headers["content-type"] ?? "").toLowerCase();
if (!ALLOWED_TYPES.test(type)) {
res.destroy();
throw new ToolError(`Nicht unterstützter Inhaltstyp: ${type || "unbekannt"}`);
}
const reader = res.body?.getReader();
if (!reader) throw new ToolError("Leere Antwort");
const decoder = new TextDecoder();
let html = "";
let bytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_HTML_BYTES) {
void reader.cancel();
html += decoder.decode(value, { stream: true });
break;
}
html += decoder.decode(value, { stream: true });
}
return { url, body: html };
return { url: url.toString(), body: await readCapped(res) };
}
throw new ToolError(`Zu viele Weiterleitungen (> ${MAX_REDIRECTS})`);
}
@ -126,7 +205,10 @@ async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: str
export const readWebpageTool: Tool = {
name: "read_webpage",
description:
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen.",
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen. Der Nutzer muss jeden Aufruf freigeben.",
// Der Abruf verlässt den Rechner: die URL selbst ist ein Kanal nach außen.
// Deshalb sieht der Nutzer sie vor dem Aufruf und gibt sie frei.
requiresConfirmation: true,
parameters: {
type: "object",
properties: {
@ -134,11 +216,11 @@ export const readWebpageTool: Tool = {
},
required: ["url"],
},
async run(args) {
async run(args, ctx) {
const raw = String(args.url ?? "").trim();
if (!raw) throw new ToolError("url fehlt");
const { url, body } = await fetchWithGuards(raw);
const { url, body } = await fetchWithGuards(raw, ctx.signal);
const { document } = parseHTML(body);
const result = await Defuddle(document, url, { markdown: true });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agenttwo-tools">
<rect width="32" height="32" rx="7" fill="#0a0f0c"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#1e2c23" stroke-width="1.5"/>
<path d="M8 10.5 L13.5 16 L8 21.5" fill="none" stroke="#3ddc84" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="16.5" y="19" width="8.5" height="3" rx="1.5" fill="#ffb454"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

15
web/dist/index.html vendored
View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-CQxQcFyH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -5,6 +5,7 @@ import { Sidebar } from "./components/Sidebar";
import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel";
import { ToolConfirm } from "./components/ToolConfirm";
import type { OpenRouterModel, OllamaModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode";
@ -434,6 +435,10 @@ export default function App() {
))}
</div>
{chat.toolConfirm && (
<ToolConfirm request={chat.toolConfirm} onDecide={chat.decideToolConfirm} />
)}
{voice.error && (
<div className="voice-error">
{voice.error}

View file

@ -0,0 +1,71 @@
import type { ToolConfirmRequest, ToolDecision } from "../types";
/** Kurzer Satz, was dieser Aufruf tatsächlich tut — pro Werkzeug. */
const WHAT_HAPPENS: Record<string, string> = {
read_webpage: "Diese Adresse wird von deinem Rechner abgerufen — inklusive allem, was in der URL steht.",
remember: "Dieser Punkt landet dauerhaft im Gedächtnis und geht künftig in jeden Chat mit ein.",
};
/** Argumente als lesbare Zeilen; unlesbares JSON fällt auf den Rohtext zurück. */
function argLines(args: string): [string, string][] {
try {
const parsed: unknown = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
k,
typeof v === "string" ? v : JSON.stringify(v),
]);
}
} catch {
/* unten als Rohtext */
}
return [["", args]];
}
export function ToolConfirm({
request,
onDecide,
}: {
request: ToolConfirmRequest;
onDecide: (id: string, decision: ToolDecision) => void;
}) {
return (
<div className="tool-confirm" role="alertdialog" aria-label="Werkzeug freigeben">
<div className="tool-confirm-head">
<span className="tool-confirm-mark"></span>
<span>
Das Modell möchte <code>{request.name}</code> ausführen
</span>
</div>
<dl className="tool-confirm-args">
{argLines(request.args).map(([key, value], i) => (
<div className="tool-confirm-arg" key={`${key}-${i}`}>
{key && <dt>{key}</dt>}
<dd>{value}</dd>
</div>
))}
</dl>
{WHAT_HAPPENS[request.name] && (
<p className="tool-confirm-hint">{WHAT_HAPPENS[request.name]}</p>
)}
<div className="tool-confirm-actions">
<button className="btn-decide deny" onClick={() => onDecide(request.id, "deny")}>
Ablehnen
</button>
<button className="btn-decide allow" onClick={() => onDecide(request.id, "allow")}>
Einmal zulassen
</button>
<button
className="btn-decide always"
title="Gilt für dieses Werkzeug, bis die Seite neu geladen wird"
onClick={() => onDecide(request.id, "always")}
>
Immer zulassen
</button>
</div>
</div>
);
}

View file

@ -896,3 +896,108 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Werkzeug-Freigabe --- */
.tool-confirm {
margin: 0 16px 10px;
padding: 12px 14px;
border: 1px solid var(--accent-warm);
border-radius: 8px;
background: rgba(255, 180, 84, 0.07);
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-confirm-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.tool-confirm-mark {
color: var(--accent-warm);
}
.tool-confirm-head code {
color: var(--accent-warm);
font-weight: 600;
}
.tool-confirm-args {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 160px;
overflow-y: auto;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg);
font-size: 0.85em;
}
.tool-confirm-arg {
display: flex;
gap: 8px;
}
.tool-confirm-arg dt {
flex: 0 0 auto;
color: var(--text-dim);
}
.tool-confirm-arg dd {
flex: 1 1 auto;
/* Umbrechen statt abschneiden: eine gekürzte URL wäre wertlos zum Prüfen. */
overflow-wrap: anywhere;
}
.tool-confirm-hint {
font-size: 0.82em;
color: var(--text-dim);
}
.tool-confirm-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-decide {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
font-family: var(--mono);
font-size: 0.85em;
cursor: pointer;
}
.btn-decide:hover {
border-color: var(--text-dim);
}
.btn-decide.allow {
border-color: var(--accent-dim);
color: var(--accent);
}
.btn-decide.allow:hover {
background: rgba(61, 220, 132, 0.1);
}
.btn-decide.always {
color: var(--text-dim);
}
.btn-decide.deny:hover {
border-color: #f85149;
color: #f85149;
}
.btn-decide:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

View file

@ -31,6 +31,20 @@ export interface ToolEvent {
durationMs?: number;
}
/**
* Rückfrage des Servers, bevor ein Werkzeug mit Außenwirkung läuft.
* `args` ist das vollständige JSON bei read_webpage steckt darin die URL,
* die der Rechner sonst ungefragt abrufen würde.
*/
export interface ToolConfirmRequest {
id: string;
messageId: string;
name: string;
args: string;
}
export type ToolDecision = "allow" | "always" | "deny";
export interface ChatOptions {
model: string;
think: boolean;

View file

@ -1,6 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatSocket } from "./socket";
import type { ChatOptions, Message, Session , ToolEvent } from "./types";
import type {
ChatOptions,
Message,
Session,
ToolConfirmRequest,
ToolDecision,
ToolEvent,
} from "./types";
export type ConnStatus = "connecting" | "open" | "closed";
export interface ModelInfo {
@ -46,6 +53,9 @@ export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({});
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
const [systemPrompt, setSystemPromptState] = useState(
() => localStorage.getItem(SYSTEM_KEY) ?? "",
@ -112,9 +122,23 @@ export function useChat() {
);
return { ...prev, [data.messageId as string]: updated };
});
} else if (t === "tool-confirm") {
setToolConfirms((prev) => [
...prev,
{
id: data.id as string,
messageId: data.messageId as string,
name: data.name as string,
args: data.args as string,
},
]);
} else if (t === "done" || t === "error") {
streamingRef.current = false;
setStreaming(false);
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
setToolConfirms((prev) =>
prev.filter((c) => c.messageId !== (data.messageId as string)),
);
} else if (t === "sessions-changed" || t === "session-deleted") {
void refreshSessions();
}
@ -199,6 +223,12 @@ export function useChat() {
const abort = useCallback(() => {
socketRef.current?.send({ type: "abort" });
setToolConfirms([]);
}, []);
const decideToolConfirm = useCallback((id: string, decision: ToolDecision) => {
socketRef.current?.send({ type: "tool-confirm-reply", id, decision });
setToolConfirms((prev) => prev.filter((c) => c.id !== id));
}, []);
const newSession = useCallback(async () => {
@ -226,6 +256,8 @@ export function useChat() {
messages,
streaming,
toolEvents,
toolConfirm: toolConfirms[0] ?? null,
decideToolConfirm,
sendMessage,
abort,
newSession,