Input Validation, Sandboxing & the Confused Deputy Problem

13 min read Module 7 of 10 Topic 20 of 30

What you'll learn

  • Recognize the confused deputy pattern in an MCP tool's permission design
  • Apply per-caller authorization checks inside tools that hold broad service credentials
  • Sandbox any tool that executes code or shell commands on behalf of an agent
  • Reason about defense in depth for sandbox escape, since isolation reduces but does not eliminate risk
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

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.

Knowledge Check

3 questions to test your understanding

1 An MCP tool `read_document(doc_id)` runs with a service account that has access to every document in the company. A caller with only 'sales' team permissions asks it to read a confidential HR document by guessing its ID. What is the underlying vulnerability class?

2 A tool executes arbitrary Python code strings generated by the model to perform data transformations. What is the minimum safeguard before running that code?

3 A team sandboxes a code-execution tool with a container that has no network access and no credentials, and concludes the tool is now fully safe regardless of what code runs inside it. Is this conclusion correct?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan