← Blog
Platform Engineering·04. Oktober 2026·22 min Lesezeit

Ein Agent, zwei Mandanten, eine Datei

Agenten arbeiten zunehmend autonom — und teilen sich dabei den Anmeldezustand. Zehn von 36 Befehlen landeten in der falschen Umgebung, ohne Fehler und ohne Warnung. Wie ein deterministischer PreToolUse-Hook das abfängt, was er aus dem Gesprächsverlauf sieht, wie man ihn in Claude Code und der Copilot CLI einbaut — und wo seine Grenzen liegen.

Alle reden davon, wie autonom Agenten inzwischen arbeiten. Mehrere Threads nebeneinander, jeder an seiner Aufgabe, keiner wartet auf eine Bestätigung.

Was dabei selten jemand erwähnt: Sie teilen sich den Anmeldezustand.

Mir ist das in der eigenen Arbeit aufgefallen, als ein Key-Vault-Zugriff plötzlich mit AKV10032: Invalid issuer abbrach, obwohl er zehn Minuten vorher funktioniert hatte. Ein anderer Vorgang hatte dazwischen den Azure-Kontext umgestellt. Das war kein Fehler im Skript — das war ein Fehler in der Annahme, dass „mein” Kontext mir gehört.

Das ist kein Einzelfall-Pech, sondern gerade jetzt eine Hausaufgabe. Reihenweise werden Prozessketten auf eigene Harnesse umgestellt — nicht mehr ein Mensch, der einen Agenten etwas fragt, sondern Ketten, die nachts durchlaufen: Daten ziehen, Solution importieren, Flow aktivieren, Bericht ablegen. Wer so etwas baut, verlagert damit auch die Sicherheitsfrage. Sie wird dadurch nicht kleiner, sie wird nur unsichtbarer.

Und der nächste Schritt ist in allen Fahrplänen derselbe: die Threads von der eigenen Maschine wegbekommen — in die Cloud, auf einen Build-Agenten, in einen Container. Das klingt nach Entschärfung, und wo jede Sitzung wirklich ihre eigene Umgebung bekommt, ist es das auch. Nur ist „woanders” nicht gleichbedeutend mit „getrennt”: Mehrere Sitzungen auf derselben gemieteten Maschine teilen sich dasselbe Benutzerverzeichnis und damit denselben Anmeldezustand — wie vorher, bloß ohne jemanden, der die Ausgabe liest. Eine verwechselte Session fällt dann niemandem mehr auf. Sie hinterlässt keinen Fehler, nur ein Ergebnis aus der falschen Umgebung.

Dieser Beitrag misst, wie oft das passiert, und zeigt, was ein deterministischer Hook dagegen ausrichtet. Mit Code, der läuft, und mit den Grenzen, die ich beim Bauen gefunden habe.

Eine Datei, ein Flag, kein Lock

Die Azure CLI legt ihren Zustand in ~/.azure/azureProfile.json ab. Darin steht eine Liste von Abonnements, und genau eines ist isDefault: true:

isDefault=false  c530e80c-…  dev1@…onmicrosoft.com      (Trial, Abo-Eintrag)
isDefault=true   c530e80c-…  dev1@…onmicrosoft.com      (Trial, tenant level)
isDefault=false  0d797863-…  Admin@…                    (Marke)

Kein Prozess-Scope, kein Lock, keine Sperre. Wer az account set ausführt, schreibt in diese Datei — und verschiebt damit den Kontext jedes anderen Prozesses auf der Maschine, auch mitten in dessen Arbeit.

Für einen Agenten ist das heikler als für einen Menschen am Terminal. Zwischen „Kontext setzen” und „Befehl ausführen” liegt bei einem Agenten immer etwas: ein Dateizugriff, ein Modellaufruf, eine Antwort, die formuliert wird. Diese Lücke ist keine Millisekunde, sie sind Sekunden.

Die Messung

Zwei Threads, so gebaut, wie zwei Agenten arbeiten würden. Thread A arbeitet im Abonnement A und macht in jeder Runde drei Dinge: Kontext setzen, kurz etwas anderes erledigen, dann der eigentliche Befehl — einmal ohne Abonnementangabe, einmal mit. Thread B schaltet dazwischen auf Abonnement B um, weil er seine eigene Aufgabe hat.

