The bug is in the arrangement: finding and cutting your AI agent's data-leak paths
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

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 — when the task 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.
( a ) - [:FLOWS { dir: 'feeds' | 'emits', capacity, revocable, grant, taskCost }] -> ( b ) Enter fullscreen mode Exit fullscreen mode 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 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 Enter fullscreen mode Exit fullscreen mode 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.
Private stores are sources; public sinks are targets:
CALL algo.maxFlow ({ sourceNodes: src, targetNodes: dst, relationshipTypes: [ 'FLOWS' ], capacityProperty: 'capacity' }) YIELD maxFlow, edges, edgeFlows Enter fullscreen mode Exit fullscreen mode 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 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 Enter fullscreen mode Exit fullscreen mode Zero rows is the receipt. Not "should be fixed" — the same query that found the leak, finding nothing.
The numbers, each with the file that contains it (a rule the repo follows everywhere):
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. None of that goes on a slide as more than it is.
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 Enter fullscreen mode Exit fullscreen mode Code: https://github.com/bhanu-dev82/lethalpath Site: https://www.devanshustudios.com/lethalpath/ Walkthrough (3:20): PASTE-YOUTUBE-URL-HERE 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.