跳到正文
DEV Community· Bhanu Nagpure·· 4 小時前AI 評分59

LethalPath 以圖模型找出並切斷 AI Agent 的資料外洩路徑

The bug is in the arrangement: finding and cutting your AI agent's data-leak paths

AI 導讀

Bhanu Nagpure 開發 LethalPath,以 FalkorDB 圖模型找出 MCP Agent 跨工具資料外洩路徑,並計算需撤銷的權限;工具會重跑查詢確認路徑是否清零。

正文

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.

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" — 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. None of that goes on a slide as more than it is.

What I learned

  1. 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 INDEX and db.idx.cch.query fail. I planned around a feature that does not ship, and un-planned it into algo.SPpaths/algo.SSpaths fallbacks with a maxLen bound. The lesson: verify every load-bearing claim by execution, and commit the fallback next to the claim.
  2. 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.
  3. Refusal order beats sanitising. Every product read goes through GRAPH.RO_QUERY with 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.
  4. 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

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.

來源:DEV Community · dev.to