NVIDIA OpenShell の sandbox で Claude Code と Codex をサブスクリプションのまま動かす

NVIDIA OpenShell の sandbox で Claude Code と Codex をサブスクリプションのまま動かす

NVIDIA OpenShell の sandbox で Claude Code と Codex をサブスクリプションの OAuth のまま動かしました。
2026.11.13

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の嶋田です。

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 に届くようになりました。

/opt/homebrew/var/openshell/gateway.toml
[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 に渡すだけで済みます。

image/Dockerfile(抜粋)
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 を書きました。

profiles/claude-code-oauth.yaml
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 用のスクリプトと同じやり方です。

image/codex-in-openshell(抜粋)
: "${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 に、更新の方法を書き足します。

profiles/codex-oauth.yaml(抜粋)
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]

ターミナルで sandbox から curl で example.com に接続して失敗したあと、openshell logs に example.com への DNS と TCP 接続を DENIED とした 2 行が出ている画面
許可していない宛先への通信は、拒否の理由つきで 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]

sandbox の中のシェルで git push origin try-push を実行し、HTTP 403 の RPC failed と fatal: the remote end hung up unexpectedly が表示された画面
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 v2.1.318 の TUI。bin/osa のサブコマンドを表にしてと頼み、setup から rm までのサブコマンドと引数と内容の表が返っている画面
sandbox の中の Claude Code も、対話モードでいつもどおり使える

sandbox で起動した OpenAI Codex v0.165.0 の TUI。同じ依頼に対して、bin/osa を読んでサブコマンドと書式と用途の表を返している画面
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 の宛先を書く必要はありません。

policy/sandbox.yaml(network_policies の部分)
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 へ 接続を拒否

手順をまとめたリポジトリ

ここまでの構成を、リポジトリにまとめました。

https://github.com/reona5/openshell-agents

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 を小さく保つと、何を許可しているかを読み取りやすくなりました。

参考資料


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AI白書

関連記事