async function threadA(protokoll) {
  for (let i = 1; i <= RUNDEN; i++) {
    await az(['account', 'set', '--subscription', A.abo])
    await warte(150)   // die Zeit zwischen zwei Werkzeugaufrufen

    // So laufen die meisten Befehle: ohne Angabe, auf dem aktiven Kontext.
    eintrag.ohne = await az(['account', 'show', '--query', 'id', '-o', 'tsv'])
    // Derselbe Befehl mit ausdrücklicher Angabe.
    eintrag.mit  = await az(['account', 'show', '--subscription', A.abo,
                             '--query', 'id', '-o', 'tsv'])
  }
}

Das Ergebnis des ersten Laufs:

  Runde   ohne --subscription    mit --subscription
      1   58072c83 FALSCH        c530e80c ok
      2   c530e80c ok            c530e80c ok
      3   58072c83 FALSCH        c530e80c ok
      4   58072c83 FALSCH        c530e80c ok
      5   58072c83 FALSCH        c530e80c ok

Drei Läufe, jeweils zwölf Runden. Zehn von 36 Befehlen landeten im falschen Abonnement. Mit --subscription im Befehl: keiner. Mit getrenntem AZURE_CONFIG_DIR je Thread: auch keiner.

Der Kontext gehört nicht dem Thread — drei Varianten, dieselben 36 Runden.

Das Unangenehme daran ist nicht die Quote. Es ist, dass nichts passiert: kein Fehler, keine Warnung, kein Hinweis. Der Befehl läuft, nur eben woanders. Bei einem lesenden Aufruf sind das falsche Daten, bei einem schreibenden ein Schaden in der falschen Umgebung.

Eine Falle am Rand, die einen Messaufbau still kaputtmachen kann: --subscription will die Abonnement-Kennung, nicht die Mandanten-ID. Gibt man die Mandanten-ID an, scheitert der Befehl lautlos — und ein Testlauf, der das nicht prüft, meldet fälschlich „alles in Ordnung”. Mein erster Durchgang zeigte deshalb null Fehlgriffe.

Urteil oder Gesetz

In derselben Arbeitswoche hat mich der eingebaute Schutzmechanismus meines Harness viermal gestoppt: beim Versuch, eine Dateiendung aus der Sperrliste einer Umgebung zu nehmen, beim Hochladen einer Konfiguration mit anonymen Leserechten, bei einem DELETE auf Komponenten, und bei az ad app permission grant. Jedes Mal mit Kategorie und Begründung. Jede dieser Entscheidungen war richtig.

Trotzdem ist das etwas anderes als das, worum es hier geht. Ein eingebauter Schutz urteilt zur Laufzeit. Ein Hook ist Code: Er kennt deine erlaubten Abonnements, vergleicht, und entscheidet immer gleich. Man kann ihn lesen, prüfen, versionieren und im Diff sehen, wenn ihn jemand ändert.

Beides hat seinen Platz. Aber wer einen Harness unbeaufsichtigt laufen lässt, sollte wissen, welches von beidem er gerade hat.

Was ein Hook aus dem Gespräch sieht

Bevor es um Regeln geht, die Frage, die man zuerst klären muss: Was bekommt so ein Hook überhaupt zu sehen? Die Dokumentation beantwortet das, aber ich wollte es gemessen haben. Dafür braucht es keinen Aufwand — ein Hook, der nichts entscheidet und nur mitschreibt:

const teile = []
for await (const stueck of process.stdin) teile.push(stueck)
const roh = Buffer.concat(teile).toString('utf8')

const j = JSON.parse(roh || '{}')
fs.writeFileSync(path.join(ZIEL, `${Date.now()}-${j.tool_name}.json`),
                 JSON.stringify(j, null, 1))
process.exit(0)   // nichts ausgeben heißt: durchlassen

Eingetragen mit "matcher": "*" greift er bei jedem Werkzeug. Was dabei herauskam, sind elf Felder — hier aus einem echten Bash-Aufruf dieser Sitzung:

{
  "session_id":      "f4869369-b10c-4…",
  "transcript_path": "…/projects/…/f4869369-….jsonl",
  "cwd":             "C:\\Daten\\source\\wolkenkunde_root",
  "scratchpad_dir":  "…\\Temp\\claude\\…\\scratchpad",
  "prompt_id":       "38fdebd4-ac65-4…",
  "permission_mode": "auto",
  "effort":          { "level": "high" },
  "hook_event_name": "PreToolUse",
  "tool_name":       "Bash",
  "tool_input":      { "command": "…", "description": "…" },
  "tool_use_id":     "toolu_017je3XXoN…"
}

Elf Felder rein, drei Antworten raus.

Drei davon sind für die Frage nach parallelen Threads entscheidend:

