CodexをDockerにしまい込んで使う

Emacsを使っていると、ChatGPTやClaudeをvterm上で使いたいと感じる場面があります。
一方で、APIを自前で組み込むのはコスト管理も気になります。

そこで今回は、Codex CLIを会話相手として使い、Dockerで実行環境を強く制限する構成を試しました。

関連記事

1. Codexには「見せたいものだけ見せる」

今回の構成を一言で表すと、次のようになります。

全体像 macOS Dockerコンテナ Codex playground 検索 ✓ 特定フォルダのみアクセス可能 ✓ インターネット検索は有効 ✓ 権限は最小限に制限
  • macOS + Docker Desktop
  • コンテナ内でCodex CLIを起動
  • ホストの ~/Documents/playground だけをbind mount
  • それ以外のホストファイルは一切見せない
  • ネットワークは有効(検索のため)
  • コンテナの権限は最小限

Dockerはファイルの見える範囲を切り取る道具として使っています。

1.1. 作業フォルダを1か所に限定する意味

なぜ ~/Documents/playground だけにしたのか。

理由は単純で、事故が起きたときの被害範囲を物理的に限定したかったからです。

たとえば、

  • ~/.ssh が見えない
  • ~/Library が見えない
  • 他のプロジェクトや個人メモが見えない

これはCodexを信用していない、という話ではありません。
検索(後述)を有効にすると、外部テキストが入力に混ざります。そうなると「意図しない指示」が入り込む可能性はゼロではありません。

Dockerで見えないようにしておけば、仮に変なことを言われても、触れないものは触れません

2. Docker起動の設定(macOS)

まずは、事前準備としてシステム全体のDocker環境をセキュア化し、作業領域を適切に準備することで、後続のコンテナ起動コマンドが安全かつ確実に動作する基盤を整えます。

もし、Dockerがなければ、インストールします。

# Docker Desktop for Macのインストール
brew install --cask dockerCode language: PHP (php)

まずは、docker-desktopを起動します。

これで、dockerが常駐するようになりました。

macOS上のDockerは、実際にはLinux仮想マシン(VM)内で動作しています。この仮想マシンのLinuxカーネルは、ホストのmacOSとは完全に分離されているため、User Namespace remappingの設定(/etc/docker/daemon.jsonuserns-remap)がmacOSのセキュリティには直接寄与しません

macOS (ホスト)
└── Linux VM (仮想マシン)
└── Docker Daemon
└── コンテナ

コンテナ内のrootは、VM内のrootにマッピング
VM自体がmacOSから隔離されている
VMからmacOSへの侵入はハイパーバイザーが防御

2.1. 【補足】LinuxでUser Namespaceを使う場合。

# 事前準備(既存設定を保持)
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak 2>/dev/null || true
echo '{"userns-remap": "default"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart dockerCode language: PHP (php)

User Namespace機能は、Dockerセキュリティの中でも最も強力な防御層の一つであり、この設定なしでは「コンテナ内root = ホスト側root」という危険な状態が続きます。

  • 既存のDocker設定ファイルをバックアップします(sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak)。エラー出力を破棄し(2>/dev/null)、ファイルが存在しない場合でもエラーで止まらず続行します(|| true)。これにより、初めてDockerを設定する場合でも、既存設定がある場合でも、安全に次のステップへ進めます。
  • Docker設定ファイルにUser Namespace remapping機能を有効にする設定を書き込みます(echo '{"userns-remap": "default"}' | sudo tee /etc/docker/daemon.json)。この設定により、コンテナ内のroot(UID 0)がホスト側では非特権ユーザー(例:UID 231072)に自動マッピングされるようになります。つまり、コンテナ内で仮にroot権限を奪われても、ホスト側では何の権限も持たない一般ユーザーとして動作するため、システムへの侵入を根本的に防ぎます。
  • 変更した設定を反映させるため、Docker Daemonを再起動します(sudo systemctl restart docker)。この再起動により、以降起動するすべてのコンテナでUser Namespace機能が有効になります。注意:この再起動後、既存のDockerイメージやコンテナは一時的に見えなくなり、新しい分離されたディレクトリ(/var/lib/docker/231072.231072/など)で管理されるようになります。

