Our standard OpenShell sandbox: R, Python and CUDA from rocker/ml, a current Claude Code, a network policy that lets it stay current, and shell helpers for getting in and out.
Sandboxes follow the upstream
community layout — one directory per
sandbox holding a Dockerfile and a policy.yaml:
| path | what it is |
|---|---|
sandboxes/compute/Dockerfile |
rocker/ml-spatial + Claude Code + the supervisor's prerequisites |
sandboxes/compute/policy.yaml |
the locked flavor, and the default: Anthropic plus clone-only GitHub |
sandboxes/compute/policy-open.yaml |
the open flavor: same image, CRAN and PyPI restored |
aliases.sh |
the os / osl shell helpers |
One image, two flavors. The image is meant to be complete, so the default policy grants
no package-fetch egress at all; the open flavor restores it for when you are still
discovering dependencies. Flavor is a --policy choice, and openshell policy set can
switch a running sandbox between them:
openshell policy set dev --policy ./sandboxes/compute/policy-open.yaml --wait # widen
openshell policy set dev --policy ./sandboxes/compute/policy.yaml --wait # and backecho '. /path/to/openshell-agent/aliases.sh' >> ~/.bash_aliasesaliases.sh locates the repo from its own path, so a checkout anywhere works. Set
OPENSHELL_AGENT_DIR to override.
os # shell in `dev`, creating it on first use
os scratch # a second, separate sandbox
os dev -- claude # Claude Code as the main process instead of a shell
osl # list sandboxesos dispatches on the sandbox's phase, so the same command works whether the sandbox is
running, stopped, or doesn't exist yet. The equivalent raw commands:
# create -- only when the sandbox does not exist. Omitting `--` leaves openshell to
# pick its own default shell; anything after `--` becomes the main process.
openshell sandbox create \
--name dev \
--from ./sandboxes/compute \
--policy ./sandboxes/compute/policy.yaml \
--tty \
[-- claude]
# already running (phase Ready) -- open a new shell on a fresh pty
openshell sandbox exec -n dev --tty -- bash -l
# phase Stopped -- the workspace is intact, just bring it back. (Phase Error cannot be
# started or stopped; it is unrecoverable, see "Detach, don't exit" below.)
openshell sandbox start dev && openshell sandbox exec -n dev --tty -- bash -l
# what `os` reads to decide which of the three to run
openshell sandbox get dev -o json # -> .phase
openshell sandbox list # == oslopenshell sandbox connect reattaches to the sandbox's canonical main process. If that
process was launched without a pty — which is what you get unless create saw a terminal
on both stdin and stdout — it never became an interactive shell, so reattaching gives you
no prompt, no echo and no line editing. It still runs whatever you type, silently, which
reads exactly like connect hanging. Confirm it with cat /proc/<pid>/stat on the main
process: tty_nr=0 means no controlling terminal.
exec --tty sidesteps this by allocating a fresh pty for a new process, so os uses that
to attach to an already-running sandbox. It also means exiting that shell only ends the
exec, leaving the sandbox up.
This applies to the main process only — the shell you land in on create. Exiting it stops
the container, and OpenShell then reports the sandbox as Error rather than Stopped.
That is what the Error phase means — not a crash. Use Ctrl-P Ctrl-Q to detach and
leave it running.
Error is terminal. start refuses it ("sandbox must be Stopped to start") and so
does stop ("must be Ready to stop"), so there is no way back: the workspace and the
Claude Code login are gone and the only recovery is sandbox delete + a fresh create.
Only a sandbox you actually stopped, via openshell sandbox stop, comes back with start.
That makes Ctrl-P Ctrl-Q the difference between keeping a sandbox and rebuilding it.
rocker/ml-spatial gives us the R, Python and CUDA stack plus the geospatial libraries
(GDAL/PROJ/GEOS, sf, terra, stars, geopandas, rasterio) we actually work in, but it is not
an OpenShell base image and ships no Claude Code. sandboxes/compute/Dockerfile adds five
things:
- The supervisor's prerequisites —
iproute2,nftables,iptables,dnsutils,openssh-sftp-server. Not optional: the supervisor shells out toipandnftto build the sandbox network namespace, andsftp-serverbackssandbox upload/download. A stock image without them dies during provisioning withContainerExited. - The
sandboxandsupervisorusers the privilege drop targets.run_as_useraccepts the literal namesandboxor a numeric uid ≥ 1 — never 0, so nothing here can be root.no_new_privsis set besides, sosudoand therefore apt are unavailable at runtime no matter how the policy is written. That is why installation belongs in this file, where the layers run as root and r2u still works. - Node 22 and a current Claude Code.
- A
profile.ddrop-in forPATH/VIRTUAL_ENV, and a writable venv at/sandbox/.venv. OpenShell builds a fresh environment for exec'd processes and discards the image'sENV, so rocker'sPATHdoes not survive and its Python is reachable only at its full path; aprofile.dfile is read by the shell itself, so it does. The venv exists because/optis read-only — without itpip installdownloads a wheel and then dies onEACCES.--system-site-packagesalone is not enough either:/opt/venvis itself a venv whosehome = /usr/bin, so a child venv's "system" resolves to/usrand rocker's packages stay invisible. A.pthcomputed at build time appends them after the local site-packages, so a local install shadows the image's rather than the reverse. - Runtime R settings —
bspm.sudo = FALSE, since bspm routesinstall.packages()throughsudo apt-getfor r2u binaries and that cannot work here, andR_LIBS_USER=/sandbox/R/libraryso an install has somewhere writable to land. Packages in/sandboxsurvivestop/start;/usris read-only, so site-library never is.
A CONFIG:DEGRADED — Failed to install bypass detection rules warning at startup is
host-side, not image-side: the nft ... reject with icmp type port-unreachable rule
needs nft_reject_inet loaded on the host. Upstream's base image hits it too. It is
non-fatal — proxy enforcement is unaffected, only the diagnostic that flags direct
connection attempts. Clear it with sudo modprobe nft_reject_inet.
It also overrides rocker's HOME=/home/jovyan. Left alone that beats the passwd entry, and
Claude Code writes its credential somewhere the sandbox user cannot write and that is not
part of the persisted workspace.
The upstream community base is not an option: it was last built 2026-05-29 and has not been
rebuilt since — latest and the newest tag fffb6b2 are the same digest — so it ships
Claude Code 2.1.156, which predates Opus 5.
policy.yaml separately unblocks downloads.claude.ai so claude update also
works from inside a running sandbox. Without it the native updater fails silently, and
the only trace is $HOME/.claude/.last-update-result.json:
{"path":"native","outcome":"failed","status":"install_failed",
"version_from":"2.1.156","version_to":null}The policy also adds the paths npm actually installs to. npm install -g resolves through
to /usr/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe, a native binary —
the stock allowlist's /usr/bin/node does not cover it, and without a matching entry the
new install is denied at the proxy.
A sandbox binds to its image and its supervisor binary when it is created, and never
re-resolves either. Neither docker pull nor upgrading the openshell package affects a
sandbox that already exists — sandbox stop/start reuses the same container. Only a
fresh sandbox create picks up new versions. Treat sandboxes as disposable and keep state
in the workspace.
Policy is the exception. Editing policy.yaml does not reach a running sandbox by
itself, but openshell policy set <sandbox> --policy <file> --wait pushes a new revision
to a live one and the supervisor loads it (the CLI reports the version and hash it
activated). Useful for tightening a long-running sandbox mid-session, and for iterating on
a policy without paying for a rebuild.
Upgrade ritual:
# 1. openshell itself -- nothing auto-updates
openshell --version
# 2. re-capture the built-in default policy and diff it against ours, so upstream
# additions don't silently go missing (--policy REPLACES the default outright)
openshell policy get <sandbox> --base -o json
# 3. rebuild and recreate
docker pull rocker/ml:latest
openshell sandbox delete dev && osAdding a sandbox means a new sandboxes/<name>/ with its own Dockerfile and
policy.yaml; point os at it with --from/--policy, or set OPENSHELL_SANDBOX.
A openshell provider keeps a secret at the gateway and puts only a placeholder in the
sandbox environment:
api_token=openshell:resolve:env:v13489987411037355703_api_token
The proxy substitutes the real value on egress, wherever it finds the placeholder in the
request. It does not invent an Authorization header — the client has to send the
placeholder itself, which is the part that is easy to get wrong. Verified on 0.0.116
against GitHub:
# API: placeholder straight into the header
curl -H "Authorization: Bearer $api_token" https://api.github.com/user # -> 200
# git: placeholder as the URL password, colons percent-encoded. The proxy sees through
# git's own base64 Basic encoding, so clone/fetch/push of a PRIVATE repo all work.
ENC=$(printf %s "$api_token" | sed 's/:/%3A/g')
git clone "https://x-access-token:$ENC@github.com/<owner>/<repo>.git"A full container compromise therefore yields no token — only a placeholder that is useless anywhere the policy does not already allow.
Two mechanics worth knowing before building on this:
- A profile's
endpointsdo not compose into a custom--policy.--policyreplaces the built-in default outright, and provider-supplied endpoints do not reappear. Declare them in the policy yourself and point them at the credential withcredential_binding: {provider: <name>}. That in turn requires a profile withendpoints: [], because a profile that declares its own endpoints refuses the binding ("profile endpoints remain the credential boundary"). Upstream #2330 proposes this credentials/ endpoints split as a first-class feature; until then it is the manual recipe. - One host:port is one credential domain, so one scoped credential per sandbox. See the
header of
sandboxes/compute/policy.yaml.
auth_style in a profile (bearer, basic, …) does not change any of the above:
substitution is driven by finding the placeholder, not by the declared style.
Claude Code in the sandbox authenticates with an OAuth credential file under the sandbox
user's $HOME (/sandbox), which is the persisted workspace. Nothing is injected from the
host for this credential — no openshell provider, no bind-mounted credential, no API
key in the environment (see above for credentials that do come from a provider).
So stop/start, and -- claude vs. a shell, make no difference; but a new sandbox
means a fresh /sandbox and therefore a new login.