session_id ist je Sitzung stabil. Damit lassen sich Threads auseinanderhalten — eine Richtlinie kann einem bestimmten Thread etwas erlauben, was ein anderer nicht darf.

permission_mode sagt, in welchem Modus der Harness läuft; bei mir stand dort auto. Ein Hook kann also strenger werden, sobald niemand mehr jeden Aufruf bestätigt.

transcript_path zeigt auf den vollständigen Gesprächsverlauf als JSONL. Der Hook kann also nachlesen, was der Mensch ursprünglich wollte. Das ist mächtig — und ein Grund, Hooks genauso zu behandeln wie jeden anderen Code mit Zugriff auf Gesprächsinhalte.

agent_id und agent_type tauchten in keinem meiner Mitschnitte auf. Sie tragen laut Dokumentation die Kennung eines Subagenten und erscheinen deshalb nur, wenn der Aufruf aus einem solchen kommt — selbst gemessen habe ich das nicht.

Die Regel

Jetzt der Hook selbst. Die Richtlinie ist kein Code, sondern eine Datei namens .azure-guard.json in der Wurzel des Projekts:

mein-projekt/
  .azure-guard.json       ← die Richtlinie
  .claude/settings.json   ← wo der Hook eingetragen wird
  src/

Gesucht wird sie vom Arbeitsverzeichnis aus nach oben, bis zu sechs Ebenen weit, zuletzt im Benutzerverzeichnis (~/.azure-guard.json). Das ist für ein Repository mit mehreren Projekten gedacht: Eine Richtlinie ganz oben, und jedes Unterverzeichnis erbt sie, solange es keine eigene danebenlegt. Die Hausregel kommt aus dem Benutzerverzeichnis und greift überall dort, wo ein Projekt nichts anderes sagt.

Findet der Hook gar keine Datei, gelten die Vorgaben aus dem Code — und die prüfen nichts, weil keine Abonnements eingetragen sind. Ohne Richtlinie ist der Hook also wirkungslos, nicht etwa streng. Das ist Absicht: Ein Plugin, das nach der Installation jeden az-Aufruf sperrt, fliegt am ersten Tag wieder raus.

Ist die Datei da, aber kaputt, wird gesperrt:

} catch (e) {
  // Eine kaputte Richtliniendatei ist ein Grund zu sperren, nicht durchzulassen.
  return { ...VORGABE, kaputt: datei, fehler: String(e.message) }
}

So sieht sie aus:

{
  "allowedSubscriptions": ["c530e80c-…", "58072c83-…"],
  "requireExplicitSubscription": true,
  "onMissingSubscription": "fix",
  "allowDestructive": false,
  "allowPermissionGrants": false
}

Der Hook zerlegt die Kommandozeile, sucht die az-Aufrufe heraus und prüft jeden einzeln:

export function pruefe(kommando, richtlinie, kontext) {
  const aufrufe = azAufrufe(kommando)
  if (aufrufe.length === 0) return { entscheidung: 'allow' }

  for (const teil of aufrufe) {
    const genannt = abonnementAus(teil)
    const erlaubte = richtlinie.allowedSubscriptions || []

    // 1. Ausdrücklich genanntes Abonnement muss erlaubt sein.
    if (genannt && erlaubte.length > 0 && !erlaubte.includes(genannt)) {
      return { entscheidung: 'deny', regel: 'abonnement-nicht-erlaubt', … }
    }
    // 2. Ohne Angabe gilt der gemeinsame Kontext — und der kann wandern.
    …
    // 3. Löschen, Leeren, Rechtevergabe.
    if (!richtlinie.allowDestructive && DESTRUKTIV.test(teil)) { … }
  }
  return { entscheidung: 'allow' }
}

Eine Entwurfsentscheidung mit Messwert dahinter: Der Hook ruft nicht az account show auf, um den aktiven Kontext zu erfahren, sondern liest azureProfile.json direkt. Der Grund steht in der Stoppuhr — az account show braucht im Median 461 ms, der ganze Hook-Prozess mit Dateizugriff 47 ms. Bei etwas, das vor jedem einzelnen Werkzeugaufruf läuft, ist das der Unterschied zwischen benutzbar und unerträglich.

Einbauen: Claude Code

Der Eintrag gehört in .claude/settings.json (oder in ein Plugin, dann nach hooks/hooks.json):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/guard.mjs", "--harness", "claude"],
            "if": "Bash(az *)",
            "timeout": 10,
            "statusMessage": "azure-guard prüft den Befehl"
          }
        ]
      }
    ]
  }
}

