Claude CodeやCodexのサンドボックスを支えるbubblewrapを自作して理解する。その勢いでDockerも理解する - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Claude CodeやCodexのサンドボックスを支えるbubblewrapを自作して理解する。その勢いでDockerも理解する

こんにちは、エンジニアリンググループSREチームGeneral Managerの横本(@yokomotod)です。

この記事はSREチームブログリレーの4日目の記事です。前回は山本さんによるAWS DevOps Agentの記事でした。手書きの図が上手過ぎじゃないですかね。

www.m3tech.blog

さて、Claude Code や Codex のサンドボックス機能*1は使われていますでしょうか。安全かつ快適に使いこなすためにも、どういう仕組みによって隔離されているのか気になりますよね。

サンドボックスの実現方法はOSによって異なりますが、この記事ではLinuxでのサンドボックス化に使われている bubblewrap *2がどんな仕組みで動いているのか知るために、自分で作って動かしてみました。

ソースコードの全体は以下のリポジトリで公開しています。

github.com

そもそもサンドボックス機能とは

サンドボックス機能の使い方や設定の詳細は、こちらの記事で詳しく紹介されています。

www.m3tech.blog

Bashコマンド等をOSレベルの隔離の中で実行することで、ファイルシステムへの読み書きやネットワーク通信などの制限が可能になります。

さて、ここで言われている「OSレベルの隔離」とはなんなんでしょうか。

ドキュメントを見ると、Linux ではこの隔離に bubblewrap(と、ネットワーク制御用の socat)が使われているとあります。

github.com

bubblewrapの実体は bwrap というコマンドで、たとえば --ro-bind / /(ホストの / を隔離環境の / にReadOnlyでマウント)、--bind /tmp /tmp(/tmp をReadWriteでマウント)のようにすると

# "--" の後ろのコマンドがbwrap越しに実行される

$ bwrap --ro-bind / / --bind /tmp /tmp   -- ls $HOME  # 普通にディレクトリが見えている
(自分のホームディレクトリ一覧)

$ bwrap --ro-bind / / --bind /tmp /tmp   -- touch /etc/test  # RWじゃないので書き込めない
touch: cannot touch '/etc/test': Read-only file system

$ bwrap --ro-bind / / --bind /tmp /tmp   -- touch /tmp/test  # RWなので書き込める
(成功)

こんな感じで確かにファイルシステムの読み書きが制御されていそうです。サンドボックス環境では、読み書きを許すディレクトリだけRWマウントして使うわけですね。

ファイルシステムを制限する

まずはこの「ファイルシステムを読み取り専用にして、指定したパスだけ書き込み可能にする」隔離機能を作ってみましょう。

bwrapを真似た引数で実行できるCLI ツール mini_sandbox.py として実装します。

使うのは、namespace という、プロセスごとに OS リソースの見え方を分離するLinuxカーネルの仕組みです。全体の骨組みはこうなっています。

# mini_sandbox.py

import os

def run(command, ...):
    # user namespace を作成。非特権ユーザーが namespace を操作するため
    host_uid, host_gid = os.getuid(), os.getgid()
    os.unshare(os.CLONE_NEWUSER)
    setup_uid_map(os.getpid(), host_uid, host_gid)

    # mount namespace を作成。この中のマウント操作はホストに影響しない
    os.unshare(os.CLONE_NEWNS)

    setup_fs(ro_binds, binds)  # ファイルシステムを組み立てて
    os.execvp(command[0], command)  # 引数に指定されたコマンドを exec

def main():
    # (コマンド引数パース省略)
    run(command, ro_binds, binds, unshare_pid, unshare_net)

if __name__ == "__main__":
    main()

最初の user namespace は、非特権ユーザーがサンドボックスを作るためです。

mount namespace の作成やマウント操作は本来 root 権限が必要な操作ですが、先に user namespace を作ると「その中でだけ特権を持つ」状態になり、続く namespace 操作が許可されるようになります。

そして一番の本題、 os.unshare(os.CLONE_NEWNS) で mount namespace を作成し、ファイルシステムを分離します。

setup_fs がファイルシステムを組み立てる部分です*3

