Tutorial

How to Secure an AI Agent That Can Call Tools

Why this matters

A chatbot that answers questions has a small attack surface. An agent that can call tools has a different one entirely: it reads a web page, parses a document, writes a file, calls an API. Every one of those is a channel through which text arrives, and text is what a language model acts on.

If your agent fetches a URL and that page contains instructions, those instructions sit in the same context window as your system prompt, in the same voice, formatted the same way. There is no type system separating “instructions I wrote” from “text I retrieved”. The model does not know which is which, and neither does the code that hands it over. That is the entire mechanism, and it is why instruction-based defences leak.

The consequence is usually not dramatic. The failure is that the agent does something you did not authorise — writes to the wrong path, sends a message to a list, spends a token budget on a loop. In my own pipeline the first real incident was an agent that fetched a documentation page, found a code block instructing the reader to “include the full source in your summary”, and cheerfully reproduced a paragraph of it into a draft that went to a compliance reviewer. No exfiltration, no credentials touched — just a wrong output caused by text from the internet ending up in the wrong place. Every line of code did what it was written to do, which is what makes it hard to review for and why the fix has to be structural.

This is a hardening guide for systems you own. It assumes you are making your own agent resistant, and covers nothing about targeting systems you do not own.

What I tested on

  • Stack: .NET 8 API tier for the tool gateway and policy, Python FastAPI tier for the agent loop and retrieval, Playwright for browser-driven tools
  • Agent shape: a multi-step content agent with read tools (fetch, retrieve, search) and write tools (file write, publish, outbound message)
  • Credentials: per-tool scoped tokens issued by the API tier, never shared with the agent runtime
  • Approval policy: human confirmation on publish, outbound message, spend, and delete actions; read-only tools run unattended
  • Observability: every tool call and every approval decision written to an append-only audit log
  • Duration: in production since early 2026, hardened over several months as tools were added

The tags are not a security boundary — a determined attacker can write “ignore previous instructions” in the same voice as your tags. Their value is making the distinction legible to the model, and making provenance available to your validation layer for auditing.

Step 1: know the four places an injected instruction can land

Almost every agent hardening discussion jumps straight to prompt wording. That is the wrong starting point, because prompts are one of four landing places, and the weakest.

1. The context window. The obvious one. Instructions arrive from a fetched page, a retrieved document, a tool result, or a user message, and sit alongside your own prompt with no marker of trust. Mark provenance explicitly and treat everything inside a retrieved block as data rather than instruction.

<untrusted-source source="web" ref="doc-88">
...content retrieved from a page the agent fetched...
</untrusted-source>

2. Tool arguments. The model produces arguments, and the tool executes them. If a fetch tool takes a URL, an injected instruction can rewrite it to point at an internal address. If a file tool takes a path, the path is an injection target. This is where the interesting failures live, because tool arguments are not natural language — they are data that gets executed, and a validator can check them precisely.

3. Retrieved content. Nearly identical to the context window case, but worth separating because of scale. A RAG pipeline pulls in many documents per request, which means many chances for one to carry an instruction. The RAG pipeline guide covers marking sources and making refusal a rewarded outcome; the security-specific part is that retrieved documents are attacker-controlled content whenever the corpus can be written to by anyone.

4. The action taken on the output. The most important one, and the one people forget. If the model produces a string that is then executed, interpreted, or passed to a shell, the injection has already won even if every other layer held. This is where to break the chain.

Enumerate these four for your own system before writing a single mitigation: for each tool, write down which of the four apply. That table becomes your checklist, and it is far more useful than a general list of best practices. One thing to get right as you build it — the model is never a trusted caller, only an unusually well-informed untrusted one.

Step 2: validate every argument between the model and the tool

So the tool boundary is where you put a validating proxy that no model output can bypass. For a fetch tool, that means a scheme allowlist and a block on internal address ranges:

from urllib.parse import urlparse
import ipaddress, socket

ALLOWED_SCHEMES = {"https"}