Feld für Feld:

  • matcher entscheidet, bei welchen Werkzeugen der Hook überhaupt anläuft — hier die beiden Shell-Werkzeuge. "*" fasst alle.
  • if ist eine zweite, feinere Bedingung in Berechtigungs-Syntax: Nur wenn der Bash-Befehl mit az beginnt, startet der Prozess. Das spart die 47 Millisekunden bei allem anderen.
  • args statt alles in command: Damit läuft der Aufruf ohne Shell, und Pfade mit Leerzeichen sind kein Thema. ${CLAUDE_PLUGIN_ROOT} wird zur Laufzeit ersetzt.
  • timeout in Sekunden. Zehn sind bei 47 Millisekunden Laufzeit großzügig — warum das kein Luxus ist, steht weiter unten.
  • statusMessage erscheint im Terminal, während der Hook läuft. Ohne sie wundert man sich über die Pause.

Geantwortet wird über stdout. Sperren sieht so aus:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "[azure-guard/abonnement-fehlt] Der Befehl nennt kein Abonnement …"
  }
}

Die Begründung ist kein Protokolleintrag für später — sie geht an das Modell. Im Terminal kam das so an:

$ az group list --query "[].name" -o tsv
PreToolUse:Bash hook error: [azure-guard/abonnement-fehlt] Der Befehl nennt kein
Abonnement und läuft damit auf dem gemeinsamen, veränderlichen Kontext
(gerade: c530e80c-… / N/A(tenant level account)). Hänge --subscription <id> an.

Das ist der Unterschied zwischen einer Sperre, die blockiert, und einer, die weiterhilft: Der Agent erfährt nicht nur, dass er nicht darf, sondern was zu tun ist.

Kein Handler, sondern ein Prozess

An dieser Stelle gehört eine Zwischenfrage beantwortet, die ich mir selbst zu spät gestellt habe: Woher weiß der Harness eigentlich, welche Funktion in guard.mjs er aufrufen soll?

Gar nicht. Es gibt kein Mapping auf Funktionsnamen, und die Datei wird nie als Modul geladen. Wer aus der Microsoft-Welt kommt, erwartet hier etwas anderes:

Wie der Code gefunden wird
Dataverse-Plug-inDie Runtime lädt die Assembly und ruft IPlugin.Execute — ein Interface-Vertrag
Azure FunctionDie Runtime lädt die Assembly, ein Attribut zeigt auf die Methode — eine Namensbindung
PreToolUse-HookDer Harness startet einen Prozess und redet über stdin und stdout — kein Vertrag, nur ein Protokoll

Der nächste Verwandte ist der Git-Hook: .git/hooks/pre-commit ist irgendeine ausführbare Datei, und Git weiß nichts über ihren Inhalt — nur über ihren Exit-Code.

Was in der settings.json steht, ist deshalb keine Registrierung, sondern eine Kommandozeile. Aus command und args wird wörtlich:

node C:\…\plugins\azure-guard\hooks\guard.mjs --harness claude

Die ganze Schnittstelle besteht aus drei Kanälen: stdin bekommt das JSON mit den elf Feldern, stdout nimmt die Antwort entgegen — und wer nichts ausgibt, lässt durch —, der Exit-Code sagt, ob der Prozess selbst in Ordnung war.

Kein Handler, sondern ein Prozess — ein Werkzeugaufruf von Anfang bis Ende.

Schritt für Schritt, am Beispiel eines Azure-Befehls ohne Abonnementangabe:

① Der Harness entscheidet, ob er überhaupt startet. Werkzeug und matcher müssen zusammenpassen, die Bedingung in if ebenfalls. Trifft eines davon nicht zu, passiert nichts — kein Prozess, keine Millisekunde.

② Der Prozess startet. Einer pro Werkzeugaufruf, jedes Mal neu, ohne Zustand von vorher. Auch --harness claude ist dabei nichts Besonderes, sondern gewöhnliches argv:

const HARNESS = argWert('--harness') || 'claude'

function argWert(name) {
  const i = process.argv.indexOf(name)
  return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : null
}

③ Die Eingabe wird gelesen. Hier endet das, was der Harness vorgibt, und das eigene Programm fängt an:

const roh = await lies(process.stdin)
let eingabe = {}
try {
  eingabe = JSON.parse(roh || '{}')
} catch {
  // Unverständliche Eingabe ist kein Grund durchzulassen.
  antworte({ entscheidung: 'deny', regel: 'eingabe-unlesbar', … }, HARNESS)
}

