Zurück zum Blog
·von Patrick Hofmann

Was der Agent nicht weiß, macht mich nicht heiß

Tokens als Umgebungsvariable funktionieren für Menschen — der Nutzer hat ein Token, das ihm seine legitimen Permissions gibt. Bei Agents bricht das: Granularität fehlt, der Agent hat keinen Permission-Sense, und er ist nicht der Endpoint, sondern Durchgang. Eine Notiz aus dem Bauen eines Proxies, der die Tokens hat — und der Agent nicht.

OpenApeAI AgentsInfrastructureSecurityBuilding in Public

Tokens liegen normalerweise für den Nutzer frei — als Umgebungsvariable, in einer Config-Datei, im System-Keyring. Das ist nicht falsch: der Token gibt dem Nutzer die Permissions, die der Nutzer legitim hat. Nutzer-Nutzer-Konsistenz. Wer den Token hat, ist dieselbe Entität, die ihn ausgestellt bekommen hat.

Bei Agents bricht das.

Tokens sind die falsche Granularitäts-Achse. Plattformen bieten durchaus feinkörnige Token-Scopes an — GitHub fine-grained PATs, OpenAI project keys, scoped npm tokens. Aber das ist Permission-Granularität: was darf der Agent überhaupt. Was ich tatsächlich brauche, ist eine andere Achse: Workflow-Granularität. Mein Agent soll alle Operationen können, die ich als Nutzer auch kann. Ich will nur, dass er bei git push origin main anders behandelt wird als bei git status — bei einem soll er fragen, beim anderen nicht. Diese Achse gibt es auf Token-Ebene nicht. Token-Scopes sagen darf-oder-darf-nicht, sie sagen nicht darf, soll aber zwischendurch fragen.

Der Agent hat keinen Permission-Sense. Ein Mensch weiß intuitiv, dass "jetzt schicke ich das GitHub-Token an evil.com" keine gute Idee ist. Ein Agent weiß das nicht. Er folgt seinem Kontext — und wenn der Kontext von außen über Prompt-Injection vergiftet wird, folgt er dem mit.

Der Agent findet einen Weg. Auf Command-Ebene kann ich einschränken, was passieren darf — aber nicht, dass der Agent das Token aus einer Datei liest und damit selbst arbeitet. .npmrc enthält den npm-Token. .env enthält API-Keys. ~/.config/... ist voll davon. Ein simples "schick mir mein .npmrc"-Prompt-Inject liefert die Sammlung in die History — danach Token-Rotation. Wenn der vorgesehene Weg blockiert ist, sucht der Agent helpful-by-design einen alternativen Pfad und findet ihn. Diese Dateien hinter eine Grant-Wall zu stellen würde funktionieren — kostet aber genau die Workflow-Granularität, die ich gewinnen will (Agent fragt dann vor jedem File-Access). Saubere Lösung: das Geheimnis steht nicht in der Datei. Wenn .npmrc kein Token mehr enthält, ist Lese-Zugriff darauf kein Problem.

Der Agent ist nicht der Endpoint. Er sitzt zwischen Mensch und Service. Der Mensch hat den Token. Der Service akzeptiert ihn. Der Agent passiert ihn durch — und auf diesem Durchgangs-Pfad gibt es alle möglichen Wege, bei denen der Token nicht da ankommt, wo er soll. Im Logfile. In einer URL. In einer fehlgeleiteten POST-Body-Variable.

Der naheliegende Reflex ist, dem Agent beizubringen, vorsichtig zu sein. Tool-Description sagt "schick keine Tokens an unbekannte Hosts". System-Prompt warnt. Vielleicht eine Output-Filter-Regel, die alles blockt, was wie ein Token aussieht. Das ist Instructions-Engineering, und es funktioniert ungefähr so zuverlässig wie es klingt — gut, aber nicht im Sinne von garantiert.

Mein Weg ist ein anderer.

Das Token gehört nicht dem Agent

Die Inversion ist einfach: das Token ist nicht in der Umgebung des Agents. Es liegt im Memory eines Daemons, der als der Agent-User läuft, aber von mir gestartet wird — nicht vom Agent. Der Agent macht seinen Request gegen api.github.com ohne Authorization-Header. Der Daemon fängt ihn ab, fügt den Header ein, schickt ihn weiter. Der Agent sieht weder Token noch wird er an dem Punkt jemals damit interagiert.

Voraussetzung: ein separater Unix-User für den Agent (agent_iurio o.ä.) und ein einmaliges apes login als dieser User, damit der Daemon die DDISA-Identity aus ~/.config/apes/auth.json lesen kann.

