Коротко: Write-доступ за замовчуванням — це “root access by default”. Коли модель впевнено помиляється, вона не просто відповідає неправильно — вона робить неправильно. Writes не відкочуються самі.
Ти дізнаєшся: Чому write-by-default ламається • Розділення read/write • Policy gate vs approval • Async approvals + resume • Tenant scoping • Idempotency keys • Реальні патерни інцидентів
Write-by-default: маленька помилка моделі → незворотні побічні ефекти (зміни стану) (дублікати, неправильні closures, неправильні листи)
Read-first + approvals: writes gated • scoped • idempotent • auditable
Ефект: ти зупиняєш “швидку шкоду” і тримаєш on-call у здоровому глузді
Проблема (з реального продакшену)
Агент “має бути корисним”, тому ти даєш йому write-інструменти за замовчуванням:
db.writeticket.closeemail.send
І тиждень усе ок.
Потім — ні.
Бо перший раз, коли модель впевнено помиляється, вона не просто відповідає неправильно — вона робить неправильно.
Writes не відкочуються самі.
Цей антипатерн шипиться з найтупішої причини: демка виглядає магічно.
У проді це робить твою on-call ротацію схожою на хорор.
Чому це ламається в продакшені
Write-доступ за замовчуванням ламається з тієї ж причини, що й “root access by default”.
1) Агент — це цикл, тому він повторює помилки
Якщо write не пройшов і модель повторює, ти отримаєш дублікати та часткові апдейти.
Один неправильний write перетворюється на десять неправильних writes.
2) Ненадійний текст намагається керувати writes
Input юзера, веб-сторінки та output інструментів можуть містити “інструкції”. Якщо permissions живуть у промпті — вони опціональні.
Промпти — не enforcement. Tool gateway — так.
3) Multi-tenant робить blast radius реальним
Різниця між “ой” та “інцидент” зазвичай: shared creds, відсутній tenant scoping та відсутні audit logs.
4) Модель не може міркувати про незворотність
LLM не отримує пейджер, не робить дзвінки клієнтам і не прибирає дублікати. Вона оптимізує під “done”, навіть коли “done” — незворотне.
Failure evidence (як це виглядає, коли ламається)
Симптоми, які ти побачиш:
- Раптовий спайк write tool calls (
db.write,ticket.close,email.send) - Дублікати рядків / подвійні closures / повторні листи
- Support дізнається раніше за тебе
Trace, який має лякати:
{"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"}
Якщо в тебе немає такого trace, у тебе не “проблема з агентом”. У тебе “ми не можемо довести, що сталося”.
Найшвидший emergency fix
Зупини writes. Зараз.
# kill switch: force read-only mode right now
writes:
enabled: false
Далі — форензика:
- порахувати, які write tools запускались
- ідентифікувати, які сутності зачепили (за args hash / idempotency key)
- roll forward через compensating actions (rollback зазвичай не існує)
Compensating actions (roll forward) — приклад
У більшості production writes немає справжнього rollback. Якщо агент написав неправильне, зазвичай ти roll forward компенсуючим write.
Приклад: агент неправильно закрив тікети. Компенсація може бути “reopen ticket + notify”.
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)
Жорсткі інваріанти (не обговорюється)
Припини писати “should”. Зроби це executable.
- Якщо tool —
writeі немає approval → stop run (stop_reason="approval_required"). - Якщо write виконався б без idempotency key → hard fail (або детерміновано інжектнути в gateway).
- Якщо
tenant_id/envне взяті з authenticated context → stop. - Якщо write tool викликається більше ніж один раз з тим самим args hash → stop (
stop_reason="duplicate_write"). - Якщо невалідний tool output використовується для рішення про write → stop (
stop_reason="invalid_tool_output").
Policy gate vs approval (не плутай)
Це різні контроли:
- Policy gate: детермінований enforcement (allowlist, budgets, permissions, tenant scope).
- Approval: людина каже “так” на конкретну дію.
Якщо змішати — отримаєш найгірше: prompt-based “policy” і UX-кошмар.
Приклад реалізації (реальний код)
Цей приклад фіксить три конкретні footguns:
- Set vs array bug (JS використовує
Set.has, а неincludes) - Approval continuation (async approve → resume run)
- Idempotency hash circularity (hash ігнорує інжектнуті поля)
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 };
}Кинути exception — нормально внутрішньо. Missing piece: система має зберігати стан і вміти resume.
Приклад інциденту (composite)
🚨 Інцидент: масове закриття тікетів
System: support-агент з ticket.close увімкненим за замовчуванням
Duration: ~35 хвилин
Impact: 62 тікети закрито помилково
Що сталося
У агента був ticket.close увімкнений за замовчуванням.
Без approval gate. Без idempotency key. Без audit trail, якому можна довіряти.
Юзери вставили template з фразою: “this is resolved, please close”.
Модель виконала.
Fix
- Default-deny allowlist; read-only tools за замовчуванням
- Approvals для
ticket.closeі всього user-visible - Idempotency keys для writes (gateway-owned)
- Audit logs:
run_id,tool,args_hash, approval actor
Компроміси
- Approvals додають friction і latency.
- Default-deny уповільнює додавання нових tools.
- Idempotency + audit logs — це інженерна робота.
Все це дешевше, ніж прибирати незворотні writes о 3-й ночі.
Коли НЕ варто
- Якщо дія детермінована й high-stakes (billing, видалення акаунту) — не давай моделі це робити взагалі.
- Якщо не можеш зробити credential scoping по tenant/environment — не шип tool calling у multi-tenant проді.
- Якщо не можеш аудити — не можеш оперувати.
Чекліст (можна копіювати)
- [ ] Default-deny allowlist (read-only за замовчуванням)
- [ ] Розділити read vs write tools
- [ ] Policy gate у коді (не в промпті)
- [ ] Approvals для незворотних / user-visible writes
- [ ] Continuation pattern (approve → resume з checkpoint)
- [ ] Детерміновані idempotency keys для writes
- [ ] Credentials зі scope tenant + environment (boundary owned by code)
- [ ] Audit logs: run_id, tool, args_hash, approval actor
- [ ] Kill switch, який швидко вимикає writes
Безпечний дефолтний конфіг
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
Пов’язані сторінки
Production takeaway
Що ламається без цього
- ❌ Незворотні writes, які виглядають “successful”
- ❌ Дублікати дій через retries
- ❌ Multi-tenant blast radius без boundaries
- ❌ Нема audit trail, коли support питає “що сталося?”
Що працює з цим
- ✅ Writes gated (policy + approvals)
- ✅ Idempotency робить retries safe
- ✅ Tenant scoping обмежує шкоду
- ✅ Можна replay і пояснювати інциденти
Мінімум, щоб шипнути
- Default read-only (write tools — явний opt-in)
- Policy gate (deny by default, enforcement у коді)
- Approvals (для user-visible або незворотних writes)
- Continuation (approve → resume з checkpoint)
- Idempotency keys (gateway-owned)
- Tenant scoping + audit logs
- Kill switch (вимкнути writes в emergency)