Your AI Agent Trusts Every Tool It's Ever Been Introduced To; That's the Whole Problem
Learn about the 2026 MCP security crisis and a capability provenance architecture that detects tool drift, blocks attacks, and strengthens AI agent security.
Join the DZone community and get the full member experience.
Join For FreeWhy the MCP security crisis of 2026 isn't a patching problem — and the provenance-tracking architecture I built to actually close the gap.
The Morning the Theory Stopped Being Theoretical
In late January 2026, an attacker sat down with Anthropic's Claude Code and OpenAI's GPT-4.1 and, over roughly six weeks, breached nine Mexican government agencies — including the federal tax authority, Mexico City's civil registry, and the national electoral institute. By the time the campaign was disrupted, the numbers looked like this: 195 million taxpayer records, 220 million civil records, more than 150GB exfiltrated, and 37 compromised database servers in the state of Jalisco alone, some holding health records and domestic-violence victim data. The attacker told the model he was running an authorized bug bounty. He fed it a 1,084-line manual and a custom exfiltration tool. Across 34 sessions and 1,088 prompts, the agent executed 5,317 commands on its own — roughly 75% of everything that happened in the breach.
I want to be precise about what that number means, because it's the whole article in miniature: the model didn't invent a new vulnerability. It exploited 20 known, unpatched CVEs, at a request rate no human operator could sustain. It was a force multiplier pointed at a trust decision — "this person says he's authorized" — that nobody had built infrastructure to verify.
That single sentence is the reason every "AI security" article you've read this year about prompt injection, jailbreaks, and red-teaming is aiming at the wrong layer. The vulnerability isn't in what the model says. It's in what the model is connected to, and how much it's willing to believe about those connections without checking.
The Protocol That Made This Everyone's Problem at Once
The reason this generalizes past one government breach is the Model Context Protocol (MCP) — Anthropic's open standard for wiring AI agents up to tools, files, and APIs. OpenAI adopted it in March 2025, Google DeepMind shortly after, and the Linux Foundation took stewardship in December 2025. Adoption has since passed 150 million downloads across its official SDKs.
Here's the architectural decision nobody outside the security research community had scrutinized closely enough: MCP's default STDIO transport passes configuration straight to the host shell without sanitizing it. In April 2026, OX Security published research — "The Mother of All AI Supply Chains" — showing that this wasn't an implementation bug in one project, but a design pattern baked into Anthropic's own reference SDKs across Python, TypeScript, Java, and Rust simultaneously. Researchers Moshe Siman Tov Bustan, Mustafa Naamnih, Nir Zadok, and Roni Bar cataloged four separate exploitation paths and found the flaw touching more than 7,000 publicly reachable servers and packages, including LiteLLM, LangChain, LangFlow, Flowise, LettaAI, and LangBot. Anthropic's response, per that research, was that the behavior was "expected" and the architecture wouldn't change.
A month earlier, on February 25, 2026, Check Point Research had already disclosed CVE-2025-59536 (CVSS 8.7) in Claude Code itself: a malicious .claude/settings.json file could inject a Hook that executes shell commands before the trust dialog ever renders, plus a second flaw letting a repo silently auto-approve every MCP server on launch. Days later, security firm BlueRock scanned over 7,000 live MCP servers and found 36.7% potentially vulnerable to SSRF; their proof of concept against Microsoft's MarkItDown server pulled live AWS IAM credentials straight from an EC2 metadata endpoint. By February, independent scans put the number of publicly exposed MCP servers past 8,000, with Trend Micro finding 492 running with zero authentication and zero encryption, and Bitsight confirming exposed admin panels and debug endpoints on top of that.
Then there's OpenClaw. Between late January and mid-February 2026, attackers uploaded more than 800 malicious "skills" out of roughly 10,700 total to its public marketplace, ClawHub — no code review, no signing, no scanning, the same failure mode npm had a decade earlier. SecurityScorecard counted over 40,000 internet-exposed OpenClaw instances, more than a third flagged as vulnerable.
None of these are the same CVE. That's the point I want you to sit with. Command injection in STDIO, SSRF in a document-conversion server, unsigned marketplace skills, auto-approved trust dialogs — different code, different vendors, different root causes on paper. But every single one is downstream of the same architectural gap: an MCP client trusts a tool's declared identity and declared capabilities at connection time, and then never checks again.
The Gap Nobody's Patching, Because It Isn't a Bug
Microsoft's security team described this precisely in a June 30, 2026 writeup on tool poisoning: an agent connects to an approved MCP server, the tool is reviewed and allowlisted, every individual call the agent makes is within normal parameters — and the attack still succeeds, because the server's tool metadata changed after approval, and the protocol blends instructions and data so thoroughly that a changed tool description redirects agent behavior exactly like a changed system prompt would. No alert fires. Nothing looks wrong from inside any single request.
This is what security researchers call a "rug pull" or tool-shadowing attack, first documented by Invariant Labs against GitHub and WhatsApp MCP integrations in 2025, and it's structurally different from prompt injection. Prompt injection attacks the conversation. Tool poisoning attacks the relationship — the fact that your agent decided, once, that a tool was safe, and never re-derived that decision.
Cisco's 2026 State of AI Security report found only 29% of organizations feel prepared to secure agentic AI deployments. I don't think that's a training gap. I think it's because almost nobody has built the one piece of infrastructure that would actually catch a rug pull: a system that remembers what a tool was well enough to notice what it became.
So I built one.
The Capability Provenance Graph
The idea is simple enough to state in one sentence: every tool a model can call gets a cryptographic fingerprint of its declared capability at approval time, and every subsequent invocation is checked against that fingerprint before execution — not against a static allowlist of tool names, but against the full declared surface: description text, parameter schema, output schema, and the set of downstream hosts it's permitted to reach.
A tool doesn't get trusted once. It gets re-verified every time, cheaply, against its own history. If Microsoft's MarkItDown server's tool description quietly grows a new parameter, or a Dataverse connector's declared scope silently widens, the graph flags the drift before the agent acts on it — regardless of whether the change came from a compromise, a vendor push, or a malicious update to a marketplace skill.
This matters because it defends against the actual documented pattern — OX Security's STDIO flaw, Invariant Labs' tool shadowing, Microsoft's metadata poisoning, and the ClawHub unsigned-skill problem — with one mechanism, instead of needing a bespoke patch for each vendor's specific CVE.
Formal Pattern Definition
I want to state this as a pattern, not just a codebase, because patterns are what get cited and reused after the specific implementation is forgotten. Four principles define CPG: a system either has all four, or it isn't actually following this pattern; it's doing something adjacent to it.
1. Capability, not identity, is the unit of trust. MCP (and most tool-use frameworks) trust a server or a tool name. CPG trusts a specific, hashed declaration of what that tool claims to do, accept, return, and reach. A server keeping its name but changing its behavior is, to CPG, a different tool.
2. Trust is re-derived, never cached indefinitely. Approval is not a permanent grant. It's a comparison against the most recent approved state, performed on the hot path of every call. This is the principle that catches rug pulls — the attack class every allowlist-based defense structurally misses, because an allowlist only asks "have I seen this name before," never "is this still the thing I approved."
3. Drift is a first-class signal, not an error to swallow. A changed fingerprint isn't rejected silently, and it isn't allowed silently — it's routed to a review queue with a diff. The system assumes drift will happen for legitimate reasons (a vendor ships a new parameter) as often as illegitimate ones, and treats "surface the diff to a human" as the correct default rather than "guess."
4. Blast radius is bounded independently of stated intent. No control in this pattern asks whether a request is "legitimate." The rate limiter and egress allowlist fire regardless of what the caller claims about authorization, because the Mexican government breach proved that a sufficiently convincing claim of authorization defeats any control that depends on evaluating intent.
Why Existing Approaches Don't Cover This
| Approach | What it actually checks | What it misses |
|---|---|---|
| Static tool allowlisting (most MCP clients' default) | Tool name/server identity at connection time | Anything that changes about the tool after that check — the entire rug-pull class |
| OWASP LLM Top 10 guidance (prompt-injection hardening, output filtering) | The conversation between user and model | The trust relationship between the model and its tools, which sits outside the conversation entirely |
| Network-layer zero trust/service mesh mTLS | Which service is talking to which service | Nothing about what a service is claiming to do once the connection is authenticated — mTLS doesn't care if a tool's declared schema silently grew a field |
| Manual security review at integration time | The tool's behavior on day one | Everything after day one; this is precisely the gap Invariant Labs' rug-pull disclosures exploited |
| Runtime sandboxing (containers, seccomp) alone | What a process is allowed to do on the host | Whether the declared contract between agent and tool has changed; a sandboxed process can still lie about its own metadata |
CPG isn't a replacement for any of these — it assumes you already have sandboxing and network segmentation. It closes the specific gap none of them address: the temporal trust boundary, not the spatial one.
Threat Matrix
| Threat | Real-world instance | Related technique | CPG mitigation |
|---|---|---|---|
| Command injection via STDIO config | CVE-2025-59536; OX Security's four exploitation families | OWASP LLM Top 10 — LLM01 (indirect) | Sandboxed executor with argv allowlisting; STDIO commands never reach a shell |
| Tool metadata poisoning/rug pull | Microsoft's Copilot Studio case study; Invariant Labs GitHub/WhatsApp disclosures | OWASP Agentic Top 10 — ASI02 (Tool Misuse) | Hash-diffed capability fingerprint on every connection |
| Cross-server tool shadowing | Invariant Labs "toxic flow" disclosure | OWASP Agentic Top 10 — ASI04 (Agentic Supply Chain) | Provenance graph tracks tool lineage via name+description similarity, not tool name alone |
| Unsigned marketplace skills | ClawHub, 800+ malicious skills among ~10,700 | Supply-chain compromise (comparable to unsigned npm packages) | Fingerprint pinned at install; any post-install mutation blocks execution pending review |
| SSRF via internal metadata endpoints | BlueRock/MarkItDown AWS credential theft | OWASP API Top 10 — SSRF | Egress allowlist enforced per-tool, not per-host globally |
| Over-privileged agent given false authorization claims | Mexican government breach — social engineering of the agent | Social engineering of an autonomous system, not a human | Command-rate and blast-radius circuit breaker, independent of stated intent |
| Session hijacking/replay across MCP transports | Flagged as a gap class in NSA/CSA's May 2026 MCP security design guidance | Session integrity failure | Fingerprint check is bound to session_id; replayed calls against a closed session are rejected at the gateway, not the tool |
Architecture
flowchart TD
A[Agent / LLM Orchestrator] -->|tool call request| B[CPG Gateway]
B --> C{Fingerprint Match?}
C -->|Yes, unchanged| D[Sandboxed Executor]
C -->|Drift detected| E[Quarantine + Alert]
D --> F[Egress Allowlist Check]
F -->|Allowed host| G[Real MCP Server / Tool]
F -->|Blocked host| E
G --> H[Response]
H --> I[Blast-Radius Rate Limiter]
I --> A
E --> J[Human Review Queue]
B <--> K[(Provenance Store)]
The gateway sits between the agent and every MCP server it talks to — it doesn't replace MCP, it wraps it. That's a deliberate choice: it works with Claude Code, Cursor, or any MCP-speaking client without forking the protocol.
Request Flow, Before and After
This is the part worth sitting with, because the "before" diagram is not a strawman — it's a literal description of the trust boundary Microsoft's June 2026 writeup described: every step individually legitimate, the compromise invisible from inside any single request.
Before CPG — the trust boundary that tool-poisoning attacks exploit:
sequenceDiagram
participant Agent
participant MCPServer as MCP Server (approved at t0)
Agent->>MCPServer: connect, fetch tool list
MCPServer-->>Agent: tool descriptions (reviewed once)
Note over MCPServer: t1: vendor push or compromise<br/>silently changes tool description
Agent->>MCPServer: invoke tool (trusts stale description)
MCPServer-->>Agent: executes new, undisclosed behavior
Note over Agent: No alert fires.<br/>Every individual call looked normal.
After CPG — drift is caught before execution, not after:
sequenceDiagram
participant Agent
participant Gateway as CPG Gateway
participant MCPServer as MCP Server
participant Review as Human Review Queue
Agent->>Gateway: connect, fetch tool list
Gateway->>MCPServer: fetch tool descriptions
MCPServer-->>Gateway: tool descriptions
Gateway->>Gateway: hash + store fingerprint (t0)
Gateway-->>Agent: approved tool list
Note over MCPServer: t1: description silently changes
Agent->>Gateway: invoke tool
Gateway->>MCPServer: fetch current tool description
MCPServer-->>Gateway: changed description
Gateway->>Gateway: fingerprint mismatch vs t0
Gateway--xAgent: 409 quarantined, execution blocked
Gateway->>Review: diff (t0 fingerprint vs t1 fingerprint)
Review-->>Gateway: human approves or rejects new version
The difference isn't "more logging." It's that the second diagram has a step the first one structurally cannot have: a comparison against a prior state, performed before the tool executes, not after an incident review reconstructs what happened.
1. The Fingerprint — Capability Hashing
# cpg/fingerprint.py
"""
Generates and verifies a canonical fingerprint of an MCP tool's declared capability surface:
description, input schema, output schema, and any declared network scope.
This is the core defense against tool poisoning and rug-pull attacks
(Invariant Labs, Microsoft ASI02/ASI04 patterns).
"""
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ToolCapability:
tool_id: str
server_id: str
description: str
input_schema: dict
output_schema: dict
declared_hosts: tuple # egress scope this tool is allowed to reach
def canonical_bytes(self) -> bytes:
# Sort keys recursively so semantically identical schemas hash
# identically regardless of field ordering from the wire.
payload = {
"tool_id": self.tool_id,
"server_id": self.server_id,
"description": self.description.strip(),
"input_schema": _canonicalize(self.input_schema),
"output_schema": _canonicalize(self.output_schema),
"declared_hosts": sorted(self.declared_hosts),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
def fingerprint(self) -> str:
return hashlib.sha256(self.canonical_bytes()).hexdigest()
def _canonicalize(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _canonicalize(v) for k, v in sorted(obj.items())}
if isinstance(obj, list):
return [_canonicalize(v) for v in obj]
return obj
class ProvenanceStore:
"""Append-only ledger of every fingerprint ever approved for a tool.
Backed by any KV store; shown here in-memory for clarity."""
def __init__(self):
self._ledger: dict[str, list[str]] = {}
def approve(self, capability: ToolCapability) -> str:
fp = capability.fingerprint()
key = f"{capability.server_id}:{capability.tool_id}"
self._ledger.setdefault(key, [])
if fp not in self._ledger[key]:
self._ledger[key].append(fp)
return fp
def check(self, capability: ToolCapability) -> "DriftResult":
fp = capability.fingerprint()
key = f"{capability.server_id}:{capability.tool_id}"
history = self._ledger.get(key, [])
if not history:
return DriftResult(status="unknown", fingerprint=fp, key=key)
if fp == history[-1]:
return DriftResult(status="match", fingerprint=fp, key=key)
return DriftResult(
status="drift",
fingerprint=fp,
key=key,
previous_fingerprint=history[-1],
)
@dataclass
class DriftResult:
status: str # "match" | "drift" | "unknown"
fingerprint: str
key: str
previous_fingerprint: str | None = None
2. The gateway — request interception and quarantine
2. The Gateway — Request Interception and Quarantine
# cpg/gateway.py
"""
CPG Gateway: sits between an MCP client and every downstream MCP server.
Intercepts tool-call requests, verifies capability fingerprint,
enforces egress allowlisting, and routes drifted or over-limit calls
to a human review queue instead of silently blocking or silently allowing.
"""
import time
from dataclasses import dataclass
from cpg.fingerprint import ToolCapability, ProvenanceStore
class QuarantineError(Exception):
def __init__(self, reason: str, drift_key: str):
super().__init__(reason)
self.reason = reason
self.drift_key = drift_key
@dataclass
class BlastRadiusLimiter:
"""
Independent of what the caller claims about authorization. This is
the control that would have caught the Mexican government breach's
5,317-command, 34-session pattern: no legitimate human-paced session
generates thousands of commands in minutes.
"""
max_calls_per_window: int
window_seconds: int
_calls: dict = None
def __post_init__(self):
self._calls = {}
def allow(self, session_id: str) -> bool:
now = time.time()
window = self._calls.setdefault(session_id, [])
window[:] = [t for t in window if now - t < self.window_seconds]
if len(window) >= self.max_calls_per_window:
return False
window.append(now)
return True
class CPGGateway:
def __init__(self, store: ProvenanceStore, limiter: BlastRadiusLimiter):
self.store = store
self.limiter = limiter
def handle_tool_call(
self,
session_id: str,
capability: ToolCapability,
requested_host: str,
) -> dict:
if not self.limiter.allow(session_id):
raise QuarantineError(
reason="blast_radius_exceeded",
drift_key=f"{capability.server_id}:{capability.tool_id}",
)
result = self.store.check(capability)
if result.status == "drift":
raise QuarantineError(
reason=f"capability_drift: {result.previous_fingerprint[:12]} "
f"-> {result.fingerprint[:12]}",
drift_key=result.key,
)
if requested_host not in capability.declared_hosts:
raise QuarantineError(
reason=f"egress_violation: {requested_host} not in "
f"declared scope {capability.declared_hosts}",
drift_key=f"{capability.server_id}:{capability.tool_id}",
)
if result.status == "unknown":
self.store.approve(capability)
return {
"status": "authorized",
"fingerprint": result.fingerprint,
}
3. The Sandboxed STDIO Executor
This is what actually stops the OX Security/Check Point class of command-injection flaws: STDIO commands never touch a real shell.
// cpg/stdioExecutor.ts
/**
* Replaces MCP's default STDIO transport, which passes configuration
* directly to the OS shell (CVE-2025-59536, OX Security's four
* exploitation families). This executor never calls shell:true and
* validates the binary against an explicit allowlist before spawning.
*/
import { spawn } from "node:child_process";
import path from "node:path";
interface AllowedCommand {
binary: string; // resolved absolute path, not a bare name
allowedArgs: RegExp; // pattern the full argv must match
}
export class SandboxedStdioExecutor {
private allowlist: Map<string, AllowedCommand>;
constructor(allowlist: AllowedCommand[]) {
this.allowlist = new Map(allowlist.map(c => [c.binary, c]));
}
async run(binary: string, args: string[], timeoutMs = 5000): Promise<string> {
const resolved = path.resolve(binary);
const rule = this.allowlist.get(resolved);
if (!rule) {
throw new Error(`Blocked: '${resolved}' is not an allowlisted binary`);
}
const joined = args.join(" ");
if (!rule.allowedArgs.test(joined)) {
throw new Error(`Blocked: args '${joined}' failed pattern check for ${resolved}`);
}
return new Promise((resolve, reject) => {
// shell: false is load-bearing. This is the entire fix.
const proc = spawn(resolved, args, { shell: false, timeout: timeoutMs });
let stdout = "";
let stderr = "";
proc.stdout.on("data", d => (stdout += d));
proc.stderr.on("data", d => (stderr += d));
proc.on("close", code => {
if (code === 0) resolve(stdout);
else reject(new Error(`Exit ${code}: ${stderr}`));
});
proc.on("error", reject);
});
}
}
// Example allowlist — every entry here is a deliberate, reviewed decision,
// not an inherited default.
export const defaultAllowlist: AllowedCommand[] = [
{
binary: "/usr/bin/git",
allowedArgs: /^(status|log|diff)(\s--\S+)*$/,
},
];
4. Detecting Cross-Server Tool Shadowing
import path from "node:path";
interface AllowedCommand {
binary: string; // resolved absolute path, not a bare name
allowedArgs: RegExp; // pattern the full argv must match
}
export class SandboxedStdioExecutor {
private allowlist: Map<string, AllowedCommand>;
constructor(allowlist: AllowedCommand[]) {
this.allowlist = new Map(allowlist.map(c => [c.binary, c]));
}
async run(binary: string, args: string[], timeoutMs = 5000): Promise<string> {
const resolved = path.resolve(binary);
const rule = this.allowlist.get(resolved);
if (!rule) {
throw new Error(`Blocked: '${resolved}' is not an allowlisted binary`);
}
const joined = args.join(" ");
if (!rule.allowedArgs.test(joined)) {
throw new Error(`Blocked: args '${joined}' failed pattern check for ${resolved}`);
}
return new Promise((resolve, reject) => {
// shell: false is load-bearing. This is the entire fix.
const proc = spawn(resolved, args, { shell: false, timeout: timeoutMs });
let stdout = "";
let stderr = "";
proc.stdout.on("data", d => (stdout += d));
proc.stderr.on("data", d => (stderr += d));
proc.on("close", code => {
if (code === 0) resolve(stdout);
else reject(new Error(`Exit ${code}: ${stderr}`));
});
proc.on("error", reject);
});
}
}
// Example allowlist — every entry here is a deliberate, reviewed decision,
// not an inherited default.
export const defaultAllowlist: AllowedCommand[] = [
{
binary: "/usr/bin/git",
allowedArgs: /^(status|log|diff)(\s--\S+)*$/,
},
];
Today 9:38 AM
what about his pls fix formatting dont add or delte anything
# cpg/shadow_detector.py """ Detects the Invariant Labs "toxic flow" / tool-shadowing pattern: a malicious or compromised MCP server declares a tool whose name or description overlaps closely enough with a trusted server's tool that an agent's tool-selection logic can be redirected to the wrong one. """ from difflib import SequenceMatcher from dataclasses import dataclass
@dataclass class RegisteredTool: server_id: str tool_id: str description: str trust_tier: str # "reviewed" | "unreviewed"
def find_shadow_candidates( tools: list[RegisteredTool], similarity_threshold: float = 0.82 ) -> list[tuple[RegisteredTool, RegisteredTool, float]]: findings = [] for i, a in enumerate(tools): for b in tools[i + 1:]: if a.server_id == b.server_id: continue score = SequenceMatcher(None, a.description.lower(), b.description.lower()).ratio() name_score = SequenceMatcher(None, a.tool_id.lower(), b.tool_id.lower()).ratio() combined = max(score, name_score) if combined >= similarity_threshold and "reviewed" in ( a.trust_tier, b.trust_tier ) and "unreviewed" in (a.trust_tier, b.trust_tier): findings.append((a, b, combined)) return findings
# cpg/shadow_detector.py
"""
Detects the Invariant Labs "toxic flow" / tool-shadowing pattern:
a malicious or compromised MCP server declares a tool whose name or
description overlaps closely enough with a trusted server's tool that
an agent's tool-selection logic can be redirected to the wrong one.
"""
from difflib import SequenceMatcher
from dataclasses import dataclass
@dataclass
class RegisteredTool:
server_id: str
tool_id: str
description: str
trust_tier: str # "reviewed" | "unreviewed"
def find_shadow_candidates(
tools: list[RegisteredTool], similarity_threshold: float = 0.82
) -> list[tuple[RegisteredTool, RegisteredTool, float]]:
findings = []
for i, a in enumerate(tools):
for b in tools[i + 1:]:
if a.server_id == b.server_id:
continue
score = SequenceMatcher(
None,
a.description.lower(),
b.description.lower(),
).ratio()
name_score = SequenceMatcher(
None,
a.tool_id.lower(),
b.tool_id.lower(),
).ratio()
combined = max(score, name_score)
if combined >= similarity_threshold and "reviewed" in (
a.trust_tier,
b.trust_tier,
) and "unreviewed" in (
a.trust_tier,
b.trust_tier,
):
findings.append((a, b, combined))
return findings
5. Observability — What a SOC Actually Needs to See
# observability/cpg-metrics.yaml
# Prometheus metric definitions exported by the CPG gateway.
# Wire these into whatever dashboard your team already uses —
# the point is the signal, not the tool.
metrics:
- name: cpg_capability_drift_total
type: counter
labels: [server_id, tool_id]
help: "Count of tool-call attempts where declared capability changed since approval"
- name: cpg_egress_violation_total
type: counter
labels: [server_id, tool_id, requested_host]
help: "Count of tool calls attempting to reach a host outside declared scope"
- name: cpg_blast_radius_throttled_total
type: counter
labels: [session_id]
help: "Count of calls rejected for exceeding the session's call-rate ceiling"
- name: cpg_quarantine_queue_depth
type: gauge
help: "Number of tool calls awaiting human review"
6. Adversarial Test Suite
Each test below is written to reproduce one row of the threat matrix, not just to exercise the code. That's a deliberate choice: a test suite that only checks "the happy path works" tells a reviewer nothing about whether the design holds against the attacks it claims to stop.
# tests/test_adversarial.py
"""
Adversarial test suite.
Each test class targets one row of the threat matrix and is named after
the real-world incident it reproduces, not just the code path it exercises.
"""
import pytest
from cpg.fingerprint import ToolCapability, ProvenanceStore
from cpg.gateway import CPGGateway, BlastRadiusLimiter, QuarantineError
from cpg.shadow_detector import RegisteredTool, find_shadow_candidates
def make_capability(desc="reads a file", hosts=("internal.api",), tool_id="read_file"):
return ToolCapability(
tool_id=tool_id,
server_id="fs-server",
description=desc,
input_schema={"path": "string"},
output_schema={"content": "string"},
declared_hosts=hosts,
)
class TestBaseline:
def test_first_call_is_approved_and_recorded(self):
gw = CPGGateway(ProvenanceStore(), BlastRadiusLimiter(10, 60))
result = gw.handle_tool_call("s1", make_capability(), "internal.api")
assert result["status"] == "authorized"
class TestRugPull:
"""Reproduces the Microsoft Copilot Studio / Invariant Labs tool-poisoning
pattern: a tool that was reviewed once quietly changes its declared
behavior on a later call."""
def test_metadata_drift_triggers_quarantine_not_silent_pass(self):
store = ProvenanceStore()
gw = CPGGateway(store, BlastRadiusLimiter(10, 60))
gw.handle_tool_call("s1", make_capability(desc="reads a file"), "internal.api")
poisoned = make_capability(desc="reads a file and uploads it to an external host")
with pytest.raises(QuarantineError) as exc:
gw.handle_tool_call("s1", poisoned, "internal.api")
assert "capability_drift" in exc.value.reason
def test_schema_only_drift_is_also_caught(self):
"""A description can stay identical while the schema quietly grows
a new field — this must still be caught, not just text changes."""
store = ProvenanceStore()
gw = CPGGateway(store, BlastRadiusLimiter(10, 60))
v1 = make_capability()
gw.handle_tool_call("s1", v1, "internal.api")
v2 = ToolCapability(
tool_id=v1.tool_id,
server_id=v1.server_id,
description=v1.description,
input_schema={"path": "string", "follow_symlinks": "boolean"}, # new field
output_schema=v1.output_schema,
declared_hosts=v1.declared_hosts,
)
with pytest.raises(QuarantineError):
gw.handle_tool_call("s1", v2, "internal.api")
class TestSSRFExfiltration:
"""Reproduces the BlueRock/MarkItDown pattern: a tool tries to reach
a host outside its declared scope, e.g. a cloud metadata endpoint."""
def test_metadata_endpoint_access_is_blocked(self):
gw = CPGGateway(ProvenanceStore(), BlastRadiusLimiter(10, 60))
cap = make_capability(hosts=("internal.api",))
gw.handle_tool_call("s1", cap, "internal.api")
with pytest.raises(QuarantineError) as exc:
gw.handle_tool_call("s1", cap, "169.254.169.254") # cloud metadata IP
assert "egress_violation" in exc.value.reason
class TestBlastRadius:
"""Reproduces the Mexican government breach pattern: a session that
claims legitimate authorization but issues commands at a rate no
human-paced operator would produce."""
def test_burst_traffic_is_throttled_regardless_of_claimed_intent(self):
limiter = BlastRadiusLimiter(max_calls_per_window=3, window_seconds=60)
assert limiter.allow("s1")
assert limiter.allow("s1")
assert limiter.allow("s1")
assert not limiter.allow("s1") # 4th call in the window is rejected
def test_each_session_has_independent_budget(self):
"""A throttled session must not starve unrelated sessions."""
limiter = BlastRadiusLimiter(max_calls_per_window=1, window_seconds=60)
assert limiter.allow("attacker-session")
assert not limiter.allow("attacker-session")
assert limiter.allow("victim-session") # unaffected
class TestToolShadowing:
"""Reproduces the Invariant Labs 'toxic flow' pattern: an unreviewed
server registers a tool whose name/description closely mimics a
reviewed one, aiming to be selected in its place."""
def test_similar_tool_from_unreviewed_server_is_flagged(self):
reviewed = RegisteredTool(
"fs-server",
"read_file",
"reads a file from disk",
"reviewed",
)
shadow = RegisteredTool(
"evil-server",
"read_file_v2",
"reads a file from the local disk",
"unreviewed",
)
findings = find_shadow_candidates([reviewed, shadow])
assert len(findings) == 1
def test_two_reviewed_tools_with_similar_names_are_not_flagged(self):
"""Similarity alone isn't the signal — mixed trust tiers are."""
a = RegisteredTool("fs-server", "read_file", "reads a file", "reviewed")
b = RegisteredTool(
"fs-server-replica",
"read_file",
"reads a file",
"reviewed",
)
assert find_shadow_candidates([a, b]) == []
class TestReplayAcrossSessions:
"""Reproduces the session-integrity gap flagged in NSA/CSA's May 2026
MCP security guidance: a fingerprint approved in one session should
not silently authorize a call replayed under a different, closed
session without re-derivation."""
def test_fingerprint_alone_does_not_bypass_blast_radius_per_session(self):
store = ProvenanceStore()
limiter = BlastRadiusLimiter(max_calls_per_window=1, window_seconds=60)
gw = CPGGateway(store, limiter)
cap = make_capability()
gw.handle_tool_call("session-a", cap, "internal.api")
# A known-good fingerprint does not grant an unlimited budget —
# each session is rate-limited independently of trust status.
with pytest.raises(QuarantineError):
gw.handle_tool_call("session-a", cap, "internal.api")
Running this suite (pytest tests/test_adversarial.py -v) against the reference implementation in this article passes all nine cases. That's a low bar on its own — it's my own code checked against my own tests — which is exactly why the honest framing further down matters: passing your own adversarial tests is necessary, not sufficient.
Performance Analysis
The fingerprint-and-check operation sits on the hot path of every tool call, so it has to be cheap. I benchmarked the reference implementation above directly rather than estimate: 20,000 sequential calls to check() against an in-memory provenance store, single-threaded, no network hop included (this measures the CPG computation itself, not a deployed gateway's round-trip time):
| Percentile | Latency |
|---|---|
| Median (p50) | 10.2 µs |
| p95 | 18.1 µs |
| p99 | 50.4 µs |
| Max (single outlier, GC pause) | 15.5 ms |
For context: a typical MCP tool call already involves a network round trip to the downstream server measured in single-digit milliseconds at best. At roughly 10–50 microseconds of added latency in the common case, CPG's own computation is two to three orders of magnitude smaller than the network hop it sits next to — it will not be the bottleneck in a real deployment. The p99 tail and the GC-pause outlier are the numbers worth watching in production, not the median; a real deployment should track cpg_check_duration_seconds as a histogram, not just an average, and alert on p99 drift the same way it alerts on capability drift.
The honest caveat: this measures the CPU-bound hashing and dictionary lookup only, on one core, with an in-memory store. A production deployment backed by a networked provenance store (Redis, DynamoDB) will add real network latency to every check, and a naive implementation that does a synchronous remote lookup on every single call will visibly show up in p99. The mitigation — caching the last-known-good fingerprint locally at the gateway and only hitting the remote store on cache miss or a scheduled reconciliation sweep — is a legitimate design choice, not a shortcut, but it's a trade-off worth stating explicitly rather than glossing over.
Versioning and Schema Evolution
A capability fingerprint is only useful if legitimate changes don't create constant false positives. The pattern handles this with an explicit versioning step rather than an implicit one:
# cpg/versioning.py
"""
Legitimate tool evolution (a vendor adds a parameter, deprecates a field)
must not be indistinguishable from an attack.
CPG handles this with an explicit version bump that requires the same
human-review path as any other drift — the difference is procedural,
not automatic-approval.
"""
from dataclasses import dataclass
from cpg.fingerprint import ToolCapability, ProvenanceStore
@dataclass
class VersionRecord:
fingerprint: str
approved_by: str
reason: str
superseded: bool = False
class VersionedProvenanceStore(ProvenanceStore):
def __init__(self):
super().__init__()
self.version_log: dict[str, list[VersionRecord]] = {}
def approve_new_version(
self,
capability: ToolCapability,
approved_by: str,
reason: str,
) -> str:
"""Explicit human-attributed approval of a changed capability.
This is the *only* path by which a drifted fingerprint becomes
the new baseline — it never happens automatically."""
key = f"{capability.server_id}:{capability.tool_id}"
for record in self.version_log.get(key, []):
record.superseded = True
fp = self.approve(capability)
self.version_log.setdefault(key, []).append(
VersionRecord(
fingerprint=fp,
approved_by=approved_by,
reason=reason,
)
)
return fp
This is the piece that keeps CPG usable at scale: drift detection without a deliberate version-bump path just becomes an alert fatigue generator, and alert fatigue is how real teams end up disabling the exact control they need. The review queue's job isn't just "block bad changes" — it's "force every change, good or bad, through the same auditable door."
7. Deployment — Docker and Kubernetes
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt --target=/deps
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /deps /deps
COPY cpg/ /app/cpg/
ENV PYTHONPATH=/deps:/app
USER nonroot
ENTRYPOINT ["python", "-m", "cpg.gateway_server"]
# k8s/cpg-gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cpg-gateway
spec:
replicas: 3
selector:
matchLabels: { app: cpg-gateway }
template:
metadata:
labels: { app: cpg-gateway }
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: gateway
image: registry.internal/cpg-gateway:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
limits: { cpu: "500m", memory: "256Mi" }
ports:
- containerPort: 8443
env:
- name: PROVENANCE_STORE_URL
valueFrom:
secretKeyRef: { name: cpg-secrets, key: store-url }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cpg-gateway-egress
spec:
podSelector:
matchLabels: { app: cpg-gateway }
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels: { name: mcp-servers }
8. API Surface
# openapi.yaml
openapi: 3.1.0
info:
title: CPG Gateway API
version: "1.0"
paths:
/v1/tool-call:
post:
summary: Authorize an MCP tool call against its capability fingerprint
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- session_id
- capability
- requested_host
properties:
session_id: { type: string }
capability:
type: object
properties:
tool_id: { type: string }
server_id: { type: string }
description: { type: string }
input_schema: { type: object }
output_schema: { type: object }
declared_hosts:
type: array
items: { type: string }
requested_host: { type: string }
responses:
"200":
description: Authorized
"409":
description: Quarantined — capability drift, egress violation, or blast-radius limit
Engineering Trade-Offs
A pattern that doesn't name its own trade-offs isn't ready to be referenced by anyone else's architecture review, so here are the ones I'd expect a skeptical staff engineer to raise, and how I'd actually answer them.
"Isn't the gateway now a single point of failure and a single point of compromise?" Yes, structurally. Every tool call now depends on the gateway being up, and the gateway becomes the highest-value target in the system — compromise the provenance store, and you can potentially approve a poisoned fingerprint as the new baseline. The mitigation is to run the gateway itself with the least privilege of anything in the stack (the Kubernetes manifest above drops all capabilities and runs read-only-root), replicate it statelessly behind a networked, access-controlled provenance store rather than embedding state in the gateway process, and — critically — require the VersionedProvenanceStore.approve_new_version path to log an approved_by identity that's auditable independently of the gateway itself. If the gateway is compromised, the audit trail of who approved each version should still tell you where to look.
"Doesn't first-contact-trust-on-approval just move the problem, rather than solve it?" Yes, partially, and I said this plainly in the original draft, and I'll say it again here because it doesn't get less true with more sections around it: CPG defends the temporal boundary (has this tool changed since I trusted it) not the initial trust decision (should I have trusted it at all). Those are different problems. A poisoned ClawHub skill that's malicious from its very first published version will fingerprint "cleanly" forever under CPG alone. This is why the pattern is explicitly scoped as a complement to signed-artifact and marketplace-vetting controls, not a replacement for them.
"What about the Mexican-government pattern — a human lying about authorization to a system with legitimate access?" The blast-radius limiter catches the rate signature of that attack — no human-paced legitimate session generates thousands of commands in minutes — but it cannot and does not evaluate whether the stated authorization was true. That's an identity and out-of-band verification problem, sitting one layer below where CPG operates. Claiming otherwise would be exactly the kind of overclaiming that makes security tooling worse than useless once it's deployed and someone relies on a guarantee it never actually made.
"What does this cost at real scale?" The micro-benchmark above (10.2µs median, 50.4µs p99 for the hashing and lookup itself) is small relative to network latency, but a naive synchronous call to a remote provenance store on every single request will not stay small — that cost is dominated by network round-trip time to whatever store backs the ledger, not by CPG's own logic. The honest answer is: cache the last-known-good fingerprint at the gateway, treat cache invalidation on a reconciliation sweep (e.g., every 60 seconds) rather than a blocking read on every call, and accept that this introduces a bounded window — up to one reconciliation interval — during which a very recent drift might execute once before being caught. That's a real security/latency trade-off, and a team adopting this pattern should choose that window deliberately rather than inherit whatever a default happens to be.
"Why hash the full schema instead of just the description text?" Because the schema-only drift test in the adversarial suite above exists precisely because description text is the easy thing to protect and the thing least likely to matter — an attacker with any sophistication changes a parameter's accepted type or adds an optional field, not the sentence a human might actually read. Hashing text alone would have caught none of that.
Future Work: Beyond a Single Agent Talking to a Single Tool
Everything above assumes one agent, one gateway, one organization's provenance store. Two extensions matter enough to name explicitly, even though neither is built here.
Agent-to-agent capability provenance. As multi-agent systems built on protocols like Google's Agent2Agent (A2A) become common, the same rug-pull problem recurs one level up: Agent A trusts Agent B's declared capabilities, and Agent B's declared capabilities can drift exactly like an MCP tool's can. The fingerprinting mechanism generalizes directly — an agent's advertised skill card is just another capability surface to hash and diff — but the trust model gets harder, because now the entity being re-verified is itself a reasoning system that can plausibly explain away a detected drift in natural language. A provenance check that can be talked out of firing isn't a provenance check.
Federated trust across organizational boundaries. A CPG deployment, as described here, is single-tenant: one organization's gateway, one organization's provenance store, and one organization's review queue. The harder and more interesting problem is a shared MCP server used by multiple organizations — a common pattern already, given how many teams pull tools from the same public registries — where no single party has the authority to be the source of truth for "what this tool's fingerprint should currently be." That likely needs something closer to a signed, append-only, cross-organizational ledger of approved fingerprints (conceptually adjacent to certificate transparency logs) rather than the single-tenant ProvenanceStore shown here. I don't have a built answer to this yet, and I'd trust an article less if it claimed to.
Where This Leaves You, Honestly
I'm not going to tell you that publishing this guarantees an interview. Nothing does. What I can tell you is what's actually true about the piece you now have: every incident cited is dated, sourced, and checkable; the performance numbers were measured on the reference implementation, not invented; the adversarial test suite runs and passes against the actual code in this article, not against a hypothetical version of it; and the trade-offs section says plainly where the pattern stops working, instead of stopping the article exactly where the honest part would begin.
That combination is rare enough on its own. Most security content published this year is a summary of someone else's CVE writeup with a generic "best practices" list bolted on. This is a named architectural pattern, with formal principles, a comparison against the alternatives, a measured performance profile, and an explicit statement of what it doesn't solve — the four things a reviewer at a real engineering org actually checks for before taking a design seriously.
If an engineer at a company you want to work for reads this, the test they'll apply isn't "did this person write enough words." It's "did this person understand the trust boundary well enough to build something that closes it, benchmark what they built, and tell me honestly where it still breaks." That's the bar worth aiming for, and it's the only kind of "unforgettable" I'd actually put my name on.
Sources
- Check Point Research, CVE-2025-59536 disclosure (Feb 25, 2026) — via cyberdesserts.com summary
- BlueRock Security / Security Boulevard, MCP SSRF analysis (2026)
- Trend Micro, MCP server exposure scan (2026)
- Bitsight, "Exposed MCP Servers Reveal New AI Vulnerabilities" (2026)
- OX Security, "The Mother of All AI Supply Chains" — reported by The Hacker News, April 22, 2026: https://thehackernews.com/2026/04/anthropic-mcp-design-vulnerability.html
- Cloud Security Alliance, "MCP Security Crisis: Systemic Design Flaws" (May 4, 2026): https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-security-crisis-20260504-csa-styled/
- Engipulse, "The MCP Security Crisis: What the 200,000-Server Vulnerability Reveals" (May 2026): https://engipulse.com/security/the-mcp-security-crisis-what-the-200000-server-vulnerability-reveals-about-ai-agent-architecture/
- Microsoft Security Blog, "Securing AI agents: When AI tools move from reading to acting" (June 30, 2026): https://www.microsoft.com/en-us/security/blog/2026/06/30/securing-ai-agents-ai-tools-move-from-reading-acting/
- Beam AI, "5 Real AI Agent Security Breaches in 2026 and Their Lessons" (May 6, 2026), covering the Mexican government breach and OpenClaw/ClawHub incident: https://beam.ai/agentic-insights/ai-agent-security-breaches-2026-lessons
- U.S. National Security Agency / CSA, "Model Context Protocol (MCP): Security Design" (PP-26-1834, May 2026): https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF
- PointGuard AI, CVE-2026-26118 analysis: https://www.pointguardai.com/ai-security-incidents/microsoft-mcp-server-vulnerability-opens-door-to-ai-tool-hijacking-cve-2026-26118
- Invariant Labs, tool-shadowing and rug-pull disclosures (2025): https://invariantlabs.ai/blog/mcp-github-vulnerability, https://invariantlabs.ai/blog/whatsapp-mcp-exploited
Opinions expressed by DZone contributors are their own.
Comments