AuthonAuthon Blog
tutorial10 min read

How to Sandbox Claude Code and Codex in NVIDIA OpenShell (Keep Them Off ~/.ssh)

Claude Code's own sandbox still lets shell commands read ~/.ssh. OpenShell hides your home dir and hands the agent a fake API key. Plus 3 setup gotchas.

AW
Alan West
Authon Team
How to Sandbox Claude Code and Codex in NVIDIA OpenShell (Keep Them Off ~/.ssh)

Open Claude Code's own sandbox docs and look at the defaults table. With the sandbox switched on, shell commands can still read "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials" (Claude Code docs). Network goes through an allowlist, so a single cat won't ship your key anywhere by itself. But that's the sandbox on, and it's off by default.

Most of us run coding agents with the sandbox off and --dangerously-skip-permissions muscle memory. One poisoned README or a malicious issue comment, and the agent is holding your shell.

I moved Claude Code and Codex into NVIDIA OpenShell, the Apache-2.0 runtime NVIDIA open-sourced for running agents under policy. Below is what it isolates and the install and run commands for the current 0.1.x line, including three things the docs don't make obvious. Then where it gets annoying, and how it compares to devcontainers, bubblewrap and the built-in sandboxes.

What OpenShell actually isolates

The architecture doc splits things into a gateway (control plane), a supervisor (the trusted side) and the sandbox where your agent lives. On Linux the enforcement is real kernel stuff:

  • Filesystem: Landlock. The default policy gives read-only /bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log and read-write only on the working directory, /tmp and /dev/null. Your home directory isn't in there because it isn't in the container at all.
  • Network: zero outbound by default. Direct egress is cut at the runtime level (container networking off on Docker/Podman, no network device in the VM). TCP opens and DNS queries get trapped with seccomp user notification and handed to the supervisor, which checks policy and relays the allowed ones.
  • Per-binary rules: the sandbox identifies the calling executable from /proc, so a rule can say "/usr/bin/gh may GET api.github.com" and curl gets nothing.
  • Secrets: this is the part I like most. Per the providers doc, the agent's environment holds an opaque placeholder instead of your API key. The proxy swaps the real value in only on requests to the endpoints the credential is bound to. Send it anywhere else and you get a 403 with credential_endpoint_mismatch.
  • Process: the workload runs as a non-root user with no Linux capabilities, and run_as_user: root is rejected outright.

If the supervisor connection drops, the sandbox freezes the agent. It fails closed, which is what you want.

Requirements before you install

From the support matrix:

  • Linux (Debian/Ubuntu, x86_64 or aarch64), macOS on Apple Silicon with Docker Desktop, or Windows via WSL 2 (experimental)
  • Docker Engine/Desktop 28.0+, or Podman 5.x
  • Landlock ABI 3, which means kernel 6.2+ with Landlock enabled. Launch fails closed if it's missing.

On a Mac, the Landlock and seccomp bits run inside the Docker Desktop Linux VM, not your macOS kernel.

Install OpenShell

bash
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh

Yes, a security tool that installs via curl | sh. Read the script first; it's on GitHub. The installer sets up the CLI and a local gateway.

Telemetry is on by default, which got a lot of pushback in the HN thread. Turn it off on the gateway:

bash
export OPENSHELL_TELEMETRY_ENABLED=false

Gotcha 1: there's no agent in the default image

The default image (nvcr.io/nvidia/base/ubuntu:24.04) "does not include agent CLIs," and since 0.1.0 --from no longer builds Dockerfiles or expands the old catalog names (sandbox docs). The OpenShell-Community repo with the prebuilt agent images was archived on Oct 7. Older tutorials run openshell sandbox create -- claude against the base image; on 0.1.x there's no claude in there to run.

So you build the image yourself and pass it to --from. One trap here: current @anthropic-ai/claude-code (2.1.309 as I write this) declares node >=22.0.0, and Ubuntu 24.04's apt nodejs is 18. Pull Node 22 from NodeSource instead:

dockerfile
FROM nvcr.io/nvidia/base/ubuntu:24.04
RUN apt-get update && apt-get install -y curl ca-certificates git \
 && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
 && apt-get install -y nodejs \
 && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code @openai/codex
USER 1000
bash
docker build -t agents:local .

On Docker and Podman, OpenShell rejects an image whose user is root unless your policy sets a non-root run_as_user. An image that declares no user already runs as UID 1000. I write USER 1000 anyway so nobody "fixes" a permissions error later with USER root.

Gotcha 2: provider profiles aren't preloaded

A provider is how the agent gets a key without ever seeing it. But --type takes the ID of a profile your gateway has imported, and a fresh gateway has none. The repo's providers/ directory ships example profiles, including claude-code, codex and openai. Its README says to copy and edit them rather than import them unchanged, and for these two agents that's not optional.

Binary matching uses the executable's real path, not a symlink. The stock claude-code profile allows /usr/bin/claude and /usr/local/bin/claude, but an npm global install puts the real binary under /usr/lib/node_modules/@anthropic-ai/, which the profile's own header comment tells you to add:

bash
curl -LO https://raw.githubusercontent.com/NVIDIA/OpenShell/main/providers/claude-code.yaml
# edit: binaries: [/usr/bin/claude, /usr/local/bin/claude, /usr/lib/node_modules/@anthropic-ai/**]
openshell provider profile lint   -f claude-code.yaml
openshell provider profile import -f claude-code.yaml --global
openshell provider create --name my-claude --type claude-code --from-existing
--from-existing reads ANTHROPIC_API_KEY or CLAUDE_API_KEY from your shell. While you're editing, note the profile also opens statsig.anthropic.com and sentry.io for Claude Code's telemetry and error reporting. The header says to drop them if your policy forbids that traffic. I dropped them.

For Codex you have two options. The codex profile carries the ChatGPT sign-in OAuth tokens (CODEX_AUTH_) and its reference layout already expects /usr/lib/node_modules/@openai/*. The openai profile is for a plain OPENAI_API_KEY, but its binaries list is only curl, so as shipped your Codex process never gets the key. Change it to the Codex path, import it the same way, then:

bash
openshell provider create --name my-openai --type openai --credential OPENAI_API_KEY

A bare --credential KEY reads $KEY from your shell, so the key never shows up in your history. openshell provider list-profiles shows what the gateway has imported.

Run Claude Code (or Codex) inside the sandbox

bash
openshell sandbox create --name claude-box --from agents:local \
  --provider my-claude --provider my-openai \
  --upload ./src:/workspace/src

openshell sandbox connect claude-box
# inside:
claude
--upload can't be combined with a trailing command, which is why I connect and start claude by hand. Inside a Git work tree, uploads respect .gitignore, so your .env stays home if it's ignored. When you're done:
bash
openshell sandbox download claude-box /workspace/src ./src-out

Inside the box you can let the agent run with no approval prompts. If it gets prompt-injected into rm -rf ~, the damage stays inside a container you can delete.

Filesystem rules are fixed when the sandbox is created, and by default only the working directory and /tmp are writable. If an agent fails writing its own config under $HOME, that's the reason, and you fix it in the policy you pass to sandbox create, not live.

Gotcha 3: the first npm install fails, on purpose

OpenShell denies the connection and drafts a proposal for a narrow rule (one binary, one host, one port). The policy prover checks what that rule would add before it reaches you:

bash
openshell rule get claude-box --status pending
openshell rule approve claude-box --chunk-id <chunk-id>
openshell rule approve-all claude-box exists, and it skips anything the prover flagged unless you add --include-security-flagged. Don't add that flag. Approved rules load without a restart; the blocked request stays denied, so retry it.

Once you know what a project needs, put it in a policy file instead of clicking through approvals every session. From the schema reference:

yaml
version: 1

network_policies:
  npm_registry:
    endpoints:
      - host: registry.npmjs.org
        port: 443
        protocol: rest
        enforcement: enforce
        access: read-only
    binaries:
      - path: /usr/bin/node
npm is a script, so the rule names the Node interpreter. Two more gotchas: enforcement defaults to audit, which logs and doesn't block, so write enforce explicitly. And network rules can be changed live with openshell policy set claude-box --policy policy.yaml --wait, while filesystem and process sections can't.

OpenShell vs devcontainers, bubblewrap and the built-in sandboxes

| | Home dir / SSH keys | Network default | Real API key visible to agent | Per-binary egress rules |
|---|---|---|---|---|
| Claude Code /sandbox | Readable by default | Proxy, allowlist starts empty | Yes by default; credentials can deny or mask it | No |
| Codex --sandbox workspace-write | Writes limited to workspace (bubblewrap/Seatbelt) | Not stated in the sandboxing docs | Yes | No |
| Docker devcontainer | Only what you mount | Open unless you lock it down | Yes | No |
| Hand-rolled bubblewrap | Whatever you bind | Whatever you build | Yes | No |
| OpenShell | Not present | Deny all | No, placeholder plus injection | Yes |

The built-in sandboxes are better than nothing, and you should switch them on today if you do nothing else. They also leave more out than people assume. Claude Code's sandbox only wraps shell commands. Read, Edit and WebFetch follow permission rules instead, so a denyRead entry doesn't stop the Read tool, and hooks and local MCP servers run with your full access. There's also the dangerouslyDisableSandbox retry, and an allow rule like Bash(curl *) approves that retry silently.

A devcontainer gives you filesystem isolation, but the network is wide open by default and your tokens sit in plain env vars.

What OpenShell adds is the credential placeholder plus per-binary egress control, on by default and covering every process in the box. Claude Code now has an opt-in mask mode for credentials built on a similar idea, but it only covers sandboxed Bash. You pay for OpenShell in setup: an image, edited profiles, a policy, and a gateway process you have to keep alive.

Where I'd push back on the hype

The HN thread raised the right objections. Domain allowlists don't stop exfiltration through an allowed domain: if the agent can POST to api.github.com, it can put your code in a gist. access: read-only helps there, but your model API is necessarily read-write. And it's 0.1.5 (released Oct 20) with real breaking changes between minors. v0.1.5 alone renamed the supervisor's OCSF log files from .log to .jsonl.

I still think the design is right. When an agent goes wrong I want it to wreck a container I can delete, and my laptop to be fine.

If you change one thing today, run /sandbox in Claude Code and add ~/.ssh and ~/.aws to sandbox.filesystem.denyRead. Move the agent into OpenShell when you're ready to stop babysitting it.

So: do you run your coding agent in a sandbox, or does it have your whole machine? And if you sandbox, what broke first?

Sources

How to Sandbox Claude Code and Codex in NVIDIA OpenShell (Keep Them Off ~/.ssh) | Authon Blog