Permission Systems for Agent Tools
A 'confirm before deleting' prompt let an agent wipe 200,000 rows. Build an agent tool permissions layer that enforces rules in code, not politeness.
class Decision:
ALLOW = "allow"
DENY = "deny"
ASK = "ask" # escalate to a human
def check_permission(tool_name, args, context) -> str:
rule = PERMISSION_RULES.get(tool_name)
if rule is None:
return Decision.DENY # default-deny unknown tools
return rule(args, context)A team I know shipped an agent with a
delete_recordsThe failure above is not a model failure. The model did something plausible. The failure was architectural: a destructive capability with no boundary around it except a politely-worded suggestion. Permissions are how you turn suggestions into guarantees.
What Are Agent Tool Permissions?
Agent tool permissions are rules, enforced in code, that decide whether a given tool call is allowed to run before it runs — based on the tool, its arguments, and the context of the run. They sit between the model deciding to call a tool and the tool actually executing. The model proposes; the permission layer disposes.
The key distinction is where the rule lives. A system-prompt instruction lives inside the model's context, where any injection or unlucky rephrasing can override it. A permission check lives in your harness, where the model has no say. One is advisory. The other is a control. Real safety comes from the second kind.
Here's the shape of a minimal permission layer:
[object Object], ,[object Object],:
ALLOW = ,[object Object],
DENY = ,[object Object],
ASK = ,[object Object], ,[object Object],
,[object Object], ,[object Object],(,[object Object],) -> ,[object Object],:
rule = PERMISSION_RULES.get(tool_name)
,[object Object], rule ,[object Object], ,[object Object],:
,[object Object], Decision.DENY ,[object Object],
,[object Object], rule(args, context)What this does: Looks up a rule for the requested tool and returns allow, deny, or ask. The critical line is the default: an unregistered tool is denied, not allowed. New tools are safe until you explicitly decide how they should behave, which is the opposite of how most harnesses start.
Why It Matters
Without a permission layer, every tool operates at the same trust level — full. Your
read_weatherdelete_recordsThe cost shows up as blast radius. When something goes wrong — an injection, a hallucinated argument, a misread instruction — the damage is bounded only by what your most dangerous tool can do with no checks. A permission system lets you make the read-only tools frictionless and the destructive ones gated, so the common case is fast and the dangerous case is controlled.
⚠️ Common mistake: Treating all tools as equally risky and slapping a confirmation prompt on every one. Users learn to click "yes" reflexively within a day, and your gate becomes a rubber stamp. Reserve friction for genuinely destructive or irreversible actions; let safe reads flow freely. A gate that fires constantly is a gate everyone ignores.
Designing Permission Rules by Risk Tier
The approach that scales is sorting tools into tiers by what they can do, then attaching a default policy to each tier.
Read-only tools that touch nothing sensitive — search, weather, public lookups — are tier zero: always allow, no logging beyond the audit trail. Tools that write or spend money but are reversible — creating a draft, adding a calendar event — are tier one: allow, but always log and rate-limit. Tools that are destructive or irreversible — deleting data, sending money, emailing customers — are tier two: require explicit human approval, every time, no exceptions the model can talk its way around.
PERMISSION_RULES = {
,[object Object],: ,[object Object], a, c: Decision.ALLOW,
,[object Object],: ,[object Object], a, c: Decision.ALLOW,
,[object Object],: ,[object Object], a, c: Decision.ASK,
,[object Object],: ,[object Object], a, c: (
Decision.ASK ,[object Object], a.get(,[object Object],, ,[object Object],) > ,[object Object], ,[object Object], Decision.ALLOW
),
}What this does: Maps each tool to a rule. Searches run freely; sending email always asks a human; deleting runs unattended only for small counts and escalates for large ones. The rules can inspect the actual arguments, so "delete 3 rows" and "delete 200,000 rows" get different treatment. That argument-awareness is what would have caught the incident from the intro.
⚡ Pro tip: Make the number the permission boundary, not just the action. "Delete a record" and "delete a hundred thousand records" are different risk classes even though they call the same tool. Rules that read the arguments — thresholds on count, amount, recipient list — catch the runaway cases that a per-tool allow/deny would wave right through.
Making Permissions Contextual
Static per-tool rules are a strong start, but the real wins come from context. The same tool call can be safe or dangerous depending on who triggered the run, what's happened so far, and how much the agent has already done.
[object Object], ,[object Object],(,[object Object],):
,[object Object], context[,[object Object],] == ,[object Object],:
,[object Object], Decision.DENY ,[object Object],
,[object Object], context[,[object Object],] >= ,[object Object],:
,[object Object], Decision.ASK ,[object Object],
,[object Object], args.get(,[object Object],, ,[object Object],) > ,[object Object],:
,[object Object], Decision.ASK
,[object Object], Decision.ALLOWWhat this does: Denies deletes entirely for unattended scheduled runs, escalates when a single run has already deleted several times (a sign something's looping), and escalates on large counts. The same tool behaves differently based on the run's history and origin. Context turns a blunt rule into a sharp one.
This is where agent tool permissions stop being a simple allowlist and become a genuine policy engine. The context object — actor, running totals, time of day, prior denials — is what lets you express rules like "an agent triggered by an anonymous web user can read but never write" without writing a different tool for every caller.
⚡ Pro tip: Track running totals in the context and use them as tripwires. "This run has already spent $40" or "this run has sent 5 emails" are exactly the conditions that precede a runaway. An agent that's allowed one email but tries for fifty should hit a wall on the second, not the fiftieth.
Three Places This Earns Its Keep
A healthcare-scheduling startup gives its agent free rein to read appointment availability but routes every actual booking or cancellation through a permission check that verifies the request came from the authenticated patient, not from text in a message the agent was summarizing. The read/write split maps cleanly onto tiers.
A devops team lets its incident-response agent restart services and scale deployments automatically in staging, but the identical tools require a human approver in production — same tools, different context, enforced by a one-line environment check in the rule. Nobody maintains two codebases.
A marketing agency's content agent can draft and schedule social posts freely, but publishing to a client's live account is tier two and always asks, because an errant post on a client account is the kind of mistake that ends a contract. The gate is rare enough that approvers still take it seriously.
Testing That the Rules Do What You Think
Permission rules are code, which means they have bugs, and a buggy permission rule fails in the worst possible direction — it allows something it should have denied. The rules that matter most are the ones that almost never fire, which is exactly why they rot untested. Write assertions for them the same way you'd test any other critical branch.
[object Object], ,[object Object],():
ctx = {,[object Object],: ,[object Object],, ,[object Object],: ,[object Object],}
,[object Object], delete_rule({,[object Object],: ,[object Object],}, ctx) == Decision.DENY ,[object Object],
ctx = {,[object Object],: ,[object Object],, ,[object Object],: ,[object Object],}
,[object Object], delete_rule({,[object Object],: ,[object Object],}, ctx) == Decision.ASK
,[object Object], delete_rule({,[object Object],: ,[object Object],}, ctx) == Decision.ALLOWWhat this does: Pins down the exact behavior of the delete rule across the cases that matter — an automated actor, a huge count, a small count. If someone later "simplifies" the rule and breaks the scheduled-job denial, this test goes red immediately. The 200,000-row incident from the intro is precisely the case that a test like this would have caught before it ever ran.
⚡ Pro tip: Write a test for every
ASKDENYBeyond unit tests, log every permission decision with the tool, arguments, decision, and rule that produced it. That audit trail answers two questions you'll eventually need: after an incident, "why was this allowed?" and, during an attack, "what is this agent trying to do that keeps getting denied?" The denials are often the more interesting signal — a sudden run of them is a probe in progress.
Common Mistakes
The mistake that undoes everything is enforcing permissions in the prompt instead of the harness. If your rule is a sentence in the system prompt, it isn't a permission — it's a wish. Enforce in code, between the model's decision and the tool's execution, where the model cannot reach.
The second is having no default-deny. When an unregistered tool defaults to "allow," every new tool someone adds is a hole until they remember to write a rule. Default to deny and the failure mode flips from "silently dangerous" to "loudly broken in testing," which is the failure mode you want.
The third is not logging denials. Every allow, deny, and ask should be recorded with its arguments and the rule that fired. That log is your evidence when something goes wrong and your early warning when someone's probing — the denials are as valuable as the allows.
Conclusion
A permission system is what separates an agent you can leave running from one you have to watch. Sort tools by risk, default to deny, read the arguments, factor in context, and enforce all of it in the harness rather than the prompt. The incident from the intro — 200,000 rows gone on a "confirmed" delete — is a permission bug, not a model bug, and permission bugs are the ones you can actually fix.
As your rules mature, they become as much a part of your agent's identity as its prompts. Keeping the two together in a shared library like PromptABCD means the permission policy that took an incident to learn ships with the tools it governs, instead of being reinvented — slightly wrong — in the next service someone builds.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
