LLM 에이전트는 코드를 작성하는 데는 놀라울 만큼 뛰어나지만, 그걸 실행하는 데는 놀라울 만큼 운이 따르지 않습니다. SSH 키, .env 파일, 프로덕션 kubeconfig가 들어 있는 바로 그 머신에서 모델이 임의의 Python을 실행하게 두는 순간, 당신은 아주 공손한 원격 코드 실행 서비스를 만든 셈입니다. 그럼에도 코드 실행은 에이전트에게 줄 수 있는 가장 영향력 큰 도구 중 하나입니다. 데이터 정리, 빠른 수치 검증, 이상한 파일 형식 파싱, 자기 가설 검증까지 — 모델이 자기가 쓴 코드를 실제로 돌려볼 수 있을 때 모든 게 극적으로 잘 돌아갑니다.

답은 “코드를 실행하지 말라"가 아닙니다. 답은 “상관없는 곳에서 실행하라"입니다.

이 글에서는 FastMCP를 사용해 예제를 간결하게 유지하면서, 샌드박스 안에서 Python을 실행하는 Model Context Protocol(MCP) 서버를 만드는 과정을 다룹니다. 두 가지 격리 전략을 살펴보되, 더 엄격한 쪽에 대부분의 시간을 쓸 것입니다.

  1. Deno + Pyodide(WebAssembly) — WASM으로 컴파일된 Python을, 기본적으로 모두 거부하는 런타임 안에서 실행합니다. 네트워크도, 파일시스템도, 환경 변수도 명시적으로 하나씩 허용하지 않는 한 없습니다. 이 글의 메인 접근법입니다.
  2. Docker 컨테이너 — 진짜 CPython, 네이티브 wheel, 혹은 무거운 연산이 필요할 때를 위한 OS 수준 격리입니다.

둘은 함께 조합할 수도 있습니다 — 하지만 먼저, WASM 경로가 왜 주목받을 자격이 있는지부터 보겠습니다.

왜 이 작업에 MCP를 쓰는가?

MCP는 에이전트와 도구 사이의 표준 계약을 제공합니다. 팀마다 각자의 인증 방식과 JSON 형식을 가진 “이 스니펫 실행해줘” HTTP 엔드포인트를 따로 만드는 대신, MCP를 지원하는 어떤 클라이언트(Claude Code, IDE 에이전트, 커스텀 오케스트레이터)든 찾아서 호출할 수 있는 잘 기술된 도구 하나를 노출시키면 됩니다. 클라이언트에게 보이는 것은 run_python이라는 이름의 도구, 모델에게 언제 어떻게 쓸지 알려주는 설명, 타입이 정해진 입력 스키마, 그리고 모델이 추론할 수 있는 구조화된 출력입니다.

샌드박싱 자체는 모델에게 보이지 않습니다 — 그리고 그게 핵심입니다. 모델은 그냥 “Python을 실행"할 뿐입니다. 격리 경계는 당신의 문제이고, 서버 쪽에서 한 번만 해결하면 됩니다.

메인 접근법: Deno + Pyodide

대부분의 샌드박싱 이야기는 뺄셈식입니다. 뭐든 할 수 있는 완전한 OS 프로세스에서 시작해서 권한을 하나씩 빼나갑니다 — 이것도 막고, 저것도 막고, 아무것도 마운트하지 않고, 모든 구멍을 다 기억했기를 바랍니다. Deno + Pyodide 조합은 이 모델을 뒤집습니다. 덧셈식입니다. 코드는 아무 권한도 없는 상태에서 시작하고, 플래그를 하나씩 켜서 권한을 부여합니다.

이를 가능하게 하는 건 두 개의 층입니다.

**Pyodide**는 WebAssembly로 컴파일된 CPython입니다. Python 인터프리터 자체가 WASM 선형 메모리 샌드박스 안에서 돌아가므로, 호스트의 파일시스템도, 네트워크 인터페이스도, 프로세스 테이블도 아예 개념 자체가 없습니다. open("/etc/passwd")가 실패하는 건 당신이 설정한 권한 검사 때문이 아니라, WASM 세계에는 애초에 그런 파일이 존재하지 않기 때문입니다. Python 눈에 보이는 “파일시스템"은 인터프리터 인스턴스 안에만 존재했다가 그 인스턴스와 함께 사라지는 인메모리 에뮬레이션(Emscripten MEMFS)입니다.

**Deno**는 이 WASM 모듈을 호스팅하는 JavaScript/TypeScript 런타임입니다 — Node와 달리 Deno는 기본적으로 샌드박스 상태입니다. 시작할 때 해당하는 --allow-* 플래그를 넘기지 않는 한 파일 읽기도, 쓰기도, 네트워크도, 환경 변수도, 서브프로세스 생성도 안 됩니다. 설령 미래의 Pyodide 버그로 Python이 JavaScript 호스트 레이어에 도달하더라도, 탈출한 코드가 떨어지는 곳 역시 아무것도 건드릴 수 없는 런타임입니다.

그래서 계층 구조는 이렇게 생겼습니다.

+----------------------------------------------------------+
|  Host                                                     |
|                                                            |
|   FastMCP server (Python) --- stdio/HTTP --- MCP client    |
|        |                                                   |
|        | subprocess (timeout, output caps)                 |
|        v                                                   |
|   +---------------------------------------------------+    |
|   | Deno  (deny-by-default: no net, no fs, no env)    |    |
|   |   +-------------------------------------------+   |    |
|   |   | Pyodide (CPython in WASM)                 |   |    |
|   |   |   - in-memory virtual filesystem only     |   |    |
|   |   |   - no sockets, no host syscalls          |   |    |
|   |   |   - runs the agent's script               |   |    |
|   |   +-------------------------------------------+   |    |
|   +---------------------------------------------------+    |
+----------------------------------------------------------+

이 다이어그램에서 빠져 있는 것에 주목하세요. 잘못 설정할 네트워크 규칙도, 깜빡할 볼륨 마운트도, 강등시켜야 할 root 사용자도 없습니다. “네트워크 없음, 파일 없음"은 적용하는 설정이 아니라 — 시작 상태 그 자체입니다. 이 특성이야말로 이 접근법이 기본값이 될 자격이 있는 이유입니다. 특히 에이전트의 가장 흔한 작업, 즉 이미 컨텍스트에 들어 있는 데이터에 대한 순수 계산에서는 더욱 그렇습니다.

Pyodide 러너

작은 TypeScript 파일 하나가 실행 엔진의 전부입니다. stdin에서 스크립트를 읽어 Pyodide 안에서 실행하고, stdout으로 JSON 결과를 내보냅니다.

// runner.ts
import { loadPyodide } from "npm:pyodide";

const decoder = new TextDecoder();
const code = decoder.decode(await new Response(Deno.stdin.readable).arrayBuffer());

const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];

const pyodide = await loadPyodide({
  stdout: (line: string) => stdoutChunks.push(line),
  stderr: (line: string) => stderrChunks.push(line),
});

// Optional: let scripts use pure-Python wheels bundled with Pyodide
// (numpy, pandas, etc.) — loaded from the local package cache, not the network.
await pyodide.loadPackagesFromImports(code);

let exitCode = 0;
try {
  await pyodide.runPythonAsync(code);
} catch (err) {
  stderrChunks.push(String(err));
  exitCode = 1;
}

console.log(JSON.stringify({
  stdout: stdoutChunks.join("\n"),
  stderr: stderrChunks.join("\n"),
  exit_code: exitCode,
}));

그리고 보안 태세가 눈에 보이게 드러나는 곳이 바로 실행 명령입니다.

deno run \
  --allow-read=./node_modules \
  --node-modules-dir=auto \
  runner.ts

이것이 권한 부여의 전부입니다. 로컬 Pyodide 패키지 캐시에 대한 읽기 권한, 그 외에는 아무것도 없습니다. --allow-net도 없습니다. --allow-write도 없습니다. --allow-env도 없습니다. --allow-run도 없습니다. 이 샌드박스의 보안 검토 전체가 셸 한 줄입니다 — 컨테이너 스펙을 감사하는 것과 비교해보면 그 매력을 알 수 있습니다. (빌드/설치 시점에 --allow-net을 한 번 붙여 실행해서 패키지 캐시를 채워두세요. 그 이후 런타임 실행은 계속 오프라인 상태를 유지합니다.)

그 위의 FastMCP 서버

MCP 레이어는 얇은 래퍼입니다. 스크립트를 러너에 넘기고, 실제 경과 시간 기준 타임아웃을 강제하고, 출력 크기를 제한하고, 구조화된 결과를 반환합니다.

