
NVIDIA OpenShell の sandbox で Claude Code と Codex をサブスクリプションのまま動かす
はじめに
こんにちは、クラスメソッド製造ビジネステクノロジー部の嶋田です。
Claude Code や Codex に確認なしで作業させると速く進みます。
一方で、エージェントは手元のトークンを読めますし、どの宛先にでも通信できます。
エージェントを sandbox に入れ、通信してよい宛先とトークンを sandbox の外で管理できれば、確認を減らしても気にする点が少なくなります。
NVIDIA OpenShell は、このための sandbox を提供するツールです。
この記事では、OpenShell の sandbox で Claude Code と Codex を、サブスクリプションの OAuth で動かしました。
API キーは使っていません。
この記事で書くのは次のことです。
- 動かすまでの手順と、詰まった点
- sandbox にトークンを置かずに済む仕組みと、拒否されるべき通信が拒否されるかの確認
- clone、パッケージの取得、MCP など普段の作業に必要な宛先だけを開ける policy
- 手順をまとめたリポジトリ
OpenShell の構成
OpenShell は、ホストで動く gateway と、gateway が作る sandbox で構成されます。
この記事では sandbox を Docker のコンテナとして作る Docker driver を使いました。
Docker driver は、sandbox 1 つにつきコンテナを 2 つ作ります。
エージェントが動く workload のコンテナはネットワークを持たず、外への通信はすべて隣の supervisor のコンテナにある proxy を通ります。
手元でも、docker ps で sandbox ごとに 2 つのコンテナがあり、workload の側のネットワークが none になっていることを確かめました。
proxy は policy に従って、宛先のホストとポート、通信を始めたバイナリ、HTTP のメソッドとパスを検査し、許可されていない通信を拒否します。
policy にはファイルシステムとプロセスの制限も書きます。
トークンは provider として gateway に登録します。
provider を付けた sandbox では、エージェントの環境変数に本物のトークンではなく openshell:resolve:env:… という placeholder が入ります。
本物のトークンは supervisor が持ち、proxy は許可した宛先への通信に placeholder が含まれていたときだけ、それを本物のトークンに差し替えます。
そのため、エージェントのプロセスが環境変数を読んでも、手に入るのは placeholder だけです。
provider の種類ごとに、どの環境変数をどのヘッダーで送るか、どの宛先にどのバイナリから送ってよいかを provider profile に書きます。
profile に書いた宛先とバイナリは、provider を付けた sandbox で _provider_<name> という名前の network policy として追加されます。
インストール
公式の install.sh でバージョンを指定して入れました。
curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | OPENSHELL_VERSION=v0.1.7 sh
macOS では、install.sh が Homebrew のローカルの tap に formula を作って brew install し、gateway を brew services で 127.0.0.1:17670 に常駐させます。
openshell status が Connected になれば gateway は動いています。
macOS で sandbox が起動しない
最初の sandbox は、作成の途中で ControlSupervisorStartFailed になって起動しませんでした。
ログには failed to connect to OpenShell server が出ていました。
Docker driver では、sandbox の中の supervisor が gateway につないで設定を受け取ります。
supervisor は既定で host ネットワークを使い、https://127.0.0.1:17670 につなぎにいきます。
しかし Docker Desktop for Mac では、host networking を有効にしていないと、コンテナから見た 127.0.0.1 は Docker の Linux VM を指します。
そのため supervisor は gateway に届きませんでした。
gateway の設定ファイルで supervisor のネットワークを bridge に変えると、supervisor は host.docker.internal 経由で gateway に届くようになりました。
[openshell]
version = 2
[openshell.gateway]
[openshell.drivers.docker]
supervisor_network_mode = "bridge"
変更後に brew services restart openshell で gateway を再起動します。
OpenShell のドキュメントにも、Docker Desktop で host のまま使うには host networking を有効にする必要があると書かれています。
私は bridge に変える方法を選び、host networking を有効にする方法は試していません。
base イメージのまま使うと詰まる点
OpenShell-Community が配っている base イメージには、Claude Code と Codex、それにエージェント向けの policy が入っています。
このイメージをそのまま使うと、次の 3 点で詰まりました。
1 つ目は、同梱の policy が v0.1.7 では無効になることです。
sandbox は 300 秒待ったあと ConfigurationInvalid で失敗し、ログには理由が出ませんでした。
同じ policy を --policy で明示的に渡すと、理由が表示されました。
network policy 'claude_code': endpoint 0: unknown tls value 'terminate'
OpenShell は v0.1.0 から、policy の未知のフィールドや廃止されたフィールドを拒否するようになっています。
tls: terminate の行を消すと通りました。
2 つ目は、トークンを付ける宛先は L7 で検査する設定にしないと、provider を付けた時点で拒否されることです。
credentialed endpoint 'api.openai.com:443' in rule 'codex' uses L4-only; configure L7 inspection or explicitly set allow_uninspected_credentials: true
同梱の policy の Codex 用ルールは、ホストとポートだけを書いた L4 のルールでした。
protocol: rest を付けて L7 で検査させると通ります。
この制約は、provider profile が宣言した宛先と同じホストを書いた別のルールにも及びます。
たとえば GitHub の provider を付けると、パッケージ取得用のルールに L4 で書かれていた github.com も同じエラーになりました。
3 つ目は、同梱の Codex が 0.117.0 と古いことです。
認証は通りますが、ChatGPT のアカウントで指定できるモデルがどれも model_not_found になりました。
そこで、base イメージに版を固定した Claude Code と Codex を足すイメージを作りました。
Docker driver は同じ Docker daemon にあるローカルのイメージをそのまま使えるので、docker build したイメージ名を openshell sandbox create --from に渡すだけで済みます。
ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest
FROM ${BASE_IMAGE}
USER root
ARG CODEX_VERSION=0.165.0
ARG CLAUDE_VERSION=2.1.318
RUN npm install -g @openai/codex@${CODEX_VERSION} && codex --version
RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_VERSION} \
&& cp -L /root/.local/bin/claude /usr/local/bin/claude \
&& chmod 755 /usr/local/bin/claude && /usr/local/bin/claude --version
COPY codex-in-openshell /usr/local/bin/codex-in-openshell
RUN chmod 755 /usr/local/bin/codex-in-openshell
USER sandbox
Claude Code をサブスクリプションの OAuth で動かす
OpenShell のリポジトリには provider profile の例があり、Claude Code 用の providers/claude-code.yaml は API キーを x-api-key ヘッダーで送るものです。
サブスクリプションで使うために、claude setup-token で発行する長期の OAuth トークンを Authorization: Bearer で送る provider profile を書きました。
id: claude-code-oauth
display_name: Claude Code (OAuth)
description: Claude Code CLI with a subscription OAuth token
category: agent
inference_capable: true
credentials:
- name: oauth_token
description: Long-lived OAuth token from `claude setup-token`
env_vars: [CLAUDE_CODE_OAUTH_TOKEN]
required: true
auth_style: bearer
header_name: authorization
discovery:
credentials: [oauth_token]
endpoints:
- { host: api.anthropic.com, port: 443, protocol: rest, access: read-write, enforcement: enforce }
- { host: platform.claude.com, port: 443, protocol: rest, access: read-write, enforcement: enforce }
binaries: [/usr/local/bin/claude, /usr/bin/claude]
platform.claude.com は、対話モードで起動するときに必要でした(後述)。
binaries を claude だけにしているので、sandbox の中で curl や node からこれらの宛先には出られません。
profile を登録し、トークンを provider として作ります。
トークンを画面やシェルの履歴に残さないよう、read -s で受け取って環境変数経由で渡しました。
openshell provider profile import -f profiles/claude-code-oauth.yaml
read -rs CLAUDE_CODE_OAUTH_TOKEN # claude setup-token の出力を貼る
CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_CODE_OAUTH_TOKEN" \
openshell provider create --name claude --type claude-code-oauth --credential CLAUDE_CODE_OAUTH_TOKEN
この provider を付けて sandbox を作ると、中の CLAUDE_CODE_OAUTH_TOKEN は openshell:resolve:env:… の placeholder になっていました。
その状態で claude -p "reply with exactly: OK from openshell" を実行すると、OK from openshell が返りました。
サブスクリプションの OAuth は Claude Code などの Anthropic のアプリ向けで、ほかのツールから使うことは認められていません。
今回は Anthropic が配る Claude Code のバイナリをそのまま使い、自分のサブスクリプションのトークンで認証しています。
Claude Code の Legal and compliance には、利用者が改変していない Claude Code に自分のサブスクリプションでサインインすることは、Claude Code をホストする環境で動かす場合も制限しないと書かれています。
claude setup-token も、Authentication で CI やスクリプト向けとして案内されている方法です。
Codex を ChatGPT の OAuth で動かす
Codex はトークンを ~/.codex/auth.json から読みます。
そこで、access_token を provider に登録し、sandbox の中で placeholder を使って auth.json を組み立ててから Codex を起動するスクリプトをイメージに入れました。
OpenShell 本体のリポジトリにある Codex 用のスクリプトと同じやり方です。
: "${CODEX_AUTH_ACCESS_TOKEN:?provider codex is not attached}"
: "${CODEX_ACCOUNT_ID:?pass --env CODEX_ACCOUNT_ID=...}"
# placeholder の access_token と account_id で ~/.codex/auth.json を書く(省略)
exec codex "$@"
account_id だけは placeholder にできませんでした。
Codex はこの値をローカルでの比較にも使うので、placeholder のままだと一致しません。
OpenShell 本体の Codex 用スクリプトも、account_id が placeholder だと「must be a literal identifier」と表示して止まるようになっています。
account_id はトークンではないので、sandbox の作成時に --env CODEX_ACCOUNT_ID=… で平文のまま渡しました。
Codex 自身の sandbox はコンテナの中では起動できず、Codex はコマンドごとに承認を求めてきます。
そこで --sandbox danger-full-access(対話モードでは -c sandbox_mode="danger-full-access")で Codex の sandbox を無効にし、制限は OpenShell に任せました。
この状態で codex exec に同じプロンプトを渡すと、OK from openshell が返りました。
トークンの更新を gateway に任せる
access_token には期限があります。
OpenShell のリポジトリにある Codex 用の profile の例(providers/codex.yaml)は、refresh_token も sandbox に渡す形です。
私は refresh_token を sandbox に渡さず、gateway に持たせて更新させることにしました。
更新を gateway の 1 か所にまとめ、sandbox の中のプロセスが refresh token を扱わずに済むようにするためです。
OpenShell の gateway には、refresh token を使って access_token を期限の前に取り直し、provider に書き戻す機能があります。
profile の access_token に、更新の方法を書き足します。
credentials:
- name: access_token
env_vars: [CODEX_AUTH_ACCESS_TOKEN]
auth_style: bearer
header_name: authorization
refresh:
strategy: oauth2_refresh_token
token_url: https://auth.openai.com/oauth/token
scopes: [openid, profile, email]
material:
- { name: client_id, required: true }
- { name: refresh_token, required: true, secret: true }
provider を作ったあとで、refresh token と Codex の OAuth のクライアント ID を gateway に渡します。
CODEX_REFRESH_TOKEN="…" openshell provider refresh configure codex \
--credential-key CODEX_AUTH_ACCESS_TOKEN \
--strategy oauth2-refresh-token \
--material client_id="<access_token の client_id>" \
--secret-material-env refresh_token=CODEX_REFRESH_TOKEN \
--credential-expires-at <access_token の期限(ミリ秒)>
ここで使う refresh token は gateway 専用にログインし直して取ったもので、ホストの ~/.codex のものとは別です。
OAuth では、refresh token が使うたびに新しいものへ入れ替わる場合があります。
ホストの Codex と同じものを共有すると、gateway が更新した時点でホストの Codex のログインが切れるおそれがあるためです(OpenAI の refresh token が入れ替わるかは確かめていません)。
CODEX_HOME を別のディレクトリにして codex login すると、ホストのログインとは別のログインになります。
openshell provider refresh rotate で更新を 3 回強制し、どれも refreshed になりました。
更新後に sandbox で codex exec を実行すると応答が返り、sandbox を作り直さずに続けて更新しても、次に起動した Codex は動きました。
ホストの Codex のログインもそのまま使えました。
なお、動いている sandbox に provider を後から付けたときは、反映を待たずに Codex を起動すると credential_unavailable で拒否されました。
ドキュメントによると、openshell sandbox provider attach に --wait を付けると、反映されるまで待ってから戻ります。
境界を確かめる
エージェントが動くことを確かめたあと、拒否されるべき通信が拒否されるかを openshell sandbox exec で直接試しました。
| 試したこと | 結果 |
|---|---|
sandbox の環境変数と auth.json のトークン |
どちらも placeholder |
curl から api.anthropic.com へ |
接続を拒否(curl は許可したバイナリではない) |
Claude 用の placeholder を付けて chatgpt.com へ |
403(placeholder の宛先が違う) |
Codex 用の placeholder を付けて api.anthropic.com へ |
403 |
curl から example.com へ |
接続を拒否 |
touch /etc/x |
Permission denied |
拒否した通信は openshell logs <sandbox 名> に出ます。
NET:OPEN [MED] DENIED /usr/local/bin/claude(0) -> example.com:443 [reason:transparent_tcp_policy_denied]

