agents: Bootstrap-Logik ins Repo, ein Script für beide Maschinen-Keys

Die imperative Hälfte der Key-Architektur lag unversioniert in ~/.zshrc und
~/.local/bin, während die deklarative (.env.agents) getrackt war. Jetzt liegt
alles Agent-bezogene hier:

- agents/agent-env.zsh — SSH_AUTH_SOCK + agent-env(), gesourced von .zshrc
- agents/ensure-machine-agent — startet den Maschinen-Agent und stellt beide
  Keys sicher (ersetzt load-signing-key, das nur den Signing-Key kannte,
  während agent-env separat den Agent-Key lud)
- agents/README.md — Design-Doc, verschoben aus dem ai-Repo zum Code

.env.agents bleibt rein deklarativ, weil op run --env-file die Datei nur
parst und keinen Code ausführt — daher die Zweiteilung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aD7fG6BpN1HchyHeNXZb4
This commit is contained in:
mwiegand 2026-08-04 23:37:45 +02:00
parent 9a5dc887ce
commit 38f07179c4
No known key found for this signature in database
4 changed files with 250 additions and 0 deletions

2
.zshrc
View file

@ -40,3 +40,5 @@ bindkey '^[[A' up-line-or-beginning-search
bindkey '^[[B' down-line-or-beginning-search
bindkey '^[OA' up-line-or-beginning-search
bindkey '^[OB' down-line-or-beginning-search
source "${0:a:h}/agents/agent-env.zsh"

180
agents/README.md Normal file
View file

@ -0,0 +1,180 @@
# 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=1``User 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 — 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.

18
agents/agent-env.zsh Normal file
View file

@ -0,0 +1,18 @@
# Agent-Sessions (Claude, OpenCode): Secrets aus 1Password und SSH-Identität
# des Agent-Users. Wird von dotfiles/.zshrc gesourced.
# Architektur und Begründungen: agents/README.md.
# Git-Signing und ssh-add greifen über SSH_AUTH_SOCK auf den geteilten
# Maschinen-Agent zu — interaktiv wie in Agent-Sessions. Fürs interaktive SSH
# ist die Variable irrelevant, weil ~/.ssh/config IdentityAgent explizit setzt
# (Haupt-Key via 1Password, Agent-User via diesem Socket).
export SSH_AUTH_SOCK="$HOME/.ssh/isac-agent.sock"
agent-env() {
# Agent + beide Maschinen-Keys sicherstellen; der op-read darin teilt sich
# die 1Password-Autorisierung mit dem op-run unten, also ein Prompt.
ensure-machine-agent || return 1
op run --account WHZOJQOJPZHTFPJC6JZAOCUCBI \
--env-file ~/.env.agents --no-masking -- "$@"
}

50
agents/ensure-machine-agent Executable file
View file

@ -0,0 +1,50 @@
#!/bin/sh
# Stellt den geteilten Maschinen-ssh-agent sicher: Socket läuft und hält beide
# Keys, die auf dieser Maschine gebraucht werden. Idempotent — mehrfach
# aufrufbar, macht nur was fehlt.
#
# SSH isac agent SSH auf interne Systeme (User mwiegand-agent)
# SSH Signing Key Git-Signing, für mich UND Agent-Sessions
#
# Wird von agent-env bei jedem Start gerufen; manuell nur nötig, wenn man nach
# einem Reboot committen will, bevor die erste Agent-Session lief (Symptom:
# "error: Couldn't find key in agent?").
#
# Der kundenfähige Haupt-Key ("SSH Key mwiegand") gehört bewusst NICHT hierher:
# er lebt nur hinter der 1Password-Freigabe (~/.1password/agent.sock, siehe
# Host * in ~/.ssh/config), damit Agents ihn nicht unbemerkt nutzen können.
# Architektur und Begründungen: agents/README.md im dotfiles-Repo.
PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
SOCK="$HOME/.ssh/isac-agent.sock"
OP_ACCOUNT="WHZOJQOJPZHTFPJC6JZAOCUCBI"
export SSH_AUTH_SOCK="$SOCK"
# Agent tot oder nie gestartet? (ssh-add exit 2 = Socket antwortet nicht)
ssh-add -l >/dev/null 2>&1
if [ $? -eq 2 ]; then
rm -f "$SOCK"
/usr/bin/ssh-agent -a "$SOCK" >/dev/null || exit 1
fi
# $1 = Fingerprint (schon geladen?), $2 = op-Referenz, $3 = Anzeigename
ensure_key() {
if ssh-add -l 2>/dev/null | grep -q "$1"; then
return 0
fi
if op read --account "$OP_ACCOUNT" "$2" | ssh-add -q -; then
echo "$3 geladen (bis zum nächsten Reboot)." >&2
else
echo "$3 konnte nicht geladen werden (1Password gesperrt?)" >&2
return 1
fi
}
rc=0
ensure_key "SHA256:SvHSJQcHlAWUmF8EnflNsrv0mhWGARvAKLRPSBQ460I" \
"op://Employee/37z2tbpilrjrgyrhckb5cbrhsq/private key?ssh-format=openssh" \
"Signing-Key" || rc=1
ensure_key "SHA256:77FiJM6Z1eN7Ka7YpoCKIn9xVJFQUj6nBQa/g1rFya4" \
"op://Employee/ec2mtn4kkxnypyc4gxutg7lajq/private key?ssh-format=openssh" \
"Agent-Key" || rc=1
exit $rc