The Bug Is in the Arrangement: How Tool Composition Creates Data-Leak Paths in AI Agents A developer built LethalPath, a tool that models multi-server MCP agent tool composition as a directed graph to find data-leak paths, using FalkorDB to run max-flow/min-cut algorithms that identify the smallest set of permissions to revoke. The work cites MCPHunt, a controlled benchmark in which policy-violating credential propagation appeared in 11.5–41.3% of runs depending on the model, with no attacker required. The approach targets what researchers call the "lethal trifecta": untrusted input, private data, and public write, where each tool is individually correct but the arrangement creates exfiltration paths. Give an agent three ordinary tools: one that reads a GitHub issue, one that reads a local folder, one that posts 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 call this setup Simon Willison's lethal trifecta: read untrusted input, touch private data, write somewhere public. The uncomfortable property is that every component is correct. The bug is the composition. Individual tool authorization is table stakes. You check credentials before the agent calls read github issue or write slack message . But tool composition creates transitive data flows that bypass per-tool boundaries. Example chain: /home/user/.ssh " read local folder private data post slack message public write Each tool call passes its own authorization check. The leak happens because the agent's shared context window connects them. Output from step 2 becomes input to step 3. No single tool is malicious. The arrangement is. 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. No attacker required. The arrangement alone did it. LethalPath treats tool composition as a directed graph problem. Nodes represent servers, tools, resources, and the agent's shared context. Edges represent data flow. The goal is to find paths from untrusted input to public output that pass through private data, then compute a minimum cut to break those paths. Schema: | Node Type | Holds | |---|---| | Server | Which MCP server supplies which tools | | Tool | Name, server, how the label was derived | | Resource | Data by label: kind, scope, trust, sensitivity, visibility | | Context | The agent's shared window where one tool's output becomes another tool's input | | Meta | Config counts and a ready flag, the last write of ingest | Edge: a - :FLOWS { dir: 'feeds' | 'emits', capacity, revocable, grant, taskCost } - b The FLOWS relationship carries the weight. dir indicates whether the edge represents a tool consuming data feeds or producing data emits . capacity models how much data can flow. revocable marks whether the permission can be withdrawn. grant tracks the authorization scope. taskCost estimates the computational or policy cost of allowing the flow. The graph query looks for paths from untrusted input nodes to public output nodes that pass through private data nodes. This is a reachability problem with constraints. Cypher-style query conceptual : php MATCH path = untrusted:Resource {trust: 'untrusted'} - :FLOWS - context:Context - :FLOWS - private:Resource {sensitivity: 'high'} - :FLOWS - context - :FLOWS - public:Resource {visibility: 'public'} WHERE ALL edge IN relationships path WHERE edge.revocable = true RETURN path The query returns all paths where untrusted input can reach public output via private data. The WHERE clause filters for paths composed entirely of revocable permissions, which are candidates for the minimum cut. Once you have leak paths, you need to decide which permissions to revoke. This is a minimum cut problem: find the smallest set of edges whose removal disconnects untrusted sources from public sinks. The graph uses FalkorDB a Redis-based graph database to run max-flow/min-cut algorithms. The capacity attribute on each edge weights the cost of revocation. High-capacity edges represent permissions that many tools depend on. Low-capacity edges are narrow dependencies that can be revoked with minimal disruption. Trade-offs: | Approach | Pros | Cons | |---|---|---| | Revoke all public-write tools | Simple, guaranteed safe | Breaks legitimate workflows | | Revoke all untrusted-read tools | Prevents prompt injection | Breaks issue-tracking integrations | | Minimum cut by capacity | Balances safety and utility | Requires accurate capacity modeling | | Stateful session tracking | Allows per-task policies | Adds orchestration complexity | The minimum cut approach revokes the fewest permissions while breaking all leak paths. But it requires accurate capacity modeling. If you underestimate the cost of revoking a permission, you break more workflows than necessary. If you overestimate, you leave leak paths open. Per-call authorization is stateless. Each tool call checks credentials independently. This is fast and simple, but it cannot prevent composition attacks because it has no memory of prior calls. Stateful authorization tracks the agent's execution session. It knows which tools have been called, which data has been read, and which outputs have been written. It can enforce policies like "if you read untrusted input, you cannot write to public outputs" or "if you read private data, you cannot call external APIs." Stateful session example: python class AgentSession: def init self : self.taint = set Track tainted data sources self.sinks = set Track output destinations self.policy = Policy def call tool self, tool name, args : Check if this call violates policy if tool name in self.policy.untrusted sources: self.taint.add 'untrusted' if tool name in self.policy.public sinks: self.sinks.add 'public' Block if tainted data can reach public sink if 'untrusted' in self.taint and 'public' in self.sinks: raise SecurityError "Composition policy violation" return execute tool tool name, args This is a simplified taint-tracking system. Real implementations need to handle data transformations does summarizing private data make it safe? , multi-step flows what if the agent writes to a private location first? , and policy exceptions some public writes are intentional . Graph-based permission models need observability to debug false positives and false negatives. What to log: Common failure modes: LethalPath runs as a sidecar to the agent runtime. It ingests the MCP config, builds the graph, and exposes an API for policy queries. Architecture: ┌─────────────┐ │ Agent │ │ Runtime │ └──────┬──────┘ │ │ Tool call request ▼ ┌─────────────┐ ┌─────────────┐ │ LethalPath │◄────►│ FalkorDB │ │ Sidecar │ │ Graph Store │ └──────┬──────┘ └─────────────┘ │ │ Allow/Deny ▼ ┌─────────────┐ │ MCP Server │ └─────────────┘ The sidecar intercepts tool calls, checks them against the graph, and either allows or denies based on the current policy. It also tracks session state for stateful authorization. Latency considerations: Use when: Avoid when: Graph-based permission models solve a real problem: composition attacks that bypass per-tool authorization. They work best when you have many tools, complex data flows, and strict compliance requirements. The cost is latency 10–50ms per call , operational complexity running a graph database , and the need for accurate capacity modeling. If your agent reads untrusted input and writes to public outputs, you need some form of composition-aware authorization. Graph-based models are one option. Simpler alternatives include stateful taint tracking faster but less expressive or restricting tool combinations safer but less flexible . The key insight is that the bug is not in the tools. The bug is in the arrangement. You cannot fix it by auditing individual tools. You have to model the composition.