# The Bug Is in the Arrangement: How Tool Composition Creates Data-Leak Paths in AI Agents

> Source: <https://dev.to/mech_app_ai/the-bug-is-in-the-arrangement-how-tool-composition-creates-data-leak-paths-in-ai-agents-2e6m>
> Published: 2026-10-19 00:07:47+00:00

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.
