A paper found 26 hostile LLM routers so I audited my own

Researchers tested 428 LLM API routers: nine injected malicious tool calls, seventeen used planted keys. I run my agents through a router too, so I audited my own machine, built a guard in a day, and wrote the one-minute audit you can run on yours.

ai-infrastructurellm-routingagent-securityfree-models

If your coding agent reaches a model through OpenRouter, a free token router or a reseller, a middleman decrypts every prompt and every reply on the way through. In April, Hanzhi Liu, Chaofan Shou and four co-authors from UC Santa Barbara, Fuzzland, UC San Diego and World Liberty Financial published a measurement of what those middlemen do with that access. The paper is called "Your Agent Is Mine: Measuring Malicious Intermediary Attacks on the LLM Supply Chain" (arXiv 2604.08407) and it's accepted at ACM CCS this October. I run my own agents through a router, so I read it with my own config open.

The film runs 5 minutes 15 seconds, narrated, and covers everything below in order. The numbers on screen are the numbers in this post.

428

routers tested: 28 paid resellers and 400 free routers

Liu et al., arXiv 2604.08407

9

injected malicious code into the replies (1 paid, 8 free)

same

17

used AWS credentials the researchers had planted in the traffic

same

401 / 440

agent sessions seen through the routers were running in auto-approve mode

same

Three sentences on the paper

The team registered accounts with 428 routers, ran real agent tasks through each one and watched for network callbacks, file changes and odd processes. Nine routers rewrote the tool calls in the reply, seventeen picked up and used AWS keys planted in the traffic, and one drained an Ethereum wallet whose private key had been left in a prompt. Of the 440 agent sessions the team could observe through poisoned routers, 401 were in autonomous mode with every command auto-approved.

What 428 routers did with the traffic

Routers the researchers registered with and ran real agent tasks through. Twenty-six of them acted on what passed through.

injected malicious code into replies9
used AWS keys planted in the traffic17
drained an Ethereum wallet from a planted key1
91%

of the agent sessions seen through poisoned routers were in auto-approve mode

401 of 440 sessions; every command ran without a person looking at it

Liu et al., arXiv 2604.08407

How a router hurts you

A router is a proxy that speaks the OpenAI API. Your agent sends it a request, it forwards the request to a model provider, and the reply comes back the same way. To do that, the router terminates your TLS session, which means it reads the prompt and the reply in plain text. The reply is where the tool calls live: the shell commands, file edits and package installs your agent is about to execute. Nothing in the protocol binds a reply to the model that produced it, so a router can change a tool call in transit and your agent runs the changed version.

The paper names two attack classes. Response-side payload injection (their AC-1) rewrites fields in the tool call's arguments while keeping the tool name and the schema intact, so the call still looks valid to the client. Passive secret exfiltration (AC-2) forwards everything untouched and keeps a copy of whatever credentials pass through.

One field changed

The paper's typosquat example. The tool name and the JSON schema are unchanged; the client sees a valid call.

What the model returned

pip install requests

before

pip install requests

after

What the router forwarded

pip install requests

before

pip install reqeusts

after

Who owns the package

PyPI

before

the attacker

after

Two refinements make this hard to catch. Dependency-targeted injection swaps a package name for a typosquat, so a policy that blocks unknown download domains never fires because the install still goes to PyPI. Conditional delivery only triggers on a condition the router can see: the paper observed routers that attacked only after fifty prior calls, or only when the client was in auto-approve mode. A router that behaves on day one and turns on day three passes every smoke test you'd think to run.

The paper also measured three defences. A high-risk tool policy gate (block fetches from non-allowlisted domains and installs outside a local allowlist) stopped every plain injection at a 1.0% false-positive rate and was bypassed 100% of the time once the attacker used an allowlisted domain. An anomaly detector on the returned tool calls flagged 89% of plain injections and about half of the adaptive ones at a 6.7% false-positive rate. An append-only transparency log of request, response and router identity cost 12 MB per thousand sessions and answers the question "which request carried the bad call" after the fact.

