0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI エージェントに安全な Python の遊び場を:MCP によるサンドボックス化されたコード実行

0
Posted at

この記事は筆者のブログ LynxLine からの転載です。英語版は こちら。

LLM エージェントはコードを書くのは驚くほど得意ですが、実行するとなると話は別です。SSH 鍵や .env ファイル、本番の kubeconfig が置いてあるマシンで、モデルに任意の Python を実行させた瞬間、あなたはとても礼儀正しいリモートコード実行サービスを作り上げたことになります。それでも、コード実行はエージェントに渡せるツールの中でも特に効果の大きいものです。データの整形、ちょっとした数値チェック、変わったファイル形式のパース、自分の仮説の検証。どれも、モデルが書いたものを実際に動かせるだけで格段にうまくいきます。

答えは「コードを実行させない」ではありません。「どうなっても構わない場所で実行させる」です。

この記事では、Python をサンドボックス内で実行する Model Context Protocol(MCP)サーバーの作り方を解説します。例を簡潔にするため FastMCP を使います。隔離の方法は 2 つ取り上げ、より厳格な方に多くの紙幅を割きます。

  1. Deno + Pyodide(WebAssembly):WASM にコンパイルされた Python を、デフォルト拒否のランタイム上で動かします。ネットワークもファイルシステムも環境変数も、一つずつ明示的に許可しない限り使えません。本記事の主役です。
  2. Docker コンテナ:本物の CPython やネイティブ wheel、重い計算が必要な場合の、OS レベルの隔離です。

この 2 つは組み合わせることもできます。ただその前に、なぜ WASM の方を主役にするのかを説明します。

なぜ MCP なのか

MCP はエージェントとツールの間に標準的な契約を定めます。チームごとに独自の認証と独自の JSON 形式で「このスニペットを実行」用の HTTP エンドポイントを作る代わりに、きちんと説明の付いたツールを 1 つ公開すれば、MCP 対応のクライアント(Claude Code、IDE のエージェント、自作のオーケストレーター)ならどれでも発見して呼び出せます。クライアントから見えるのは、run_python という名前のツール、いつ・どう使うべきかをモデルに伝える説明文、型付きの入力スキーマ、そしてモデルが解釈できる構造化された出力です。

サンドボックスの仕組みそのものはモデルからは見えません。そこが肝心です。モデルはただ「Python を実行する」だけ。隔離の境界はサーバー側の責任で、一度解決すれば済みます。

主役のアプローチ:Deno + Pyodide

サンドボックスの多くは引き算で作られます。何でもできる OS プロセスから出発し、権限を取り除いていく。これを落とし、あれをブロックし、何もマウントせず、穴を一つも見落としていないことを祈る。Deno + Pyodide の組み合わせはこの発想を逆転させます。足し算なのです。コードは何も持たない状態から始まり、フラグ一つずつ権限を与えていきます。

