How to Stop an Autonomous Agent From Doing Something Destructive
Most agent frameworks give you a binary choice by default: the agent runs a tool autonomously, or every single tool call stops for human approval. Neither is right. The first risks something irreversible happening unsupervised; the second makes the agent so slow to use that people start rubber-stamping approvals without reading them — which is worse than no approval step at all, because it creates the appearance of a safeguard that isn’t actually catching anything.
Classify actions before you write any agent code
The fix is a three-tier classification of every action the agent can take, decided up front — not improvised the first time the agent is about to do something scary:
from enum import Enum
class ActionTier(Enum):
AUTONOMOUS = "autonomous" # reversible, low-consequence — just do it
CONFIRM = "confirm" # real consequence, but recoverable — ask first
BLOCKED = "blocked" # irreversible or high-consequence — never autonomous
ACTION_TIERS = {
"read_file": ActionTier.AUTONOMOUS,
"search_web": ActionTier.AUTONOMOUS,
"send_email": ActionTier.CONFIRM,
"delete_file": ActionTier.CONFIRM,
"transfer_funds": ActionTier.BLOCKED,
"delete_database": ActionTier.BLOCKED,
}
The classification key is reversibility, not perceived risk. Sending an email feels low-stakes but is irreversible the moment it’s sent, so it belongs at CONFIRM at minimum. Deleting a file to a recoverable trash/versioned-backup location is more reversible than it looks, so it can sometimes live at AUTONOMOUS if your system actually has that safety net — the tier should track what actually happens on failure, not the action’s name.
Enforce it in the tool-calling loop, not in the prompt
Telling the model “ask before doing dangerous things” in the system prompt is a suggestion the model can and will forget under context pressure. The enforcement has to live in the code that executes tool calls, where it can’t be talked around:
def execute_tool(name: str, arguments: dict, get_human_approval) -> str:
tier = ACTION_TIERS.get(name, ActionTier.CONFIRM) # unknown actions default safe
if tier is ActionTier.BLOCKED:
return f"Action '{name}' is blocked and cannot be executed autonomously."
if tier is ActionTier.CONFIRM:
if not get_human_approval(name, arguments):
return f"Action '{name}' was not approved. Continuing without it."
return TOOL_IMPLEMENTATIONS[name](**arguments)
Two details matter here. First, unknown actions default to CONFIRM, not
AUTONOMOUS — a new tool added later without an explicit classification should never
silently inherit the least restrictive tier. Second, the model never sees or controls
the tier lookup; it can request transfer_funds all it wants, and the answer is
structurally “no,” not “the model chose to ask.”
Batch confirmations, don’t drown people in them
A CONFIRM tier that fires on every single email or file deletion in a long-running agent trains people to click “approve” without reading — the exact failure mode this pattern exists to prevent. Where the actions are independent and reviewable together, batch them into one checkpoint instead of many:
pending = collect_confirm_tier_actions(planned_actions)
if pending:
approved = get_human_approval_batch(pending) # one review, one decision point
This mirrors how a well-run engineering team actually works: low-stakes changes ship without a meeting, consequential-but-recoverable changes get a single review pass before a batch goes out, and truly irreversible actions require an explicit, separate, deliberate decision — not a checkbox buried in a list of forty things.
The rule that generalizes
Classify by reversibility, not by how dangerous an action sounds; enforce the classification in code the model doesn’t control, not in prompt instructions it can forget; and batch confirmations so the human reviewing them is actually paying attention. An agent that asks permission for everything and an agent that asks permission for nothing fail the same way — by making the approval step meaningless.