許可していない宛先への通信は、拒否の理由つきで proxy のログに残る
Claude Code にツールを使わせた場合も、許可していない宛先は拒否されました。
WebFetch で example.com を開かせると connect EACCES になり、Claude Code が送る Datadog へのテレメトリも拒否のログに出ていました。
普段の作業で使う
私の公開リポジトリを題材に、clone から push までの操作を sandbox の中で試しました。
GitHub
GitHub には、OpenShell のリポジトリにある profile の例 providers/github.yaml をそのまま使いました。
この profile は clone と fetch を許可し、push を許可していません。
gh auth token の値を provider に登録し、sandbox の中で gh auth setup-git を実行して git にも placeholder を渡します。
GH_TOKEN=$GITHUB_TOKEN gh auth setup-git
これで gh api user と git clone、git fetch は通り、git push は 403 になりました。
ログでは、_provider_github のルールが L7 で拒否しています。
HTTP:POST [MED] DENIED POST https://github.com/<user>/<repo>/git-receive-pack [policy:_provider_github engine:l7]

clone と fetch は通るが、push は proxy が 403 で止める
push を許可したい場合は、OpenShell の tutorial「Grant GitHub Push Access to a Sandboxed Agent」に、特定のリポジトリにだけ書き込みを許可する手順があります。
この手順は試していません。
なお、openshell sandbox upload でホストのリポジトリを送ると、.gitignore のフィルタで .git が送られませんでした。
アップロード先のパスも親ディレクトリとして扱われ、upload <name> dir /x/dir は /x/dir/dir に置かれます。
--no-git-ignore を付けるとフィルタなしで送れますが、私は sandbox の中で clone することにしました(--no-git-ignore は試していません)。
MCP
Claude Code に HTTP の MCP サーバーを追加すると、最初は ECONNREFUSED で接続できません。
openshell policy update で宛先を 1 つ足すと、sandbox を作り直さずに数秒で新しい policy が読み込まれ、接続できるようになりました。
openshell policy update <sandbox 名> \
--add-endpoint mcp.context7.com:443:read-write:rest:enforce \
--binary /usr/local/bin/claude \
--rule-name context7-mcp --wait
skill は、/sandbox/.claude/skills/<name>/SKILL.md に置けば Claude Code から使えました。
NVIDIA が配っている Agent Skills の探し方は、森茂さんの記事「NVIDIA が配る Agent Skills の使い方と探し方を整理してみた」にまとまっています。
対話モードで使う
ここまでは claude -p や codex exec で確かめていました。
対話モードで使うと、別の問題が出ました。
まず、openshell sandbox connect で入っても何も反応しませんでした。
connect は新しいシェルを開くコマンドではなく、sandbox のメインプロセスにつなぐコマンドです。
私は sandbox を -- sleep infinity で作っていたので、つないだ先が sleep でした。
コマンドを渡さずに作るとメインプロセスは bash -l になり、connect で使えます。
別のシェルを開くときは openshell sandbox exec --name <sandbox 名> --tty -- bash -l を使います。
次に、Claude Code が起動の時点で止まりました。
Unable to connect to Anthropic services ... ECONNREFUSED
対話モードは起動時に platform.claude.com への接続を確認しており、ここを塞ぐと先に進みません。
provider profile の宛先に platform.claude.com を足しました。
ほかに、次の 2 点に対処しました。
- sandbox を作るたびに、初回のテーマ選択とフォルダの信頼確認が出る。イメージの
~/.claude.jsonにhasCompletedOnboardingを書き、フォルダの信頼は clone の直後に、clone したディレクトリにだけ付ける /usrが読み取り専用なので自動更新が失敗し、画面の下にAuto-update failedが出る。DISABLE_AUTOUPDATER=1で止め、版はイメージで固定する
DISABLE_AUTOUPDATER は、最初は Dockerfile の ENV に書きました。
しかし、イメージの ENV は sandbox のプロセスに渡りませんでした。
openshell sandbox create --env DISABLE_AUTOUPDATER=1 で渡すと、Auto-update failed は出なくなりました。
これで、TUI から送ったプロンプトにも応答が返るようになりました。

