# Können erzwungene SSH-Befehle ein KI-Dienstkonto absichern?

Ein KI-Agent sollte niemals einen SSH-Zugangsschlüssel erhalten, der bedeutet: «Mach alles, was dieses Konto kann.» Das ist keine Berechtigungsgrenze. Es ist eine Einladung, eine solche Grenze zu finden, meist über ein unerwartetes Argument, eine weitergeleitete Verbindung, deren Deaktivierung du vergessen hast, oder ein Deployment-Skript, das seinem Aufrufer zu stark vertraut.

Erzwungene SSH-Befehle geben dem entfernten Server das letzte Wort darüber, was nach der Authentifizierung gestartet wird. Sie eignen sich gut für Deployment- und Diagnosekonten, weil sie eine vage Fähigkeit, den Zugriff auf eine entfernte Shell, durch eine klar benannte Operation ersetzen, die du kontrollierst und prüfen kannst. Sie ersetzen nicht die menschliche Genehmigung für die Verwendung des Zugangsschlüssels. Halte diese beiden Kontrollen getrennt: Eine Person entscheidet, ob ein Agent den Schlüssel verwenden darf, und der Server entscheidet, welche eng begrenzte Aktion dieser Schlüssel ausführen kann.

## Ein erzwungener Befehl begrenzt die Ausführung, nicht die Authentifizierung

Ein erzwungener Befehl weist sshd an, ein vom Server ausgewähltes Programm auszuführen, selbst wenn der Client eine Shell anfordert oder einen anderen Befehl übergibt. Der Client authentifiziert sich weiterhin zuerst. Diese Unterscheidung klingt selbstverständlich, bis ein Dienstkonto in einer Agent-Konfiguration auftaucht und ein erfolgreicher Login wie ein genehmigtes Deployment behandelt wird.

OpenSSH unterstützt diese Kontrolle an zwei Stellen. Du kannst `command="/path/to/wrapper"` an einen einzelnen öffentlichen Schlüssel in `authorized_keys` anhängen oder `ForceCommand` in `sshd_config` für einen Benutzer oder eine Gruppe setzen. In beiden Fällen speichert sshd den vom Client angeforderten Befehl in der Umgebungsvariable `SSH_ORIGINAL_COMMAND` und startet stattdessen das erzwungene Programm.

Das OpenSSH-Handbuch `sshd(8)` ist beim ersten Punkt eindeutig: Eine `command`-Option erzwingt nach der Authentifizierung die Ausführung des angegebenen Befehls. Es dokumentiert auch, dass der ursprüngliche Befehl für dieses erzwungene Programm weiterhin verfügbar bleibt. Genau dieses Detail führt bei vielen schwachen Entwürfen zum Fehler. Der Wrapper erhält eine Zeichenfolge von einem nicht vertrauenswürdigen Client. Er muss diese Zeichenfolge als Anfrage parsen und darf sie nicht an eine Shell weitergeben.

Verwende die Variante pro Schlüssel, wenn ein Konto mehrere sorgfältig voneinander getrennte Zugangsschlüssel hat. Ein Release-Schlüssel kann den Deployment-Wrapper starten, während ein Operations-Schlüssel einen schreibgeschützten Diagnose-Wrapper startet. So bleibt die Absicht in `authorized_keys` sichtbar und du kannst einen Schlüssel entfernen, ohne den übrigen Zugriff des Kontos zu verändern.

Verwende `ForceCommand`, wenn das Konto selbst niemals eine allgemeine Shell bereitstellen darf, unabhängig davon, wie es sich authentifiziert. Dazu gehören auch ein Passwort, dessen Deaktivierung du vergessen hast, eine künftige Zertifizierungsstelle oder ein Administrator, der einen weiteren öffentlichen Schlüssel hinzufügt, ohne die erforderlichen Optionen zu kopieren. Ein Block wie `Match User deploy` macht die Regel bei einer Prüfung schwer zu übersehen.

