AAI Agentic Handbook
Problem 118 · Agents

Prompt-Injection-Safe Tool Router with an Allowlist

MediumAI engineering
01Problem

Model output proposes tool calls like `{"name": ..., "arguments": {...}}`. Write `ToolRouter(tools, allowed_domains).route(call, session_allowlist, tainted)` returning a `Decision(allowed, reason, needs_approval)`. Reject unknown tools (exact name match only), tools not in this session's allowlist, missing or extra arguments, and wrong argument types (exact type: a bool is not an int). For URL parameters require `https` and a hostname equal to, or a subdomain of, an allowed domain. If the context is `tainted` (it contains untrusted content such as a fetched web page) a `write` tool is not executed but returned with `needs_approval=True`.

02Key insight

You cannot prompt your way out of prompt injection; the model will sometimes follow instructions hidden in a web page or email. So the security boundary is deterministic code between the model and the tools: an allowlist scoped per session, strict argument validation, and egress control on URLs, all of which assume the model's output is attacker-controlled. Parse URLs with a real parser and compare hostnames, because substring checks fall to `docs.example.com.evil.io` and `user@host` tricks. The taint rule encodes the 'untrusted input + private data + an exfiltration channel' combination: once untrusted text is in context, side-effecting calls need a human.

03Solution

Try it first.

TimeO(number of arguments + allowed domains) per call
SpaceO(number of tools)
tests.py · 12 passing
tests.pypython23 lines
router = ToolRouter([
    Tool("fetch", {"url": str}, "read", ("url",)),
    Tool("send_email", {"to": str, "body": str}, "write"),
    Tool("set_limit", {"n": int}, "write"),
], ["docs.example.com", "example.org"])
allow = {"fetch", "send_email", "set_limit"}
ok = lambda d: d.allowed
assert ok(router.route({"name": "fetch", "arguments": {"url": "https://docs.example.com/a"}}, allow, False))
assert ok(router.route({"name": "fetch", "arguments": {"url": "https://api.example.org/x"}}, allow, True))
for url in ["https://docs.example.com.evil.io/", "https://evil.io/?u=docs.example.com",
            "http://docs.example.com/", "https://docs.example.com@evil.io/", "https://notexample.org/",
            "javascript:alert(1)"]:
    assert not ok(router.route({"name": "fetch", "arguments": {"url": url}}, allow, False)), url
assert router.route({"name": "Send_Email", "arguments": {}}, allow, False).reason == "unknown tool"
assert router.route({"name": "send_email", "arguments": {"to": "a", "body": "b"}}, {"fetch"}, False).reason.startswith("tool not allowed")
assert not ok(router.route({"name": "send_email", "arguments": {"to": "a"}}, allow, False))
assert not ok(router.route({"name": "send_email", "arguments": {"to": "a", "body": "b", "bcc": "x"}}, allow, False))
assert not ok(router.route({"name": "set_limit", "arguments": {"n": True}}, allow, False))
assert ok(router.route({"name": "set_limit", "arguments": {"n": 5}}, allow, False))
d = router.route({"name": "send_email", "arguments": {"to": "a", "body": "b"}}, allow, True)
assert not d.allowed and d.needs_approval
assert ok(router.route({"name": "send_email", "arguments": {"to": "a", "body": "b"}}, allow, False))
assert not ok(router.route("send_email()", allow, False))
04Staff-level follow-up

How do you know when context is tainted? Track provenance per message (system, user, tool output from a trusted internal API, tool output from the open web) and propagate taint through summaries the model writes. Then go beyond allowlists: per-tool rate limits, argument-level policies (send only to the user's own domain), dry-run previews for writes, and a separate quarantined model that reads untrusted content and returns only structured fields.