Giving an AI agent real tools without handing over the keys
A useful agent acts: it searches, queries a database, sends messages. How I give it powerful tools while bounding what a prompt injection can do, with an interactive lab.
Die Artikel erscheinen auf Französisch und Englisch.
An agent becomes useful the moment it can act: search, query a database, send a message. It becomes dangerous for exactly the same reason. And the instruction that hijacks it doesn’t have to come from the user: it can sit in a document, a web page or an email the agent reads. That is indirect prompt injection.
Bound the capability, don’t detect the attack
Detecting injection is a race you lose: it only takes a phrasing the filter has never seen. So I start from the opposite principle. The worst case must remain a tool the person already had, on data they could already read. Everything else happens in code, not in the prompt.
The lab below replays a classic attack: a booby-trapped email asks the agent to exfiltrate the customer list. Turn the defenses on one by one and see where the attack stops.
Marking external content as data helps the model, but replay with that defense alone: one run in three, it still obeys. The defenses that hold are the ones that don’t rely on its judgement.
Three layers, only one of which counts
In the agent I put into production, each user’s rights are enforced three times. A denied tool is never described to the model. The schema of the remaining tools only offers allowed collections. And each tool’s code checks everything again, element by element when one search targets several collections.
def tools_for(profile):
# layer 1: a denied tool is never even described to the model
return [t for t in REGISTRY
if profile.allows_tool(t.name)
and t.requires_collections <= profile.collections]
async def run_tool(name, args):
tool = get_handler(name) # a hallucinated name never reaches a handler
for collection in collections_in(args):
current_profile().require(collection) # layer 3: code, not a prompt
return await tool(args)A SQL tool written by the model, contained by the engine
The most powerful tool is also the riskiest: the model writes its own SQL. Queries run on a nightly, read-only copy of chosen columns, never on the production database. Each query opens a fresh connection that only attaches the allowed collections, then locks it down:
LOCKDOWN = (
"SET autoinstall_known_extensions = false",
"SET autoload_known_extensions = false",
"SET allow_community_extensions = false",
"SET enable_external_access = false", # no files, no network
"SET max_temp_directory_size = '0B'",
"SET lock_configuration = true", # last, so none of it can be undone
)
ESCAPES = ["SELECT * FROM read_text('/etc/passwd')", "ATTACH 'x.db'",
"SET enable_external_access = true", "INSTALL httpfs",
"COPY t TO 'out.csv'", "SELECT 1; SET threads = 64"]A parser does check that there is a single SELECT, but that is only a second belt. Red-team tests try every escape in the list against real files, and one test checks that the locked connection refuses on its own, even when that check is bypassed.
Errors written for the model
A failing tool must tell the model what to do next. A denied call answers “this tool is not available for this user, answer with the ones you have”. A slow query: “stopped after 10 s, aggregate, filter or add a LIMIT”. A truncated result says so and suggests counting with COUNT(*). And every bound lives in code: 10 results per search, 200 rows per query, 5 loop turns at most.
Revoking access doesn’t revoke the context
The trap almost nobody sees: removing a right mid-conversation is not enough. The history still holds tool results obtained while access existed, and the model will keep drawing on them. So the profile is recomputed on every message, and if it narrowed, the history is cleared. Same logic during an outage: if roles can’t be read, the user falls back to the public floor, never to broad access.
When the agent acts outside: a human gate
In another experiment, an agent runs a small online business. Its rules: no spending without an approved line in the budget, no password or token ever handled (the tools use my session, not its own), web pages are data and never instructions, and every outward-facing action waits for an explicit “yes”, whose exact wording the agent drafts itself. It even refused, on its own, a workaround it judged to be a security bypass.
And an honest lesson: the approval gate was a convention. The publishing scripts never read it; only the agent respected it. A rule that only the model enforces is not a rule: the fix is a guard inside the script that refuses until the approval exists.
The checklist
- Expose the minimum tools per task. A denied tool is not described.
- Check rights again inside each tool’s code.
- Read-only by default, writes behind human approval.
- Contain with the engine, not the prompt, and attack your own sandbox in tests.
- Errors written for the model, bounds in code: rows, turns, time, money.
- Every tool result is untrusted data. Marking it helps, relying on it doesn’t.
- Revoking also means clearing the context.
- Log every tool call with its inputs and outputs, and show them to the user.