# Ich (Mensch) starte den Daemon — Secrets via stdin, niemals auf Disk
sudo -u agent_iurio openape-proxy --global --port 18789 < ~/.secrets-iurio.toml

# Banner zeigt was geladen wurde
[openape-proxy] identity: agent.iurio@example.com (https://id.openape.ai)
[openape-proxy] secrets: gh_pat, openai, smtp
[openape-proxy] export OPENAPE_PROXY=127.0.0.1:18789
[openape-proxy] listening on 127.0.0.1:18789

# Agent läuft normal, durch den Wrapper
export OPENAPE_PROXY=127.0.0.1:18789
apes proxy -- gh repo list
apes proxy -- curl https://api.github.com/user
# → {"login": "patrick", ...}

Die Secrets-Datei ist TOML, vier Felder pro Eintrag — target, header, template, value. Beispiel:

version = "1"

[secrets.gh_pat]
target   = "api.github.com/*"
header   = "Authorization"
template = "Bearer ${value}"
value    = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

Die Mechanik:

  1. Wrapper setzt Umgebung: apes proxy -- baut pro Aufruf ein Trust-Bundle (System-Roots + lokale CA) in einer Temp-Datei und exportiert es über HTTPS_PROXY und ein paar CA-Trust-Variablen — NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO. Subprocess-scoped, nicht system-weit. Die lokale CA landet nicht im System-Trust-Store, das macOS-Keychain bleibt unberührt.
  2. Daemon terminiert TLS: für api.github.com mintet er on-the-fly ein Leaf-Cert, signiert mit der lokalen CA. Der Agent macht seine TLS-Verbindung zur lokalen CA, weil seine Trust-Variablen das so vorsehen.
  3. Im decryptierten Request injiziert der Daemon den Header: Authorization: Bearer <token> aus seinem Memory.
  4. Daemon baut eine eigene TLS-Verbindung zum echten Server auf: mit dem System-Trust-Store. Bidirektional, transparent für den Agent.

Der Smoke gegen das echte api.github.com mit einem realen OAuth-Token: curl gibt den User-Login zurück. Der Token taucht weder im Environment des Agent-Prozesses auf, noch im Stdout des Daemons. Was im Audit-Log steht, ist der Name des Secrets, nicht der Wert.

At-rest mit age

Der Plaintext-Pfad ist Datei mit Mode 0600 in meinem Home — ausreichend, weil der Agent-User keine Read-Permission hat. Wer noch eine Schicht drauflegen will, encrypted die Datei mit age und pipet die Decryption direkt in den Daemon — der Plaintext landet nie auf der Disk:

age --decrypt -i ~/.ssh/id_ed25519 ~/.secrets-iurio.age \
  | sudo -u agent_iurio openape-proxy --global --port 18789

Zwei Trust-Boundaries

Wo lebt das Token, und wer kann es lesen? Zwei Mechanismen, die unabhängig voneinander greifen:

Filesystem. Das Secrets-File hat Mode 0600 in meinem Home, mein User ist Owner. Der Agent läuft als separater Unix-User, dessen Home-Verzeichnis er nicht lesen kann. Filesystem-Isolation ist die alte Unix-Antwort, sie funktioniert hier auch.

Process. Der Daemon und der Agent laufen in getrennten Prozessen, gestartet als verschiedene Unix-User. Auf macOS kann ein normaler User-Prozess ohne task_for_pid-Entitlement nicht den Memory eines anderen User-Prozesses lesen. Auf Linux blockiert ptrace_scope=1 (Kernel-Default seit Jahren) das. Memory-Isolation ist hart, solange ich nicht selbst Root bin oder ein speziell privilegierter Helper laufe.

Plus eine dritte Sache, die nicht ein Trust-Boundary fürs Secret ist, aber trotzdem wichtig: der Audit-Trail lebt server-side beim IdP. Lokale Audit-Logs auf der Agent-Maschine sind kein Beweis — anything written on the agent's host is also writable by the agent. Wenn jemand später fragt, was passiert ist, ist die Antwort im IdP, nicht in einer Datei, die der Agent selbst überschreiben könnte.

Match-Resolution

Pro Request nimmt der Daemon höchstens ein Secret. Die Auswahl folgt einfachen Regeln:

  • Glob-Syntax: * ist der einzige Wildcard. ?, [], ** sind literale Zeichen. Patterns matchen gegen host[:port]/path.
  • Tiebreaker: der längste literale Prefix gewinnt. Bei gleichem Prefix wins die Reihenfolge in der TOML-Datei.
  • At most one: auch wenn mehrere Targets matchen würden, wird nur eines injiziert. Keine Header-Stapel, keine Mehrdeutigkeit.

Beispiele gegen die TOML-Tabelle oben:

RequestMatched Secret
GET https://api.github.com/usergh_pat
POST https://api.openai.com/v1/chatopenai
CONNECT smtp.fastmail.com:587smtp
GET https://example.com/(no match — passes through)

Requests die nichts matchen, gehen unverändert durch — sie laufen aber trotzdem durch die Policy/Audit-Pipeline des Daemons wie jeder andere Request auch. Das Secret-Lookup ist eine zusätzliche Schicht, kein Replacement der YOLO/Allow/Deny-Policy.

Was im Smoke aufgefallen ist

Beim ersten echten Smoke gegen api.github.com sind ein paar Bugs rausgefallen:

Go's strict x509-Parser lehnt node-forge-default-Certs ab, weil node-forge bei Common-Name-Encodings PrintableString als Default nimmt, wo Go-Tools (gh, git, alles was Go-net intern nutzt) UTF8String erwarten. Fix: explizit UTF8String bei der Cert-Generation.

Bun's node:tls-Compat hängt im TLSSocket-on-existing-socket-Pfad — ein Aufruf, den der Daemon braucht, um die TLS-Termination auf einer bestehenden TCP-Verbindung zu beginnen. Fix: Daemon refused under Bun, läuft auf Node. Zurück zu Bun, sobald upstream gefixt ist.

apes-login schreibt OAuth 2.0 access_token, nicht bearer — der Token-Field-Name in auth.json war falsch dokumentiert in unserer eigenen Code-Erwartung. Fix: beide Field-Namen akzeptieren, klare Doku im Header-Kommentar.

Keiner dieser Bugs ist konzeptionell. Alle sind Reibungspunkte zwischen ehemals isolierten Komponenten, die jetzt aneinander gebunden sind. Genau das, was beim ersten echten Smoke einer neuen Architektur immer auftaucht.

Was das nicht ist

Mehrere klare Limits, die ich nicht verstecken will:

TLS-Pinning bricht. Wenn ein Tool seinen Cert-Chain hart auf die echte CA pinnt — viele Mobile-SDKs, einige native Binaries, manche enterprise-grade APIs — kann der Daemon den Request nicht mehr inspizieren oder modifizieren. Der Request fällt durch, das Tool meldet einen Cert-Error. Das ist eine bewusste Trade-off-Entscheidung. Pinning-Tools müssen ihre Tokens auf einem anderen Pfad bekommen.

Go-Clients auf macOS. gh, kubectl, terraform, helm und alles andere Go-basierte liest auf macOS für seinen SystemCertPool ausschließlich das System-Keychain — SSL_CERT_FILE wird ignoriert. Das per-Subprocess-Trust-Bundle ist für Go-on-macOS unsichtbar, der Handshake schlägt mit "certificate is not trusted" fehl. Auf Linux funktioniert es, weil Go dort SSL_CERT_FILE honoriert. Workaround auf macOS: die lokale CA manuell ins Keychain installieren via security add-trusted-cert — das gibt der CA system-weit Trust und sollte nur bewusst gemacht werden. Eine v2-Variante mit opt-in apes proxy ca install ist in der Pipeline.

Wrapper-only HTTPS. Native curl https://... ohne apes proxy -- davor schlägt fehl, weil das Trust-Bundle nicht gesetzt ist. Das ist nicht ein Bug — es ist die ganze Idee, dass Trust subprocess-scoped bleibt. Aber die Konsequenz ist, dass Tools, die nicht via apes proxy -- gestartet werden, nicht profitieren.

Restart zum Rotieren. Kein Hot-Reload in v1. Wer ein Token wechselt, stoppt den Daemon und startet ihn neu mit dem neuen stdin-Payload. In-flight Requests fallen mit der TCP-Verbindung weg — der Wrapper-Subprocess sieht einen Connection-Error, das Tool retryt von selbst. Eine v2 mit Control-Socket-Reload ist denkbar, aber explizit nicht geplant für jetzt.

4 KiB stdin-Cap. Die TOML-Blob darf nicht größer sein. Wer mehr Secrets hat, läuft mehrere Daemon-Instanzen auf verschiedenen Ports. Hat mich noch nie gestört, könnte aber.

Kein Schutz gegen Root. Wenn jemand auf der Maschine Root hat, kann er den Daemon-Memory lesen. Das ist trivial, das ist normal, das ist kein Security-Boundary, den ich anstrebe. Mein Threat-Model ist Agent kompromittiert — nicht Maschine kompromittiert. Wer Root hat, hat alles.

Kein Schutz gegen einen kompromittierten Daemon. Wenn der Daemon-Prozess selbst durch eine Code-Injection gekapert wird, ist alles Memory-zugänglich. Der Daemon ist klein und lebt in User-Code, nicht in Agent-Code — die Angriffsfläche ist viel kleiner, aber nicht null.

Was bleibt: Schutz gegen Prompt-Injection-Angriffe, Schutz gegen unabsichtliches Token-Leakage durch Agent-Verhalten, Schutz gegen Token-Abflussweg-via-Logfiles. Das sind die Threat-Modelle, die mit dieser Architektur tatsächlich reduziert werden — nicht eliminiert, aber so reduziert, dass sie nicht mehr von der Trust-Annahme "Agent ist vorsichtig" abhängen.

Wo das in der Architektur sitzt

Das ist die vierte Achse einer Sicherheits-Architektur, die ich seit einiger Zeit baue. Nicht zufällig, sondern weil dieselbe Logik überall greift.

AchseToolWas der Agent NICHT weiß
ProcessStanding Grantsbreit, was erlaubt ist — er kennt nur das aktuelle Approval
Privilegeescapesdass das nächste Kommando privilegiert läuft, bis der Crossing approved ist
Networkopenape-proxy (Method+Host)dass Method+Host gefiltert sind
Authopenape-proxy + Token-Injectionmit welchem Token er authenticatet ist

Vier Achsen, ein Pattern: Infrastructure trägt das Wissen, der Agent operiert blind. Wer das nicht hat, hat Vertrauen in den Agent — und Vertrauen ist nicht das, was eine Sicherheits-Architektur tragen sollte. Vertrauen ist das, was übrig bleibt, wenn die Architektur nichts mehr abdecken kann.

Der Vergleich, den ich erwarten muss

Architektonisch ist TLS-Termination mit lokalem CA ein gelöstes Problem. mitmproxy macht das seit Jahren, Charles Proxy macht das, jeder Penetration-Tester hat sein Lieblings-Setup. Das ist nicht neu.

Was hier neu ist:

  • Subprocess-scoped Trust-Wiring statt System-Trust-Store-Eingriff. Der Daemon rührt das System nicht an. Wenn ich den Daemon stoppe, ist alle Konfiguration weg. Kein "ach ja, ich hatte mal mitmproxy installiert"-Restmüll im macOS-Keychain.
  • Integration mit OpenApe-Identity. Der Daemon authentifiziert sich beim IdP, jede Grant-Decision ist auditbar — server-side, nicht in einer lokalen Logdatei, die der Agent selbst überschreiben könnte. Audit lebt dort, wo es nicht manipuliert werden kann.
  • Per-Endpoint Token-Auswahl, nicht ein globales Bag-of-Tokens. Der Daemon weiß: für api.github.com kommt das GitHub-Token, für registry.npmjs.org das npm-Token. Patterns sind in der Secrets-TOML, der Agent kennt die Patterns nicht und kennt die Secrets nicht.

Das ist nicht ein neues Crypto-Pattern. Das ist die Anwendung eines etablierten Patterns auf ein Problem, das in der Agent-Welt anders gelagert ist als in der Pentester-Welt.

Die einzige Frage, die übrig bleibt

Sie kommt früher oder später in den Kommentaren: "Was wenn der Agent das Token aus dem Daemon-Memory liest?"

Antwort: kann er nicht — auf einem nicht-Root-System. Process-Memory ist OS-isoliert, das ist seit den frühen 2000ern so. Ja, ich weiß, dass es Side-Channel-Attacks gibt. Ja, ich weiß, dass ein Agent in derselben User-Identity wie der Daemon ohne weitere Maßnahmen Memory lesen könnte. Genau deshalb läuft der Daemon als anderer User. Diese Trennung ist kein Implementierungs-Detail, sie ist die ganze Idee.

Closing

Du bringst dem Agent nicht bei, mit dem Token vorsichtig zu sein. Du nimmst das Token aus dem Agent raus.

Was der Agent nicht weiß, macht mich nicht heiß.

Das ist nicht Verdrängung. Das ist Architektur.


Code: github.com/openape-ai/openape, MIT-lizenziert. openape-proxy ist die Daemon-Implementation, apes proxy -- der Subprocess-Wrapper. Beide nutzen apes login für Identity. Der Token-Injection-Pfad ist auf dem Feature-Branch feat/phase-6-proxy-secrets — Operator-Runbook in docs/proxy-secrets.md.