feat(oauth): ARIA bekommt Zugangstokens nicht mehr zu sehen

oauth_get_token hat den access_token im Klartext als Tool-Ergebnis
zurueckgegeben. Tool-Ergebnisse sind Teil der Unterhaltung — der Token ging
damit an Anthropic und lag je nach Konto-Einstellung 30 Tage oder 5 Jahre dort.

Neu: das Brain fuehrt den API-Aufruf SELBST aus.

- `oauth_call(service, url, method, body)` haengt die Authorization an,
  schickt den Request und gibt nur die Antwort zurueck. Der Token verlaesst
  den Brain-Prozess nicht.
- `oauth_get_token` sagt nur noch, OB authentifiziert ist und wie lange noch.
  Refresh laeuft weiter automatisch.
- Skills bleiben unveraendert: die holen den Token serverseitig ueber
  {BRAIN_INTERNAL_URL}/oauth/<service>/token — der laeuft nie durch ein
  Gespraech.

Host-Sperre als Sicherheitskern: die URL kommt vom Modell, also koennte ein
Aufruf den Token sonst an einen beliebigen Server schicken. Erlaubt sind nur
Hosts, die zum Provider gehoeren (aus auth_url/token_url, plus optionale
api_hosts). Verglichen wird auf den letzten zwei Labels, NICHT per endswith —
"boesespotify.com" endet sonst auch auf "spotify.com". Dazu: nur https (sonst
ginge der Token im Klartext ueber die Leitung) und nur die ueblichen Methoden.

19 Pruefungen, darunter die Angriffsvarianten: boesespotify.com,
spotify.com.angreifer.de, accounts.spotify.com.evil.io, spotify.co, leerer
Host — alle blockiert; api.spotify.com und abschliessender Punkt erlaubt.

System-Prompt und seed_rules nachgezogen, sonst erklaert ARIA sich das alte
Verhalten selbst. Beide sagen jetzt zusaetzlich ausdruecklich: Secrets gehoeren
nie in den Chat, und ein Secret nie in einer Antwort wiederholen. Genau so ist
hier naemlich ein client_secret abgeflossen — nicht ueber ein Tool, sondern
weil es eingetippt und dann zurueckzitiert wurde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:16:41 +02:00
co-authored by Claude Opus 5
parent 752e2abc5f
commit 2c60a19ed6
4 changed files with 219 additions and 22 deletions
+73 -12
View File
@@ -820,13 +820,13 @@ META_TOOLS = [
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Prueft, ob ein Service authentifiziert ist, und wie lange der "
"Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
"GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
"fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
"Authorization selbst an. In einem Skill holst Du ihn zur "
"Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
"Wirft, wenn der Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
@@ -838,6 +838,35 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_call",
"description": (
"Ruft eine Provider-API MIT dem hinterlegten Zugangstoken auf, "
"ohne dass Du den Token siehst. Das Brain haengt den "
"Authorization-Header an, fuehrt den Request aus und gibt Dir nur "
"die Antwort.\n\n"
"Nimm das fuer einzelne Aufrufe (z.B. Spotify-Wiedergabe steuern, "
"GitHub-Issue anlegen). Fuer etwas, das Du oefter brauchst, bau "
"lieber einen Skill.\n\n"
"Nur https, und nur Hosts, die zum Service gehoeren — eine fremde "
"Domain wird abgelehnt (sonst koennte der Token irgendwohin "
"geschickt werden). Antwort wird bei 200 KB gekuerzt."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, github."},
"url": {"type": "string", "description": "Vollstaendige https-URL der API."},
"method": {"type": "string", "description": "GET (Default), POST, PUT, PATCH, DELETE."},
"body": {"type": "string", "description": "Request-Body, meist JSON als String."},
"content_type": {"type": "string", "description": "Default application/json."},
},
"required": ["service", "url"],
},
},
},
{
"type": "function",
"function": {
@@ -3723,15 +3752,47 @@ class Agent:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
hosts = ", ".join(oauth_mod.allowed_hosts(svc)) or "(keine hinterlegt)"
# Der Token wird hier BEWUSST nicht mehr herausgegeben. Frueher
# stand er im Klartext in dieser Tool-Antwort und damit im
# Gespraechsprotokoll — also auch bei Anthropic. Fuer API-Aufrufe
# gibt es oauth_call: das Brain haengt den Header selbst an.
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
f"- Einzelne API-Aufrufe: `oauth_call(service='{svc}', url=...)` — "
f"das Brain haengt die Authorization an.\n"
f"- In einem Skill: GET {{BRAIN_INTERNAL_URL}}/oauth/{svc}/token "
f"zur Laufzeit holen, nie hartkodieren.\n"
f"Erlaubte Hosts: {hosts}"
)
if name == "oauth_call":
svc = (arguments.get("service") or "").strip()
url = (arguments.get("url") or "").strip()
if not svc or not url:
return "FEHLER: service und url sind Pflicht."
body = arguments.get("body")
if body is not None and not isinstance(body, str):
body = json.dumps(body, ensure_ascii=False)
try:
status, text = oauth_mod.call(
svc, url,
method=(arguments.get("method") or "GET"),
body=body,
content_type=(arguments.get("content_type") or "application/json"),
)
except ValueError as exc:
return f"FEHLER: {exc}"
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_call fehlgeschlagen")
return f"FEHLER: Aufruf fehlgeschlagen: {exc}"
ok = 200 <= status < 300
head = f"HTTP {status}" + ("" if ok else " (Fehler)")
return f"{head}\n{text}" if text else head
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc: