Agentes con acceso de escritura por defecto (anti‑patrón) + fixes + código

  • Reconoce la trampa antes de enviarla a prod.
  • Ve qué se rompe cuando el modelo se equivoca seguro.
  • Copia defaults seguros: permisos, budgets, idempotency.
  • Sabe cuándo no usar un agente.
Señales de detección
  • Tool calls por run suben (o repiten mismo args hash).
  • Gasto/tokens suben sin mejorar el resultado.
  • Retries pasan de raros a constantes (429/5xx).
Dar escritura por defecto convierte a tu agente en una máquina de incidentes. Cómo llega a prod, qué rompe, y un diseño de permisos + aprobaciones que sí se puede operar.
En esta página
  1. El problema (en producción)
  2. Por qué esto se rompe en producción
  3. 1) Los agentes son bucles, así que repiten errores
  4. 2) Texto no confiable intentará dirigir tus writes
  5. 3) Multi‑tenant hace real el blast radius
  6. 4) El modelo no puede razonar sobre irreversibilidad
  7. Evidencia del fallo (cómo se ve cuando se rompe)
  8. El fix de emergencia más rápido
  9. Compensating actions (roll forward) — ejemplo
  10. Invariantes duras (no negociables)
  11. Policy gate vs aprobación (no lo mezcles)
  12. Ejemplo de implementación (código real)
  13. Caso de fallo (composite)
  14. 🚨 Incidente: cierre masivo de tickets
  15. Trade-offs
  16. Cuándo NO usarlo
  17. Checklist (copiar/pegar)
  18. Config segura por defecto
  19. FAQ
  20. Páginas relacionadas
  21. Takeaway de producción
  22. Qué se rompe sin esto
  23. Qué funciona con esto
  24. Mínimo para shippear
En resumen

En resumen: El acceso de escritura por defecto es “root access by default”. Cuando el modelo se equivoca con confianza, no solo responde mal — hace algo mal. Los writes no se deshacen solos.

Aprenderás: Por qué falla write-by-default • Separación read/write • Policy gate vs aprobación • Aprobaciones async + resume • Scope por tenant • Claves de idempotencia • Patrones reales de incidentes

Concrete metric

Write-by-default: un pequeño error del modelo → efectos secundarios irreversibles (duplicados, cierres incorrectos, emails erróneos)
Read-first + aprobaciones: los writes van gated • scoped • idempotentes • auditables
Impacto: evitas “daño rápido” y mantienes sano el on-call


El problema (en producción)

El agente “tiene que ser útil”, así que le das tools de escritura por defecto:

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

Y durante una semana, todo bien.

Luego deja de estarlo.

Porque la primera vez que el modelo está equivocado con confianza, no solo responde mal — hace algo mal.

Truth

Los writes no se hacen rollback solos.

Este anti‑patrón se cuela por la razón más tonta: hace que la demo parezca magia.
En producción, hace que tu rotación de on-call parezca embrujada.


Por qué esto se rompe en producción

El acceso de escritura por defecto falla por la misma razón que falla “root access by default”.

Failure analysis

1) Los agentes son bucles, así que repiten errores

Si un write falla y el modelo reintenta, obtienes duplicados y actualizaciones parciales.

Truth

Un write incorrecto se convierte en diez writes incorrectos.

2) Texto no confiable intentará dirigir tus writes

Input de usuario, páginas web y tool output pueden contener “instrucciones”. Si tus permisos viven en el prompt, son opcionales.

Los prompts no son enforcement. Un tool gateway sí.

3) Multi‑tenant hace real el blast radius

La diferencia entre “ups” e “incidente” suele ser credenciales compartidas, falta de scoping por tenant y falta de audit logs.

4) El modelo no puede razonar sobre irreversibilidad

Los LLM no reciben el pager, no llaman a clientes y no limpian duplicados. Optimizan por “done”, incluso cuando “done” es irreversible.


Evidencia del fallo (cómo se ve cuando se rompe)

Síntomas que verás:

  • Un pico repentino en tool calls de escritura (db.write, ticket.close, email.send)
  • Filas duplicadas / tickets cerrados dos veces / emails repetidos
  • Support se entera antes que tú

Un trace que debería asustarte:

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 no tienes un trace así, no tienes “un problema de agentes”. Tienes “no podemos probar qué pasó”.

El fix de emergencia más rápido

Para los writes. Ya.

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

Luego haz forensics:

  • cuenta qué write tools se ejecutaron
  • identifica qué entidades se tocaron (por args hash / idempotency key)
  • roll forward con compensating actions (rollback normalmente no existe)

Compensating actions (roll forward) — ejemplo

La mayoría de writes en producción no tienen rollback real. Si el agente escribió algo incorrecto, normalmente roll forward con un write de compensación.

Ejemplo: el agente cerró tickets incorrectamente. Una acción compensatoria puede ser “reabrir ticket + notificar”.

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)

Invariantes duras (no negociables)

