Agenten mit Write-Zugriff per Default (Anti-Pattern) + Fixes + Code

  • Erkenne die Falle, bevor sie in Prod landet.
  • Sieh, was bricht, wenn das Modell überzeugt danebenliegt.
  • Sichere Defaults kopieren: Permissions, Budgets, Idempotency.
  • Wissen, wann du keinen Agent brauchst.
Erkennungs-Signale
  • Tool-Calls pro Run steigen (oder wiederholen sich mit args-hash).
  • Kosten/Tokens pro Request steigen ohne bessere Ergebnisse.
  • Retries kippen von selten zu konstant (429/5xx).
Write-Zugriff per Default macht aus einem Agenten einen Incident-Generator. Wie das shippt, was es kaputtmacht und ein Permissions+Approval-Design, das du wirklich betreiben kannst.
Auf dieser Seite
  1. Problem (zuerst)
  2. Warum das in Production bricht
  3. 1) Agenten sind Loops, also wiederholen sie Fehler
  4. 2) Untrusted Text versucht Writes zu steuern
  5. 3) Multi-Tenant macht den Blast Radius real
  6. 4) Das Modell kann Irreversibilität nicht „fühlen“
  7. Failure evidence (wie es aussieht, wenn es bricht)
  8. Der schnellste Emergency-Fix
  9. Compensating actions (roll forward) — Beispiel
  10. Hard invariants (nicht verhandelbar)
  11. Policy Gate vs Approval (nicht vermischen)
  12. Implementierung (echter Code)
  13. Example failure case (composite)
  14. 🚨 Incident: Mass ticket closure
  15. Abwägungen
  16. Wann NICHT nutzen
  17. Checklist (Copy-Paste)
  18. Safe default config
  19. FAQ
  20. Related pages
  21. Production takeaway
  22. What breaks without this
  23. What works with this
  24. Minimum to ship
Kurzfazit

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

Concrete metric

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.write
  • ticket.close
  • email.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.

Truth

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“.

Failure analysis

1) Agenten sind Loops, also wiederholen sie Fehler

Wenn ein Write fehlschlägt und das Modell retryt, bekommst du Duplikate und Partial Updates.

Truth

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:

JSON
{"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.

YAML
# 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“.

PYTHON
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 write ist 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 / env nicht 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.

Diagram
Production control flow (read vs write)

Implementierung (echter Code)

Dieses Beispiel fixt drei konkrete Footguns:

  1. Set vs array bug (JS nutzt Set.has, nicht includes)
  2. Approval continuation (async approve → run resume)
  3. Idempotency hash circularity (hash ignoriert injizierte Felder)
PYTHON
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}
JAVASCRIPT
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 };
}
Insight

Eine Exception zu werfen ist intern okay. Das fehlende Stück ist: State persistieren und sauber resumieren.


Example failure case (composite)

Incident

🚨 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

  1. Default-deny Allowlist; read-only Tools per Default
  2. Approvals für ticket.close und alles user-visible
  3. Idempotency Keys für Writes (gateway-owned)
  4. Audit Logs: run_id, tool, args_hash, approval actor

Abwägungen

Trade-offs
  • 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

Don’t
  • 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)

Production checklist
  • [ ] 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

YAML
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

FAQ
Können wir dem Modell nicht einfach sagen, dass es nie schreiben soll?
Du kannst es sagen. Du kannst es nicht enforce’n. Enforce Writes im Tool Gateway.
Welche Writes brauchen Approval?
Alles Irreversible oder user-visible: Emails, Tickets schließen, Billing, Records löschen.
Brauchen wir Idempotency Keys, wenn wir Approvals haben?
Ja. Approvals stoppen schlechte Intention. Idempotency verhindert Duplicate Execution und Retry Storms.
Wie resumieren wir nach Approval?
Signed Checkpoint persistieren (tool + args + run/step ids), auf Approval warten, dann genau einmal mit Idempotency Key resumieren.

Related

Production takeaway

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

  1. Default read-only (Write-Tools explizit opt-in)
  2. Policy Gate (deny by default, enforced in code)
  3. Approvals (für user-visible oder irreversible Writes)
  4. Continuation (approve → resume mit Checkpoint)
  5. Idempotency Keys (gateway-owned)
  6. Tenant Scoping + Audit Logs
  7. Not-Aus (kill switch) (Writes im Notfall deaktivieren)

Nicht sicher, ob das dein Fall ist?

Agent gestalten ->
⏱️ 12 Min. LesezeitAktualisiert Mär, 2026Schwierigkeit: ★★★
In OnceOnly umsetzen
Safe defaults for tool permissions + write gating.
In OnceOnly nutzen
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Integriert: Production ControlOnceOnly
Guardrails für Tool-Calling-Agents
Shippe dieses Pattern mit Governance:
  • Budgets (Steps / Spend Caps)
  • Tool-Permissions (Allowlist / Blocklist)
  • Kill switch & Incident Stop
  • Idempotenz & Dedupe
  • Audit logs & Nachvollziehbarkeit
Integrierter Hinweis: OnceOnly ist eine Control-Layer für Production-Agent-Systeme.
Autor

Diese Dokumentation wird von Engineers kuratiert und gepflegt, die AI-Agenten in der Produktion betreiben.

Die Inhalte sind KI-gestützt, mit menschlicher redaktioneller Verantwortung für Genauigkeit, Klarheit und Produktionsrelevanz.

Patterns und Empfehlungen basieren auf Post-Mortems, Failure-Modes und operativen Incidents in produktiven Systemen, auch bei der Entwicklung und dem Betrieb von Governance-Infrastruktur für Agenten bei OnceOnly.