# server.py
import asyncio
import json
from dataclasses import dataclass

from fastmcp import FastMCP

mcp = FastMCP("python-sandbox")

EXECUTION_TIMEOUT_SECONDS = 30
MAX_OUTPUT_CHARS = 50_000

DENO_CMD = [
    "deno", "run",
    "--allow-read=./node_modules",
    "--node-modules-dir=auto",
    "runner.ts",
]


@dataclass
class ExecutionResult:
    stdout: str
    stderr: str
    exit_code: int
    timed_out: bool


def _truncate(text: str) -> str:
    if len(text) <= MAX_OUTPUT_CHARS:
        return text
    return text[:MAX_OUTPUT_CHARS] + f"\n... [truncated, {len(text)} chars total]"


@mcp.tool
async def run_python(code: str) -> ExecutionResult:
    """Execute a Python script in an isolated WebAssembly sandbox.

    The script runs in a fresh interpreter with NO network access and
    NO access to any real filesystem: it cannot read host files, make
    HTTP requests, or open sockets. Common scientific packages
    (numpy, pandas) are available via normal imports. Each call is
    independent — variables do not persist between calls. Execution
    is killed after 30 seconds.
    """
    proc = await asyncio.create_subprocess_exec(
        *DENO_CMD,
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        raw_out, raw_err = await asyncio.wait_for(
            proc.communicate(code.encode()), timeout=EXECUTION_TIMEOUT_SECONDS
        )
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        return ExecutionResult(stdout="", stderr="", exit_code=-1, timed_out=True)

    if proc.returncode != 0:
        # Runner itself failed (not the user script) — surface it plainly.
        return ExecutionResult(
            stdout="",
            stderr=_truncate(raw_err.decode(errors="replace")),
            exit_code=proc.returncode,
            timed_out=False,
        )

    result = json.loads(raw_out)
    return ExecutionResult(
        stdout=_truncate(result["stdout"]),
        stderr=_truncate(result["stderr"]),
        exit_code=result["exit_code"],
        timed_out=False,
    )


if __name__ == "__main__":
    mcp.run()

짚어볼 만한 의도적인 선택이 몇 가지 있습니다.

  • docstring은 프롬프트다. 모델은 그 텍스트를 보고 도구를 어떻게 쓸지 결정합니다. 처음부터 “네트워크 없음, 파일시스템 없음"이라고 말해두면, 에이전트가 애초에 될 리 없는 requests.get(...) 시도로 턴을 낭비하지 않습니다.
  • 뭉뚱그린 텍스트가 아니라 구조화된 결과. stdout / stderr / exit_code / timed_out을 별도 필드로 나누면, 모델이 산문을 파싱하지 않고도 “아무것도 출력 안 함"과 “충돌함"과 “멈춤"을 구별할 수 있습니다.
  • 호출마다 새 인터프리터. 전역 상태 공유도 없고, “이전 호출에서 pandas를 import했으니까 돌아갔다” 같은 일도 없습니다. 상태 없는 호출은 모델이 추론하기 훨씬 쉽습니다.
  • 잘라내되 알려준다. 말없이 출력을 잘라내면 모델이 혼란스러워합니다. *“2,400,000자 중 일부만 표시, 잘림”*이라고 알려주면 모델이 거기에 맞춰 대응할 수 있습니다.
  • 타임아웃은 Deno 프로세스 전체를 죽인다. 폭주하는 루프에 대해 WASM 자체에는 선점 메커니즘이 없습니다. 목줄은 한 단계 위, 서브프로세스 경계에 있고 — 바로 거기가 가장 저렴한 위치입니다.

에이전트의 스크립트가 할 수 있는 것과 없는 것

시도 결과
open("/etc/passwd") FileNotFoundError — 인메모리 FS에 해당 경로가 존재하지 않음
requests.get(...) / raw 소켓 실패 — 호스트에 닿는 네트워크 스택 자체가 없음, Deno에 --allow-net이 없음
os.environ 비어 있음/에뮬레이션됨 — Deno에 --allow-env를 준 적이 없음
subprocess.run("bash") WASM에는 그런 기능 자체가 없음, Deno에 --allow-run이 없음
“파일” 10GB 쓰기 인터프리터 하나 안의 인메모리 FS를 채우고, 그 인터프리터와 함께 사라짐
while True: pass 서버 쪽 30초 타임아웃으로 종료됨
Pyodide에서 JS로 탈출한다는 가정 떨어지는 곳이 Deno 안이고, 거기서도 여전히 net/fs/env를 건드릴 수 없음

이 접근법이 언제 맞는지 판단할 수 있도록, 솔직한 한계도 짚고 갑니다.

  • 패키지: 순수 Python wheel과 Pyodide가 함께 제공하는 과학 계산 세트(numpy, pandas, scipy, matplotlib, scikit-learn 등 수백 개)는 동작합니다. 임의의 네이티브 확장(psycopg2, torch)은 동작하지 않습니다.
  • 속도: 네이티브 CPython보다 대략 1~3배 느리고, 호출마다 인터프리터 시작 비용이 추가로 듭니다. “이 계산 맞는지 확인"에는 상관없지만, 무거운 수치 연산에는 영향을 줍니다.
  • 스레드/프로세스: multiprocessing 계열은 사용할 수 없습니다.

에이전트의 가장 흔한 사용 사례 — 내가 준 데이터로 이 독립된 스니펫을 실행해서 결과를 보여줘 — 에서는 이 중 어느 것도 문제가 되지 않으며, 가장 적은 설정으로 가장 강력한 격리를 얻습니다.

두 번째 접근법: Docker 컨테이너

GPU 작업, 데이터베이스 드라이버, multiprocessing, C 확장이 있는 패키지처럼 진짜 네이티브 CPython이 필요할 때는, “시스템 콜이 아예 존재하지 않는다"에서 “시스템 콜이 봉쇄되어 있다"로 물러서게 됩니다. MCP 서버 자체는 거의 바뀌지 않습니다. 같은 FastMCP 래퍼 패턴을 그대로 쓰되, 서브프로세스로 Deno 러너 대신 python script.py를 실행할 뿐입니다. 달라지는 건 이제 보안이 컨테이너 스펙 안에 들어 있고, 그 안의 모든 줄이 구조적으로 중요해진다는 점입니다.

FROM python:3.12-slim

RUN pip install --no-cache-dir fastmcp numpy pandas matplotlib

# Non-root: a sandbox running as root is a sandbox in name only.
RUN useradd --create-home --shell /usr/sbin/nologin sandbox \
    && mkdir /workspace && chown sandbox:sandbox /workspace

COPY server.py /app/server.py
USER sandbox
WORKDIR /workspace
ENTRYPOINT ["python", "/app/server.py"]
# docker-compose.yml
services:
  python-sandbox:
    build: .
    read_only: true                 # root fs immutable
    tmpfs:
      - /workspace:size=256m        # writable scratch, RAM-backed, size-capped
      - /tmp:size=64m
    network_mode: "none"            # no exfiltration, no surprises
    mem_limit: 512m
    pids_limit: 128                 # fork bombs die here
    cpus: "1.0"
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    stdin_open: true

이것도 충분히 훌륭한 샌드박스입니다 — 하지만 부담이 어디로 옮겨갔는지 주목하세요. Pyodide에서는 격리가 시작 상태였고 예외를 허용해주는 식이었습니다. 여기서는 프로세스가 처음부터 뭐든 할 수 있는 상태로 시작하고, YAML 한 줄 한 줄이 공격 유형을 하나씩 빼나갑니다. network_mode: none을 깜빡하면 스크립트가 데이터를 빼돌릴 수 있고, pids_limit을 깜빡하면 포크 폭탄이 호스트를 마비시키고, 편의상 볼륨을 마운트하면 벽에 구멍을 뚫은 셈이 됩니다. 뺄셈식 보안은 열린 채로 실패하고, 덧셈식 보안은 닫힌 채로 실패합니다. 이 비대칭성이야말로 WASM을 먼저 고려해야 하는 이유 전체입니다.

이중 안전장치

두 접근법은 경쟁 관계가 아닙니다. 강화된 배포를 원한다면 Deno + Pyodide 서버를 잠긴 컨테이너 안에서 실행하세요. WASM 경계가 신뢰할 수 없는 스크립트를 처리하고, 컨테이너가 혹시 모를 런타임 탈출을 처리하며, 어느 한쪽 레이어가 뚫리는 것만으로는 충분하지 않게 됩니다. Pyodide 레이어는 런타임에 네트워크도, 의미 있는 파일시스템도 필요하지 않으므로, 컨테이너 설정을 최대한 엄격하게 유지해도 잃는 것이 없습니다.

둘 중 무엇을 고를지

Deno + Pyodide(WASM) Docker 컨테이너
보안 모델 덧셈식 — 플래그 하나씩 권한을 부여 뺄셈식 — 줄 단위로 권한을 제거
네트워크 없음 보장 본질적(애초에 준 적이 없음) 설정함(network_mode: none)
파일 없음 보장 본질적(인메모리 FS뿐) 설정함(마운트 없음 + 읽기 전용 + tmpfs)
설정 실수 위험 감사할 셸 한 줄 모든 플래그가 구조적으로 중요함
Python 호환성 순수 Python + Pyodide 과학 계산 세트 완전한 CPython, 모든 wheel
성능 약 1~3배 느림, 호출마다 시작 비용 네이티브
인프라 전제조건 Deno 바이너리 Docker 데몬
적합한 경우 기본 선택지: 계산, 데이터 가공, 독립적인 스니펫 네이티브 의존성, 무거운 연산, 오래 유지되는 작업 공간

WASM으로 시작하세요. 개별 작업을 컨테이너로 옮기는 건 네이티브 패키지나 성능 한계 같은 구체적인 요구사항이 그 트레이드오프를 강제할 때만 하세요.

MCP 클라이언트에 연결하기

stdio 전송을 쓰면, 클라이언트가 서버를 직접 실행하고 그 stdin/stdout으로 MCP를 주고받습니다.

{
  "mcpServers": {
    "python-sandbox": {
      "command": "python",
      "args": ["/opt/python-sandbox/server.py"]
    }
  }
}

공유 배포나 원격 배포에서는 FastMCP를 HTTP 전송으로 전환하세요.

if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8000)