④ Die Felder werden ausgepackt. Erst jetzt kommt der Adapter ins Spiel, der zwischen den beiden Harnessen unterscheidet:

const cwd        = eingabe.cwd || process.cwd()
const kommando   = kommandoAus(eingabe, HARNESS)   // tool_input.command bzw. toolArgs.command
const richtlinie = ladeRichtlinie(cwd)             // .azure-guard.json
const kontext    = aktiverKontext()                // azureProfile.json

Das cwd stammt aus der Eingabe, nicht aus dem Prozess — der Hook startet nicht zwangsläufig im Projektverzeichnis. Drei Dateien werden gelesen, keine CLI wird aufgerufen, kein Netzwerk berührt.

⑤ Geprüft wird in drei Zeilen. Das ist die ganze „Verdrahtung”, nach der man sucht:

const pac = pruefePac(kommando, richtlinie, aktivesPacProfil())
if (pac.entscheidung === 'deny') antworte(pac, HARNESS)

antworte(pruefe(kommando, richtlinie, kontext), HARNESS)

pruefe ist ein frei gewählter Name. Ich könnte die drei Funktionen a, b und c nennen, und der Hook liefe unverändert — solange diese Zeilen sie aufrufen.

⑥ Geantwortet wird über stdout. antworte() übersetzt das interne { entscheidung, regel, grund } in das Format, das der jeweilige Harness versteht:

if (ergebnis.entscheidung === 'allow') process.exit(0)   // nichts ausgeben = durchlassen
…
process.stdout.write(JSON.stringify({ hookSpecificOutput: { … } }))

⑦ und ⑧ gehören wieder dem Harness. Er liest das JSON, ersetzt bei updatedInput die Kommandozeile durch die neue und führt erst dann das Werkzeug aus. Der Prozess ist zu diesem Zeitpunkt längst beendet.

Warum die Funktionen trotzdem exportiert sind

Wenn der Harness die Datei nie importiert — wozu dann export function pruefe? Für die Testskripte:

import { mitAbonnement } from './guard.mjs'   // probe-umschreiben.mjs

Das ist der Grund für eine Abfrage, die beim ersten Lesen überflüssig aussieht:

const direktGestartet = process.argv[1]
  && import.meta.url === pathToFileURL(process.argv[1]).href

Ohne sie würde ein import die Datei auch als Hook starten: Sie würde auf stdin warten, und der Test hinge, bis jemand ihn abbricht. Direkt gestartet heißt deshalb „sprich das Protokoll”, importiert heißt „stell nur die Funktionen bereit” — derselbe Code, zwei Betriebsarten, prüfbar ohne Harness.

Und weil die Schnittstelle nur aus einem Prozess und zwei Strömen besteht, ist auch die Sprache frei. Wo bei mir node steht, könnte genauso gut pwsh stehen.

Einbauen: GitHub Copilot CLI

