Give an agent three ordinary tools: one that reads a GitHub issue, one that
reads a local folder, one that can post a GitHub comment. Each behaves exactly
as documented. Each is useful on its own. Together they form an exfiltration
path: an instruction hidden in the issue can steer the agent into reading the
folder and publishing it to a public repository.
Security researchers have a name for the setup: Simon Willison's
lethal trifecta:
read untrusted input, touch private data, write somewhere public. What made me
build something about it is how little malice the leak actually needs. In
MCPHunt, a controlled benchmark of
multi-server MCP agents, policy-violating credential propagation appeared in
11.5–41.3% of runs depending on the model, in tasks that allowed redacting
the credential. No attacker required. The arrangement alone did it.
That is the uncomfortable property of this bug class: every component is
correct. The bug is the composition, and a composition of tools and data is a
path. Paths and cuts are what graphs answer, so I built LethalPath: it reads
a Claude Desktop-style MCP config, models the tools, the data they touch and
the agent's shared context as a graph in FalkorDB, finds the leak routes,
computes a minimum cut of permissions to revoke, and then proves the fix by
re-running the query.
The model: small enough to read in one sitting
The whole schema fits in a table:
| Node | Holds |
|---|---|
Server |
which MCP server supplies which tools |
Tool |
name, server, how the label was derived |
Resource |
data, by label only: kind, scope, trust, sensitivity, visibility |
Context |
one hub per agent: the window one tool's output enters and another tool reads from |
Meta |
config counts and a ready flag, the last write of ingest |
One relationship carries the weight:
(a)-[:FLOWS {dir: 'feeds'|'emits', capacity, revocable, grant, taskCost}]->(b)
Two decisions worth explaining. One relationship type, because
algo.maxFlow on FalkorDB 6.0.2 accepts exactly one. My planned
FEEDS/EMITS pair collapsed into FLOWS with a dir property, and the
schema ended up simpler than the plan. And capacity encodes cost, not
volume: a grant with a declared task behind it costs more to cut, so the cut
prefers revoking permissions that nothing depends on.
The query a join cannot write
The leak has a shape: untrusted input reaches the shared context through one
tool, and a private store reaches a public sink through a chain that passes
through the same context.
MATCH inj = (u:Resource {trust:'untrusted'})-[:FLOWS]->(:Tool)-[:FLOWS]->(ctx:Context)
MATCH leak = (p:Resource {sensitivity:'private'})
-[:FLOWS*1..5]->(ctx)
-[:FLOWS*1..5]->(s:Resource {visibility:'public'})
RETURN u.kind AS injected_from,
[n IN nodes(leak) WHERE n:Tool | n.name] AS tools,
length(leak) AS hops
ORDER BY hops ASC LIMIT 25
Two things matter more than they look. First: "through the same context" is
the entire bug class. A join over permissions tells you a tool can read and a
tool can write. It does not tell you that one tool's output lands exactly where
the next tool picks it up. A per-server scanner sees a read here and a write
there; the laundering hop in the middle is invisible to it. A vector index can
rank tools by similarity; it cannot answer reachability through a hub.
Second: the walk is anchored and bounded. My first version let the path
wander (*2..10); a twelve-tool config timed the server out, because the
walk re-enters the shared hub exponentially. Bounding each segment at five
hops and anchoring both on the tainted context fixed the performance and
tightened the semantics: the route must pass through that context itself.
On a 40-tool config that chains through the hub, the walk count is still
enormous (126,321), so the CLI catches the failure and retries with a query
that counts without materialising paths, marking the order as arbitrary so
nothing downstream mistakes arbitrary for shortest. The cut is unaffected.
The fix: one call, and the cut is yours
Private stores are sources; public sinks are targets:
CALL algo.maxFlow({sourceNodes: src, targetNodes: dst,
relationshipTypes: ['FLOWS'], capacityProperty: 'capacity'})
YIELD maxFlow, edges, edgeFlows
Here is the part I did not expect: algo.maxFlow returns flows, not a cut.
A maximum flow admits two canonical minimum cuts, one hugged to the sources,
one to the sinks. Both close every route; they revoke different permissions and
break different declared tasks. LethalPath walks the residual graph in its own
code, compares both canonical cuts, and picks the cheaper one: fewer distinct
grants first, fewer broken declared tasks second, and on a full tie, the
write side — so the agent keeps its ability to read and loses only the
publish.
That cut-selection code is the most product-shaped part of the repo, and it
exists only because the engine didn't do it for me. I am glad it ended up in
my code instead of hidden behind a yield.
The receipt
The fix ends by re-running the leak query with the revoked grants excluded:
WHERE all(rel IN relationships(leak)
WHERE rel.grant IS NULL OR NOT (rel.grant IN $revoked))
RETURN count(leak) AS remaining
Zero rows is the receipt. Not "should be fixed", but the same query that found
the leak, finding nothing.
The numbers, each with the file that contains it (a rule the repo follows
everywhere):
- 40/40 detection and 40/40 fix-quality across 40 seeded configs (16 planted leaks, 8 laundering-only, 8 clean, 8 tie cases; seed committed).
- The 8 clean configs return an empty cut and the tool stays silent on them.
- A config built from the official MCP server READMEs: 567 routes closed by 9 revocations; the re-run finds 0 of 567.
- The 40-tool hub config: 126,321 routes → one grant → 0 of 126,321.
Limits, stated as plainly as the numbers: the eval is synthetic (my generator),
so it shows the engine is consistent with its spec, not that it is right about
your setup. Labels are inferred from tool names, not verified against server
code. The traversal is bounded at five hops per segment. The runtime guard is a
demo with simulated tool results: real MCP stdio, no real GitHub or filesystem
behind it yet. And computer-use servers, which read the screen and type into it,
don't fit name-based labels at all: one screenshot is untrusted input and
private data at once, so the only static cut is "remove typing". That class
needs a runtime rule, not a revocation.
What I learned
-
The docs were ahead of the engine. FalkorDB's shortest-path index (CCH)
is documented; on the pinned 6.0.2 image both
CREATE CCH INDEXanddb.idx.cch.queryfail. I planned around a feature that does not ship, and un-planned it intoalgo.SPpaths/algo.SSpathsfallbacks with amaxLenbound. The lesson: verify every load-bearing claim by execution, and commit the fallback next to the claim. - A constraint made the design better. One relationship type wasn't a compromise; it forced the model to carry direction as data and ended up readable.
-
Refusal order beats sanitising. Every product read goes through
GRAPH.RO_QUERYwith a 3-second timeout, a LIMIT cap, and write keywords refused before dispatch. The server rejects instead of negotiating, and the guard follows the same shape — block first, then log the tool, the decision, the reason, and a hash of the arguments. Never the arguments. - The weakest part is the labelling, and it is worth saying so. MCP tools don't declare their trust level; we infer it from names with a conservative fallback for anything the rules can't classify. Mislabel a tool and the route set changes. It is on the site, in the README, and in the tool's own limits section.
Try it
Everything runs locally: Docker + pnpm, no model call, no API key, no
telemetry:
git clone https://github.com/bhanu-dev82/lethalpath && cd lethalpath
docker compose up -d && pnpm install && pnpm build
node packages/cli/dist/index.js scan --mcp your-mcp-config.json
- Code: https://github.com/bhanu-dev82/lethalpath
- Site: https://www.devanshustudios.com/lethalpath/
- Walkthrough (3:20): https://youtu.be/1nb_fyTwl_I
If you wire MCP servers into an agent, point scan --mcp at your own config
and look at what the arrangement can do. The routes are modelled and bounded,
the guard is a demo, and the receipt is still a count of zero.
Build note: this project was built with AI coding assistance. The repository
carries an AI_DISCLOSURE.md describing exactly where and how; every number
above traces to a committed file.
Top comments (4)
The "labels are inferred from tool names" limit gets sharpest with computer-use MCP servers, the ones that read the screen and click/type for the agent. There the trifecta lives inside one server and often inside one tool pair. A
screenshotorread_textcall is untrusted input (whatever web page or email is open) and private data (whatever else is on screen) in the same result, andtypeis a sink whose visibility depends on which window has focus at that moment. Nothing in the tool name tells you which of the three it is on a given call.With the conservative fallback, I'd guess the cheapest cut for such a config is always "revoke the write side", which leaves the server useless rather than safe. Did you consider modelling the screen itself as a Resource that is untrusted, private and public at once, so the graph at least shows the route as internal to one server? That points the fix at a runtime rule (confirm before send/submit, treat screen text as data, never as instructions) instead of a static revocation, which seems to be the only cut that keeps that class of tool usable.
Great point, and you've found exactly where name-based labelling breaks. With a computer-use server the trifecta lives inside one tool's output, so the conservative fallback marks it as everything at once, and the cheapest cut is "revoke type/click", which is safe but useless, as you say.
Modelling the screen as a single Resource that is untrusted, private and public at once is a nice idea. The graph would then show the route as internal to that server instead of hiding it, and the report could say "no useful static cut exists here, this needs a runtime rule" rather than proposing a revocation. That's also where the guard is heading: confirm before send/submit, and treat screen text as data, never instructions. I'll add it to the limits and open an issue for it. Thanks for reading this close
Thanks, glad it was useful. One detail for that issue: for the type sink, "confirm before send" is not quite enough on its own, because the same keystrokes become public or private depending on which window has focus at that moment. A runtime rule worth adding: check, right before every type call, that the focused app and window are the ones the task named, and refuse if focus moved. It is cheap and it catches the case no confirmation dialog would.
Good catch. That's the case a confirm dialog can't cover. A focus check right before every type call (the focused app and window must match the task, otherwise refuse) is cheap and deterministic. I'll put it in the issue alongside the screen-as-Resource idea.