Your agent has a deleteRecord tool. Your OAuth token says it's allowed to call the records API. So when the model decides, halfway through a reasonable looking chain of thought, that cleaning up duplicates means deleting forty rows, nothing in your stack objects. Your token was valid. The tool existed. Off it went.
That's the gap this post is about, and the fix is smaller than you'd think: a gatekeeper that sits between the model's request and the actual side effect, and answers a question OAuth never asks.
Why OAuth scope is not enough for agentic tool safety
Scopes are fine at what they do. A scope answers "is this app allowed to touch the calendar API at all", once, at grant time, for the whole session. What it can't answer is "should this specific call, with these specific arguments, in this specific conversation, run right now". Scopes are static and coarse. AI agent tool calling is dynamic and argument dependent, which is a completely different shape of problem.
The failure data backs this up. Across a survey of 27 benchmark papers and 19 benchmarks, tool invocation and parameter level errors came out as the single biggest failure cluster in LLM agents. Not reasoning. Not retrieval. The call itself, and what got passed into it.
Meanwhile the surface area keeps growing. Autonomous agents now outnumber humans in enterprise environments at a ratio of 82 to 1, and only 22 percent of organizations treat AI agents as identity bearing entities with formal access controls. So most of these callers have no identity, no policy attached to them, and full use of whatever credential the process happens to hold.
What's missing is contextual authorization: a decision made per call, with the arguments in hand, that knows who is acting and what the blast radius is. That's the layer we're building.
The three tier approval model
The pattern that holds up in production is a three tier model. Auto approve for reads and searches. Notify for actions that land somewhere visible but stay recoverable. Block or require human approval for anything irreversible or high stakes.
Here's how that splits for a normal SaaS backend:
| Tool | Tier | Why |
|---|---|---|
searchKnowledgeBase |
auto approve | Read only, no side effect, cheap to get wrong |
readDocument |
auto approve | Same, though watch what the doc contains |
sendEmail |
notify | Visible to a human, embarrassing, survivable |
postToSlack |
notify | Same shape, plus a delete button exists |
createRecord |
notify | Additive, easy to reverse |
deleteRecord |
block | Data loss, and nobody notices until later |
issueRefund |
block | Real money leaves the building |
deployToProduction |
block | You know why |
Three questions decide where a tool lands. Can you undo it? How far does the damage travel? And would a human notice in time to react?
Sending a wrong email fails all three gracefully. It's reversible enough (you send a correction), the blast radius is one inbox, and somebody replies within the hour. Deleting a record fails all three badly. It's gone, it quietly breaks whatever referenced it, and you find out during a quarterly report.
Notice that createRecord and deleteRecord sit in different tiers despite hitting the same table. That's the whole point. Tier is about consequence, not about which service you're talking to.
Building a TypeScript gatekeeper middleware
Three pieces: a policy table keyed by tool name, a decision function, and a wrapper that every call has to pass through.
Start with the types and the table.
export type Tier = "allow" | "notify" | "deny";
export interface CallerContext {
actorId: string;
actorRole: "owner" | "admin" | "member" | "readonly";
conversationId: string;
}
export interface Decision {
tier: Tier;
reason: string;
}
type Rule = (args: Record<string, unknown>, ctx: CallerContext) => Decision;
const POLICY: Record<string, Rule> = {
searchKnowledgeBase: () => ({ tier: "allow", reason: "read only" }),
sendEmail: (args, ctx) => {
const to = String(args.to ?? "");
if (!to.endsWith("@yourcompany.com")) {
return { tier: "notify", reason: "external recipient" };
}
if (ctx.actorRole === "readonly") {
return { tier: "deny", reason: "readonly actor cannot send" };
}
return { tier: "allow", reason: "internal recipient" };
},
deleteRecord: (args, ctx) => {
const ids = Array.isArray(args.ids) ? args.ids : [args.id];
if (ctx.actorRole !== "owner") {
return { tier: "deny", reason: "owner only" };
}
if (ids.length > 1) {
return { tier: "deny", reason: `bulk delete of ${ids.length} rows` };
}
return { tier: "notify", reason: "single row delete by owner" };
},
};
export function decide(
tool: string,
args: Record<string, unknown>,
ctx: CallerContext,
): Decision {
const rule = POLICY[tool];
if (!rule) return { tier: "deny", reason: "tool not in allowlist" };
return rule(args, ctx);
}
Two things worth calling out. The default is deny, so an unknown tool fails closed instead of sliding through. And every rule gets the arguments, not just the tool name, because sendEmail to a colleague and sendEmail to your entire customer list are the same tool and very different events.
That argument inspection is where most of the value lives. Agent RBAC in TypeScript falls out of the same place: CallerContext carries the acting identity down to the rule, so a member and an owner asking for the same delete get different answers.
Now the wrapper. Nothing calls a tool directly anymore.
type ToolFn = (args: Record<string, unknown>) => Promise<unknown>;
export function gatekeep(tool: string, fn: ToolFn, ctx: CallerContext): ToolFn {
return async (args) => {
const decision = decide(tool, args, ctx);
await auditLog({ tool, args, ctx, decision });
if (decision.tier === "deny") {
throw new ToolDeniedError(`${tool} blocked: ${decision.reason}`);
}
if (decision.tier === "notify") {
await notifyHumans({ tool, args, ctx, decision });
}
return fn(args);
};
}
When the gatekeeper denies a call, throw an error the model can read. Agents recover surprisingly well from "you may not delete more than one row at a time" and will usually retry with something narrower. A silent null just makes it try again identically.
Where to put the policy: execution layer, not model layer
Here's the part teams get wrong, and it's the difference between a control and a suggestion.
Policy enforcement has to live at the tool execution layer, not inside the agent's reasoning loop. Writing "never delete more than one record" in your system prompt isn't enforcement. It's a request. The model is a suggestion engine, it can be argued out of a rule by a user or by its own chain of thought, and prompt injected content in a retrieved document can push it around too. If you want the same reasoning on how that attack surface works, I wrote about AI agent security patterns separately.
Enforcement is code that runs after the model has decided and before the side effect happens. That's it. If your rule can be talked around in English, it isn't a rule.
MCP tool access control gets easy here, because an MCP server is already the chokepoint. Every tool call funnels through one request handler, so you have exactly one place to wrap.
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
const ctx = contextFromSession(extra.sessionId);
const { name, arguments: args = {} } = req.params;
const decision = decide(name, args, ctx);
await auditLog({ tool: name, args, ctx, decision });
if (decision.tier === "deny") {
return {
isError: true,
content: [{ type: "text", text: `Blocked by policy: ${decision.reason}` }],
};
}
if (decision.tier === "notify") await notifyHumans({ tool: name, args, ctx, decision });
return runTool(name, args);
});
One handler, every tool covered, no way for a new tool to ship without inheriting the policy.
Production patterns: logging, alerting, rollback
Log every decision, including the ones you allowed. Tool name, arguments, tier, reason, actor. That log is the only way you'll ever tune the policy, because on day one you're guessing about tiers and by week three the log tells you which guesses were wrong.
async function auditLog(entry: {
tool: string;
args: Record<string, unknown>;
ctx: CallerContext;
decision: Decision;
}) {
await db.insert("agent_tool_audit", {
tool: entry.tool,
args: redactSecrets(entry.args),
tier: entry.decision.tier,
reason: entry.decision.reason,
actor_id: entry.ctx.actorId,
conversation_id: entry.ctx.conversationId,
at: new Date(),
});
}
Alert on deny spikes rather than on individual denies. A steady trickle is the policy working. A sudden cluster means either a tier is too tight and you're breaking a real workflow, or something is probing you.
Guard the notify tier from becoming wallpaper. If every notification is routine, people stop reading them, and then notify is just allow with extra steps. Keep the tier small enough that a message in it still means something, and route it somewhere with a response expectation.
For rollback, bias the whole system toward reversible writes. Soft deletes, append only ledgers, staged changes that need a second call to commit. When tier two mistakes are cheap to undo, you can afford a broader tier two, and the agent gets more useful without getting more dangerous.
OWASP's Agentic AI Top 10 names Excessive Agency as a critical vulnerability with three root causes: excessive functionality, excessive permissions, and excessive autonomy. Each one maps to a cut you can make today. Excessive functionality means trimming the tool allowlist, because most agents ship with tools nobody uses. Excessive permissions means giving the agent its own scoped credential instead of the service account that can do everything. Excessive autonomy is the tier three list, and it should feel slightly too long rather than slightly too short.
Once that's running, the audit log doubles as your eval set. Replaying real denied and allowed calls is a much better signal than synthetic cases, which is worth pairing with a proper approach to evaluating agent tool calls.
Three things to verify right now
- Call a tool that isn't in your policy table and confirm you get a deny, not an execution. Fail closed or you have nothing.
- Send the same tool two argument sets, one benign and one destructive, and confirm they get different tiers.
- Grep your system prompt for the word "never". Every rule you find there needs a matching rule in code.
FAQ
What is excessive agency in AI agents?
OWASP's Agentic AI Top 10 lists it as a critical vulnerability, and it shows up when an agent can do more than its job requires. Three root causes: excessive functionality (tools it never needed), excessive permissions (a credential broader than its task), and excessive autonomy (no approval step on actions you can't undo). Cut any one and the damage from a bad tool call drops.
How do you control which tools an AI agent can use?
Put a gatekeeper between the model and the tool runtime. It looks up the tool in a policy table, inspects the actual arguments plus the acting identity, and returns allow, notify, or deny. Unknown tools deny by default. Because it runs at the execution layer, the model can't reason its way past it the way it can with a system prompt instruction.
What is the difference between OAuth scope and tool level authorization?
Scope is granted once and answers whether an app may touch an API at all. Tool level authorization runs on every single call and answers whether this call, with these arguments, from this actor, should proceed. You need both. Scope stops the wrong system connecting, and the gatekeeper stops the right system doing the wrong thing with a perfectly valid token.
If you want a deeper look at securing agent tool calls, I cover it in more detail on my site.
If you want this wired up on your own stack end to end, that is exactly the kind of work I take on.
Drop a comment if your setup looks different. Curious what tier boundaries people are actually running in production.
Top comments (0)