Verwende keine der beiden Varianten, um ein menschliches Administratorkonto in ein Automatisierungskonto umzuwandeln. Menschen benötigen für Reparaturarbeiten irgendwann eine echte Shell. Gib der Automatisierung ein eigenes Unix-Konto, einen eigenen Zugangsschlüssel, einen eigenen Befehls-Wrapper und passende Zuständigkeitsgrenzen.

## Der Server muss den Deployment-Einstiegspunkt kontrollieren

Ein Deployment-Konto sollte in ein einzelnes Skript gelangen, das du kontrollierst, nicht in einen allgemeinen Befehlsinterpreter. Das Skript kann eine kleine Auswahl von Anfragen akzeptieren, sollte Repository-Pfad, Zielverzeichnis, Service-Unit und ausführbare Datei jedoch selbst bestimmen.

Dieser `authorized_keys`-Eintrag beschränkt einen einzelnen Zugangsschlüssel auf einen Wrapper und verweigert Verbindungsfunktionen, die in einem Deployment-Konto nichts zu suchen haben:

```text
restrict,command="/usr/local/libexec/release-gate" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

Die Option `restrict` ist nützlich, weil OpenSSH sie als Kurzform dokumentiert, die Port-Weiterleitung, Agent-Weiterleitung, X11-Weiterleitung und die Zuweisung eines Pseudo-Terminals deaktiviert. Das genaue Verhalten richtet sich nach den vom Server unterstützten OpenSSH-Optionen. Teste die Option deshalb mit der von dir eingesetzten Version. Wenn deine Umgebung für Prüfungen oder Kompatibilität ausdrückliche Optionen verlangt, schreibe sie einzeln aus:

```text
command="/usr/local/libexec/release-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... release-agent
```

Der Wrapper sollte keinen frei formulierten Deployment-Befehl annehmen. Gib den Aufrufern feste Verben und einen begrenzten Wert. So können sie beispielsweise ein Release nur anhand einer unveränderlichen Revision anfordern:

```text
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
```

Ein sicherer Wrapper kann genau diese Grammatik und nichts anderes akzeptieren:

```sh
#!/bin/sh
set -eu

request=${SSH_ORIGINAL_COMMAND-}
case "$request" in
  "release "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]* )
    revision=${request#release }
    case "$revision" in
      *" "*|*[!0-9a-f]*)
        echo "invalid revision" >&2
        exit 64
        ;;
    esac
    exec /usr/local/libexec/run-release "$revision"
    ;;
  *)
    echo "unsupported remote request" >&2
    exit 64
    ;;
esac
```

Dieses Beispiel benötigt weiterhin eine Längenprüfung, wenn dein Revisionsformat eine bestimmte Länge verlangt. Ein produktiver Wrapper sollte eine vollständige unveränderliche Objekt-ID oder eine Release-Kennung akzeptieren, deren Format du festlegst. Akzeptiere keinen Branch-Namen wie `main`, wenn der Aufrufer ihn zwischen Genehmigung und Deployment verschieben kann. Ein Branch ist ein Zeiger. Eine unveränderliche Revision sorgt dafür, dass Genehmigungsprotokoll, Deployment-Log und erzeugtes Artefakt auf dasselbe Objekt verweisen.

Das Skript `run-release` sollte absolute Pfade verwenden und seine eigene Umgebung festlegen. Verlasse dich nicht auf `PATH`, Arbeitsverzeichnis, Locale, `GIT_DIR`, `GIT_SSH_COMMAND` oder `LD_PRELOAD`, die vom Aufrufer stammen. Ein minimaler Anfang sieht so aus:

```sh
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
unset CDPATH ENV BASH_ENV GIT_DIR GIT_WORK_TREE GIT_SSH_COMMAND
cd /srv/release-repo

