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

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,