The confused deputy problem, a program with more privilege than its caller being tricked into misusing that privilege, is a decades-old security pattern that maps directly onto MCP tool design: an MCP server frequently runs with a single powerful service credential (a database role, a CRM API token) shared across every caller, and it is easy to forget to check whether this specific caller should be allowed this specific action, versus just checking that the server’s own credential is valid. This lesson also covers the highest-risk tool category, code execution, and is explicit about a point that is easy to overstate: sandboxing substantially reduces risk but should not be treated as an absolute, final guarantee.
Recognizing the Pattern
# VULNERABLE: the tool has broad access via its own service credential,
# but never checks whether THIS caller should see THIS document.
@mcp.tool(name="read_document")
async def read_document(doc_id: str) -> str:
"""Reads any document, service credential has access to everything."""
return await docstore_client.get(doc_id) # No per-caller check at all
Any caller who can guess or enumerate doc_id values gets access to documents far outside what their own role should permit, the tool (the “deputy”) is confused about whose authority it is acting under.
flowchart TD
Caller["Caller: sales team\n(should only see sales docs)"] -->|"read_document('hr-confidential-042')"| Tool["read_document tool"]
Tool -->|"uses its own broad\nservice credential"| Store["Document Store\n(all documents accessible)"]
Store -->|"returns HR document\nno per-caller check"| Tool
Tool -->|"leaks out-of-scope data"| Caller
style Caller fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Tool fill:#fef2f2,stroke:#dc2626,color:#0F172A
style Store fill:#fff7ed,stroke:#f59e0b,color:#0F172A
Fix: Check Caller Entitlement Inside the Tool
@mcp.tool(name="read_document")
async def read_document(doc_id: str, request=None) -> dict:
"""Reads a document, but only if the calling identity's team has access."""
doc_meta = await docstore_client.get_metadata(doc_id)
caller_team = get_caller_team(request.state.subject) # from validated token, Module 4
if doc_meta.owning_team != caller_team and "docs:read_all" not in request.state.scopes:
return {"isError": True, "message": "Not authorized to read this document"}
return {"content": await docstore_client.get_content(doc_id)}
The service credential’s broad reach is now gated by an explicit, per-request check against the caller’s own identity and scopes, the server no longer blindly exercises its full privilege on every request. This is precisely the resource/row-level authorization introduced in Lesson 12, applied here to the specific case of a document store rather than a CRM.
Sandboxing Code-Execution Tools
Tools that execute model-generated code or shell commands are the highest-risk category: they combine the confused deputy pattern (broad execution environment access) with the prompt injection risk from Lesson 19 (the code being run may itself be influenced by untrusted content the model read earlier).
import asyncio
@mcp.tool(name="run_data_transform")
async def run_data_transform(code: str, input_data: dict) -> dict:
"""
Executes a short Python transform against input_data inside an isolated
sandbox container: no network access, no filesystem beyond a tmpfs,
no credentials, strict CPU/memory/wall-clock limits.
"""
try:
result = await asyncio.wait_for(
sandbox_client.execute(
code=code,
input_data=input_data,
network_access=False,
credentials=None,
memory_limit_mb=256,
cpu_limit_cores=0.5,
),
timeout=10,
)
return {"result": result}
except asyncio.TimeoutError:
return {"isError": True, "message": "Execution exceeded 10s time limit"}
flowchart LR
Model["Model-generated code"] --> Sandbox["Isolated Sandbox\n(container/microVM)"]
Sandbox -.->|"no network"| Blocked1["(blocked)"]
Sandbox -.->|"no credentials"| Blocked2["(blocked)"]
Sandbox -->|"time/memory/cpu limited"| Result["Result returned\nto MCP tool"]
style Model fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Sandbox fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style Blocked1 fill:#fef2f2,stroke:#dc2626,color:#0F172A
style Blocked2 fill:#fef2f2,stroke:#dc2626,color:#0F172A
style Result fill:#fff7ed,stroke:#f59e0b,color:#0F172A
Sandbox Escape: The Residual Risk After Isolation
It is important not to treat container isolation, no network access, no credentials, resource limits, as an absolute, mathematically complete guarantee against everything arbitrary code inside it could attempt. Container escape vulnerabilities, bugs in the shared kernel or the container runtime that let code inside a container affect the host or other containers, have a real, documented history, and a determined attacker capable of crafting code intended to exploit such a bug is a different threat than the accidental-bug or naive-injection cases the sandbox mainly protects against. This does not mean sandboxing is not worth doing, it dramatically reduces the realistic attack surface for the overwhelming majority of cases, but a security review should describe it accurately: a strong, necessary mitigation, layered with others, not an unbreachable wall.
# For the highest-risk code-execution tools, prefer microVM isolation
# (e.g. Firecracker) over plain containers: microVMs run each execution
# in its own lightweight virtual machine with its own kernel, which
# removes the shared-kernel attack surface that container escapes exploit.
async def execute_high_risk_transform(code: str, input_data: dict) -> dict:
return await firecracker_client.run_in_microvm(
code=code, input_data=input_data,
vcpu_count=1, memory_mb=256, timeout_seconds=10,
)
Choosing between plain containers and microVM isolation is a cost/risk tradeoff, microVMs have higher operational overhead (slower cold starts, more infrastructure to run) and are worth reserving for the tools whose potential blast radius justifies the extra isolation, while lower-stakes, well-scoped code execution can reasonably use standard containers as the default. Either way, monitoring for anomalous sandbox behavior (unexpected syscalls, unusual resource consumption patterns) remains a valuable additional layer regardless of which isolation primitive is chosen, since it is the layer most likely to catch something the isolation itself did not fully prevent.
The next lesson closes out this module with the operational side of security: audit logging every tool call, rate limiting, and detecting anomalous call patterns that might indicate a compromised or misbehaving agent.