def setup_fs(ro_binds, binds):
    # マウント操作がホスト側へ伝播しないようにする
    do_mount("", "/", "", MS_PRIVATE | MS_REC)

    # --ro-bind で指定されたパスを read-only で bind mount
    # (--ro-bind / / なら、/ を自分自身の上に重ねて read-only 化)
    for src, dest in ro_binds:
        do_mount(src, dest, "", MS_BIND | MS_REC)
        do_mount("", dest, "", MS_REMOUNT | MS_BIND | MS_REC | MS_RDONLY)  # bind mount は作成時にフラグを指定できないので、再マウントで read-only 化

    # --bind で指定されたパスは書き込み可能で bind mount
    for src, dest in binds:
        do_mount(src, dest, "", MS_BIND | MS_REC)
        do_mount("", dest, "", MS_REMOUNT | MS_BIND | MS_REC)

MS_* は mount システムコールに渡すフラグで、それぞれの意味はこうなっています。

フラグ 意味
MS_BIND bind mount(既存のパスを別の場所に重ねてマウント)
MS_RDONLY read-only でマウント
MS_REMOUNT 既存マウントのフラグを変更
MS_REC サブマウントにも再帰的に適用
MS_PRIVATE マウントイベントを他の namespace に伝播させない

では動かしてみましょう。

$ python3 mini_sandbox.py --ro-bind / / --bind /tmp /tmp -- touch /etc/test
touch: cannot touch '/etc/test': Read-only file system

$ python3 mini_sandbox.py --ro-bind / / --bind /tmp /tmp -- touch /tmp/test
(成功)

bwrapと同じように、/etc への書き込みは禁止、--bind で指定した /tmp には書き込み可、と制御できました!

さて、ファイルシステムは隔離できたので、次はネットワークを隔離してみます。

ネットワークを遮断する

いまはまだどこへでも通信が出来る状態です。

$ python3 mini_sandbox.py --ro-bind / / -- curl -s -o /dev/null -w '%{http_code}' https://example.com
200

ネットワークを隔離するために必要なことは簡単で os.unshare() のフラグを追加するだけです --unshare-net 引数に応じてセットするようにします。

    ns_flags = os.CLONE_NEWNS
    if unshare_net:
        ns_flags |= os.CLONE_NEWNET  # ← これ
    os.unshare(ns_flags)

これだけで通信が通らなくなります。

$ python3 mini_sandbox.py --ro-bind / / --unshare-net \
    -- curl -s -o /dev/null -w '%{http_code}' https://example.com
000

network namespace を作っただけの状態だとループバックインターフェースすら存在しない空の状態で、なにも通信が出来ません。

Codexなどでは、bubblewrapに加えてsocat プロキシなども組み合わせて、完全遮断だけでなく一部の通信のみ許可、といった制御をしています。

プロセスを隔離する

プロセス空間も隔離してみます。ここではフラグの追加に加えて、プロセスのforkが必要になります*4

    ns_flags = os.CLONE_NEWNS
    if unshare_net:
        ns_flags |= os.CLONE_NEWNET
    if unshare_pid:
        ns_flags |= os.CLONE_NEWPID  # ← これを追加
    os.unshare(ns_flags)

    pid = os.fork()
    if pid == 0:
        setup_fs(ro_binds, binds)
        os.execvp(command[0], command)  # forkした子プロセスでコマンドを exec
    else:
        _, status = os.waitpid(pid, 0)  # forkした親は子の終了を待つだけ

動かしてみると

# プロセス隔離無し
$ python3 mini_sandbox.py --ro-bind / / -- ps aux | head -5
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1 168928 13056 ?        Ss   0:10 /sbin/init
root           2  0.0  0.0      0     0 ?        S    0:00 [kthreadd]
...

# プロセス隔離発動
$ python3 mini_sandbox.py --ro-bind / / --unshare-pid -- ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
ubuntu         1  0.0  0.0  14544  4644 ?        R    06:16   0:00 ps aux

ホストで動いているプロセスが見えなくなって、自分だけになりました!PID も 1 になっています。

これが PID namespace です。fork で生まれた子プロセスは新しい PID 空間で動いています。ホストのプロセスは存在していますが、この namespace からは見えず、もちろん kill などで手を出すこともできません。

ここまでで、Linux の namespace を4つ使いました。

namespace 効果
user uid/gid を対応付ける。非特権で他の namespace を作るための前提
mount ファイルシステムを制限する
network ネットワークを遮断する
PID ホストのプロセスを見えなくする

実際に Codex のサンドボックス実装 (codex-rs/linux-sandbox/src/bwrap.rs) を見てみると、--unshare-user--unshare-pid--unshare-net--ro-bind--bind を組み合わせてサンドボックスを構成していることがわかります。

mount 隔離を完全にする — pivot_root

ここまでの setup_fs は、ホストの / の上に bind mount を重ねる方式でした。実はこれにはまだ穴があります。たとえば --ro-bind /usr /usr/usr だけをマウントしてみると

