dotfiles/agents/README.md
mwiegand c80b9bbd3d
agents: Dialog-Blindheit gilt auch für op read mit Item-Referenz
Geprüft: op read "op://Employee/<item>/password" zeigt denselben
generischen full-access-Dialog wie op item get. 1Password autorisiert das
Werkzeug, nie den Zugriff — auch wenn dieser vollständig spezifiziert ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aD7fG6BpN1HchyHeNXZb4
2026-08-04 23:54:59 +02:00

11 KiB

Design: SSH-Key- und Git-Signing-Architektur (Arbeitsrechner mwiegand)

Stand 2026-08-04 — umgesetzt und verifiziert. Beschreibt das Warum hinter dem Code in diesem Verzeichnis (agent-env.zsh, ensure-machine-agent, .env.agents) plus ~/.ssh/config und ~/.gitconfig.

Umsetzungsstand, Historie und die offenen Schritte auf ISAC-Seite stehen im ai-Repo: tasks/2026-07-31-agent-key-isolation/HANDOFF.md, Vorarbeit in tasks/2026-06-13-isac-infra-agent-isolation/.

Problem

Auf dem Rechner laufen zwei Arten von Akteuren mit unterschiedlichem Vertrauensniveau:

  • Ich (interaktive Shell) — brauche den persönlichen SSH-Key, der über LDAP auch auf Kundensystemen autorisiert ist.
  • Agents (Claude/OpenCode via agent-env) — sollen interne Systeme erreichen, aber strukturell nicht unbemerkt an den kundenfähigen Key kommen.

Vor dem Umbau war die Trennung rein konventioniert: der private Haupt-Key lag unverschlüsselt als ~/.ssh/id_mwiegand auf der Platte (für jede Agent-Session lesbar), der 1Password-SSH-Agent bot alle Keys jedem Prozess an, und ein Login-Script lud den Haupt-Key zusätzlich in den Apple-Agent. Git-Signing hing am Datei-Key.

Dazu die Komfort-Anforderung: Signieren darf im Alltag nicht nerven — keine Fingerabdruck-Freigabe pro Commit.

Anforderungen

  1. Agents verwenden einen eigenen SSH-Key (nur interne Systeme); der kundenfähige Haupt-Key ist für sie nicht still nutzbar.
  2. Jede Nutzung des Haupt-Keys erzeugt eine sichtbare, bewusste Freigabe (Fingerabdruck) — der Prompt ist die Schranke.
  3. Kein privater Schlüssel liegt als Datei auf der Platte.
  4. Git-Commits sind signiert, prompt-frei — Herkunft „diese Maschine" ist nachweisbar (Mensch/Agent unterscheiden die Commit-Trailer).
  5. Kein Autoload beim Login (explizite Entscheidung): was in einem Agent liegt, wurde bewusst dorthin gelegt.

Architektur: drei Schlüssel, drei Wege

Schlüssel (1Password-Item, Vault Employee) Fingerprint Lebt in Zweck Prompt-Verhalten
SSH Key mwiegand (Haupt-Key) SHA256:lIx1Na… nur 1Password SSH überall, inkl. Kundensysteme 1Password-Freigabe pro Key und Terminal-Session — bewusst spürbar
SSH isac agent (Agent-Key) SHA256:77FiJM… geteilter Maschinen-ssh-agent ~/.ssh/isac-agent.sock SSH auf interne Systeme (User mwiegand-agent) einmal op read pro Boot, danach still
SSH Signing Key mwiegand (Signing-Key) SHA256:SvHSJQ… geteilter Maschinen-ssh-agent ~/.ssh/isac-agent.sock Git-Signing aller Commits der Maschine — meine und Agent-Commits einmal ensure-machine-agent pro Boot (passiert automatisch mit der ersten Agent-Session), danach still

Die Auswahl des Schlüssels erfolgt über den Agent-Socket, nicht über Key-Dateien:

  • ~/.ssh/config Host *IdentityAgent ~/.1password/agent.sock (Haupt-Key, promptet pro Nutzung).
  • Match exec USE_AGENT_SSH_USER=1User mwiegand-agent + IdentityAgent ~/.ssh/isac-agent.sock (Ein-Key-Agent; steht vor Host *, nach den ckn-Host-Blöcken).
  • Git-Signing läuft über SSH_AUTH_SOCK, der überall (interaktive Shell via agent-env.zsh, Agent-Sessions zusätzlich via .env.agents) auf den Maschinen-Agent zeigt — signiert wird immer mit dem Signing-Key. Fürs interaktive SSH ist SSH_AUTH_SOCK irrelevant, weil die ssh_config IdentityAgent explizit setzt.

