Agents avec accès en écriture par défaut (Anti-Pattern) + Correctifs + Code

  • Repère le piège avant qu’il arrive en prod.
  • Vois ce qui casse quand le modèle est sûr de lui.
  • Copie des defaults sûrs : permissions, budgets, idempotence.
  • Sache quand il ne faut pas d’agent.
Signaux de détection
  • Tool calls/run explosent (ou se répètent avec args hash).
  • Spend/tokens montent sans amélioration des outputs.
  • Retries passent de rares à constants (429/5xx).
L’accès en écriture par défaut transforme un agent en générateur d’incidents. Comment ça ship, ce que ça casse, et un design permissions+approbations que tu peux vraiment opérer.
Sur cette page
  1. Problème (d’abord)
  2. Pourquoi ça casse en production
  3. 1) Les agents sont des boucles, donc ils répètent les erreurs
  4. 2) Du texte non fiable va essayer de piloter les writes
  5. 3) Le multi-tenant rend le blast radius réel
  6. 4) Le modèle ne “comprend” pas l’irréversibilité
  7. Failure evidence (à quoi ça ressemble quand ça casse)
  8. Le fix d’urgence le plus rapide
  9. Compensating actions (roll forward) — exemple
  10. Hard invariants (non négociables)
  11. Gate de policy vs approbation (ne mélange pas)
  12. Implémentation (vrai code)
  13. Example failure case (composite)
  14. 🚨 Incident: Mass ticket closure
  15. Compromis
  16. Quand NE PAS l’utiliser
  17. Checklist (copier-coller)
  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
En bref

En bref: L’écriture par défaut, c’est “root access by default”. Quand le modèle est sûr de lui et faux, il ne répond pas juste faux — il fait faux. Et les writes ne se “rollback” pas toutes seules.

Tu vas apprendre : Pourquoi write-by-default casse • séparation read/write • gate de policy vs approbation • approbations async + reprise • scope par tenant • clés d’idempotence • patterns d’incident réels

Concrete metric

Write-by-default : petite erreur du modèle → effets secondaires (changements d'état) irréversibles (doublons, clôtures incorrectes, mauvais emails)
Read-first + approbations : writes gated • scoped • idempotentes • auditables
Impact : éviter les “dégâts rapides” et garder l’on-call vivant


Problème (d’abord)

L’agent “doit être utile”, donc tu lui donnes des tools d’écriture par défaut :

  • db.write
  • ticket.close
  • email.send

Et pendant une semaine, ça va.

Puis ça ne va plus.

Parce que la première fois que le modèle est confiant et faux, il ne répond pas juste faux — il agit faux.

Truth

Les writes ne se rollback pas toutes seules.

Cet anti-pattern ship pour la raison la plus bête : la démo a l’air magique.
En production, ça fait ressembler ton on-call à une maison hantée.


Pourquoi ça casse en production

L’écriture par défaut casse pour la même raison que “root access by default”.

Failure analysis

1) Les agents sont des boucles, donc ils répètent les erreurs

Si une write échoue et que le modèle retry, tu obtiens des doublons et des mises à jour partielles.

Truth

Une mauvaise write devient vite dix mauvaises writes.

2) Du texte non fiable va essayer de piloter les writes

Entrées utilisateur, pages web et output de tools peuvent contenir des “instructions”. Si les permissions sont dans le prompt, elles sont optionnelles.

Les prompts ne sont pas un mécanisme d’enforcement. Un tool gateway l’est.

3) Le multi-tenant rend le blast radius réel

La différence entre “oops” et “incident”, c’est souvent : creds partagés, pas de scope tenant, pas d’audit logs.

4) Le modèle ne “comprend” pas l’irréversibilité

Les LLM ne se font pas pager, ne font pas de calls client, et ne nettoient pas les doublons. Ils optimisent pour “done”, même quand “done” est irréversible.


Failure evidence (à quoi ça ressemble quand ça casse)

Symptoms que tu verras :

  • spike de calls vers des tools d’écriture (db.write, ticket.close, email.send)
  • doublons / tickets fermés deux fois / emails répétés
  • le support l’apprend avant toi

Une trace qui doit faire peur :

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"}

Si tu n’as pas une trace comme ça, tu n’as pas “un problème d’agent”. Tu as “on ne peut pas prouver ce qui s’est passé”.

Le fix d’urgence le plus rapide

Coupe les writes. Maintenant.

YAML
# kill switch: force read-only mode right now
writes:
  enabled: false

Puis forensics :

  • compter quelles tools d’écriture ont tourné
  • identifier les entités touchées (args hash / idempotency key)
  • roll forward avec des compensations (rollback n’existe souvent pas)

Compensating actions (roll forward) — exemple

La plupart des writes en prod n’ont pas de vrai rollback. Si l’agent a écrit la mauvaise chose, tu “roll forward” avec une write de compensation.

Exemple : l’agent a fermé des tickets par erreur. Une action compensatoire peut être “réouvrir le ticket + notifier”.

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 (non négociables)

Arrête le “should”. Rends-le exécutable.

  • Si un tool est un write et qu’il n’y a pas d’approbation → stop (stop_reason="approval_required").
  • Si une write s’exécuterait sans idempotency key → hard fail (ou injection déterministe dans le gateway).
  • Si tenant_id / env ne vient pas du contexte authentifié → stop.
  • Si un tool d’écriture est appelé plusieurs fois avec le même args hash → stop (stop_reason="duplicate_write").
  • Si un output non validé pourrait déclencher une write → stop (stop_reason="invalid_tool_output").