Dasselbe Skript, andere Config-Datei. Die Konfiguration liegt unter ~/.copilot/hooks/*.json:

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "node \"$COPILOT_PLUGIN_ROOT/hooks/guard.mjs\" --harness copilot",
        "matcher": "bash|shell",
        "timeout": 10
      }
    ]
  }
}

Die Unterschiede sind klein, aber sie brechen jeden Hook, der sie nicht kennt:

Claude CodeCopilot CLI
Befehl im Eingabe-JSONtool_input.commandtoolArgs.command
Werkzeugnametool_nametoolName
Antwort beim SperrenhookSpecificOutput.permissionDecisionpermissionDecision auf oberster Ebene
Eingabe umschreibenupdatedInputnicht vorgesehen

Deshalb hat das Skript einen Schalter --harness und zwei schmale Adapter:

function kommandoAus(eingabe, harness) {
  if (harness === 'copilot') {
    const args = eingabe.toolArgs || {}
    return args.command || args.bash || args.script || ''
  }
  const ti = eingabe.tool_input || {}
  return ti.command || ti.script || ''
}

Dieselbe Regel, dieselben Entscheidungen — in einem End-to-End-Test mit beiden Eingabeformaten sieben von sieben Fällen identisch.

In der Power Platform kreuzen sich zwei Anmeldungen

Bis hierhin ging es um die Azure CLI. Wer mit der Power Platform arbeitet, hat das Problem zweimal — und das zweite Mal ist das unangenehmere.

Die pac CLI führt ihren Zustand genauso: eine Datei, ein aktives Profil, für alle Prozesse zugleich.

%LOCALAPPDATA%\Microsoft\PowerAppsCli\authprofiles_v2.json

Profiles: [
  { FriendlyName: "Default Directory (default)", Resource: "https://orgf707a816.crm16.dynamics.com/" },
  { FriendlyName: "Sales Trial",                 Resource: "https://org58340192.crm16.dynamics.com/" }
]
Current:  { UNIVERSAL: { … EnvironmentId, Resource, TenantId … } }

Statt eines isDefault-Flags heißt der Schlüssel hier Current. Das Muster ist dasselbe: pac auth select schreibt in diese Datei, und ab dann zeigt jeder pac-Befehl auf der Maschine woanders hin.

Ein Unterschied macht es schlimmer. Bei der Azure CLI konnte ich den Befehl reparieren, indem der Hook --subscription hineinschreibt. Ein Gegenstück dazu gibt es bei pac nicht: Welche Umgebung ein Befehl trifft, steht nicht im Befehl, sondern ausschließlich im aktiven Profil. pac solution import, pac pages upload-code-site, pac plugin push — sie alle folgen dem, was zuletzt jemand ausgewählt hat. Da bleibt nur zulassen oder sperren.

Und dann kommt die Stelle, an der sich beide Welten kreuzen. In Teil 1 dieser Reihe war einer der drei undokumentierten Stolpersteine genau dieser: Der App-Builder schreibt mit dem Token der Azure CLI nach Dataverse, während pac nur die Umgebungs-URL beisteuert. Beide Anmeldungen müssen also dieselbe Identität tragen — aber sie liegen in zwei unabhängigen Dateien, die nichts voneinander wissen.

Für einen zweiten Thread heißt das: Er muss gar nichts Dramatisches tun. Es reicht, dass er az account set ausführt, weil er gerade in einem anderen Mandanten etwas braucht. Der laufende Build hat danach eine Azure-Identität, die nicht mehr zur Power-Platform-Umgebung passt — und bekommt entweder einen Fehler, den niemand einem zweiten Thread zuordnet, oder er schreibt dorthin, wo er nicht hingehört.

Der Hook prüft deshalb beide Seiten. Für pac sieht die Richtlinie so aus:

{
  "allowedEnvironments": ["https://orgf707a816.crm16.dynamics.com/"]
}

Und die Entscheidung, gegen das echte Profil dieser Maschine:

$ pac solution list
[azure-guard/umgebung-nicht-erlaubt] Die Power Platform CLI zeigt auf
Default Directory (default) (https://orgf707a816.crm16.dynamics.com/).
Erlaubt sind hier nur: https://org58340192.crm16.dynamics.com/.
Wechsle mit `pac auth select`, bevor du weitermachst.

Eine Ausnahme ist bewusst eingebaut: pac auth list|select|create|who kommt immer durch. Sonst könnte ein Agent seinen eigenen Zustand weder feststellen noch in Ordnung bringen — er säße fest.

Dasselbe Muster findet sich überall dort, wo ein Werkzeug „den aktuellen Kontext” kennt: kubectl mit seinem Current Context in der kubeconfig, gh mit dem aktiven Host, gcloud mit der aktiven Konfiguration, terraform mit dem ausgewählten Workspace. Die Dateien heißen anders, das Problem ist dasselbe — und die Prüfung lässt sich nach demselben Schema bauen.

Nicht nur sperren: den Befehl reparieren

Hier wird es interessant. Claude Code erlaubt dem Hook, die Eingabe zu verändern, bevor das Werkzeug läuft:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": { "command": "az group list --subscription c530e80c-…" },
    "additionalContext": "Hinweis von azure-guard: … hat ihn an c530e80c-… gebunden."
  }
}

Das ist nicht die mildere, sondern die stärkere Antwort. Ein deny sperrt den Aufruf und überlässt es dem Agenten, es richtig zu machen. Ein Umschreiben trägt den geprüften Kontext in den Befehl hinein — und schließt damit genau die Lücke, um die es im ganzen Beitrag geht. Danach kann ein anderer Thread umschalten, so oft er will; dieser Befehl ist gebunden.

Live sah das so aus. Abgeschickt wurde ein Befehl ohne Abonnement:

$ az group list --query "length(@)" -o tsv

PreToolUse:Bash hook additional context: Hinweis von azure-guard: Der Befehl lief
ohne Abonnement auf dem gemeinsamen Kontext. azure-guard hat ihn an c530e80c-… gebunden.

ERROR: (SubscriptionNotFound) The subscription 'c530e80c-…' could not be found.

Die Fehlermeldung von Azure nennt ein Abonnement, das in der abgeschickten Zeile nicht stand. Der Beweis, dass die Zeile unterwegs verändert wurde.

Zwei Fehler, die ich beim Bauen gemacht habe

Das Umschreiben war zu eng gedacht. Zuerst griff es nur, wenn genau ein Abonnement erlaubt war — bei mehreren hätte der Hook raten müssen. Jetzt nimmt er in dieser Reihenfolge: den aktiven Kontext, wenn er erlaubt ist; sonst eine benannte Vorgabe; sonst das einzige erlaubte. Gibt es keinen dieser Fälle, wird gesperrt. Dasselbe gilt dort, wo sich die Option nicht gefahrlos in die Zeile schreiben lässt — etwa bei einem az-Aufruf in Anführungszeichen, der Text für ein anderes Programm ist.

Befehlsketten liefen aus dem Ruder. Bei az account set --subscription X && az keyvault … las der Hook den Kontext vor der Kette und band den zweiten Befehl an den alten Mandanten. Genau das ist mir im Einsatz passiert. Jetzt führt er den Kontext durch die Kette mit: Schaltet ein Teil um, gilt das Neue für alles danach — und ist das Umschaltziel nicht erlaubt, wird gesperrt.

Zwei Grenzen, die bleiben

Falsch-positive Treffer gehören zur Bauweise. Der Hook durchsucht Kommandozeilen nach Mustern und trifft damit auch Text, der nur so aussieht. Beim Bauen dieses Plugins hat er mich viermal selbst ausgesperrt — einmal, weil eine Abonnement-Kennung als Zeichenkette in einem Testfall stand:

PreToolUse:Bash hook error: [azure-guard/abonnement-nicht-erlaubt] Der Befehl nennt
das Abonnement 11111111-1111-1111-1111-111111111111',. Erlaubt sind hier nur: …

Das Komma und das Anführungszeichen am Ende verraten es: Das war kein Befehl, das war ein Testdatum. Wer weniger Fehlalarme will, muss die Kommandozeile richtig zerlegen statt sie zu durchsuchen — oder in Kauf nehmen, dass die Regel weicher wird. Ich habe mich für die einfache, lesbare Regel entschieden und lebe mit den Fehlalarmen.

Eine Zeitüberschreitung lässt durch. Das ist der unangenehmste Punkt, und er steht in beiden Dokumentationen. GitHub schreibt für die Copilot CLI ausdrücklich: „timeout errors are always fail-open, even for preToolUse and admin-deployed policy hooks”. Claude Code verwirft bei Zeitüberschreitung die Hook-Ausgabe und lässt die Aktion zu.

Ein Hook, der zu lange braucht, schützt also nicht — er wird übergangen, und der Agent merkt davon nichts. Das ist der eigentliche Grund für die Messung weiter oben: 47 Millisekunden gegen ein Zeitlimit von zehn Sekunden sind Faktor zweihundert Sicherheitsabstand. Wer in seinem Hook eine CLI aufruft, ein Netzwerk befragt oder gar ein Modell konsultiert, hat diesen Abstand nicht mehr.

Anmerkung zur Redlichkeit: Diese Eigenschaft ist dokumentiert, von mir aber nicht gemessen. Mein Versuch, sie mit einem absichtlich langsamen Hook nachzustellen, ist gescheitert — in fünf Anläufen über den nicht-interaktiven Modus lief der Hook überhaupt nicht an, während derselbe Hook in einer laufenden Sitzung sofort greift. Woran das liegt, weiß ich nicht.

Was sonst noch in so eine Regel passt

Das Abonnement ist nur der Fall, an dem mir die Sache aufgefallen ist. Der Hook selbst ist kein Spezialwerkzeug: Der Harness ruft ein Kommando auf, schickt JSON in dessen stdin und liest JSON von dessen stdout. Welche Sprache dahinter steht, ist ihm gleichgültig — bei mir ist es Node, in einer Windows-Landschaft wäre ein PowerShell-Skript die naheliegendere Wahl. Und es läuft im Kontext des angemeldeten Benutzers, mit allem, was dieser Kontext weiß.

Damit wird „was darf dieser Aufruf” zu einer Frage, die jedes Haus für sich beantworten kann:

  • Wer sitzt da eigentlich? whoami /groups liefert die Sicherheitsgruppen aus dem Windows-Token des angemeldeten Benutzers, ohne eine Zeile Netzwerk. Wer nicht in PP-Deployment ist, kommt an die Produktionsumgebung nicht heran — auch dann nicht, wenn sein Agent es für eine gute Idee hält.
  • Von welchem Rechner? Gerätename, Domänenmitgliedschaft, ob eine VPN-Verbindung steht. Ein pac solution import aus dem Heimnetz ist etwas anderes als eines aus dem Büro.
  • Wann? Ein Change-Freeze ist eine Datumsprüfung. Freitagnachmittag kein Import, im Jahresabschluss gar keiner — das sind drei Zeilen Code und eine Diskussion, die man einmal führt statt bei jedem Vorfall neu.
  • Was steht in dem Aufruf? Der Hook sieht tool_input vollständig, und das nicht nur bei Shell-Befehlen: bei einem Schreibzugriff also auch den Dateiinhalt. Eine Konfiguration mit anonymen Leserechten, ein Solution-Name ohne das eigene Publisher Prefix, eine Connection auf einen Mandanten, der hier nichts zu suchen hat — alles prüfbar, bevor es passiert.

Auch die Richtlinie muss nicht im Projekt liegen. Die Datei im Benutzerverzeichnis lässt sich per Intune oder Group Policy verteilen, dann gilt dieselbe Hausregel auf jedem Rechner. Wer sie unübergehbar haben will, muss allerdings die Reihenfolge umdrehen: Mein Suchpfad nimmt die projektnächste Datei und lässt sich damit vom Projekt aus auch lockern. Für eine Vorgabe, die von oben kommt, müsste die zentrale Datei zuerst gelesen werden und das Projekt dürfte nur verengen.

Eine Grenze setzt dabei die Stoppuhr von weiter oben. Alles, was die Maschine schon weiß, kostet Mikrosekunden. Alles, was erst erfragt werden muss — eine Gruppenmitgliedschaft aus Entra ID, der Umgebungstyp aus der Admin-API — kostet einen Netzwerkaufruf und rückt den Hook an das Zeitlimit heran, hinter dem er stillschweigend übergangen wird. Was selten wechselt und oft gebraucht wird, gehört deshalb in eine lokale Datei, die ein eigener Vorgang auffrischt, und nicht in den Hook.

Was ich mitnehme

Der Hook ist nicht die erste Verteidigungslinie. Die eigentliche Trennung paralleler Threads ist ein eigenes AZURE_CONFIG_DIR je Thread — in der Messung oben hat sie alle 36 Runden sauber gehalten. Wer das einrichten kann, sollte es tun.

Und noch etwas fällt auf, wenn man das Problem von hinten betrachtet: Es ist eines von Agenten, die lokal laufen. Der geteilte Zustand entsteht, weil mehrere Threads sich eine Maschine und damit ein Benutzerverzeichnis teilen. Genau in diese Richtung bewegen sich gerade alle Anbieter — die Ausführung wandert in die Cloud, jede Sitzung bekommt ihre eigene Umgebung — sofern sie wirklich eine eigene bekommt und nicht bloß einen gemeinsamen Build-Agenten. Damit verschwindet der Effekt aus diesem Beitrag von selbst, denn jede Umgebung bringt ihr eigenes ~/.azure mit. Es ist dieselbe Isolation wie oben, nur konsequent und nicht von Hand gebaut.

Was dabei nicht verschwindet, ist der Rest. Ein Agent, der in seiner eigenen Umgebung mit einem Dienstprinzipal arbeitet, kann genauso in der falschen Umgebung landen — nur sitzt dann niemand mehr daneben und sieht die Ausgabe. Die Verlagerung in die Cloud nimmt einem die Nebenwirkungen des gemeinsamen Rechners ab. Die Frage, was ein Agent darf, wenn niemand mehr zuschaut, stellt sie schärfer, nicht milder. Hooks laufen in beiden Welten gleich.

Aber Isolation ist nicht immer praktikabel, und sie hilft auch nicht gegen das, worum es hier eigentlich geht: dass ein autonom laufender Agent Befehle absetzt, die niemand mehr liest. Dagegen hilft eine Regel, die im Repository steht, bei jedem Aufruf dieselbe Entscheidung trifft und sich im Diff prüfen lässt.

Die Frage ist nicht, ob man seinem Agenten vertraut. Die Frage ist, was passiert, wenn die Annahmen nicht mehr stimmen — und ob man das dann merkt.


Das Plugin liegt offen: azure-guard im Marketplace-Repo, mit Selbsttest über 21 Fälle und einer README, die die Messungen als Begründung mitführt.