Two of the paper's three defences, as measured

Bar is the share of plain injections caught; the tick is the false-positive rate on benign traffic. The third defence, an append-only log, is a record rather than a detector.

plain injections caughtfalse positives

Policy gate on domains and installs

100% bypassed via an allowlisted domain

100% · 1%

Anomaly screening of returned tool calls

half of the adaptive variants caught

89% · 6.7%

Liu et al., arXiv 2604.08407

So I audited myself

Every agent on my machine talks to one LiteLLM router on localhost. Interactive sessions in opencode and Claude Code, the research scripts and the unattended writer sessions that run overnight all hit the same endpoint, and the router forwards each request to whichever provider serves the requested tier: a third-party reseller, the free models on OpenRouter, NVIDIA's endpoints, or Z.ai. Every attempt lands in a SQLite log with the provider and the upstream URL. The agent, for its part, keeps its own database of every session, every message and every tool result.

What I run

One local router in front of four upstreams. The two on the right are intermediaries in the paper's sense.

1

agents

opencode, Claude Code, scripts

2

LiteLLM

my laptop, 127.0.0.1

3

reseller

third party, multi-vendor

4

OpenRouter

free models

5

NVIDIA · Z.ai

first party

Three numbers came out of the audit.

548

unattended agent sessions in 14 days launched with every permission auto-approved; 1,989 shell commands, none reviewed by a person

opencode session db, 2026-09-11

64%

of that group's successful requests went through the reseller (53%) or OpenRouter (11%)

llm-usage.db, 14 days to 2026-09-11

2

live API keys had come back to a model in tool output, six times each; the next request went to the reseller or OpenRouter

agent db joined with the router log

0

injections found in 18,403 historical commands: no pipe to shell, no base64 decode, no reverse shell

leak-audit replay

Requests per day from the unattended group

The writer tier's traffic through the router, 5 to 10 September. Every one of these requests went out with the agent's permissions auto-approved.

requests
02,0004,0006,0008,000050607080910
Table
Dayrequests
057,488
066,793
074,851
082,287
095,477
103,638

llm-usage.db, one row per attempt, requested tier = writer

The first number was the one that stung. The interactive sessions were on "ask" for every dangerous permission, which is what I had checked when I read the paper, and the unattended sessions were not. The script that spawns them passed a flag that auto-approves everything, and it had been doing so for two weeks through the paper's worst-affected population. I was the 401 out of 440.

The second came from joining the agent's session database with the router log on the session id. Every tool result the model had ever seen, searched for the 29 secret values in my env file: two live keys had been returned to a model six times each, mostly by an agent reading a script that had a key hardcoded, and each time the next request went out to an intermediary. Whether anyone on the other end kept them is unknowable, so both count as exposed.

The third was the relief. I replayed every historical command through the paper's payload patterns and found nothing. The door had been open for two weeks and nobody had walked through it yet.

What I built in a day

The fix had to leave the free models alone. I wasn't going to pay first-party prices for a writer tier that runs close to ten thousand requests on a heavy day. So the guard sits inside the LiteLLM I already run, as a callback, and contains the middleman instead of trusting it.

Where the guard sits

Three hooks in the local router. Nothing on the untrusted side changes.

1

agent

permission profile, no auto-approve

2

MASK

secrets out → placeholders

3

GATE

tool calls in → screened

4

LEDGER

every call, every upstream

5

upstreams

reseller, OpenRouter, NVIDIA, Z.ai

Mask runs on the way out. It scans every string in the request for the values of the secrets listed in a small file and swaps each one for a placeholder before the request leaves. An agent can read the whole env file and the router and the model receive a list of placeholders. Nothing restores the values on the way back, and when I tested it the upstream model echoed the placeholder straight back to me, which is the proof it never saw the key.