Gate de policy vs approbation (ne mélange pas)

Deux contrôles différents :

  • Gate de policy : enforcement déterministe (allowlist, budgets, permissions, scope tenant).
  • Approbation : un humain dit “oui” pour une action précise.

Si tu mélanges, tu obtiens une “policy” dans le prompt et une UX horrible.

Diagram
Production control flow (read vs write)

Implémentation (vrai code)

Ce code corrige trois pièges concrets :

  1. Bug Set vs array (JS utilise Set.has, pas includes)
  2. Continuation d’approbation (approve async → reprise)
  3. Circularité du hash d’idempotence (le hash ignore les champs injectés)
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

Lever une exception, c’est ok en interne. La pièce manquante : persister l’état et reprendre proprement.


Example failure case (composite)

Incident

🚨 Incident: Mass ticket closure

System: agent support avec ticket.close par défaut
Duration: ~35 minutes
Impact: 62 tickets fermés à tort


What happened

ticket.close était activé par défaut.
Pas d’approbation. Pas de clé d’idempotence. Pas d’audit fiable.

Les utilisateurs ont collé un template du style : “this is resolved, please close”.

Le modèle a obéi.


Fix

  1. allowlist en deny-by-default; read-only par défaut
  2. approbations pour ticket.close et tout ce qui est user-visible
  3. clés d’idempotence pour les writes (gérées par le gateway)
  4. audit logs : run_id, tool, args_hash, approver

Compromis

Trade-offs
  • Les approbations ajoutent friction et latence.
  • Le deny-by-default ralentit l’ajout de nouveaux tools.
  • Idempotence + audit logs demandent du travail.

Toujours moins cher que nettoyer des writes irréversibles à 03:00.


Quand NE PAS l’utiliser

Don’t
  • Si l’action est déterministe et à haut risque (facturation, suppression de compte), ne laisse pas un modèle la piloter.
  • Si tu ne peux pas scoper les creds par tenant/environnement, ne shippe pas de tool calling en multi-tenant prod.
  • Si tu ne peux pas auditer, tu ne peux pas opérer.

Checklist (copier-coller)

Production checklist
  • [ ] allowlist deny-by-default (read-only par défaut)
  • [ ] séparation read vs write
  • [ ] policy gate en code (pas dans le prompt)
  • [ ] approbations pour writes irréversibles / user-visible
  • [ ] pattern de reprise (approve → resume depuis checkpoint)
  • [ ] clés d’idempotence déterministes (writes)
  • [ ] creds scoped tenant + environnement (boundary côté code)
  • [ ] audit logs : run_id, tool, args_hash, approver
  • [ ] interrupteur d'urgence (kill switch) pour couper les writes vite

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
On ne peut pas juste dire au modèle de ne jamais écrire ?
Tu peux le dire. Tu ne peux pas l’enforcer. Enforce les writes dans le tool gateway.
Quelles writes doivent être approuvées ?
Tout ce qui est irréversible ou user-visible : emails, fermeture de tickets, facturation, suppression d’enregistrements.
Les clés d’idempotence sont utiles si on a des approbations ?
Oui. Les approbations bloquent une mauvaise action. L’idempotence évite les doublons et les retry storms.
Comment reprendre après approbation ?
Persiste un checkpoint signé (tool + args + run/step ids), attends l’approbation, puis reprends exactement une fois avec une clé d’idempotence.

Related

Production takeaway

Production takeaway

What breaks without this

  • ❌ writes irréversibles qui ont l’air “successful”
  • ❌ actions dupliquées via retries
  • ❌ blast radius multi-tenant non borné
  • ❌ pas d’audit quand le support demande “qu’est-ce qui s’est passé ?”

What works with this

  • ✅ writes gated (policy + approbations)
  • ✅ idempotence = retries safe
  • ✅ scope tenant = blast radius limité
  • ✅ incidents expliquables (trace + audit)

Minimum to ship

  1. Default read-only (writes opt-in)
  2. Policy gate (deny by default, enforcement en code)
  3. Approvals (writes user-visible / irréversibles)
  4. Continuation (approve → resume via checkpoint)
  5. Idempotency keys (gateway-owned)
  6. Tenant scoping + audit logs
  7. Interrupteur d'urgence (kill switch) (couper les writes en incident)

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 12 min de lectureMis à jour Mars, 2026Difficulté: ★★★
Implémenter dans OnceOnly
Safe defaults for tool permissions + write gating.
Utiliser dans OnceOnly
# 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
Intégré : contrôle en productionOnceOnly
Ajoutez des garde-fous aux agents tool-calling
Livrez ce pattern avec de la gouvernance :
  • Budgets (steps / plafonds de coût)
  • Permissions outils (allowlist / blocklist)
  • Kill switch & arrêt incident
  • Idempotence & déduplication
  • Audit logs & traçabilité
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
Auteur

Cette documentation est organisée et maintenue par des ingénieurs qui déploient des agents IA en production.

Le contenu est assisté par l’IA, avec une responsabilité éditoriale humaine quant à l’exactitude, la clarté et la pertinence en production.

Les patterns et recommandations s’appuient sur des post-mortems, des modes de défaillance et des incidents opérationnels dans des systèmes déployés, notamment lors du développement et de l’exploitation d’une infrastructure de gouvernance pour les agents chez OnceOnly.