$ python3 mini_sandbox.py --ro-bind /usr /usr -- ls /home
(home配下のディレクトリ一覧)

マウントしていない /home の中身がそのまま見えてしまいます。bind mount はホストのファイルシステムの上に重ねるだけなので、指定していないパスは素通しで見えてしまいます。

本家の bubblewrap のように、--ro-bind / / する以外にも、好きなディレクトリをマウントして、マウントしたディレクトリ以外は見えないようにしてみましょう。

そのために pivot_root を使います。空の tmpfs を用意し、そこにこれまでのように必要なパスだけをマウントしてから、pivot_rootでルートそのものを差し替えて旧ルートを切り離します。

def setup_fs(ro_binds, binds, unshare_pid):
    do_mount("", "/", "", MS_PRIVATE | MS_REC)

    # 空の tmpfs を新しいルートとして作る
    do_mount("tmpfs", "/tmp", "tmpfs", 0)

    # その中に --ro-bind / --bind で指定されたパスだけをマウント
    for src, dest in ro_binds:
        target = f"/tmp{dest}"
        os.makedirs(target, exist_ok=True)
        do_mount(src, target, "", MS_BIND | MS_REC)
        do_mount("", target, "", MS_REMOUNT | MS_BIND | MS_REC | MS_RDONLY)
    # binds のマウントも同様(省略)

    # pivot_root でルートを差し替え
    oldroot_fd = os.open("/", os.O_DIRECTORY | os.O_RDONLY)
    os.chdir("/tmp")
    do_pivot_root(".", ".")

これでマウントしたディレクトリしか見えなくなりました。

$ python3 mini_sandbox.py --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \
    -- /usr/bin/ls /home
ls: cannot access '/home': No such file or directory

Docker のようにも動く

pivot_root 方式になったことで、--ro-bind で指定したものだけが見える環境が作られるようになりました。

では --ro-bind の SRC をホストの / ではなく、別のディレクトリにしたらどうなるでしょう。

ここでおもむろにAlpine Linux の minirootfs をダウンロードしてきます。

$ mkdir rootfs
$ curl -fsSL https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.4-x86_64.tar.gz \
    | tar xz -C rootfs

中身は /bin, /etc, /lib... という「Alpine のルートディレクトリがまるごと入ったディレクトリツリー」です。

これを --ro-bind の SRC に指定してみます。

$ python3 mini_sandbox.py --ro-bind ./rootfs / -- /bin/cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.21.4
PRETTY_NAME="Alpine Linux v3.21"

Ubuntu だったホストが、サンドボックス内の世界では Alpine になりました。

「これだけで?」という気持ちにもなりますが、実はここまで見てきたnamespaceによるファイルシステム/ネットワーク/PIDの分離は、Dockerを始めとしたコンテナ仮想化の仕組みそのものなのでした*5

サンドボックスとして使うか、コンテナとして使うか

まとめ

以上、bubblewrap を自作してサンドボックスの隔離を再現しました。

今回自作したのは namespace を扱う基本の部分ですが、本家 bubblewrap はその上に

  • capabilities の制御: サンドボックス内で特権操作ができないように Linux capabilities を落とす
  • seccomp フィルタ: BPF で危険なシステムコールを制限

などなど、さらに多層的な防御を積んでいます。

それでも、基本部分を作ってみることでnamespace と pivot_root を基本としてプロセス・ファイルシステム・ネットワークを隔離できること、サンドボックスとコンテナが同じ部品でできていることがわかりました。

AIエージェントで使われるサンドボックスや、コンテナを支える技術を感じてもらえていたら幸いです。

We are hiring!

エムスリーではエンジニアを募集しています。カジュアル面談からでもぜひご応募お待ちしています。

open.talentio.com

jobs.m3.com

*1:Claude Code: https://code.claude.com/docs/en/sandboxing / Codex: https://learn.chatgpt.com/docs/sandboxing

*2:macOS では Seatbelt (sandbox-exec) が使われています

*3:mount システムコールは Python の os モジュールには用意されていないため、ctypes で libc を直接呼ぶ薄いラッパー do_mount を用意しています。全文はリポジトリを参照してください

*4:PID は生成された瞬間に決まり変わらないため。unshare(CLONE_NEWPID) も、呼んだプロセス自身は移らず「これ以降に fork した子プロセスが新空間に入る」という動きになる

*5:bubblewrapのREADME でもコンテナランタイムとの関係に触れられています