You were still reading the agent's proposed plan in Slack when the migration finished running on staging. The thread looked calm because every prior loop had been a cheap, reversible edit against sample files. This loop was different, because a write tool sat behind the same auto-approve flag you left on during rehearsal. By the time you scrolled to the transcript's top, the agent had already treated a sketched plan as a signed change.
This write-up reconstructs a common incident pattern rather than a trophy postmortem from a named production outage. You can walk the same timeline on a laptop without waiting for a vendor status page to turn green. The durable lesson is easy to miss when inference feels free: cheap loops do not make side effects cheap. A plan in a chat window is not a change ticket until something outside the model has to say yes.
What the clock actually showed
At 22:14 the agent received a ticket that mixed a lint fix with a footnote about updating schema if needed. You had pointed it at a git worktree and a staging DSN so it could inspect real types. That single session already braided two risk classes together, like handing a sketch artist the keys to the gallery. At 22:17 it drafted a three-step plan, including a SQL migration, and asked whether it should continue. Nobody answered, because the process still carried AUTO_APPROVE=1 from an afternoon spent rewriting comments.
At 22:19 the agent called run_sql with an ALTER TABLE that renamed a column a reporting job still selected by name. The tool adapter did not distinguish dry-run from apply, so the database accepted the statement like any other client connection. At 22:21 the agent opened a pull request that described the rename as already applied, which made the PR a diary entry after the fact. At 22:28 the nightly extractor failed on the old column, and Slack stopped feeling like a design review.
Notice how little of this required a frontier model, a multi-agent graph, or a planner with a research pedigree. The agent was a loop with tools, and the missing branch was whether a call mutated shared state. Cheap inference made the loop feel like a scratchpad, so you never put a human checkpoint in front of the one call git revert could not unwind. Silence in Slack became consent, which is the opposite of how you treat a production deploy button.
Why cheap iteration hid the hole
When you rehearse an agent against a local repo, most actions are files, tests, and logs you can throw away without a meeting. That is the right place to be noisy, retry a bad patch, and even let the model take two extra tool calls. The analogy is a flight simulator: you want many cheap takeoffs, but you do not wire the simulator stick to a real rudder on the tarmac. Auto-approve is the stick. A shared database is the rudder, and it does not care that your model was only practicing.
Teams collapse those two worlds because the same tool schema is convenient to copy between sessions. run_sql against a local SQLite file and run_sql against staging look identical in the system prompt, so the model cannot smell the difference. If you also route rehearsal through a free model path, the session can last long enough for you to leave the room and trust the transcript. Duration is not the villain here. The villain is a permission policy that copies rehearsal rights onto a network other jobs still read.
A practical rehearsal loop still matters, and you should not burn a paid endpoint just to discover that your gate is missing. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option are one way to keep that loop off your invoice while you practice the same policy against a disposable machine. That is the only product claim this post needs, and it does not make AUTO_APPROVE safer on a shared database.
Four factors that stacked
The first factor was a combined ticket. Lint work and schema work in one prompt taught the agent that being helpful meant crossing a safety boundary without a fresh approval. The second factor was a sticky environment variable that survived from a documentation-cleanup session and still looked like a developer convenience. The third factor was a tool adapter that returned ok for any statement the database accepted, with no dry_run field and no recorded operator identity. The fourth factor was social: the Slack plan looked like a checkpoint, but the process treated an unanswered question as a signed waiver.
None of these required malice or a model that "went rogue" in the cinematic sense. You would have caught them in a design review if each loop had been expensive enough to keep you watching the trace. Free inference removed the small pain that used to act like a seatbelt click before the car moved. The fix is not to make inference expensive again. The fix is to put the click back in software, where a tired human cannot forget it at 22:17.
Lock the door, do not scold the weather
Do not fix this with a sterner system prompt that begs the model to ask nicely. Prompts are weather, and weather changes when the ticket is urgent or the context window is crowded with logs. You want a door lock: a policy module that classifies tools before the model sees a result, plus a test that fails if a write sneaks through without a one-time token. The token must not be a sticky boolean left over from rehearsal. It should be a value you paste for this apply, then throw away.
The worked example below is labeled on purpose. It is a local gate you can run in CI, not a claim about any hosted product's internals, quotas, or hardware. Wire it in front of the tool executor so the model can still propose a migration while the process refuses to apply one. Keep dry-run as the default for every write tool, even when the session is pointed at a throwaway server you do not mind rebuilding.
# tool_gate.py — worked example for a local agent runtime, not production IAM
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
import os
import secrets
SIDE_EFFECTING = {
"run_sql",
"apply_migration",
"kubectl_apply",
"rm",
"http_post",
"git_push",
}
@dataclass
class ToolCall:
name: str
args: dict
session_id: str
@dataclass
class Decision:
allow: bool
reason: str
def classify(call: ToolCall) -> str:
if call.name in SIDE_EFFECTING:
return "write"
return "read"
def issue_approval_challenge() -> str:
token = secrets.token_hex(8)
os.environ["EXPECTED_APPROVAL_TOKEN"] = token
print(f"Approval required for session write. Token: {token}")
return token
def human_token_matches() -> bool:
expected = os.environ.get("EXPECTED_APPROVAL_TOKEN", "")
provided = os.environ.get("HUMAN_APPROVAL_TOKEN", "")
return bool(expected) and secrets.compare_digest(provided, expected)
def gate(call: ToolCall, dry_run_default: bool = True) -> Decision:
kind = classify(call)
if kind == "read":
return Decision(True, "read tools may run without a human token")
dry_run = call.args.get("dry_run", dry_run_default)
if dry_run is True:
return Decision(True, "write tools may preview when dry_run is true")
if human_token_matches():
os.environ.pop("HUMAN_APPROVAL_TOKEN", None)
os.environ.pop("EXPECTED_APPROVAL_TOKEN", None)
return Decision(True, "write apply permitted after one-time token match")
return Decision(False, "refusing write apply without a human approval token")
def guarded_execute(call: ToolCall, execute: Callable[[ToolCall], Any]) -> Any:
decision = gate(call)
if not decision.allow:
raise PermissionError(decision.reason)
return execute(call)
You can feel the difference in a three-line rehearsal. First the agent proposes SQL with dry_run left true, and the gate lets a preview printer run. Then it retries with dry_run=false and no token, and the executor raises before the database client is even constructed. Only after you paste the challenge token into the environment does the same function call reach execute. That sequence is the seatbelt click the Slack plan never was.
# test_tool_gate.py — run with: python -m pytest test_tool_gate.py -q
import os
import pytest
from tool_gate import ToolCall, gate, guarded_execute, issue_approval_challenge
def test_write_without_token_is_blocked():
call = ToolCall("run_sql", {"sql": "ALTER TABLE t RENAME COLUMN a TO b", "dry_run": False}, "s1")
decision = gate(call)
assert decision.allow is False
def test_dry_run_write_is_allowed():
call = ToolCall("run_sql", {"sql": "ALTER TABLE t RENAME COLUMN a TO b", "dry_run": True}, "s1")
assert gate(call).allow is True
def test_token_is_single_use(monkeypatch):
call = ToolCall("apply_migration", {"path": "003.sql", "dry_run": False}, "s1")
token = issue_approval_challenge()
monkeypatch.setenv("HUMAN_APPROVAL_TOKEN", token)
guarded_execute(call, lambda c: "applied")
monkeypatch.setenv("HUMAN_APPROVAL_TOKEN", token)
with pytest.raises(PermissionError):
guarded_execute(call, lambda c: "applied")
Run the tests from a clean directory that contains both files. If the third test ever starts passing without popping the token, you have reintroduced the sticky AUTO_APPROVE bug under a fancier name. Keep the side-effect set in code, not in a prompt appendix, so adding git_push later is a reviewable diff instead of a hope. If a new tool lands without a classification, default it to write until someone argues otherwise in a pull request.
python -m pytest test_tool_gate.py -q
# expected: three passed
While you are there, split the ticket shape that started this story. A lint fix should not be allowed to mention schema in the same user message unless a second, empty approval challenge has already been issued. That is not clever alignment. It is the same instinct that keeps "refactor" and "migrate production" on two different change windows. Your agent can still be fast on the first window. It should be still, almost rude, on the second.
Rehearse the lock on a box you can burn
After the gate exists, you still need a place to let the agent flail without teaching it that staging is a notepad. Clone a sanitized schema into a disposable database, point the tool adapter at that DSN only, and keep the real staging credentials out of the session environment. Drive the same failing ticket through the loop until the preview path is boring and the apply path always stops for a token. Boring is the goal. Drama belongs in the postmortem, not in the nightly extractor.
If you lack a spare machine, a free server option is enough to host that disposable adapter and nothing else. Do not put production secrets on it, and do not treat it as a long-lived environment with a reputation to protect. Pair it with free model access so the rehearsal can run long enough to catch the sticky-flag mistake without turning into a billing incident of its own. When the gate and the tests are green, you graduate the policy to the runtime that can actually see staging, still without auto-approve.
Who should not use this as-is
Skip this pattern if your writes are already behind a change-management system that issues one-time tickets with an audit trail you actually read. The snippet above does not replace IAM, database roles, network policy, or a migration service that records who applied what. It also will not help if your "agent" is allowed to open a raw shell, because a shell is a tool that can impersonate every other tool. In that case you need to take the shell away, not classify it.
Do not use a free rehearsal box for customer data, production credentials, or anything your threat model treats as a crown jewel. A disposable server is disposable twice: you can burn it after a good rehearsal, and an attacker can burn it if you left the wrong secret there. The gate also assumes a single operator pasting a token. If several people share one agent session, you need a named approver, not a shared environment variable that anyone in the channel can copy.
Finally, do not wait for a trending glossary of agent terms to tell you whether your loop is "agentic enough." The incident above would have looked the same if you described the runtime as a script with retries. Side-effecting tools do not become safer because the marketing noun got longer. If a call can rename a column that another job still reads, you already have enough architecture to require a human checkpoint.
The next time a plan looks reasonable in Slack, let the preview run and keep the apply dark until a token exists for that call alone. You will spend a few extra seconds on changes that deserved those seconds anyway. The extractor will keep finding the column it knew yesterday, and you will have turned a cheap loop back into a simulator that cannot move the real rudder. If you want a throwaway machine for that rehearsal, MonkeyCode's free server option is one place to run the same gate before a shared database ever sees the agent's confidence.
Top comments (0)