revision=$1
/usr/bin/git cat-file -e "$revision^{commit}"
/usr/local/libexec/build-and-activate "$revision"
```

Das Konto sollte nur die Dateien besitzen, die es ändern muss. Wenn es einen Dienst neu starten muss, gib ihm einen einzelnen, eng begrenzten `sudoers`-Befehl mit festen Argumenten, statt passwortlosen Zugriff auf einen allgemeinen Paketmanager oder eine Shell zu gewähren. Ein Deployment-Konto, das seinen eigenen Wrapper schreiben, seine eigenen `authorized_keys` ändern oder die Unit bearbeiten kann, unter der sein Code läuft, kann normalerweise weitreichende Kontrolle zurückgewinnen. Prüfe diese Pfade und nicht nur die SSH-Konfiguration.

## `SSH_ORIGINAL_COMMAND` ist eine Eingabe, keine Befehlszeile

Der häufigste Fehler bei erzwungenen Befehlen ist diese Zeile:

```sh
sh -c "$SSH_ORIGINAL_COMMAND"
```

Damit hebst du die gerade eingerichtete Kontrolle wieder auf. Der Client kann `release goodrev; curl ... | sh`, Befehlsauswertung, umgeleitete Ausgabe oder ein sorgfältig maskiertes Argument anfordern, das ein privilegiertes Tool erreicht. Ein Wrapper, der `eval`, `sh -c`, `bash -c` oder eine nicht maskierte Expansion aufruft, hat den Zugriff auf eine entfernte Shell unter einem anderen Dateinamen wiederhergestellt.

Versuche nicht, einen vollständigen Shell-Parser zu bauen. Du brauchst keinen. Definiere ein bewusst kleines Protokoll und lehne alles ab, was außerhalb davon liegt. Für ein Deployment-Konto kann eine Anfrage aus einem Verb und einer Kennung bestehen. Für ein Diagnosekonto kann sie aus einem exakten Wort wie `health` oder `version` bestehen.

Ein Verteiler-Wrapper für Diagnosen kann ganz auf Parsing verzichten:

```sh
#!/bin/sh
set -eu

case "${SSH_ORIGINAL_COMMAND-}" in
  health)
    exec /usr/local/libexec/report-health
    ;;
  queue-depth)
    exec /usr/local/libexec/report-queue-depth
    ;;
  version)
    exec /usr/local/libexec/report-version
    ;;
  "")
    echo "a diagnostic name is required" >&2
    exit 64
    ;;
  *)
    echo "diagnostic is not allowed" >&2
    exit 64
    ;;