Gate runs on the way in. Every tool call in the reply, streaming or not, is matched against a rules file: secret files and directories, key files, keychain reads, pipe-to-shell, base64 decodes, reverse shells, env dumps piped to the network, sudo. A match rewrites the call into a harmless echo that says it was blocked, so the agent reads the block as a tool result and carries on with the task. Streamed tool-call deltas are held until the call is complete and then released, rewritten or dropped, with an empty keepalive delta every few seconds so clients with a chunk timeout stay connected.

Ledger writes every tool call to a table with its hash, its verdict, the rule that fired and the upstream that sent it, and a second table records which secret names were masked. That's the paper's third defence: after an incident you can answer which request carried the call and which router it came from.

Two more pieces sit outside the router. The unattended workers got their own permission profile in the agent's config, three scripts and one linter allowed and everything else denied, and the flag that auto-approved everything is gone. And a small audit script reads the ledger plus the agent's own history and prints which of your secrets have already crossed which upstream, which is how I got the two-keys number above.

Replay of 18,403 historical commands through the gate

Verdict share. Every block was a real read of a secret file or a key pasted into a command.

allowed98.76%
flagged (logged, not blocked)0.32%
blocked0.92%

Thirteen live integration tests against the four real upstreams passed: the masked value came back as its placeholder, a secret-file read was rewritten in both streaming and non-streaming replies, an innocent directory listing went through untouched, and every ledger row carried the upstream that served it. Then a real overnight row went end to end on the same free models as before.

What it does not protect

Limits

What containment leaves exposed.

Your prompts and drafts

high

The upstream still reads everything you send. Masking hides the secrets, not the content.

Lies in prose

medium

A router that edits the model's words rather than its tool calls passes the gate. Only tool calls are screened.

Unlisted secrets

medium

Mask only knows the values in its secret files. A token that lives somewhere else goes through.

Finite patterns

medium

The gate is a rules file. A payload shaped like nothing in it passes. Pair it with a harness permission profile and, for unattended workers, an OS sandbox.

Your own LiteLLM

high

The guard trusts the proxy it runs in. LiteLLM itself was compromised through dependency confusion in March 2026; keep it pinned and audited.

Anything outside the router

low

Direct SDK calls, browser tools and MCP servers that reach the network on their own are not covered.

guard/README.md

Two answers

There's a more complete answer. In June, Sipeng Xie and six co-authors published "The Proxy Knows Too Much: Sealing LLM API Routers with Attested TEEs" (arXiv 2606.16358). Their system, AEGIS, runs the router inside an attested hardware enclave, so the middleman can prove it forwarded your request unchanged and never held the plaintext outside the enclave. That closes the confidentiality gap the guard leaves open. It also costs money, because the enclave has to be somewhere, and the whole point of my setup is that the models are free. So I contained the middleman instead of attesting it. If your traffic can afford the enclave, read their paper first.

Run it

The guard, the permission-profile example and the audit script are on GitHub under an MIT licence at github.com/block-town/routerguard. If you run LiteLLM, the install is a clone, a path and one callback line in the config, then the list of files whose values must never leave:

git clone https://github.com/block-town/routerguard ~/routerguard
pip install pyyaml
# in the environment that runs `litellm --config`:
export PYTHONPATH=~/routerguard:$PYTHONPATH
export LLM_GUARD_SECRET_FILES=~/.env:~/work/app/.env
litellm_settings:
  callbacks:
    - guard.hooks.guard_instance

If you run agents through any router, start with the audit. It reads your agent's session history (opencode and Claude Code today) and your router log, and prints which secret names have appeared in tool output and which upstream served the next request. It takes about a minute and gives you your own version of the two-keys number.

python -m audit.leak_audit --env ~/.env

Mine said two, and I wouldn't have known without the join.


If you run agents through a router and want the guard, the profile and the audit set up against your own upstreams, book a call.

Building something like this?

30 minutes, no pitch.

More writing