Der Sicherheitskern: die Prompt-Semantik bleibt sauber. Ein 1Password-Prompt bedeutet immer „jemand will den kundenfähigen Key" — er kommt selten und ist ernst zu nehmen. Signing und Agent-SSH erzeugen nie Prompts, also gibt es keine Gewöhnung an reflexhaftes Bestätigen.

Komponenten

Alles Agent-bezogene liegt versioniert in diesem Verzeichnis; ~/.zshrc und ~/.local/bin enthalten nur noch Verweise darauf.

Datei Rolle
agents/agent-env.zsh (gesourced von dotfiles/.zshrc) exportiert SSH_AUTH_SOCK fest auf ~/.ssh/isac-agent.sock; agent-env() ruft ensure-machine-agent und dann op run --env-file ~/.env.agents
agents/ensure-machine-agent (Symlink in ~/.local/bin/) idempotent: startet den Maschinen-Agent bei Bedarf und stellt beide Keys sicher (Signing + Agent). Wird von agent-env gerufen; manuell nur nötig, wenn man nach einem Reboot committen will, bevor die erste Agent-Session lief
agents/.env.agents (Symlink ~/.env.agents) rein deklarativ — op run parst die Datei nur, führt keinen Code aus: USE_AGENT_SSH_USER=1, SSH_AUTH_SOCK (Robustheit: Konsumenten ohne agent-env finden den richtigen Socket), BW_SSH_ARGS, GIT_CONFIG_*user.signingkey = Signing-Pubkey (Env schlägt alle git-Configs, auch lokale), Secret-Referenzen
~/.ssh/config Socket-Routing s. o.; keine IdentityFile-Einträge für persönliche Keys
~/.gitconfig gpg.format=ssh, commit.gpgsign=true, user.signingkey = Signing-Pubkey-String (kein Pfad), gpg.ssh.allowedSignersFile
~/.ssh/allowed_signers beide Pubkeys (Signing + Agent) für lokale Verifikation (git log --format=%G?)

Gelöscht: ~/.ssh/id_mwiegand (unverschlüsselter Datei-Key), add-ssh-key-to-agent (Login-Autoload des Haupt-Keys), lokale user.signingkey-Overrides in 8 Repos.

Entscheidungen und verworfene Alternativen

Signing-Key als eigener Schlüssel statt Haupt-Key. Die Prompt-Frequenz hängt nicht am Schlüssel, sondern am Mechanismus — aber ein gemeinsamer Schlüssel hätte Haupt-Key-Prompts beim Committen alltäglich gemacht und die Schranke durch Gewöhnung entwertet. Außerdem: Forges kennen den Signing-Key nur als Signaturschlüssel; ein Leak ist kein Systemzugriff.

Ein gemeinsamer Signing-Key für Mensch und Agents (statt Agent-Commits mit dem Agent-SSH-Key zu signieren, wie zuerst gebaut). Die Key-Trennung hätte nur ehrliche Agent-Commits von meinen unterschieden: die Agent-Sockets sind für jeden lokalen Prozess erreichbar, ein böswilliger Agent könnte ohnehin als ich signieren. Für ehrliche Commits leistet der Co-Authored-By-Trailer die Unterscheidung. Die Signatur bedeutet ehrlich „kam von meiner Maschine"; dafür halbiert sich die Forge-Pflege (ein Key registrieren/verifizieren statt zwei) und der Boot-Load läuft automatisch über die erste Agent-Session mit. Ältere Commits (2026-08-04) sind noch mit dem Agent-Key signiert — er bleibt dafür in allowed_signers.

op-ssh-sign erwogen und verworfen. Zuerst umgesetzt (Key verlässt 1Password nie), dann zurückgebaut: die 1Password-Autorisierung gilt global pro App(+Terminal-Session) für alle Keys. Das Signing-Nerv-Problem hätte sich nur durch Lockern des Modells lösen lassen — was die Haupt-Key-Schranke mitgelockert hätte. Der Agent-Weg entkoppelt beides: Signing prompt-frei, 1Password-Modell bleibt streng. (Zwischenschritt Apple-Agent als Signing-Halter wurde durch den Maschinen-Agent ersetzt — ein Agent weniger.)

macOS-Keychain als Signer verworfen. Die Keychain speichert Bytes, kann aber keine SSH-Signaturen erzeugen (security bietet nur cms). Jeder Keychain-Weg endet in einer Klartext-Tempdatei pro Commit — schlechter als jeder Agent. (ssh-add --apple-use-keychain speichert nur Passphrasen und setzt eine Key-Datei voraus.)