sandbox の中の Claude Code も、対話モードでいつもどおり使える

Codex も同じ sandbox の中で対話モードで動く
policy を絞る
base イメージの policy は、Claude Code や Codex 用のルールで /usr/bin/node を許可していました。
この場合、sandbox の中の任意の node スクリプトが、トークン付きでエージェントの API に通信できます。
実際に node から placeholder を付けて api.anthropic.com と chatgpt.com にリクエストすると、どちらも 200 が返りました。
そこで、トークンを付ける宛先は provider profile にだけ書き、policy にはファイルシステムと、トークンの要らないパッケージレジストリだけを書くことにしました。
provider profile の宛先は _provider_<name> のルールとして自動で追加されるので、policy に Anthropic や OpenAI の宛先を書く必要はありません。
network_policies:
pypi:
name: pypi
endpoints:
- { host: pypi.org, port: 443, protocol: rest, enforcement: enforce, access: read-only }
- { host: files.pythonhosted.org, port: 443, protocol: rest, enforcement: enforce, access: read-only }
binaries:
- { path: /sandbox/.venv/bin/python }
- { path: /sandbox/.venv/bin/python3 }
- { path: /sandbox/.venv/bin/pip }
- { path: /usr/local/bin/uv }
- { path: "/sandbox/.uv/python/**" }
npm_registry:
name: npm-registry
endpoints:
- { host: registry.npmjs.org, port: 443, protocol: rest, enforcement: enforce, access: read-only }
binaries:
# npm は node で動くので node を許可するが、宛先はレジストリの読み取りだけ
- { path: /usr/bin/node }
この policy で sandbox を作り直し、同じ確認をしました。
| 確認 | 結果 |
|---|---|
claude -p |
応答が返る |
codex exec |
応答が返る(profile の binaries に node がなくても動く) |
gh api user |
自分のユーザー名が返る |
git clone |
成功 |
uv pip install six |
成功 |
npm view left-pad |
1.3.0 |
node から placeholder を付けて api.anthropic.com へ |
EACCES(絞る前は 200) |
node から placeholder を付けて chatgpt.com へ |
EACCES(絞る前は 200) |
node から registry.npmjs.org へ GET |
200 |
node から registry.npmjs.org へ POST |
403(読み取りのみ) |
curl から example.com へ |
接続を拒否 |
手順をまとめたリポジトリ
ここまでの構成を、リポジトリにまとめました。
bin/osa は openshell コマンドの薄いラッパーです。
bin/osa setup # provider profile の登録とイメージのビルド
bin/osa login claude # claude setup-token のトークンを貼る(表示されない)
bin/osa login codex # gateway 専用に codex login し、更新を gateway に任せる
bin/osa login github # gh auth token を登録する
bin/osa new work owner/repo # sandbox を作り、中で owner/repo を clone する
bin/osa claude work # Claude Code を起動する
bin/osa codex work # Codex を起動する
bin/osa shell work # シェルを開く
bin/osa allow work mcp.example.com # 宛先を 1 つ開ける
bin/osa rm work
login で登録しなかった provider は sandbox に付けないので、Claude Code だけを使う構成にもできます。
macOS では、前述の gateway.toml の変更が別に必要です。
残っている課題
- Codex の更新は
rotateで強制した 3 回だけを確かめており、期限による自動の更新を長い期間回したことはありません - Codex の
account_idは平文の環境変数で渡しています osa allowで許可するバイナリのパスは、このイメージでのインストール先を前提にしています
おわりに
OpenShell の sandbox で、Claude Code と Codex をサブスクリプションの OAuth のまま動かせました。
エージェントのプロセスから見えるのは placeholder だけで、トークンは許可した宛先への、許可したバイナリからの通信にだけ付きます。
push のように通したくない操作は、L7 のルールで拒否できました。
詰まった点の多くは、v0.1 系で policy の検査が厳しくなったことと、base イメージがそれに追いついていないことによるものでした。
トークンを付ける宛先を provider profile にまとめ、policy を小さく保つと、何を許可しているかを読み取りやすくなりました。