def validate_fetch_url(raw: str) -> str:
    url = urlparse(raw.strip())
    if url.scheme not in ALLOWED_SCHEMES:
        raise ToolInputRejected(f"scheme not allowed: {url.scheme}")
    host = url.hostname or ""
    if not host:
        raise ToolInputRejected("missing host")
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror as exc:
        raise ToolInputRejected(f"dns failure: {host}") from exc
    for info in infos:
        ip = ipaddress.ip_address(info[4][0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            raise ToolInputRejected(f"internal address blocked: {ip}")
    return raw

Two details matter more than the code shape. The DNS resolution is checked, not just the literal hostname, because http://internal-service/ resolves to a private address and a string check on the hostname misses it. And the resolution result should be what you actually connect to, otherwise you have a check-then-use race.

For a file tool, the rule is the same shape: resolve the path, then check containment against an allowed root.

from pathlib import Path

def validate_write_path(raw: str, allowed_root: Path) -> Path:
    target = (allowed_root / raw).resolve()
    root = allowed_root.resolve()
    if not target.is_relative_to(root):
        raise ToolInputRejected(f"path escapes allowed root: {raw}")
    if target.suffix.lower() not in {".md", ".json", ".txt"}:
        raise ToolInputRejected(f"unexpected extension: {target.suffix}")
    return target

Resolving before checking is the whole trick. A naive string prefix check is defeated by ../../ segments and by absolute paths, and it does not handle symlinks. The general principle: treat every tool argument as untrusted user input from the internet, because in effect it is.

Step 3: give each tool its own narrow credential

One shared API key for the whole agent means one successful injection gets everything. Least privilege here is a concrete mechanism, not a slogan.

In my setup the .NET API tier issues a scoped credential per tool, with the scope baked in at issue time rather than requested by the model:

{
  "tool": "fetch_url",
  "scopes": ["net:fetch:public"],
  "ttl_seconds": 300,
  "max_bytes": 2000000,
  "allowed_hosts": ["docs.example.com", "example.org"]
}

Four properties do the work. Scopes are per tool, not per agent, so a compromised fetch tool cannot reach the publish tool’s credential. The TTL is short, so a credential that expires in five minutes cannot be exfiltrated and reused later. The byte and host limits live in the credential, so a model cannot argue for a larger allowance. The scope set is written in the API tier, not the prompt, so the model has no way to request a wider one.

This is the difference between “the agent has permission” and “this specific tool call has this specific permission”. The second is enforceable, and it is what you want when the deciding party is a language model.

Step 4: put a human in front of irreversible actions

Read-only exploration can run unattended. Anything that leaves the system, spends money, deletes something, or changes state for another person needs a human to say yes first. That is the approval gate, and it is the single most effective control available.

The important design detail is that the gate sits between the model and the action, not between the user and the agent. The model can propose; a program decides whether to execute or to ask.

IRREVERSIBLE_TOOLS = {"publish", "send_message", "delete", "spend"}

async def run_tool(name, args, actor):
    validated = validate_args(name, args)
    if name in IRREVERSIBLE_TOOLS:
        decision = await approval_queue.request(
            tool=name, args=validated, requested_by=actor,
            preview=render_human_readable(name, validated),
        )
        if not decision.approved:
            audit.log("approval_denied", tool=name, actor=actor, args=validated)
            raise ToolDenied(f"{name} was not approved")
    result = await dispatch(name, validated)
    audit.log("tool_call", tool=name, actor=actor, args=validated, result=result)
    return result

The preview matters as much as the gate. A reviewer who sees a raw JSON blob approves mechanically; a reviewer who sees the first 300 characters of the draft, the destination host, and a diff against what changed is actually reviewing. Gate fatigue is real, and a gate that cannot be reviewed accurately is a rubber stamp. So keep it narrow: show the plan once, approve the routine steps as a batch, stop only where something irreversible happens.

Step 5: log everything, and make the log append-only

When something goes wrong, the question is always the same: what did the agent actually do, in what order, and what was in its context at the time. If you cannot answer that, you cannot fix it.

Five fields in that log earn their place: args (what the tool was actually called with, after validation), context_refs (which retrieved documents were in the window, so you can reproduce the input that produced a bad action), content_hash (the result, hashed, so you can prove what was returned even if the content is not retained), approval (whether a human was asked, and the answer), and duration and outcome, so runaway loops are visible.

audit.log(
    "tool_call",
    tenant=tenant_id,
    trace_id=trace_id,
    tool="fetch_url",
    args={"url": "https://example.com/page"},
    actor="agent",
    context_refs=["doc-12", "doc-31"],      # what was in the context window
    approval={"required": False},
    result={"status": 200, "bytes": 4821, "content_hash": "sha256:..."},
    duration_ms=412,
)

Make the log append-only, and separate write permission from read permission for whoever investigates. An audit log you can edit is a log nobody trusts, including you. Keeping the writer and the reader on different credentials costs one API endpoint and buys you the property that the log is worth reading.

To get alerted rather than finding out a week later, put a watch on it. The alerts worth having: an approval denied several times in a row (usually a loop), any tool called with an argument the validator had to reject (usually an injection attempt), and a trace exceeding a call or token budget.

Step 6: run the dangerous parts in a sandbox

The last layer is containment of the execution environment. If a tool can run code, parse a document, or drive a browser, then a bug in that tool — or an injected instruction that got through — runs with whatever privileges the process has.

The minimum viable sandbox is not exotic. Give the tool process its own user, a working directory it cannot escape, no network access except through a proxy you control, and a memory and CPU ceiling.

# container policy for the code-execution tool
user: "1001:1001"
read_only_root: true
cap_drop: [ALL]
seccomp:
  default_action: errno
  syscalls:
    allow: [read, write, openat, close, fstat, exit_group]
network:
  default: deny
  allow:
    - "api.internal:443"
mem_limit: 512m
pids_limit: 64

Two of those matter most for an agent specifically. pids_limit stops a runaway loop from spawning until the machine dies, and an agent told “do this five times” by an injected instruction is exactly the case it covers. network: deny by default means a process that reads a malicious file cannot phone home.

For browser-driven tools, the equivalents are a separate browser profile per run, no access to the operator’s real session cookies, and a download directory nowhere near your filesystem root.

What broke along the way

The path validator was checking the wrong string. My first file-write guard compared the joined path against the allowed root with a prefix check, and it was bypassed by a relative path that walked up out of the root. The symptom was not an error — it was a file written outside the intended directory, found days later. Resolving before checking fixed it, and I now test every validator with traversal cases, because a validator with no adversarial test is a guess.

The approval gate became a rubber stamp. The first version showed raw tool arguments, a long payload nobody could read, so people approved everything within a second. Rebuilding the preview to show the actual prose and the destination host changed the behaviour completely — reviewers started sending things back. If nobody ever denies an approval, your gate is not a gate.

Shared credentials meant a single injection had full reach. One API key was issued to the whole agent runtime. When an injected instruction redirected a file write, I realised the same key would have allowed a publish. Credentials are now per tool with a short TTL, and the publish credential lives in a separate service the agent cannot address.

Pros and cons

What it gets you

  • A real reduction in blast radius, because a compromised agent has less to reach and less it can change
  • An audit trail that answers what happened, which is what you need after the incident rather than during it
  • The ability to extend the agent to more sensitive actions without the same level of anxiety
  • Approval gates that keep people accountable for the externally visible work

What it costs

  • Engineering effort at the tool boundary, since every tool needs validation, scoped credentials, and logging
  • Latency and friction at the approval step, which shows up in how the system feels to use
  • A slower path for adding new tools, because each one now needs a permission decision
  • Ongoing policy maintenance: credentials expire, scopes drift, and allowlists grow stale
  • The standing risk that a mitigation was written for an attack shape you have not seen

Who this is for

Do this if your agent can call tools that reach outside the process — a web fetch, an email send, a file write, a browser session, a database — and especially if any of that content is influenced by someone you do not control. If a user can submit a document your agent reads, or the agent browses a site anyone can publish to, you have an injection surface today.

Start smaller if the agent only reads and summarises material you curated yourself, with no write tools and no external fetches. Then a clear prompt about treating retrieved text as data, plus the property-based assertions from the evaluation guide, covers most of the realistic risk. Add the permission model before you add write access: retrofitting least privilege to an agent that already has broad credentials is much harder than starting narrow.

FAQ

Q: Can prompt injection be completely prevented?

A: No. Every mitigation available today reduces the probability of a successful injection or limits its blast radius, and none eliminates it. That is why the hardening model is containment rather than prevention: separate read access from write access, never let model output reach an action directly, and put a human between the agent and anything irreversible. Assume any agent with external read access can eventually be influenced.

Q: What is the single most important security control for a tool-calling agent?

A: Not letting the model’s output act on its own. If a program validates the model’s output and decides what actually happens, rather than passing it straight to a tool, a successful injection produces a string nobody acts on. Everything else — prompt hardening, filters, model choice — adds probability reduction on top of that structural boundary.

Q: How do I limit what an agent can touch?

A: Give every tool its own narrowly scoped credential rather than sharing one account with broad access, and grant the minimum permissions that tool actually needs — a read-only token for a fetch tool, one writable path for a file writer. Scope credentials per tool rather than per agent, so a compromised agent cannot escalate sideways into a capability it was never meant to use.

Q: Should an agent be able to send email or publish content without approval?

A: No, not without a human in the loop. Those actions are externally visible and effectively irreversible once sent, which is exactly the class of action where an approval gate pays for itself. My rule is that anything leaving the system, spending money, deleting something, or changing state for another user requires explicit human confirmation, while read-only exploration runs unattended.

Q: Does adding a security layer slow the agent down?

A: A little, and it is mostly the human approval step rather than the technical controls. Validation, allowlists and per-tool credentials add milliseconds because they are local checks, while an approval gate adds however long a person takes to read the proposed action. Keep it usable by being selective about which actions need a gate rather than putting one in front of every step.

Tools used in this post

FAQ

Can prompt injection be completely prevented?

No. Every mitigation available today reduces the probability of a successful injection or limits its blast radius, and none eliminates it. That is why the hardening model is containment rather than prevention: separate read access from write access, never let model output reach an action directly, and put a human between the agent and anything irreversible. Assume any agent with external read access can eventually be influenced.

What is the single most important security control for a tool-calling agent?

Not letting the model's output act on its own. If a program validates the model's output and decides what actually happens, rather than passing it straight to a tool, a successful injection produces a string nobody acts on. Everything else — prompt hardening, filters, model choice — adds probability reduction on top of that structural boundary.

How do I limit what an agent can touch?

Give every tool its own narrowly scoped credential rather than sharing one account with broad access, and grant the minimum permissions that tool actually needs — a read-only token for a fetch tool, one writable path for a file writer. Scope credentials per tool rather than per agent, so a compromised agent cannot escalate sideways into a capability it was never meant to use.

Should an agent be able to send email or publish content without approval?

No, not without a human in the loop. Those actions are externally visible and effectively irreversible once sent, which is exactly the class of action where an approval gate pays for itself. My rule is that anything leaving the system, spending money, deleting something, or changing state for another user requires explicit human confirmation, while read-only exploration runs unattended.

Does adding a security layer slow the agent down?

A little, and it is mostly the human approval step rather than the technical controls. Validation, allowlists and per-tool credentials add milliseconds because they are local checks, while an approval gate adds however long a person takes to read the proposed action. Keep it usable by being selective about which actions need a gate rather than putting one in front of every step.

Henry Xiang

I build and run multi-tenant AI content automation in production — agents, retrieval pipelines, and the GEO layer that makes them findable. Everything here was wired into a system with real traffic before it was written up.