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
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.writeticket.closeemail.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.
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”.
1) Los agentes son bucles, así que repiten errores
Si un write falla y el modelo reintenta, obtienes duplicados y actualizaciones parciales.
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:
{"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.
# 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”.
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
writey 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/envno 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.
Ejemplo de implementación (código real)
Este ejemplo arregla tres footguns concretos:
- Bug set vs array (JS usa
Set.has, noincludes) - Approval continuation (approve async → resume de un run)
- Circularidad del hash de idempotencia (el hash ignora campos inyectados)
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 };
}Lanzar una excepción está bien internamente. La pieza que falta es: tu sistema debe persistir estado y reanudar.
Caso de fallo (composite)
🚨 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
- Allowlist default-deny; tools read-only por defecto
- Aprobaciones para
ticket.closey cualquier cosa user-visible - Idempotency keys para writes (propiedad del gateway)
- Audit logs:
run_id,tool,args_hash, actor de aprobación
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
- 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)
- [ ] 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
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
Páginas relacionadas
Takeaway de producción
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
- Default read-only (write tools opt-in explícito)
- Policy gate (deny by default, enforced en código)
- Aprobaciones (para writes user-visible o irreversibles)
- Continuación (approve → resume con checkpoint)
- Idempotency keys (propiedad del gateway)
- Tenant scoping + audit logs
- Kill switch (deshabilitar writes en emergencias)