LLM-агенты на удивление хорошо пишут код и на удивление не везёт им с его выполнением. В тот момент, когда вы позволяете модели выполнять произвольный Python на той же машине, где лежат ваши SSH-ключи, файлы .env и продакшен-kubeconfig, вы построили очень вежливый сервис удалённого выполнения кода. И всё же выполнение кода — один из самых мощных инструментов, которые можно дать агенту: обработка данных, быстрые численные проверки, разбор странных форматов файлов, проверка собственных гипотез — всё это работает радикально лучше, когда модель может реально запустить то, что написала.
Ответ не в том, чтобы «не выполнять код». Ответ в том, чтобы выполнять его там, где это не имеет значения.
В этой статье разбирается, как построить сервер Model Context Protocol (MCP), выполняющий Python в песочнице, с использованием FastMCP для компактности примеров. Рассмотрим две стратегии изоляции и большую часть времени уделим более строгой из них:
- Deno + Pyodide (WebAssembly) — Python, скомпилированный в WASM, работает внутри рантайма, который запрещает всё по умолчанию: ни сети, ни файловой системы, ни окружения, пока вы явно не разрешите каждое из них. Это основной подход статьи.
- Docker-контейнер — изоляция на уровне ОС для случаев, когда нужен настоящий CPython, нативные wheel-пакеты или тяжёлые вычисления.
Их можно и комбинировать — но сначала о том, почему WASM-путь заслуживает отдельного внимания.
Зачем здесь MCP?
MCP даёт стандартный контракт между агентом и инструментом. Вместо того чтобы каждая команда изобретала собственный HTTP-эндпоинт «выполни этот сниппет» со своей авторизацией и своим форматом JSON, вы выставляете один хорошо описанный инструмент, который может найти и вызвать любой MCP-совместимый клиент (Claude Code, агенты в IDE, кастомные оркестраторы). Клиент видит инструмент с именем run_python, описание, сообщающее модели когда и как его использовать, типизированную схему входных данных и структурированный вывод, над которым можно рассуждать.
Сама песочница невидима для модели — и в этом суть. Модель просто «выполняет Python». Граница изоляции — ваша забота, решённая один раз, на стороне сервера.
Основной подход: Deno + Pyodide
Большинство историй про песочницы устроены по принципу вычитания: начинаем с полноценного процесса ОС, который может всё, а затем отбираем возможности — это запретить, то заблокировать, ничего не монтировать, и надеяться, что вспомнили про все дыры. Комбинация Deno + Pyodide переворачивает эту модель. Она аддитивная: код начинает без ничего, и вы выдаёте возможности по одному флагу за раз.
За этим стоят два слоя.
Pyodide — это CPython, скомпилированный в WebAssembly. Сам интерпретатор Python работает внутри WASM-песочницы с линейной памятью: у него нет понятия о файловой системе хоста, сетевых интерфейсах или таблице процессов. open("/etc/passwd") падает не из-за настроенной вами проверки прав — она падает, потому что такого файла в мире WASM просто не существует. «Файловая система», которую видит Python, — это эмуляция в памяти (Emscripten MEMFS), существующая только внутри экземпляра интерпретатора и исчезающая вместе с ним.
Deno — это рантайм JavaScript/TypeScript, в котором размещается этот WASM-модуль, — и в отличие от 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 и выводит JSON-результат в stdout:
// 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. Весь аудит безопасности этой песочницы — одна строка в shell — сравните это с аудитом спецификации контейнера, и привлекательность станет очевидной. (Один раз запустите с --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». О вызовах без состояния модели рассуждать гораздо проще.
- Обрезка с пометкой. Молчаливое обрезание вывода сбивает модель с толку; сообщение «обрезано, всего 2 400 000 символов» позволяет ей адаптироваться.
- Таймаут убивает весь процесс Deno. У WASM нет механизма вытеснения для зациклившихся программ; поводок находится на уровень выше, на границе подпроцесса — и именно там он обходится дешевле всего.
Что скрипт агента может и не может
| Попытка | Что происходит |
|---|---|
open("/etc/passwd") |
FileNotFoundError — такого пути нет во внутрипамятной ФС |
requests.get(...) / сырые сокеты |
Ошибка — сетевого стека до хоста нет, у Deno нет --allow-net |
os.environ |
Пусто/эмулировано — Deno никогда не выдавал --allow-env |
subprocess.run("bash") |
В WASM такой возможности нет, у Deno нет --allow-run |
| Записать 10 ГБ “файлов” | Заполняет внутрипамятную ФС одного интерпретатора, исчезает вместе с ним |
while True: pass |
Убивается серверным таймаутом в 30 секунд |
| Гипотетический побег из Pyodide в JS | Попадает внутрь Deno, который по-прежнему не может трогать сеть/ФС/окружение |
Честные ограничения, чтобы вы могли решить, когда этот подход подходит:
- Пакеты: чистые Python-wheel и научный набор, который поставляется с Pyodide (numpy, pandas, scipy, matplotlib, scikit-learn и сотни других), работают; произвольные нативные расширения (
psycopg2,torch) — нет. - Скорость: ожидайте примерно в 1–3 раза медленнее, чем нативный CPython, плюс старт интерпретатора на каждый вызов. Неважно для «проверь этот расчёт», важно для тяжёлых вычислений.
- Потоки/процессы:
multiprocessingи подобное недоступны.
Для основного сценария использования агента — выполни этот самодостаточный сниппет на данных, которые я дал, и покажи вывод — ни одно из этих ограничений не мешает, а вы получаете самую сильную изоляцию при минимальной настройке.
Второй подход: Docker-контейнер
Когда действительно нужен настоящий CPython — работа с GPU, драйверы БД, multiprocessing, пакеты с C-расширениями — вы отступаете от «системных вызовов не существует» к «системные вызовы заперты». Сам MCP-сервер меняется совсем немного: применяется тот же паттерн обёртки FastMCP, только в подпроцессе выполняется python script.py вместо раннера на Deno. Меняется то, что безопасность теперь живёт в спецификации контейнера, и каждая её строка несёт структурную нагрузку:
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) |
| Гарантия отсутствия файлов | Встроенная (только ФС в памяти) | Настраиваемая (без монтирований + read-only + tmpfs) |
| Риск неверной настройки | Одна строка shell для аудита | Каждый флаг несёт структурную нагрузку |
| Совместимость с Python | Чистый Python + научный набор Pyodide | Полноценный CPython, любой wheel |
| Производительность | Медленнее примерно в 1–3 раза, старт на каждый вызов | Нативная |
| Требования к инфраструктуре | Бинарник Deno | Демон Docker |
| Лучше всего для | Выбор по умолчанию: вычисления, обработка данных, самодостаточные сниппеты | Нативные зависимости, тяжёлые вычисления, долгоживущие рабочие пространства |
Начинайте с WASM. Переносите отдельные нагрузки в контейнер только тогда, когда конкретное требование (нативный пакет, потолок производительности) вынуждает пойти на этот компромисс.
Подключение к MCP-клиенту
При транспорте stdio клиент сам запускает сервер и общается по MCP через его stdin/stdout:
{
"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не затопил транспорт - Описание инструмента сообщает модели ограничения (нет сети, нет файлов, нет состояния между вызовами), чтобы она не боролась с песочницей
- Если контейнеризовано: не-root,
cap_drop: ALL,no-new-privileges, без монтирования томов,network_mode: none, лимиты по памяти/CPU/PID, read-only корневая ФС - Никаких секретов в окружении сервера или директории установки — песочница, из которой нечего красть, делает большинство атак бессмысленными по построению
Заключительная мысль
Самый глубокий урок здесь не про Python, не про Deno и не про Docker — он про то, в какую сторону направлена ваша модель безопасности. Песочница, построенная отбиранием возможностей, настолько надёжна, насколько хорошо вы помните, что нужно отобрать. Песочница, построенная выдачей возможностей, настолько надёжна, насколько короток список выданного — а для выполнения одноразового Python-кода агента этот список может быть почти пустым: прочитать один кеш пакетов, и больше ничего.
Дайте своему агенту комнату, где можно всё ломать. При подходе на WASM эта комната не просто заперта — большинство дверей в ней вообще никогда не строились. Агент сломает всё, что сможет, научится на трассировке стека и отдаст вам рабочий код, а радиус поражения будет равен файловой системе в памяти, которая и так должна была исчезнуть.