2.2. 作業用フォルダの用意

次に作業用フォルダを作ります。
今回は、~/Documents/playground に作りました。

# playground作成&パーミッション設定
mkdir -p "$HOME/down/playground/.npm"
chmod -R 755 "$HOME/down/playground"Code language: PHP (php)
  • ホスト側に作業用ディレクトリとnpmパッケージ保存用のサブディレクトリを作成します(mkdir -p "$HOME/Documents/playground/.npm")。-pオプションにより、親ディレクトリが存在しない場合も自動作成され、既に存在する場合はエラーになりません。
  • 作成したディレクトリとその配下すべてに、所有者が読み書き実行可能、グループとその他のユーザーが読み取り・実行可能な権限を設定します(chmod -R 755 "$HOME/Documents/playground")。この設定により、コンテナ内で異なるUID/GIDで実行される場合でも、ファイルへのアクセスが保証されます。特にUser Namespace有効化後は、コンテナ内のUIDがホスト側で異なる番号にマッピングされるため、この権限設定が重要になります。

2.3. 実行コマンド

あとは、実行だけ。

# 実行
docker run --rm -it \
  --user $(id -u):$(id -g) \
  --mount type=bind,src="$HOME/down/playground",dst=/work \
  --workdir /work \
  --tmpfs /tmp:rw,size=1g,mode=1777 \
  --tmpfs /home:rw,size=200m \
  --tmpfs /root:rw,size=50m \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  --memory="1g" \
  --memory-swap="1g" \
  --cpus="2.0" \
  --pids-limit=200 \
  --network bridge \
  --env HOME=/tmp \
  node:20-slim \
  bash -c 'set -e && \
    npm config set prefix /work/.npm && \
    export PATH="/work/.npm/bin:$PATH" && \
    npm i -g @openai/codex && \
    codex'Code language: PHP (php)

やっていることを噛み砕くと、

Dockerコンテナを起動します(docker run)。コンテナ終了時に自動削除され(--rm)、キーボード入力とターミナル表示を有効にして対話的に操作できます(-it)。
コンテナ内のプロセスを、現在ログインしているユーザーと同じUID・GIDで実行します(--user $(id -u):$(id -g))。これにより、コンテナ内でroot権限を持たず、ホスト側と同じ権限レベルで動作するため、仮に侵入されてもroot特権を奪われません。
ホストの~/Documents/playgroundディレクトリだけをコンテナ内の/workとして公開します(--mount type=bind,src="$HOME/Documents/playground",dst=/work)。この領域だけが読み書き可能で、それ以外のホストファイル(.sshLibraryなど)には一切アクセスできません。コンテナ起動時の作業ディレクトリも/workに設定されます(--workdir /work)。
/tmpディレクトリをメモリ上の一時ファイルシステムとして最大1GBまで作成します(--tmpfs /tmp:rw,size=1g,mode=1777)。npmのパッケージダウンロードや一時ファイル作成に使われ、コンテナ終了時にメモリから完全に消去されるため情報漏洩リスクがありません。
ルートファイルシステム全体を読み取り専用にします(--read-only)。これにより、/usr/etcなどのシステムディレクトリへの書き込みが一切できず、マルウェアによるシステム改ざんを防ぎます。書き込みが必要な場所は/work/tmpのみです。
Linuxのすべてのcapability(特権機能)を削除します(--cap-drop=ALL)。これにより、ネットワーク設定の変更、他プロセスのkill、システムファイルの所有者変更など、通常rootが持つ特権操作がすべて禁止されます。さらに、プロセスが後から追加の特権を取得することも禁止され(--security-opt=no-new-privileges:true)、setuid/setgidによる権限昇格攻撃を防ぎます。
メモリ使用量を最大1GBまでに制限し(--memory="1g")、スワップメモリも1GBまでに抑えます(--memory-swap="1g")。CPU使用量は2コア分まで(--cpus="2.0")、起動できるプロセス数は200個まで(--pids-limit=200)に制限されます。これにより、メモリ枯渇攻撃、CPU占有攻撃、フォーク爆弾(無限にプロセスを生成する攻撃)などを防ぎます。
デフォルトのブリッジネットワークを使用します(--network bridge)。コンテナはホストネットワークから分離され、インターネットへのアクセスは可能ですが、ホストマシンのネットワーク設定には干渉できません。
Node.js 20のスリム版公式イメージを使用します(node:20-slim)。軽量イメージのため不要なツールが含まれず、攻撃面が最小化されています。