그 앞에 평소 쓰던 ingress, TLS, 인증을 두면 됩니다. 도구 코드는 전혀 바뀌지 않습니다 — MCP에서 전송 방식은 배포상의 디테일일 뿐이고, 바로 그래야 맞습니다.

보안 체크리스트

  • 실행 엔진이 기본적으로 모두 거부: --allow-net 없음, --allow-write 없음, --allow-env 없음, --allow-run 없음
  • 패키지 캐시를 설치 시점에 미리 채워둠, 런타임 실행은 완전히 오프라인 유지
  • 모든 실행에 실제 경과 시간 기준 타임아웃을 걸고, 러너 프로세스 전체를 종료
  • 출력 잘라내기로 print 루프가 전송 채널을 범람시키지 못하게 함
  • 도구 설명이 제약조건을 모델에게 알려줌(네트워크 없음, 파일 없음, 호출 간 상태 없음)으로써 모델이 샌드박스와 싸우지 않게 함
  • 컨테이너화한다면: non-root, cap_drop: ALL, no-new-privileges, 볼륨 마운트 없음, network_mode: none, 메모리/CPU/PID 제한, 읽기 전용 root fs
  • 서버의 환경 변수나 설치 디렉터리에 비밀 정보 없음 — 훔칠 게 없는 샌드박스는 구조적으로 대부분의 공격을 무의미하게 만듦

마치며

여기서 가장 깊은 교훈은 Python도, Deno도, Docker도 아닙니다 — 보안 모델이 어느 방향을 향하는가입니다. 권한을 빼는 방식으로 만든 샌드박스는 당신이 무엇을 빼야 하는지 기억하는 정도만큼만 견고합니다. 권한을 부여하는 방식으로 만든 샌드박스는 그 허용 목록이 얼마나 짧은지만큼 견고합니다 — 그리고 에이전트의 일회용 Python을 실행하는 데는, 그 목록이 거의 비어 있을 수 있습니다. 패키지 캐시 하나 읽기, 그게 전부입니다.

에이전트에게 뭔가를 부숴도 되는 방을 하나 주세요. WASM 방식에서는 그 방이 그냥 잠겨 있는 정도가 아니라 — 애초에 대부분의 문이 지어지지도 않았습니다. 에이전트는 부술 수 있는 걸 부수고, 스택 트레이스에서 배우고, 동작하는 코드를 건네줄 것입니다. 그리고 피해 반경은 어차피 사라질 예정이었던 인메모리 파일시스템 하나로 끝납니다.