Kein Login-Autoload. Bewusste Entscheidung: Keys werden nicht beim Login geladen, sondern lazy — mit der ersten Agent-Session (automatisch) bzw. per ensure-machine-agent von Hand. Symptom eines fehlenden Loads ist ein Commit-Fehler Couldn't find key in agent? — eindeutig und folgenlos.

Geteilter Agent statt Agent pro Session. Ein fester Socket (~/.ssh/isac-agent.sock) statt mktemp pro Session: ein op read pro Key und Boot statt pro Session, kein Cleanup verwaister Agents, und der Match-Block kann auf einen stabilen Pfad zeigen (wirkt auch für Subprozesse mit bereinigtem Env). Beide Bewohner (Agent-Key, Signing-Key) sind per Definition die Schlüssel, die Agents zugänglich sein dürfen — Langlebigkeit weicht nichts auf.

SSH_AUTH_SOCK zusätzlich fest in .env.agents. Lektion aus dem Umbau: eine Shell mit veralteter agent-env-Funktion lud die neue .env.agents (Signing-Config auf Agent-Key), zeigte aber noch auf den leeren Apple-Agent → Couldn't find key in agent?. Signing-Config und Socket dürfen nicht auseinanderfallen; beide stehen jetzt in derselben Datei.

Grenzen (bewusst offen)

Die Architektur ist eine Zustimmungs- und Erkennungsschranke, keine technische Unerreichbarkeit:

  • Der 1Password-Socket bleibt für Agent-Prozesse erreichbar (allowAllUnixSockets: true in der Claude-Sandbox-Config; 1Password kann Keys nicht pro Client filtern). Ein Agent, der den Haupt-Key anfordert, löst aber einen sichtbaren Prompt aus, den ich ablehnen kann.
  • op-CLI-Pfad (empirisch geprüft 2026-08-04): ein Agent kann op read auf beliebige Items versuchen — u. a. den Haupt-Key — aber nicht unbemerkt. Vier Versuche aus einer Agent-Session ergaben vier 1Password-Dialoge; die Freigabe für das op run beim agent-env-Start deckt nachgelagerte op-Aufrufe nicht mit ab (ob eine bestätigte Freigabe danach ein 10-Minuten-Fenster öffnet, ist unverifiziert — der Dialog behauptet das, gemessen wurde es nicht). Die verbleibende Schwäche ist die Blindheit des Dialogs: er sagt „full access to your account", nennt aber nicht das angefragte Item. Beim Bestätigen ist nicht unterscheidbar, ob ein Agent ein harmloses API-Passwort oder den kundenfähigen SSH-Key liest. Das ist systematisch, nicht nur eine Eigenschaft offener Abfragen: auch op read mit einer punktgenauen op://Employee/<item>/<feld>-Referenz zeigt denselben Dialog (geprüft 2026-08-04) — 1Password autorisiert das Werkzeug, nie den Zugriff, selbst wenn dieser vollständig spezifiziert ist. Gerade die Secrets in .env.agents sind ausschließlich solche Referenzen — und weil legitime Agent-Starts denselben Dialog erzeugen, trainiert der Alltag reflexhaftes Bestätigen. Genau das macht der gescopte Service-Account (Schritt 1) überflüssig: was nicht anfragbar ist, muss nicht beurteilt werden.
  • Vollständige Schließung = HANDOFF-Schritte 1/3: gescopter Service-Account mit eigenem Vault für den Agent-Key, Socket-Sperre in der Sandbox, Seatbelt-Variante für unsandboxte Kommandos.

Betrieb

  • Nach Reboot: die erste agent-env-Session lädt beide Keys automatisch; nur wer vorher committen will, ruft einmal ensure-machine-agent (Symptom sonst: Couldn't find key in agent?).
  • Verifikation: git log --format='%h %G? %GK'G + SvHSJQ… für alle Maschinen-Commits (Commits vom 2026-08-04 noch mit 77FiJM…). ssh-add -l in einer Agent-Session zeigt genau die zwei Maschinen-Keys.
  • Forges: der Signing-Pubkey muss bei Bitbucket DC/Forgejo als Signing Key registriert sein; bei Forgejo zusätzlich ownership-verifiziert (Challenge signieren, nur via Web-UI), sonst „unverified" (offen).
  • Rotation: neuer Key = neues 1Password-Item + Pubkey in ~/.gitconfig/allowed_signers/.env.agents bzw. ISAC (admin_agent_users) nachziehen; es gibt keine Key-Dateien zu tauschen.