Deny by Default: Permissions for Agents That Act
A chatbot that says something wrong is embarrassing. An agent that sends something wrong is a fact about the world. The moment an agent can send, post, create, or delete, it stops being a text generator and becomes a principal in your system — with credentials and reach, taking actions other people receive.
The instinct at that point is to write a better system prompt. Tell the model to be careful. Tell it to confirm before sending. This doesn't work, and it doesn't fail in an exotic way — it fails because a system prompt is a suggestion addressed to the thing you're trying to constrain.
Permissions for agents are an ordinary authorization problem. Same shape as any other: a caller, a resource, an action, a decision. But the caller has two properties you don't usually design for. It is non-deterministic — the same input can produce a different tool call tomorrow. And it is persuadable — the content it reads can change what it decides to do. A retrieved document, a message body, a web page: all of it is input, and any of it can argue.
So you design accordingly. The rest of this is where the gate goes, and how it fails.
One gate, and the executor is dumb
The tool gate is the sole approval authority. Not the primary one — the sole one. The sandbox where the agent's code actually runs is a pure executor, and the design intent is written down in exactly those terms:
The box is a PURE EXECUTOR: every piece of tool METADATA (catalog, enabled bindings, allowed accounts) is resolved run-side. The box NEVER calls the gate to discover tools — it has no whole-catalog access and cannot list. Its tools are therefore fixed at creation/dispatch time.
The load-bearing word is discover. An executor that can enumerate its own capabilities can be talked into enumerating more of them — a persuadable caller plus a listable catalog is a negotiation, and you've handed the model the agenda. Fixing the toolset at dispatch and denying the box any catalog access means the blast radius is decided before the model gets a turn. Whatever the run argues for later, it argues from a fixed hand.
The second half of that principle matters just as much: two components both making authorization decisions is not defense in depth. It's two sources of truth, and one of them will be stale. Depth is layers that narrow — a gate, then a scope, then an audit. Two peers independently answering "is this allowed?" isn't depth; it's a race where the wrong answer only has to win once.
Fail closed, at the bottom of the function
Every permission resolver is a cascade. Check the user's setting. Check the
binding. Check the catalog. Check the tier above. And then, at the bottom of the
function, after every rule has declined to match, there is a return — and that
return is your actual security posture. Everything above it is decoration.
Ours ends with an explicit default: manual approval required. Not "allow, because we didn't find a rule against it." Require a human, because we didn't find a rule.
# Nothing matched: no user override, no binding rule, no catalog entry.
# This is not "we found nothing to object to" — it is "we know nothing."
# An unknown action is exactly the case where a human should look.
return GuardrailDecision(
requires_approval=True,
tier="default",
reason="no matching rule; unknown actions require manual approval",
)
The failure mode this prevents is quiet. A new toolkit gets added, nobody writes a rule, and a permissive default means it's live and unsupervised on day one — not because anyone decided that, but because a fallthrough decided it. Write the default branch first, and the rest of the cascade can only ever loosen from a safe floor.
Allowlist reads, gate everything else
The tempting move is a blocklist: enumerate the dangerous actions and gate those. This is an infinite, ever-growing set, maintained by hand, always one integration behind. You lose that race permanently on the first day you run it.
So we inverted it. The platform seeds an explicit auto-allowlist of 2,298 read actions across 86 toolkits, derived mechanically from the integration catalog's own schemas. Anything not on the list is manual by default. The list grows when the catalog grows, by the same mechanism, with no committee.
Take a mail toolkit as the concrete case. Exactly 27 actions are auto-allowed — every one of them a read. Fetch a message. List drafts. Read a profile. List history. Zero send actions. Zero create, reply, or delete. An agent that decides to send mail surfaces an approval, always, unless the user has explicitly chosen otherwise for themselves.
| Action class | Default | Why |
|---|---|---|
| Read / list / fetch | Auto-allowed | Recoverable. Re-running it just repeats the request. |
| Send / post / reply | Manual approval | Lands in someone else's inbox. Not undoable. |
| Create / update | Manual approval | Changes state others depend on. |
| Delete | Manual approval | The one with no inverse at all. |
| Anything unrecognized | Manual approval | We don't know what it is. That's the point. |
The rule underneath the table is the useful part: reads are recoverable, writes are not. That asymmetry — not a threat model, not a severity score — is the cleanest line available, and the only one we've found that a machine can draw from a schema instead of a human drawing it from judgment in a meeting. Severity scores need someone to assign them and someone else to disagree. "Does this mutate anything?" is a property of the API.
Defaults are written for onboarding, not for you. Wiring up a chat platform's bot, we hand-assembled the permission integer bit by bit rather than taking the integration's suggested default — because the convenient default granted full administrator rights. Least privilege is work. The default is someone else's optimization, and their objective was time-to-first-success, not your blast radius.
Permissions are personal
Guardrails resolve from the invoking user's own settings plus the catalog. Nothing another person can edit changes your approval behavior.
This sounds like a small storage decision. It's actually the whole multi-tenant argument. A shared "this agent is trusted" flag means one teammate's convenience silently becomes everyone's standing exposure — including people who never saw the toggle and have no reason to suspect their approvals stopped firing. "I trust this agent" is a statement about you. It has to be stored against you, or it isn't the statement you thought you were making.
Configuration history is append-only. A change soft-deletes the old row and inserts a new version, so every past configuration stays queryable — you can ask what the rules were at any moment, not just what they are now. And the gate logs each decision with the tier that won it. That detail earns its keep: "the agent was allowed" is not an answer to any question worth asking. "The agent was allowed by this rule, set by this person, at this time" is.
Approval state must not lie to the retry layer
Here's the one that took real thought.
Tool calls carry idempotency keys, so retries are safe. Standard. But an approval gate returns HTTP 200 with no error — and it is not an executed call:
An
approval_requiredGATE response is HTTP 200 with noerrorkey but is NOT an executed call — the tool was suspended for the user's decision and never ran. Recording it would dedup the REAL execution on the resume re-invoke (the box presents the SAME idempotency key after approval), so the approved tool would return the "already ran" duplicate guidance with no result.
Read that failure through: a user approves an action, the resume arrives with the same idempotency key, the store says "seen it," and the tool the human explicitly approved never runs — while everything reports success. The gate would have converted an approval into a silent no-op.
So the idempotency store records only completed calls. A gate is a pause, not
an outcome. Approval ids are deterministic — {run_id}:{tool_call_id} — so the
same decision resolves the same call across a resume. The general lesson: any
"we've seen this" cache needs a crisp definition of seen, and "returned 200" is
not it. Suspension isn't completion.
Write your errors for the model, because the model is the client
When a genuine duplicate is detected, the response is HTTP 200 carrying prose. Not a 409. Not an error code. And the reason is precise: a raw error would read as a transient failure the model might blindly retry.
DUPLICATE TOOL CALL — this exact call already completed on an earlier attempt;
your previous response was most likely lost in transit. The call ran exactly
once and was NOT lost. If this tool WRITES or takes an action (send, create,
update, delete, post, pay, …) it is already done — do NOT call it again. If it
is a READ / query and you still need the data, issue a NEW call to fetch it
again...
This generalizes further than it looks. When your caller is a language model, your API's error strings are prompt engineering. A 409 is a token sequence whose training-data association is transient conflict, back off, try again — so it teaches the model to retry the send. A sentence teaches it what happened. Status codes were designed for clients that parse; you now have a client that reads.
What we don't have
Approval is binary per action: auto or manual. There's no "draft for approval" tier, no read/draft/send gradient. That's a real limit, and it's worth defending honestly rather than pretending it's finished design: a binary gate is legible and enforceable — you can describe it in one sentence and verify it in one query. A gradient is more expressive and much easier to get subtly wrong. A permission system that's subtly wrong is worse than one that's coarsely right, because you'll believe it.
Approval scopes are defined for both a single call and a whole session. Only the single-call scope is wired today.
And the one nobody wants to say out loud: an approval gate's real enemy isn't bypass, it's fatigue. A gate that fires on everything trains people to click approve without reading — worse than no gate at all, because it manufactures the appearance of oversight. You get the audit trail, the sign-off, the timestamp, and none of the thinking. That is the actual argument for allowlisting reads. Not convenience. Preserving the meaning of the clicks that remain.
The boundary lives outside the loop
An agent that can act needs a boundary that exists outside its own reasoning. Not a stronger instruction, not a more emphatic prompt, not a self-check the model performs on itself — those all live inside the thing you're trying to constrain, and a persuadable caller auditing its own persuasion is not a control.
The model can be talked into things. That's not a defect; it's most of what makes it useful. A deny-by-default gate that fails closed at the bottom of the function, allowlists only what's recoverable, resolves against the person who actually asked, and logs who allowed what and when — that cannot be talked into anything. It doesn't listen. That's the feature.
Written by CatalEx Engineering. We build the AI operating layer for AI-native companies — one platform to build, deploy, and run AI agents in production. More at catalex.co.