Note: the scenarios in this post are hypothetical — assembled from common policy-design patterns, not accounts of any real company.
Most teams start agent governance with a denylist. It's the intuitive move: sit down, list the things the agent must never do — don't delete records, don't send email, don't touch billing, don't message customers directly — and wire those into a blocklist. It feels thorough. The list has fourteen items and everyone nods. Ship it.
Here's how that list dies. Six weeks later, the support agent discovers the reporting tool. Nobody put the reporting tool on the denylist, because six weeks ago nobody knew the agent had access to it — the tool was added to the shared toolkit in a sprint nobody flagged. The agent, trying to be helpful with a customer's "send me my data" request, exports the customer's full profile to CSV and delivers it through the notify_with_attachment action. The denylist banned send_email. It did not ban notify_with_attachment. The customer's data left through a door the list didn't name, because the list can only name doors the team already knows about.
That's the whole problem in one incident. A denylist is a bet that you can enumerate every dangerous thing an agent might do, today and after every future tool addition, rename, and "harmless" new integration. You can't. The tool surface changes every sprint. Actions get renamed (close_ticket becomes resolve_ticket — we lived this one). New tools arrive with capabilities nobody reviewed. And agents are creative: two allowed actions composed in sequence can do what one banned action couldn't. The denylist is a suggestion. The agent, politely, declines to follow it.
The enumeration problem, stated plainly
Think of it as a counting problem. Your agent has N tools today, each with M parameters. The denylist has to cover every dangerous combination — and "dangerous" changes with context. read_record is harmless until the record contains payment details. export_csv is harmless until the CSV contains a thousand customers. The dangerous set isn't a list; it's a moving frontier, and it moves every time someone ships a tool.
Worse, denylists fail open. Anything not on the list is permitted. Every gap — every tool you forgot, every rename you missed, every parameter you didn't constrain — is an open door, and it stays open until someone notices. You are betting your security on the completeness of a list that a human maintains by hand. Humans are bad at completeness. That's not an insult; it's the reason checklists exist, and even checklists only work for things you remembered to put on the checklist.
The flip is to stop enumerating the forbidden and start enumerating the permitted. Allowlist the actions the agent needs (we wrote the whole migration diary here), park the risky ones behind approvals, and deny everything else by default. Now the failure mode inverts: a new tool the agent legitimately needs gets denied until someone reviews it. That's an inconvenience — a ticket, a review, a policy update. A denylist's failure mode is a breach. I'll take the inconvenience.
Fail-closed: the proxy decides, every time
The allowlist is the policy. The proxy is what makes it real. Here's the difference that matters: the policy has to be evaluated on every action, before the action runs, by something the agent can't bypass. If the agent holds the real API keys, it can skip your policy entirely — which is why per-agent credentials matter (the proxy holds the real keys; the agent only holds a credential to ask). The request lifecycle looks like this:
Agent presents its credential and the action it wants to take. The proxy identifies the agent, pulls its policy, and runs the decision: is this action on the allowlist? Is it approval-required? Does the estimated cost fit the remaining budget? Has the agent been killed? Each check returns a verdict, and the proxy acts on the strictest one. Allow, deny, or require approval — decided in milliseconds, before anything touches a real API.
And the "fail-closed" part: when the proxy can't decide — policy store unreachable, credential unrecognized, evaluation error — the answer is deny. Not "allow and log." Not "retry the check." Deny. A gateway that fails open is a gateway that disappears exactly when things are going wrong, which is the only time you needed it. This is the single most important property of the whole system, and it's a one-line design decision: default to no.
You can watch this decision engine run in our policy playground — paste an action log or pick a preset like the runaway pay loop, and see allow, deny, and require_approval verdicts with reasons. Same engine the production proxy runs.
What the agent sees when it hits the wall
This part gets less attention than it deserves, because it's about the agent's experience, and nobody thinks of agents as having an experience. But what the agent receives on a deny determines what happens next, and "what happens next" is the difference between a contained incident and a weird one.
A good deny is machine-readable and specific:
{
"verdict": "deny",
"reason": "action_not_allowlisted",
"action": "reporting.export_csv",
"agent": "support-bot",
"hint": "request approval or contact your policy owner"
}
A well-built agent reads this, understands the action isn't permitted, and does something productive: tries an alternative approach, asks the human for help, or escalates the ticket. The deny becomes information. A poorly built agent retries the same denied action in a loop — which is how you discover that your retry policy and your deny policy were designed by different people who never met. Cap retries on denied actions. A deny is an answer, not a network error. Treating it like a network error is how loops start.
The hint field earns its keep with human operators too. When the on-call engineer sees "action_not_allowlisted" with a pointer to the policy owner, the next step is obvious. When they see a bare "403," the next step is a archaeology expedition through three systems. Deny verdicts are user interface — for the agent and the human reading the logs at 2 AM.
Where the proxy sits (and why it has to be one place)
One enforcement point. That's the architecture, and it's non-negotiable. Every tool call from every agent flows through the proxy, which holds the real credentials and makes the decision. Not "most calls." Not "calls from the agents we remembered." Every call.
The reason is bypass. The moment there's a second path to the tools — a direct API key in one agent's config, a webhook that skips evaluation, a "temporary" direct integration — the proxy is decoration. Attackers and accidents both find the unguarded path. This is also why the shared-key migration matters: as long as any agent holds a real third-party key, it can walk around the proxy whenever it wants. The proxy is the policy only if it's the only way through.
Yes, this makes the proxy a single point of failure, and yes, that should make you slightly nervous. The answer is the boring one: run it highly available, keep the policy store replicated, and make the failure mode deny (fail closed, again). An agent fleet that pauses when the proxy is down is an inconvenience. An agent fleet that runs ungoverned when the proxy is down is the thing the proxy exists to prevent.
The honest caveats
Latency first, because it's the question everyone asks: each evaluation adds milliseconds — a credential check, a policy lookup, a budget comparison. Against tool calls that take seconds (model inference, API round trips), it's noise. If your agent makes ten thousand sub-second tool calls a minute, measure it; for everyone else, the overhead doesn't register.
Second: the proxy doesn't stop the agent from being wrong. It stops the agent from acting wrong in expensive or dangerous ways. An agent with a perfect allowlist can still draft a terrible reply to a customer — the draft action is allowed, the content is bad. Content judgment is a different problem (and partly what approval queues are for). The proxy bounds the blast radius; it doesn't fix the aim.
Third: policy is now code, and code needs the same discipline as the rest of your stack. Version it. Review changes. Test in staging before production. The allowlist migration diary covers the operational side; the point here is that a proxy with an unreviewed, untested policy is just a denylist with better marketing. The mechanism is only as good as the policy it enforces.
The short version
Denylists fail because you can't enumerate everything an agent shouldn't do — every tool addition, rename, and creative combination walks around them, and they fail open. Flip it: allowlist what the agent needs, park the risky stuff behind approvals, deny everything else by default, and enforce it at a single proxy that fails closed. Make the deny verdicts machine-readable so agents (and on-call humans) know what happened. One enforcement point, no bypasses, policy as reviewed code.