diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 9e5e3f9..8c3f794 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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 `.\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//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: diff --git a/aria-brain/oauth.py b/aria-brain/oauth.py index 9a935de..bd51eb3 100644 --- a/aria-brain/oauth.py +++ b/aria-brain/oauth.py @@ -28,6 +28,7 @@ import logging import os import secrets import time +import urllib.error import urllib.parse import urllib.request from pathlib import Path @@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str, scopes: Optional[list[str]] = None, client_auth: str = "body", extra_auth_params: Optional[dict] = None, - accept_header: Optional[str] = None) -> dict: + accept_header: Optional[str] = None, + api_hosts: Optional[list[str]] = None) -> dict: """Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird vom Brain-Tool `oauth_register_provider` gerufen. @@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str, entry["extra_auth_params"] = extra_auth_params if accept_header is not None: entry["accept_header"] = accept_header + if api_hosts is not None: + # Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf. + # Noetig, wenn die API auf einer anderen Domain liegt als der Login + # (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber + # manche Provider nutzen voellig andere Namen). + entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()] apps[svc] = entry _save_json(APPS_FILE, apps) logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)", @@ -416,6 +424,108 @@ def revoke(service: str) -> bool: return True +def allowed_hosts(service: str) -> list[str]: + """Hosts, an die ein Token dieses Services geschickt werden darf. + + SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den + Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt + schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst + gehoert: die Hosts aus auth_url/token_url plus optional eingetragene + api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit + (accounts.spotify.com -> api.spotify.com), fremde Domains nie. + """ + cfg = _provider_config(service) + hosts: set[str] = set() + for key in ("auth_url", "token_url"): + u = cfg.get(key) or "" + try: + h = urllib.parse.urlparse(u).hostname or "" + except Exception: + h = "" + if h: + hosts.add(h.lower()) + for h in (cfg.get("api_hosts") or []): + h = str(h).strip().lower() + if h: + hosts.add(h) + return sorted(hosts) + + +def _host_allowed(host: str, allowed: list[str]) -> bool: + """True, wenn host zu einem der erlaubten Hosts gehoert. + + Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner + endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf + 'spotify.com'. + """ + h = (host or "").lower().strip(".") + if not h: + return False + + def base(x: str) -> str: + parts = x.lower().strip(".").split(".") + return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower() + + hb = base(h) + return any(h == a or hb == base(a) for a in allowed) + + +def call(service: str, url: str, method: str = "GET", + body: Optional[str] = None, content_type: str = "application/json", + timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]: + """Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben. + + Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token + verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem + Gespraechsprotokoll landen. + + Returns (status_code, text). Wirft ValueError bei unerlaubter URL. + """ + svc = (service or "").strip() + if not svc: + raise ValueError("service ist Pflicht") + u = (url or "").strip() + if not u.startswith("https://"): + # Kein http:// — der Token ginge im Klartext ueber die Leitung. + raise ValueError("url muss mit https:// beginnen") + host = urllib.parse.urlparse(u).hostname or "" + allowed = allowed_hosts(svc) + if not allowed: + raise ValueError( + f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.") + if not _host_allowed(host, allowed): + raise ValueError( + f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}") + + m = (method or "GET").upper() + if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"): + raise ValueError(f"Methode nicht erlaubt: {method}") + + record = get_token(svc) # refresht bei Bedarf + token = record.get("access_token", "") + ttype = record.get("token_type") or "Bearer" + + data = body.encode("utf-8") if body else None + req = urllib.request.Request(u, data=data, method=m) + req.add_header("Authorization", f"{ttype} {token}") + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", content_type) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read(max_bytes + 1) + status = resp.status + except urllib.error.HTTPError as exc: + raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b"" + status = exc.code + truncated = len(raw) > max_bytes + text = raw[:max_bytes].decode("utf-8", "replace") + if truncated: + text += "\n…(gekuerzt)" + logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw)) + return status, text + + def list_services() -> list[dict]: """Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist + Ablaufzeit. Wird von Diagnostic genutzt.""" diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 0c73cc7..35484ed 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -457,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None, "", "Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) " "Services via OAuth2 ansprechen. Workflow ist IMMER:", - "1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.", + "1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.", "2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.", + "3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt " + "die Authorization selbst an.", + "", + "**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** " + "Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag " + "also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs" + "variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill " + "— der holt sich den Token zur Laufzeit ueber " + "`{BRAIN_INTERNAL_URL}/oauth//token`, serverseitig.", + "", + "**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen " + "API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. " + "Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. " + "Und wiederhole ein Secret niemals in Deiner Antwort.", "", f"**Callback-URL (fest, NICHT raten):** `{base}`", "Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige " diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index d530710..8079ae4 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -185,11 +185,21 @@ SEED_RULES: List[dict] = [ "category": "skills", "content": ( "Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder " - "client_secret direkt in den Skill-Code. Fuer OAuth-Services " - "(Spotify, Google, GitHub etc.) nutze das Brain-Tool " - "`oauth_get_token('')` — das macht Auto-Refresh und " - "haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten " - "manuell neu einloggen, das nervt." + "client_secret direkt in den Skill-Code — und schreibe sie auch " + "NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird " + "uebertragen und gespeichert.\n" + "\n" + "Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n" + "- Im Gespraech: `oauth_call('', url, ...)` — das Brain " + "haengt die Authorization an, Du siehst den Token nie.\n" + "- Im Skill-Code: den Token zur Laufzeit holen ueber " + "GET {BRAIN_INTERNAL_URL}/oauth//token. Der Aufruf macht " + "Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu " + "einloggen, das nervt.\n" + "- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, " + "API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht " + "in den Code und nicht in den Chat. Der Skill bekommt sie zur " + "Laufzeit als CFG_-Umgebungsvariable." ), }, { @@ -235,9 +245,11 @@ SEED_RULES: List[dict] = [ "content": ( "Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / " "'token expired' zurueckgibt: RUFE ZUERST " - "`oauth_get_token('')`. Brain holt entweder den noch " - "gueltigen Token oder refresht ihn automatisch ueber den " - "gespeicherten refresh_token. In 99% der Faelle reicht das.\n" + "`oauth_get_token('')`. Das refresht automatisch ueber den " + "gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig " + "ist (den Token selbst bekommst Du bewusst nicht — " + "`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: " + "danach denselben Aufruf einfach nochmal.\n" "\n" "Nur wenn `oauth_get_token` selbst einen Fehler wirft " "('refresh failed', 'no refresh_token', 'service nicht "