Kurzfazit: Write-Zugriff per Default ist „root access by default“. Wenn das Modell selbstbewusst falsch liegt, antwortet es nicht nur falsch — es macht etwas Falsches. Writes rollen sich nicht von allein zurück.
Du lernst: Warum write-by-default bricht • Read/Write-Trennung • Policy Gate vs Approval • Async Approvals + Resume • Tenant Scoping • Idempotency Keys • reale Incident-Patterns
Write-by-default: kleiner Modellfehler → irreversible Seiteneffekte (Zustandsänderungen) (Duplikate, falsche Closures, falsche Emails)
Read-first + Approvals: Writes sind gated • scoped • idempotent • auditierbar
Impact: du verhinderst „schnellen Schaden“ und hältst On-Call halbwegs sane
Problem (zuerst)
Der Agent „muss nützlich sein“, also gibst du ihm Write-Tools per Default:
db.writeticket.closeemail.send
Und eine Woche lang ist alles okay.
Dann nicht mehr.
Denn beim ersten Mal, wenn das Modell selbstbewusst falsch liegt, ist es nicht nur „eine Antwort ist daneben“ — es macht Mist.
Writes rollen sich nicht von allein zurück.
Dieses Anti-Pattern shippt aus dem dümmsten Grund: Die Demo wirkt magisch.
In Production wirkt es wie ein Spuk in deiner On-Call-Rotation.
Warum das in Production bricht
Write-Zugriff per Default bricht aus demselben Grund wie „root access by default“.
1) Agenten sind Loops, also wiederholen sie Fehler
Wenn ein Write fehlschlägt und das Modell retryt, bekommst du Duplikate und Partial Updates.
Ein falscher Write wird schnell zu zehn falschen Writes.
2) Untrusted Text versucht Writes zu steuern
User Input, Webseiten und Tool-Output können alle „Instructions“ enthalten. Wenn Permissions nur im Prompt leben, sind sie optional.
Prompts sind kein Enforcement. Ein Tool Gateway ist es.
3) Multi-Tenant macht den Blast Radius real
Der Unterschied zwischen „oops“ und „Incident“ ist oft: shared creds, fehlendes Tenant Scoping, fehlende Audit Logs.
4) Das Modell kann Irreversibilität nicht „fühlen“
LLMs werden nicht gepaged, führen keine Kunden-Calls und räumen keine Duplikate auf. Sie optimieren auf „done“, auch wenn „done“ irreversibel ist.
Failure evidence (wie es aussieht, wenn es bricht)
Symptoms, die du siehst:
- plötzlicher Spike in Write-Tool-Calls (
db.write,ticket.close,email.send) - duplicate rows / doppelt geschlossene Tickets / wiederholte Emails
- Support hört davon vor dir
Ein Trace, der dich nervös machen sollte:
{"run_id":"run_9f2d","step":4,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":5,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":6,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":7,"event":"stop","reason":"loop_detected","note":"same write call 3x"}
Wenn du keinen Trace wie diesen hast, hast du kein „Agent-Problem“. Du hast „wir können nicht beweisen, was passiert ist“.
Der schnellste Emergency-Fix
Writes stoppen. Jetzt.
# kill switch: force read-only mode right now
writes:
enabled: false
Dann Forensik:
- zählen, welche Write-Tools gelaufen sind
- betroffene Entities finden (args hash / idempotency key)
- roll forward mit compensating actions (Rollback existiert oft nicht)
Compensating actions (roll forward) — Beispiel
Die meisten Production-Writes haben keinen echten Rollback. Wenn der Agent das Falsche geschrieben hat, „rollst du vorwärts“ mit einer kompensierenden Write.
Beispiel: Der Agent hat Tickets fälschlicherweise geschlossen. Eine kompensierende Action kann sein: „Ticket wieder öffnen + benachrichtigen“.
def compensate_wrong_ticket_closures(*, ticket_ids: list[str], run_id: str, tools) -> None:
for ticket_id in ticket_ids:
tools.call("ticket.reopen", args={"ticket_id": ticket_id, "note": f"Reopened by compensation (run_id={run_id})"}) # (pseudo)
tools.call(
"email.send",
args={
"to": "requester@example.com",
"subject": f"Correction: ticket {ticket_id} was closed by mistake",
"body": f"We reopened ticket {ticket_id}. Sorry — this was an automated error (run_id={run_id}).",
},
) # (pseudo)
Hard invariants (nicht verhandelbar)
Hör auf mit „should“. Mach es ausführbar.
- Wenn ein Tool ein
writeist und es kein Approval gibt → Run stoppen (stop_reason="approval_required"). - Wenn ein Write ohne Idempotency Key laufen würde → hard fail (oder deterministisch im Gateway injizieren).
- Wenn
tenant_id/envnicht aus authenticated context kommt → stop. - Wenn ein Write-Tool mehr als einmal mit demselben args hash läuft → stop (
stop_reason="duplicate_write"). - Wenn invalid Tool-Output genutzt würde, um einen Write zu entscheiden → stop (
stop_reason="invalid_tool_output").
Policy Gate vs Approval (nicht vermischen)
Das sind zwei verschiedene Controls:
- Policy Gate: deterministisches Enforcement (Allowlist, Budgets, Tool Permissions, Tenant Scope).
- Approval: ein Mensch sagt „ja“ zu einer konkreten Action.
Wenn du das vermischst, bekommst du Prompt-basierte „Policy“ und eine UX, die niemand will.
Implementierung (echter Code)
Dieses Beispiel fixt drei konkrete Footguns:
- Set vs array bug (JS nutzt
Set.has, nichtincludes) - Approval continuation (async approve → run resume)
- Idempotency hash circularity (hash ignoriert injizierte Felder)
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import hmac
import json
from typing import Any
READ_TOOLS = {"search.read", "kb.read", "http.get"}
WRITE_TOOLS = {"ticket.close", "email.send", "db.write"}
def stable_json(obj: Any) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def args_hash(args: dict[str, Any]) -> str:
# IMPORTANT: hash ignores fields injected by the gateway.
filtered = {k: v for k, v in args.items() if k not in {"idempotency_key", "approval_token"}}
return hashlib.sha256(stable_json(filtered)).hexdigest()[:24]
@dataclass(frozen=True)
class Policy:
allow: set[str]
require_approval: set[str] # usually write tools
class Denied(RuntimeError):
pass
@dataclass(frozen=True)
class PendingApproval:
approval_id: str
checkpoint: str # signed blob the server can resume
def evaluate(policy: Policy, tool: str) -> str:
if tool not in policy.allow:
raise Denied(f"not_allowed:{tool}")
if tool in WRITE_TOOLS and tool in policy.require_approval:
return "approve"
return "allow"
def sign_checkpoint(payload: dict[str, Any], *, secret: bytes) -> str:
raw = stable_json(payload)
sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return sig + "." + raw.decode("utf-8")
def verify_checkpoint(blob: str, *, secret: bytes) -> dict[str, Any]:
sig, raw = blob.split(".", 1)
expected = hmac.new(secret, raw.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise Denied("bad_checkpoint_signature")
return json.loads(raw)
def request_approval(*, tenant_id: str, tool: str, args_preview: dict[str, Any]) -> str:
# Return an approval id/token from your approval system.
# (pseudo)
return "appr_31ac"
def call_tool(*, ctx: dict[str, Any], policy: Policy, tool: str, args: dict[str, Any], secret: bytes) -> Any:
"""
Execute a tool with governance.
CRITICAL:
- tenant_id/env come from authenticated context (ctx), never from model output.
- writes require approval and resume via checkpoint.
"""
tenant_id = ctx["tenant_id"]
env = ctx["env"]
run_id = ctx["run_id"]
step_id = ctx["step_id"]
decision = evaluate(policy, tool)
if decision == "approve":
approval_id = request_approval(
tenant_id=tenant_id,
tool=tool,
args_preview={"args_hash": args_hash(args), "args": {k: v for k, v in args.items() if k != "body"}},
)
checkpoint = sign_checkpoint(
{
"run_id": run_id,
"step_id": step_id,
"tenant_id": tenant_id,
"env": env,
"tool": tool,
"args": args,
"args_hash": args_hash(args),
"kind": "tool_call",
},
secret=secret,
)
return PendingApproval(approval_id=approval_id, checkpoint=checkpoint)
# Deterministic idempotency key injection for writes (gateway-owned, not model-owned).
if tool in WRITE_TOOLS:
base_hash = args_hash(args)
args = {**args, "idempotency_key": f"{tenant_id}:{tool}:{base_hash}"}
creds = load_scoped_credentials(tool=tool, tenant_id=tenant_id, env=env) # (pseudo) NEVER from model
return tool_impl(tool, args=args, creds=creds) # (pseudo)
def resume_after_approval(*, checkpoint: str, approval_token: str, secret: bytes) -> dict[str, Any]:
"""
Continuation pattern:
- verify signed checkpoint
- attach approval token
- execute exactly once (idempotent)
"""
payload = verify_checkpoint(checkpoint, secret=secret)
tool = payload["tool"]
args = payload["args"]
# Keep hash stable by putting approval_token outside args hashing.
args = {**args, "approval_token": approval_token}
if tool in WRITE_TOOLS:
base_hash = payload["args_hash"]
args = {**args, "idempotency_key": f"{payload['tenant_id']}:{tool}:{base_hash}"}
creds = load_scoped_credentials(tool=tool, tenant_id=payload["tenant_id"], env=payload["env"]) # (pseudo)
out = tool_impl(tool, args=args, creds=creds) # (pseudo)
return {"status": "ok", "run_id": payload["run_id"], "step_id": payload["step_id"], "tool": tool, "result": out}import crypto from "node:crypto";
const READ_TOOLS = new Set(["search.read", "kb.read", "http.get"]);
const WRITE_TOOLS = new Set(["ticket.close", "email.send", "db.write"]);
function stableJson(obj) {
return JSON.stringify(obj, Object.keys(obj).sort());
}
export function argsHash(args) {
// IMPORTANT: hash ignores fields injected by the gateway.
const filtered = {};
for (const [k, v] of Object.entries(args || {})) {
if (k === "idempotency_key" || k === "approval_token") continue;
filtered[k] = v;
}
return crypto.createHash("sha256").update(stableJson(filtered), "utf8").digest("hex").slice(0, 24);
}
export class Denied extends Error {}
export function evaluate(policy, tool) {
// policy.allow / policy.requireApproval are Sets (use .has)
if (!policy.allow.has(tool)) throw new Denied("not_allowed:" + tool);
if (WRITE_TOOLS.has(tool) && policy.requireApproval.has(tool)) return "approve";
return "allow";
}
export function signCheckpoint(payload, { secret }) {
const raw = JSON.stringify(payload);
const sig = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
return sig + "." + raw;
}
export function verifyCheckpoint(blob, { secret }) {
const [sig, raw] = blob.split(".", 2);
const expected = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Denied("bad_checkpoint_signature");
return JSON.parse(raw);
}
export function requestApproval({ tenantId, tool, argsPreview }) {
// (pseudo) send to your approval system, return approval id
return "appr_31ac";
}
export function callTool({ ctx, policy, tool, args, secret }) {
const { tenant_id: tenantId, env, run_id: runId, step_id: stepId } = ctx;
const decision = evaluate(policy, tool);
if (decision === "approve") {
const approvalId = requestApproval({
tenantId,
tool,
argsPreview: { args_hash: argsHash(args), args: args },
});
const checkpoint = signCheckpoint(
{
run_id: runId,
step_id: stepId,
tenant_id: tenantId,
env,
tool,
args,
args_hash: argsHash(args),
kind: "tool_call",
},
{ secret },
);
return { status: "needs_approval", approval_id: approvalId, checkpoint };
}
if (WRITE_TOOLS.has(tool)) {
const baseHash = argsHash(args);
args = { ...args, idempotency_key: tenantId + ":" + tool + ":" + baseHash };
}
const creds = loadScopedCredentials({ tool, tenantId, env }); // (pseudo) NEVER from model
return toolImpl(tool, { args, creds }); // (pseudo)
}
export function resumeAfterApproval({ checkpoint, approvalToken, secret }) {
const payload = verifyCheckpoint(checkpoint, { secret });
const tool = payload.tool;
// Keep hash stable by putting approval_token outside args hashing.
let args = { ...payload.args, approval_token: approvalToken };
if (WRITE_TOOLS.has(tool)) {
const baseHash = payload.args_hash;
args = { ...args, idempotency_key: payload.tenant_id + ":" + tool + ":" + baseHash };
}
const creds = loadScopedCredentials({ tool, tenantId: payload.tenant_id, env: payload.env }); // (pseudo)
const out = toolImpl(tool, { args, creds }); // (pseudo)
return { status: "ok", run_id: payload.run_id, step_id: payload.step_id, tool, result: out };
}Eine Exception zu werfen ist intern okay. Das fehlende Stück ist: State persistieren und sauber resumieren.
Example failure case (composite)
🚨 Incident: Mass ticket closure
System: Support-Agent mit ticket.close per Default
Duration: ~35 Minuten
Impact: 62 Tickets fälschlich geschlossen
What happened
Agent hatte ticket.close per Default aktiviert.
Kein Approval Gate. Kein Idempotency Key. Kein Audit Trail, dem du trauen kannst.
User haben ein Template gepastet inklusive: „this is resolved, please close“.
Das Modell hat’s gemacht.
Fix
- Default-deny Allowlist; read-only Tools per Default
- Approvals für
ticket.closeund alles user-visible - Idempotency Keys für Writes (gateway-owned)
- Audit Logs:
run_id,tool,args_hash, approval actor
Abwägungen
- Approvals bringen Friction und Latenz.
- Default-deny verlangsamt neue Tools.
- Idempotency + Audit Logs sind Engineering-Arbeit.
Trotzdem billiger als irreversiblen Schaden um 03:00 aufzuräumen.
Wann NICHT nutzen
- Wenn die Action deterministisch und high-stakes ist (Billing, Account Deletion), lass kein Modell entscheiden.
- Wenn du Credentials nicht nach Tenant/Environment scopen kannst, shippe kein Tool Calling in Multi-Tenant Prod.
- Wenn du nicht auditieren kannst, kannst du nicht operieren.
Checklist (Copy-Paste)
- [ ] Default-deny Allowlist (read-only by default)
- [ ] Read vs Write Tools trennen
- [ ] Policy Gate im Code (nicht im Prompt)
- [ ] Approvals für irreversible / user-visible Writes
- [ ] Continuation pattern (approve → resume from checkpoint)
- [ ] Deterministische Idempotency Keys für Writes
- [ ] Tenant + Environment scoped credentials (Boundary gehört dem Code)
- [ ] Audit Logs: run_id, tool, args_hash, approval actor
- [ ] Kill switch / Not-Aus (kill switch) der Writes schnell deaktiviert
Safe default config
tools:
default_mode: "read_only"
allow: ["search.read", "kb.read", "http.get"]
writes:
enabled: false
require_approval: true
idempotency: "gateway_inject"
credentials:
scope: { tenant: true, environment: true }
kill_switch:
mode_when_enabled: "disable_writes"
FAQ
Related pages
Production takeaway
What breaks without this
- ❌ irreversible Writes, die „successful“ aussehen
- ❌ Duplicate Actions durch Retries
- ❌ Multi-Tenant Blast Radius ohne Boundaries
- ❌ Kein Audit Trail, wenn Support fragt „was ist passiert?“
What works with this
- ✅ Writes sind gated (Policy + Approvals)
- ✅ Idempotency macht Retries safe
- ✅ Tenant Scoping begrenzt Schaden
- ✅ Incidents sind erklärbar (Trace + Audit)
Minimum to ship
- Default read-only (Write-Tools explizit opt-in)
- Policy Gate (deny by default, enforced in code)
- Approvals (für user-visible oder irreversible Writes)
- Continuation (approve → resume mit Checkpoint)
- Idempotency Keys (gateway-owned)
- Tenant Scoping + Audit Logs
- Not-Aus (kill switch) (Writes im Notfall deaktivieren)