ここまでのお膳立てをしてコンテナ起動し、次のコマンドを順に実行します(bash -c 'set -e && ...'):

  1. エラー時即座停止set -e):
    いずれかのコマンドが失敗したら即座に停止し、エラーを見逃しません。
  2. npmインストール先変更npm config set prefix /work/.npm):
    npmのグローバルパッケージを/work/.npmにインストールするよう設定します。
    これにより、コンテナを再起動してもplaygroundディレクトリにパッケージが残り、毎回再インストールする必要がありません。
  3. 実行パス追加export PATH="/work/.npm/bin:$PATH"):
    インストールしたCodex CLIを実行できるよう、パスを通します。
  4. Codexインストールnpm i -g @openai/codex):
    Codex CLIをグローバルインストールします。
  5. Codex起動codex):
    Codexを起動し、対話的に使用できるようになります。

2.4. CodexはどこまでUnixコマンドを使えるか

答えはシンプルで、普通のLinuxコンテナで使える範囲までです。

たとえば、

  • lsgrepsed
  • ファイルのコピーや差分確認
  • playground内のコード編集

こういったことは問題なくできます。

一方で、

  • sudo
  • mount
  • ホスト操作やDocker操作

こうしたものは、そもそも権限的に通りません。

3. インターネット検索(cached / live)

Codex CLIにはインターネット検索機能があります。
ここが少し誤解されやすい点です。

学習データと検索の違い 学習 データ 過去の知識 cached 検索 整理された資料 live 検索 ウェブ参照 記憶 ≠ 参照 この区別が使い方の理解を助ける
  • 学習データ:
    過去にモデルに染み込ませた知識
  • cached検索:
    推論時に参照する資料集
  • live検索:
    実際にネットを見に行く行為

つまり、検索は「記憶」ではなく「参照」です

3.1. cached検索とは何か

cached検索は、Codexが直接インターネットを見に行かない検索です(といっても、OpenAIにはアクセスしますが)。
OpenAI側で整理・要約された、比較的最近の情報を参照します。

  • 最新性はそこそこ
  • 安定性が高い
  • 再現性がある

基本的なことを調べるには、これで十分だと感じました。

3.2. live検索とは何か

live検索は、実際にウェブへアクセスします。
メリットは、最新情報が取れることですが、デメリットもあります。

それは、外部テキストをそのまま読み取ることです。
というのも、そのテキスト内に「指示」のようなものが含まれていれば、その動作を「実行」しかねないことです。
これを「プロンプトインジェクション」といいます。

私は「仕様変更や直近のトラブル調査」だけに限定して使っています。

4. 実際に使ってみて感じた限界

便利ではありますが、万能ではありません。

  • macOS操作はできない
  • 長時間常駐するサービス用途には向かない
  • 大規模リファクタは慎重になる

特に、検索結果をそのまま信じてコマンド実行させるのは避けています。
必ず「何をするのか」を要約させてから判断しています。

4.1. この構成をどう評価しているか

個人的には、

  • APIを組むほどではない
  • でも「会話+手元ファイル」は欲しい

という用途に、かなりちょうどいい落とし所だと感じています。

Dockerで囲うことで、Codexの能力を信頼ではなく構造で制限する
この考え方は、今後他のAIツールにも使える気がしています。