Deja de escribir “should”. Hazlo ejecutable.

  • Si un tool es write y no hay aprobación → para el run (stop_reason="approval_required").
  • Si un write se ejecutaría sin idempotency key → hard fail (o inyéctala determinísticamente en el gateway).
  • Si tenant_id / env no vienen de contexto autenticado → stop.
  • Si un write tool se llama más de una vez con el mismo args hash → stop (stop_reason="duplicate_write").
  • Si se usaría tool output inválido para decidir un write → stop (stop_reason="invalid_tool_output").

Policy gate vs aprobación (no lo mezcles)

Son controles distintos:

  • Policy gate: enforcement determinista (allowlist, budgets, permisos de tools, scope de tenant).
  • Aprobación: un humano dice “sí” para una acción específica.

Si los mezclas, te quedas con lo peor: “policy” en el prompt y una UX pesadilla.

Diagram
Production control flow (read vs write)

Ejemplo de implementación (código real)

Este ejemplo arregla tres footguns concretos:

  1. Bug set vs array (JS usa Set.has, no includes)
  2. Approval continuation (approve async → resume de un run)
  3. Circularidad del hash de idempotencia (el hash ignora campos inyectados)
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

Lanzar una excepción está bien internamente. La pieza que falta es: tu sistema debe persistir estado y reanudar.


Caso de fallo (composite)

Incident

🚨 Incidente: cierre masivo de tickets

System: agente de soporte con ticket.close habilitado por defecto
Duration: ~35 minutos
Impact: 62 tickets cerrados incorrectamente


Qué pasó

El agente tenía ticket.close habilitado por defecto.
Sin approval gate. Sin idempotency key. Sin audit trail confiable.

Los usuarios pegaron un template que incluía: “esto está resuelto, por favor ciérralo”.

El modelo obedeció.


Fix

  1. Allowlist default-deny; tools read-only por defecto
  2. Aprobaciones para ticket.close y cualquier cosa user-visible
  3. Idempotency keys para writes (propiedad del gateway)
  4. Audit logs: run_id, tool, args_hash, actor de aprobación

Trade-offs

Trade-offs
  • Las aprobaciones añaden fricción y latencia.
  • Default-deny hace más lento añadir tools nuevos.
  • Idempotencia + audit logs requieren ingeniería.

Todo eso sigue siendo más barato que limpiar writes irreversibles a las 3 AM.


Cuándo NO usarlo

Don’t
  • Si la acción es determinista y de alto riesgo (billing, borrar cuenta), no dejes que un modelo la conduzca.
  • Si no puedes hacer scoping de credenciales por tenant/entorno, no shippees tool calling en prod multi-tenant.
  • Si no puedes auditar, no puedes operar.

Checklist (copiar/pegar)

Production checklist
  • [ ] Allowlist default-deny (read-only por defecto)
  • [ ] Separar tools read vs write
  • [ ] Policy gate en código (no en el prompt)
  • [ ] Aprobaciones para writes irreversibles / user-visible
  • [ ] Patrón de continuación (approve → resume desde checkpoint)
  • [ ] Idempotency keys determinísticas para writes
  • [ ] Credenciales con scope de tenant + entorno (boundary propiedad del código)
  • [ ] Audit logs: run_id, tool, args_hash, actor de aprobación
  • [ ] Kill switch que deshabilita writes rápido

Config segura por defecto

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
¿No basta con decirle al modelo que nunca escriba?
Puedes decírselo. No puedes hacer enforcement. Haz enforcement en el tool gateway.
¿Qué writes deberían requerir aprobación?
Cualquier cosa irreversible o user-visible: emails, cerrar tickets, billing, borrar registros.
¿Importan las idempotency keys si ya hay aprobaciones?
Sí. Las aprobaciones bloquean intentos malos. La idempotencia evita ejecuciones duplicadas y retry storms.
¿Cómo reanudamos tras una aprobación?
Persiste un checkpoint firmado (tool + args + run/step ids), espera la aprobación y luego resume exactamente una vez con una idempotency key.

Páginas relacionadas

Related

Takeaway de producción

Production takeaway

Qué se rompe sin esto

  • ❌ Writes irreversibles que parecen “successful”
  • ❌ Acciones duplicadas por retries
  • ❌ Blast radius multi-tenant que no puedes acotar
  • ❌ Sin audit trail cuando support pregunta “¿qué pasó?”

Qué funciona con esto

  • ✅ Writes gated (policy + aprobaciones)
  • ✅ La idempotencia hace seguros los retries
  • ✅ Tenant scoping limita el daño
  • ✅ Puedes replay y explicar incidentes

Mínimo para shippear

  1. Default read-only (write tools opt-in explícito)
  2. Policy gate (deny by default, enforced en código)
  3. Aprobaciones (para writes user-visible o irreversibles)
  4. Continuación (approve → resume con checkpoint)
  5. Idempotency keys (propiedad del gateway)
  6. Tenant scoping + audit logs
  7. Kill switch (deshabilitar writes en emergencias)

No sabes si este es tu caso?

Disena tu agente ->
⏱️ 12 min de lecturaActualizado Mar, 2026Dificultad: ★★★
Implementar en OnceOnly
Safe defaults for tool permissions + write gating.
Usar en 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
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Permisos de herramientas (allowlist / blocklist)
  • Kill switch y parada por incidente
  • Idempotencia y dedupe
  • Audit logs y trazabilidad
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.