esac
```

Auch diese Diagnose-Skripte müssen ihre Argumente selbst kontrollieren. `report-health` sollte feste Binärdateien gegen feste lokale Sockets oder bekannte Dienstnamen aufrufen. Es sollte keinen Host-Parameter akzeptieren und `curl "$host"` ausführen, ebenso wenig einen Journal-Filter an eine Shell weitergeben. Ein schreibgeschützter Befehl kann trotzdem Datenbankzugangsdaten, interne Netzwerktopologie, Umgebungswerte oder Kundendaten offenlegen.

Oft heißt es, ein sorgfältig maskierter Shell-Befehl reiche aus, weil der aufrufende Agent vertrauenswürdig sei. Diese Annahme scheitert, wenn der Agent feindseligen Anweisungen aus einem Repository folgt, einen Wert mit einer Anweisung verwechselt oder schlicht einen Fehler macht. Der entfernte Server kann nicht erkennen, ob eine gefährliche Anfrage aus böser Absicht oder aus einem übereifrigen Tool-Aufruf stammt. Er sieht nur Eingaben. Seine Entscheidung muss deterministisch sein.

Wenn du strukturierte Eingaben brauchst, verwende ein begrenztes Format und einen Parser, der zusätzliche Felder ablehnt. JSON ist nicht automatisch sicherer, weil ein Shell-Wrapper es weiterhin falsch behandeln kann. Eine kleine Anfrage wie `release <64 Kleinbuchstaben im Hexadezimalformat>` lässt sich leichter validieren, dokumentieren, testen und prüfen als ein JSON-Objekt mit optionalen Feldern.

## Weiterleitungen können den Sinn der Einschränkung umgehen

Ein erzwungener Befehl verhindert nicht automatisch, dass ein authentifizierter Client SSH als Tunnel verwendet. Das OpenSSH-Handbuch behandelt Befehlsausführung und Weiterleitungen als getrennte Kontrollen. Wenn du nur `command="..."` hinzufügst, kann ein Client je nach übriger Serverkonfiguration weiterhin verlangen, dass sshd einen lokalen Port zu einem internen Dienst weiterleitet.

Das ist wichtig, weil ein eingeschränktes Konto möglicherweise über Netzwerkzugriff verfügt, den der Agent nicht haben sollte. Ein Agent, der auf dem entfernten Host kein `/usr/bin/ps` ausführen kann, erreicht über diesen Host vielleicht trotzdem einen Datenbankport, wenn Weiterleitungen offen bleiben. Dann ist das Konto keine Deployment-Identität mehr, sondern ein Netzwerk-Sprungbrett.

Für ein Konto, das keine interaktive Sitzung benötigt, solltest du alle folgenden Funktionen verweigern, sofern du nicht begründen kannst, warum das Konto eine davon braucht:

- TCP-Weiterleitung
- Agent-Weiterleitung
- X11-Weiterleitung
- Zuweisung eines Pseudo-Terminals
- vom Benutzer kontrollierte Umgebungsvariablen

`restrict` deckt die ersten vier Kategorien bei modernen OpenSSH-Installationen ab. Wenn dein Konto tatsächlich eine Ausnahme benötigt, solltest du nicht die gesamte Einschränkung aufheben. OpenSSH unterstützt Optionen wie `permitopen="host:port"`, um das Ziel einer Weiterleitung zu begrenzen. Behandle dies als eigenes Zugriffskonzept und teste sowohl erlaubte als auch verweigerte Ziele.

Prüfe außerdem den ausgehenden Netzwerkzugriff des Wrappers. Ein Deployment-Skript, das beliebige URLs abrufen, beliebige Repositories klonen oder beliebige Daten nach außen senden kann, hat einen breiten Kommunikationskanal, auch wenn SSH-Weiterleitungen deaktiviert sind. Feste Artefaktquellen und angeheftete Revisionen verringern dieses Risiko. Firewall-Regeln oder dienstspezifische Zugangsdaten müssen den Rest möglicherweise absichern.

## Die Genehmigung muss vor dem Verbindungsaufbau erfolgen

Ein erzwungener Befehl begrenzt den Schaden, den eine genehmigte SSH-Nutzung anrichten kann. Er beantwortet nicht die Frage, ob der aktuelle Agent-Prozess den Zugangsschlüssel überhaupt verwenden darf. Diese Entscheidung gehört an die Zugriffskontrolle, bevor der Agent eine SSH-Verbindung herstellt.

Das ist besonders für autonome Coding-Agenten wichtig. Ein Repository kann den Agenten anweisen, einen Deployment-Befehl auszuführen. Eine Tool-Ausgabe kann dies anfordern. Eine kompromittierte Abhängigkeit kann den Agenten in diese Richtung lenken. Liegt der Zugangsschlüssel in der Umgebung oder im Dateisystem des Agenten, kann dieser ihn verwenden, ohne dass jemand den Moment der Verwendung sieht.

Bewahre private SSH-Schlüssel außerhalb des Agent-Prozesses auf und fordere eine Genehmigung an, wenn ein neuer Agent-Lauf zum ersten Mal Zugriff verlangt. Bei Konten mit weitreichenden Folgen solltest du jede Verwendung bestätigen lassen. Der erzwungene Befehl auf dem entfernten Server setzt dann eine feste Obergrenze für die von dieser Genehmigung erlaubte Aktion.

Sallyport setzt diese Trennung um, indem SSH-Schlüssel in seinem verschlüsselten Tresor bleiben, neue Agent-Prozesse standardmäßig pro Sitzung autorisiert werden und SSH über den Helfer `sp-ssh` ausgeführt wird, statt den Schlüssel an den Agenten zu übergeben.

Verwechsle eine Genehmigungskarte nicht mit der Serverautorisierung. Die Genehmigung beantwortet: «Darf dieser Prozess diesen Zugangsschlüssel jetzt verwenden?» Der Server beantwortet: «Was darf dieser Zugangsschlüssel nach dem Login tun?» Du brauchst beide Antworten, weil sie auf unterschiedliche Weise versagen. Eine Genehmigung kann einen überraschenden Prozess stoppen. Erzwungene Befehle können verhindern, dass ein genehmigter Prozess einen Release-Schlüssel in eine Shell verwandelt.

Die Beschreibung der Genehmigung sollte nützlich sein. Nenne Umgebung und Aktion in der Bezeichnung des Zugangsschlüssels, etwa `production release` oder `staging diagnostics`. Eine Bezeichnung wie `deploy-key-2` zwingt die prüfende Person, sich während einer Unterbrechung an die Vorgeschichte zu erinnern. So werden routinemäßige Genehmigungen schnell zu automatischen Klicks.

## Deployment und Diagnose trennen, bevor die Allowlist wächst

Deployment und Diagnose wirken ähnlich, weil beide SSH benötigen. Sie haben jedoch unterschiedliche Datenflüsse und Fehlerbilder. Lege sie nach Möglichkeit hinter separate Konten oder separate Zugangsschlüssel mit erzwungenen Befehlen.

Ein Deployment-Konto verändert den Zustand. Es kann eine feste Revision abrufen, ein Artefakt bauen, ein Release-Verzeichnis austauschen und einen Dienst neu starten. Seine Ausgabe sollte Revision, Ziel, Exit-Status und eine kurze Fehlermeldung enthalten. Es benötigt weder beliebigen Log-Zugriff noch Prozessinspektion oder Datenbankabfragen.

Ein Diagnosekonto liest den Zustand. Es kann das Ergebnis eines Health-Endpunkts, eine begrenzte Anzahl von Warteschlangeneinträgen, eine Dienstversion oder das Ende eines sorgfältig gefilterten lokalen Logs melden. Es sollte keine Dienste neu starten, Dateien rotieren, jeden Prozess abfragen oder beliebige Pfade lesen. Sobald ein Diagnose-Wrapper einen vom Benutzer angegebenen Dateinamen, Unit-Namen, Host oder eine Befehlsoption akzeptiert, musst du sein Eingabemodell erneut prüfen.

Ein gemeinsames Konto beginnt mit einer harmlosen Liste:

```text
release <revision>
health
logs <service>
restart <service>
```

Dann braucht jemand `logs api --since`, eine andere Person einen Notfallneustart, und der Wrapper beginnt, Argumente an `journalctl` oder `systemctl` durchzureichen. Bald enthält der Code Sonderfälle, die niemand erklären kann. Teile die Konten, bevor es so weit kommt. Separate Zugangsschlüssel ermöglichen strengere Genehmigungen für Änderungen in der Produktion und zugleich einen weniger riskanten Diagnoseablauf.

Jede Aktion sollte einen Eintrag erzeugen, der festhält, was der Wrapper akzeptiert hat, nicht nur die undurchsichtige SSH-Befehlszeichenfolge. Bei einem Release gehören die unveränderliche Revision und der Zielname ins Log. Bei einer Diagnose gehören der benannte Diagnoseschritt und sein Ergebnis hinein. Halte geheime Werte aus Argumenten und Logs heraus. Wenn eine Aktion ein Geheimnis benötigt, sollte das entfernte Skript es über einen eigenen kontrollierten Mechanismus abrufen, statt es vom SSH-Client entgegenzunehmen.

## Ablehnungspfade von einem Wegwerf-Client aus testen

Ein eingeschränktes Konto ist erst dann wirklich eingeschränkt, wenn du die Anfragen testest, die es ablehnen muss. Führe diese Prüfungen von einem Wegwerf-Konto oder Test-Host aus, bevor du die Konfiguration in einer Produktionsumgebung einsetzt. Die Beispiele setzen voraus, dass der Zugangsschlüssel bereits auf dem Server installiert ist.

```sh
ssh release@deploy.example "release 9f2a7c6d1e4b8a03"
ssh release@deploy.example
ssh release@deploy.example "id"
ssh release@deploy.example "release 9f2a; id"
ssh -N -L 15432:db.internal:5432 release@deploy.example
ssh -tt release@deploy.example "health"
```

Der erste Befehl sollte nur dann den Release-Wrapper erreichen, wenn die Revision seine Regeln erfüllt. Die nächsten drei sollten mit der Ablehnungsnachricht des Wrappers und einem Status ungleich null fehlschlagen. Der Versuch einer Port-Weiterleitung sollte scheitern, bevor ein Listener eingerichtet wird. Die Terminalanforderung sollte fehlschlagen oder ohne Terminal laufen, je nachdem, wie dein Client die verweigerte Zuweisung meldet.

Teste anschließend die weniger offensichtlichen Fälle. Probiere führende und nachgestellte Leerzeichen, Tabulatoren, eine leere Befehlszeichenfolge in Anführungszeichen, Zeilenumbrüche, ein sehr langes Argument, Unicode-Leerzeichen, Befehlsauswertung, Umleitungen und doppelte Argumente aus. Wenn deine Shell oder dein Wrapper einen dieser Fälle in eine akzeptierte Anfrage umwandelt, verschärfe die Grammatik.

Prüfe im Rahmen des Tests auch die Dateiberechtigungen und Eigentumsverhältnisse des Kontos. Ein Angreifer, der `/usr/local/libexec/release-gate` ersetzen kann, muss SSH nicht umgehen. Ein Konto, das seine eigene Deployment-Quelle ändern kann, kann möglicherweise auch den Code verändern, der mit höheren Rechten ausgeführt wird. Prüfe die gesamte Kette: `authorized_keys`, sshd-Konfiguration, Wrapper, Deployment-Skripte, Service-Definitionen, beschreibbare Verzeichnisse und jeden `sudoers`-Eintrag.

## Die Anfrage auf beiden Seiten der Grenze prüfen

Entfernte Logs erklären, was der Server akzeptiert hat. Logs an der Zugriffskontrolle erklären, welcher lokale Prozess die Verbindungsberechtigung angefordert hat. Bewahre beide auf, denn keines kann die Frage des anderen beantworten.

Protokolliere auf der entfernten Seite erfolgreiche Authentifizierungen, die vom erzwungenen Wrapper akzeptierte Aktion, die unveränderliche Revision oder den Diagnosenamen, eine Anfrage-ID und den abschließenden Status. Sende diese Einträge an einen Ort, den das Dienstkonto nicht überschreiben kann. Protokolliere nicht den rohen Wert von `SSH_ORIGINAL_COMMAND`, wenn Aufrufer darin geheime Daten unterbringen könnten. Noch besser: Lass dein Protokoll von vornherein keine geheimen Daten zu.

Bewahre auf der lokalen Seite die Sitzungsidentität und die einzelne Anfrage zur SSH-Aktion auf. Sallyport zeichnet Agent-Läufe und einzelne Aufrufe in getrennten Protokollen innerhalb eines verschlüsselten, hashverketteten Audit-Logs auf. Mit `sp audit verify` lässt sich diese Kette offline und ohne Tresorschlüssel prüfen.

Eine Hash-Kette macht aus einer schlechten Berechtigung keine gute. Sie erleichtert jedoch das Erkennen nachträglicher Änderungen am aufgezeichneten Verlauf. Das ist nach einem fehlgeschlagenen Deployment, einer strittigen Genehmigung oder einer überraschenden Befehlsanfrage hilfreich. Gleichzeitig erzwingt sie eine gute Disziplin: Definiere das Aktionsvokabular früh, damit die Einträge für Menschen verständlich bleiben.

Die erste Umsetzung sollte langweilig sein. Erstelle ein eigenes Unix-Konto, einen erzwungenen Wrapper, eine Operation mit fester Eingabegrammatik, deaktivierte Weiterleitungen und einen Test, der beweist, dass `ssh account@host` keine Shell liefert. Füge Fähigkeiten erst hinzu, wenn du ihre Eingaben, Ausgaben, Dateizugriffe, Netzwerkzugriffe und die Person benennen kannst, die sie genehmigen sollte.