これを実現しているのは 2 つの層です。

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 ファイル 1 つで完結します。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 もありません。このサンドボックスのセキュリティレビューはシェル 1 行で終わります。コンテナ定義を監査する手間と比べれば、魅力は明らかでしょう。(ビルド時・インストール時に一度だけ --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 していたから動いた」もありません。状態を持たない呼び出しの方が、モデルにとってずっと扱いやすいのです。
  • 切り詰めたら、そう伝える。 黙って出力を切るとモデルが混乱します。「truncated, 2,400,000 chars total」と伝えれば、モデルは対応を変えられます。
  • タイムアウトでは Deno プロセスごと終了させる。 WASM には暴走ループを横取りして止める仕組みがありません。手綱は一段上のサブプロセス境界に持たせます。そこが一番安上がりな場所です。

エージェントのスクリプトにできること、できないこと

試みること 結果
open("/etc/passwd") FileNotFoundError:インメモリ FS にそのパスは存在しない
requests.get(...) や生ソケット 失敗:ホストに届くネットワークスタックがなく、Deno にも --allow-net がない
os.environ 空またはエミュレーション:Deno は --allow-env を与えていない
subprocess.run("bash") WASM にその機能がなく、Deno にも --allow-run がない
10 GB の「ファイル」を書き込む 1 つのインタープリター内のインメモリ FS が埋まり、インスタンスと一緒に消える
while True: pass サーバー側の 30 秒タイムアウトで終了
仮に Pyodide から JS へ脱出できたら 着地先は Deno で、やはりネットワーク・FS・環境変数には触れられない

このアプローチが合うかどうか判断できるよう、正直な制約も挙げておきます。

  • パッケージ:純粋な 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 を忘れれば fork 爆弾でホストが固まります。便利だからとボリュームをマウントすれば、壁に穴を開けたことになります。引き算のセキュリティは開いた状態で失敗し、足し算のセキュリティは閉じた状態で失敗します。この非対称性こそ、まず WASM を選ぶべき理由のすべてです。

二重の備え

2 つのアプローチは競合するものではありません。堅牢なデプロイにしたいなら、Deno + Pyodide のサーバーを厳重に絞ったコンテナの中で動かしましょう。信頼できないスクリプトは WASM の境界が受け止め、万一のランタイム脱出はコンテナが受け止めます。どちらか一方の層が破られただけでは足りません。Pyodide 層は実行時にネットワークも実質的なファイルシステムも必要としないので、コンテナ設定を最大限厳しくしても失うものはありません。

どちらを選ぶか

Deno + Pyodide(WASM) Docker コンテナ
セキュリティモデル 足し算:フラグ一つずつ権限を付与 引き算:一行ずつ権限を除去
ネットワークなしの保証 本質的(そもそも付与しない) 設定による(network_mode: none)
ファイルなしの保証 本質的(インメモリ FS のみ) 設定による(マウントなし + 読み取り専用 + tmpfs)
設定ミスのリスク 監査対象はシェル 1 行 すべてのフラグが欠かせない
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)

あとは通常のイングレス、TLS、認証を前段に置くだけです。ツールのコードは一切変わりません。MCP ではトランスポートはデプロイ上の詳細にすぎず、それが本来あるべき姿です。

セキュリティチェックリスト

  • 実行エンジンがデフォルト拒否:--allow-net、--allow-write、--allow-env、--allow-run のいずれもなし
  • パッケージキャッシュをインストール時に用意済みで、実行時は完全にオフライン
  • すべての実行に実時間のタイムアウトがあり、ランナーのプロセスごと終了させる
  • 出力の切り詰めにより、print のループでトランスポートがあふれない
  • ツールの説明文でモデルに制約を伝えている(ネットワークなし、ファイルなし、呼び出し間の状態なし)ので、モデルがサンドボックスと格闘しない
  • コンテナ化する場合:非 root、cap_drop: ALL、no-new-privileges、ボリュームマウントなし、network_mode: none、メモリ・CPU・PID の制限、ルート FS は読み取り専用
  • サーバーの環境変数やインストールディレクトリに秘密情報を置かない。盗むものが何もないサンドボックスなら、ほとんどの攻撃は構造的に無意味になる

おわりに

ここでの一番深い教訓は、Python や Deno や Docker の話ではありません。セキュリティモデルがどちらの方向を向いているかです。権限を取り除いて作るサンドボックスの強さは、何を取り除くべきかをどれだけ覚えていられるかで決まります。権限を与えて作るサンドボックスの強さは、許可リストの短さで決まります。そしてエージェントの使い捨ての Python を動かすだけなら、そのリストはほぼ空にできます。パッケージキャッシュを 1 つ読めるだけ、それ以外は何もなし。

エージェントには、壊してもいい部屋を与えましょう。WASM のアプローチでは、その部屋は鍵がかかっているだけではありません。ほとんどのドアが最初から作られていないのです。エージェントは壊せるものを壊し、スタックトレースから学び、動くコードを渡してくれます。そして被害が及ぶ範囲は、どうせ消えるはずだったインメモリのファイルシステムだけです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?