Allow-lists, not deny-lists
A deny-list is a bet that you thought of everything. For an agent holding real credentials, that is the wrong shape of bet.
An agent with shell access gets a deny-list: no rm -rf, no curl | sh, no
writes outside the working directory. Every entry was added after somebody
thought of the failure it prevents.
Which is the problem. A deny-list is a claim that you have enumerated the ways things go wrong, made by the party with the least visibility into what a model will try at 3am on input nobody anticipated.
A deny-list fails open. Anything you did not think of is permitted, and the whole category of things you did not think of is exactly what you are defending against.
Enumerate what is allowed
The inversion is not clever, it is just the correct default: nothing is permitted unless it is named.
type ToolGrant = {
tool: string
/** Constraints on the arguments, not merely on the tool name. */
constraints?: Record<string, Constraint>
/** Below this, execute freely. At or above it, a human approves. */
approvalAbove?: Threshold
}
const GRANTS: ToolGrant[] = [
{ tool: "read_file", constraints: { path: within("./workspace") } },
{ tool: "write_file", constraints: { path: within("./workspace/out") } },
{
tool: "http_get",
constraints: { url: hostIn(["api.internal", "docs.internal"]) },
},
{
tool: "send_email",
constraints: { to: domainIn(["ourcompany.com"]) },
approvalAbove: { always: true }, // never unattended
},
// No shell. No database write. Not restricted — absent.
]
The comment at the bottom is the design. Tools that are not in the list do not exist from the agent's point of view, and adding one is a deliberate act with a diff attached.
The tool name is not the permission
This is where most allow-lists are actually weak. http_get sounds read-only and
harmless. Then the agent calls
http_get("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
and reads the instance's cloud credentials out of the metadata endpoint.
Nothing was violated. http_get was allowed, and the argument was where the
danger lived.
So constraints have to be checked on resolved arguments:
const within = (root: string): Constraint => (value: unknown) => {
if (typeof value !== "string") return deny("not a path")
// Resolve BEFORE comparing. "workspace/../../.ssh/id_rsa" starts with
// the right prefix as a string and does not stay inside it as a path.
const resolved = path.resolve(value)
const bound = path.resolve(root)
return resolved === bound || resolved.startsWith(bound + path.sep)
? allow()
: deny(`outside ${root}`)
}
const hostIn = (hosts: string[]): Constraint => (value: unknown) => {
const url = safeParseUrl(value)
if (!url) return deny("unparseable url")
if (url.protocol !== "https:") return deny("non-https")
// Exact host match. endsWith(".internal") also matches
// "evil.attacker-internal" and "api.internal.attacker.com".
if (!hosts.includes(url.hostname)) return deny(`host ${url.hostname}`)
// Link-local, loopback and private ranges, resolved. Blocking the
// literal string 169.254.169.254 does not block a DNS name pointing at it.
if (isPrivateAddress(resolveSync(url.hostname))) return deny("private address")
return allow()
}
Three specific things there earn their place: resolving paths before comparing, matching hosts exactly rather than by suffix, and resolving DNS before deciding whether an address is private. Each of them is a bypass that a string comparison alone permits.
Scope the grant to the run, not the agent
A single global allow-list drifts upward. Every new task adds a tool, nothing is ever removed, and eventually the agent that summarises documents can also send email because a different workflow needed that once.
Grants belong to a run:
const run = await agent.start({
task: "Summarise the Q3 support tickets",
grants: [
{ tool: "read_file", constraints: { path: within("./tickets/q3") } },
{ tool: "write_file", constraints: { path: exactly("./out/summary.md") } },
],
})
That run cannot make a network call, because summarising local files does not need one. The next run gets its own list. The blast radius of a prompt injection in a ticket is now bounded by what this task legitimately required.
Log the denials
Denials are the highest-signal events an agent runtime produces:
select tool, reason, count(*), max(at) as last_seen
from tool_denials
where at > now() - interval '7 days'
group by 1, 2 order by 3 desc;
Two patterns to read for.
A benign tool denied repeatedly. The agent keeps trying to read a config file just outside the workspace. The grant is too tight and someone should widen it deliberately.
A tool denied that has nothing to do with the task. A summarisation run attempting an HTTP call to an external host is not the model being creative. It is the strongest available signal of prompt injection in the input, and it should page someone. This is the payoff for the whole design: the allow-list does not just prevent the action, it reports the attempt.
What this does not do
Allow-listing bounds what an agent can do. It does not decide whether the agent should do it.
An agent with a legitimate grant to send email to your own domain can still send a wrong or embarrassing email to your own domain. That is what the approval gate is for, and the two are complementary: the allow-list constrains the space of possible actions, and the approval gate constrains the specific action within it.
Neither replaces the other. An agent with a narrow allow-list and no approval gate does a small number of things unsupervised; an agent with an approval gate and no allow-list asks permission for a fully unbounded set.
More in the approval gate is a state machine, put a hard ceiling on the run, and agentic workflows with human approval gates.