sbclaude
sbclaude runs Claude Code (or opencode) inside a throwaway Docker container
with the bash sandbox and permission prompts disabled, against a configurable set of host bind
mounts. The container stores nothing (--rm); everything lives on the host. A
Gentoo box is the exception, and is saved between sessions. You only ever
invoke sbclaude. It manages its own image and containers via the Docker SDK.
The host must be Linux. The host copy of claude is bind-mounted into a Linux container and
executed there, so the host must supply an ELF build of it (a Mac’s own claude is Mach-O and
cannot run in the container); the identity mirroring reads os.getuid(), for which Windows has no
equivalent; and every device and socket the run flags pass through (/dev/nvidia*, /dev/dri,
/dev/kvm, /dev/bus/usb, /tmp/.X11-unix, /run/user/<uid>, /var/run/usbmuxd) is a Linux
one. sbclaude refuses to start anywhere else rather than failing later with a Docker error. That
refusal can be lifted for a host you are prepared to set up by hand — see
Running from a non-Linux host — but nothing beyond Linux is
tested or supported.
Two images are built on demand from one Dockerfile. sbclaude:latest runs Claude Code and
sbclaude:opencode runs opencode. They share every layer except the last, where the Claude Code
image adds its managed settings and cc-session-recover. Both bundle everyday coding tools
(Debian slim + git, ripgrep, Node 24/Yarn, a C toolchain, gh, glab, uv, jq), formatters and
linters kept at their latest upstream release (clang-format, jsonnet, jsonnetfmt, shellcheck), Qt 6
development (qt6-base-dev plus ninja), a Rust toolchain (rustup stable with the
wasm32-unknown-unknown target and cargo bin, for repositories that build WebAssembly),
webshot (a GPU Chrome driver for screenshots and visual diffs, with Chromium baked in), and a
mobile reverse-engineering toolchain (a JDK, frida, mitmproxy, dex2jar, baksmali/smali, and
launchers for the host-mounted Ghidra, Android SDK, jadx, and apktool).
Cargo keeps its registry in $HOME/.cargo, which is part of the box and goes away with it, so a
Rust build starts from an empty cache each time. Add rw = ["~/.cargo"] to keep the downloaded
crates and git checkouts on the host between boxes.
How it works
- The
claudeexecutable is not installed in the image. It is a self-contained native binary (Node is bundled in), so the host copy is bind-mounted read-only at run time — the image always tracks whatever version the host has. (Only glibc ≥ 2.17 is needed, which is why the base is Debian, not Alpine/musl.) - Your identity is mirrored:
UID/GID/USER/HOMEare passed in and an entrypoint recreates that user inside the container. This means:- mounted
~/.claudefiles (incl. the0600.credentials.json) are owned correctly; - paths resolve identically — a host path you paste into a prompt
(
/home/you/dev/foo) is bind-mounted at that same absolute path inside the box.
- mounted
--dangerously-skip-permissionsis passed (hence the non-root user — that flag refuses root), and a patched copy of yoursettings.jsonis mounted over the in-container one withsandbox.enabled=false,skipDangerousModePermissionPrompt=true(no bypass dialog) andtui="fullscreen". Your real settings file is never modified.- The image auto-builds on first use and rebuilds automatically when the packaged Dockerfile/entrypoint change (tracked via a content-hash label).
Running from a non-Linux host
This is unsupported, but it can be done with caveats. Most flags such as --gpu, --x11,
--wayland, etc will not work.
Note that docker will not show an error if bind mount is missing to the daemon. You will need to
make sure all paths are correct.
Windows
Use WSL2. Install sbclaude and claude inside the distribution, turn on Docker Desktop’s WSL
integration (or run dockerd in the distribution itself), and keep projects on the WSL2 filesystem
rather than under /mnt/c.
macOS
-
Set environment variable
SBCLAUDE_ALLOW_UNSUPPORTED_PLATFORM=1. -
Pass
--claude-binarywith the path to a Linux build ofclaude. This must match the container architecture. -
Keep
~/.claude, the project, and theclaudebinary under a path Docker Desktop shares into its VM (/Usersis shared by default). -
Set
TMPDIRsomewhere to somewhere shared such as~/Library/Caches/sbclaude.
You will probably want to use --net bridge instead of --net host.
Install
uv tool install . # or: pipx install .
# the image builds itself on first `sbclaude run`; or pre-build:
sbclaude build
Usage
sbclaude # run claude; cwd is the project (writable)
sbclaude run -p ~/dev/foo # explicit project dir (writable, becomes workdir)
sbclaude run -r /data -w ~/scratch # extra read-only / read-write mounts
sbclaude run --re --x11 # Ghidra/Android mounts + GUI passthrough
sbclaude run -- --version # everything after -- goes to claude
sbclaude run --agent opencode # run opencode instead of claude
sbclaude ls # list running sbclaude containers
sbclaude stop [--all] # stop this project's boxes (or all with --all)
sbclaude shell # debug shell in this project's box, as your user
sbclaude shell --root # ... as root instead
sbclaude run --gentoo # Gentoo box for ebuild work, saved between sessions
sbclaude build [--no-cache] # (re)build the image
sbclaude build --gentoo # (re)build the Gentoo images
sbclaude reset [--all] # discard this project's saved Gentoo box (or every one)
sbclaude delete-image # remove the sbclaude image
sbclaude config # show the config file path
sbclaude scaffold-noclip [DIR] # write a noclip visual-regression harness into a project
webshot
Inside the box, webshot drives a real GPU-accelerated Chromium (baked into the image, so no
project needs its own browser download), screenshots it, and compares the result against a
baseline. It is deliberately generic: it knows about browsers, waiting, settling, screenshots, and
image comparison, and nothing about any particular application — per-project knowledge is supplied
with --wait-fn/--eval.
webshot doctor # report the renderer actually in use
webshot shot http://localhost:3000 --out out.png --wait '#app'
webshot check http://localhost:3000 --baseline base.png
webshot compare a.png b.png --diff d.png
Exit codes: 0 ok, 1 regression, 2 software renderer (i.e. the GPU is not really being used),
3 missing baseline, 64 usage. Pair it with --gpu, and with --wayland when a headed window
is wanted.
scaffold-noclip
Writes the project-side glue for a visual-regression workflow built on the webshot tool that
ships in the image, into DIR (default: the current directory):
viewer-tests/nc.mjs runner -- talks only to the app's own API, copied verbatim per project
viewer-tests/views.json the only file you edit: which scene, and where the camera goes
viewer-tests/baselines/ approved output; effectively source, nothing can regenerate it
viewer-tests/out/ captures and diffs, rewritten every run
NEW_GAME.md getting-started guide, including the non-obvious failure modes
sbclaude scaffold-noclip --scene MyGame/Level1 # substitute the scene id while writing
sbclaude scaffold-noclip ~/dev/foo --force # overwrite existing files
Existing files are never overwritten without --force: an edited views.json or a blessed
baseline is judgement that cannot be regenerated.
You can run several boxes against the same project directory at once. Each run gets a
unique container name — the project name plus a short random suffix — so there is no name
collision; override it with -n. sbclaude ls lists them all, sbclaude stop stops every box
for the current project, and sbclaude shell attaches to it (or asks you to pass -n NAME when
more than one is running).
run flags
| Flag | Effect |
|---|---|
--re |
enable --ghidra + --android together |
--ghidra |
mount host Ghidra (/usr/share/ghidra) read-only |
--android |
mount Android SDK + ~/.android + /dev/kvm (adb/emulator) |
--usb |
expose /dev/bus/usb for adb over USB |
--ios |
mount the host usbmuxd socket so frida reaches an iOS device over USB |
--docker |
forward the host Docker daemon socket (root on the host unless rootless) |
--gpu |
expose the host GPUs (NVIDIA runtime plus the DRM render nodes) |
--keyring |
forward the D-Bus session bus to reach the host keyring (grants every secret) |
--keyring-keys |
copy only the named host secrets in as environment variables |
--wayland |
forward the Wayland socket for GUI apps (preferred over --x11) |
--desktop |
forward the host desktop session for agent capture and input |
--x11 |
forward DISPLAY + XAUTHORITY for GUI apps (Ghidra GUI, jadx-gui, emulator) |
--ssh |
mount the host ~/.ssh read-only and forward the ssh-agent, for SSH git remotes |
--gpg |
mount the host GnuPG home + agent socket for signing commits |
--sudo |
passwordless sudo in the box (drops no-new-privileges) |
--gentoo |
Gentoo image for ebuild work, saved between sessions (implies --sudo) |
--venv-dir DIR |
put the box’s virtualenv in DIR (e.g. a volume) instead of beside the project |
--agent opencode |
run opencode instead of claude (host PATH, or the latest release downloaded) |
--claude-binary |
mount this claude build instead of the first one on PATH |
--no-modify |
stop sbclaude writing anything into the project directory |
--no-fullscreen |
do not force the fullscreen TUI, so a failing session’s output survives |
--session-recover |
install cc-session-recover (auto-resume) into the project on start |
--net bridge |
isolate the box’s network (default is host — localhost = your host) |
-r/-w/-p/-n/-i |
extra ro/rw mount, project, container name, image override |
When a box does not start
A box that never gets going — docker exits 125–127, or the container dies within seconds — is
launched again, up to three attempts, then sbclaude exits with the last code. A session that ran
and then ended is never retried, whatever its exit code. Every attempt is logged to syslog with
docker’s own message and the docker run command behind it; read them with
journalctl -t sbclaude. The terminal cannot be trusted here, because the fullscreen TUI erases
its own screen on exit; --no-fullscreen keeps a failing session’s output on the normal screen.
--gpu probes for a GPU with nvidia-smi -L before asking Docker for one, retrying a few times
because that probe is also what wakes a card the kernel has parked. If none answers, the box starts
without --gpus all (the DRM render nodes are still passed): an unresolvable nvidia.com/gpu=all
is a warning to the daemon but death to the container.
Configuration
All options live under a [tool.sbclaude] table. They are read from the global file at
~/.config/sbclaude/config.toml (path from platformdirs), overlaid with the selected
profile from ~/.config/sbclaude/profiles/, and, for run, overlaid with the target
project’s pyproject.toml [tool.sbclaude] table — project values win, so a repo can
pin its own defaults. All keys optional:
[tool.sbclaude]
desktop = true # forward the host desktop session for portal capture and input
gpg = true # mount the GnuPG home + agent for signing
keyring = true # forward the D-Bus session bus to reach the host keyring
network = "host" # default; "bridge" to isolate the box's network
re = true # enable the Ghidra + Android mounts together
ssh = true # mount ~/.ssh read-only + forward the ssh-agent for SSH git remotes
sudo = true # passwordless sudo in the box (drops no-new-privileges)
wayland = true # forward the Wayland socket for GUI apps
x11 = true # forward X11 for GUI apps
# image = "custom:latest" # force a different image
# debian_mirror = "http://ftp.us.debian.org/debian" # apt mirror for image builds
# memory = "8g" # override the auto host-RAM cap ("0" disables)
# cpus = "4" # cap CPUs (uncapped by default)
# recover = true # install cc-session-recover into every project (off by default)
# modify = false # never write into the project directory (same as --no-modify)
# venv_dir = "/venv-cache" # hold the box's virtualenv here (pair with a docker_args volume)
# claude_binary = "~/bin/claude" # mount this claude build rather than the first one on PATH
# agent = "opencode" # run opencode instead of claude (same as --agent opencode)
# default_profile = "work" # apply this profile when --profile is absent
# gentoo = true # run the Gentoo image for ebuild work (same as --gentoo)
# fullscreen = false # do not force the fullscreen TUI (keeps start-up errors visible)
keyring_keys = ["GH_TOKEN=gh:github.com"] # copy only these host secrets in, as env vars
pass_env = ["AWS_REGION"] # forward host vars (AWS_PROFILE is default)
ro = ["~/dev*", "~/ghidra_scripts", "~/Downloads"] # read-only mounts (globs + ~ ok)
rw = [] # the project dir is always rw automatically
[tool.sbclaude.env] # inject fixed vars (e.g. Amazon Bedrock)
CLAUDE_CODE_USE_BEDROCK = "1"
The toggle keys re, ghidra, android, docker, gentoo, gpu, usb, ios, keyring,
wayland, desktop, x11, ssh, gpg, and sudo mirror the matching run flags and
default to false; setting a key is the same as always passing the matching flag.
Profiles
Named profiles live beside the global config in ~/.config/sbclaude/profiles/<name>.toml
and store any key from [tool.sbclaude], under a [tool.sbclaude.profile] table:
[tool.sbclaude.profile]
network = "bridge"
ssh = true
[tool.sbclaude.profile.env]
ENV_VAR = "some value"
Select one with sbclaude run --profile <name> (build accepts the flag as well), or set
default_profile = "<name>" in the global config to apply a profile when --profile is
absent. An explicit --profile wins over default_profile. Profile values override the
global config, and the project pyproject.toml overrides both; the [env] tables merge
key by key at each step.
Secrets
Prefer keyring_keys when the applications in the box take their secrets from environment
variables. Each entry is NAME=SERVICE, where NAME is the variable and SERVICE is the keyring
service attribute, so GH_TOKEN=gh:github.com presents the stored gh token as GH_TOKEN and
GITLAB_TOKEN=glab:gitlab.com does the same for glab. Both CLIs store under their own name and a
host, so a self-managed instance takes that host instead, as in glab:gitlab.example.com.
Only the named secrets are read, and no bus is forwarded. Reading uses secret-tool, from
libsecret, on the host. The value is passed by name rather than by value, so it does not appear in
the argv of a process other users can list, though docker inspect shows it as it does every
environment variable.
Use keyring = true when an application cannot take its secret from the environment and insists on
the Secret Service. That forwards the whole D-Bus session bus, which grants every entry in the
login keyring rather than the ones you named, along with the other services on that bus.
Debian mirror — when deb.debian.org is slow, point image builds at a faster archive
mirror with debian_mirror (or --debian-mirror on build/run). Only the image’s main
archive URI is rewritten; debian-security keeps pointing at deb.debian.org.
ro/rw accept globs (~/dev* → every matching dir) and ~; non-matching or
missing paths are dropped. Every path is mounted at its real absolute path, so a host
path you paste into a prompt resolves inside the box. The project (cwd or -p) is always
read-write and overlays any read-only parent (e.g. ~/dev ro + ~/dev/proj rw → proj
is writable).
Environment variables — inject with the [tool.sbclaude.env] table, forward host values
by name with pass_env, or per-run with -e KEY=VALUE (precedence: managed defaults <
[tool.sbclaude.env] < pass_env < -e). AWS_PROFILE is forwarded by default. This is how
you point the box at a different backend such as Amazon Bedrock
(CLAUDE_CODE_USE_BEDROCK=1 + your AWS_* vars; mount ~/.aws via ro if you use
profiles).
uv project environment — by default the box sets UV_PROJECT_ENVIRONMENT to
.sbclaude-venv, so uv sync/uv run build the virtualenv there instead of writing .venv
into your bind-mounted project. A host-built .venv hard-codes the host interpreter path and
would not resolve in the box anyway, so the two are kept apart; the host’s .venv is
additionally re-mounted read-only so nothing in the box can corrupt it. Beside the project
rather than inside the container because the container is --rm, so a virtualenv in it would
be rebuilt from nothing every run. /.sbclaude-venv/ is appended to the project’s .gitignore
(once, and only when it is not already there) so it stays out of git status. Override the
path with venv_dir (below) or by setting your own UV_PROJECT_ENVIRONMENT (via [env] or
-e), or disable the behaviour entirely with manage_uv_env = false.
None of it happens unless the project is a Python one: a pyproject.toml, setup.py,
setup.cfg, requirements.txt, or Pipfile at the top, or a .py file anywhere below it
(ignoring dotted directories, node_modules, vendor, and target). A project with no Python
in it gets no .sbclaude-venv, no .gitignore line, and no virtualenv variables at all.
There is no clean equivalent for Node — node_modules is fixed to the package root by Node’s
resolver (only Yarn Berry’s YARN_NODE_LINKER=pnp removes it, at the cost of changing module
resolution), and node_modules built on the box’s Linux is usually reusable on a Linux host
anyway, so it is left untouched.
A virtualenv that outlives the box — --venv-dir DIR (config key venv_dir) puts the box’s
virtualenv in DIR instead of beside the project. Combined with a volume in docker_args, the
environment survives the box without anything being written into the project:
[tool.sbclaude]
docker_args = ["-v", "sbclaude-venv-cache:/venv-cache"]
venv_dir = "/venv-cache"
DIR gets one subdirectory per project, named after it with a digest of its absolute path
appended (/venv-cache/sbclaude-venv-foo-1a2b3c), so a single volume serves every project and
~/dev/foo and ~/work/foo do not end up sharing an environment. The directory is created and
handed to your user by the entrypoint, which is what makes a fresh (root-owned) named volume
usable. venv_dir applies even with manage_uv_env = false — naming a directory is asking for
the environment to be managed — but not to a project with no Python in it.
Leaving the project untouched — --no-modify (or modify = false) stops sbclaude itself
writing anything into the directory it was launched from. Three things are suppressed:
- the
/.sbclaude-venv/line is not appended to.gitignore; UV_PROJECT_ENVIRONMENTpoints inside the container instead of beside the project, so neither sbclaude nor a lateruv synccreates a virtualenv there. That path is under/tmpand dies with the box, unlessvenv_dirnames somewhere that persists;- the start-up
uv syncis skipped (it refreshesuv.lock), as is thecc-session-recoverinstall, which writes into.claude/andHANDOFF.md. Asking for both--session-recoverand--no-modifyprints a note and leaves recovery off.
This constrains sbclaude, not Claude: the project is still bind-mounted read-write and the agent can edit it exactly as before.
Which claude gets mounted — by default, whichever one PATH reaches first.
--claude-binary PATH (config key claude_binary) names one instead, which is how you pin a
session to a particular build rather than to whatever PATH reaches first. It must be an
executable file — that is checked up front, because docker would otherwise bind-mount a missing
path as an empty directory and fail much later with nothing pointing back at the setting. Like
every other mount, it has to exist on the machine the daemon runs on.
Unlike every other key, claude_binary is read from the global config only: a project’s
pyproject.toml cannot set it. It names what the box executes as claude, with your ~/.claude
credentials mounted, so letting a cloned repository choose it would be arbitrary code execution on
behalf of whoever cloned it.
Alternate config dir — if CLAUDE_CONFIG_DIR is set on the host, sbclaude mounts that
directory (its .claude.json, settings.json, history) and points claude at it inside the
box instead of ~/.claude.
Session recovery
cc-session-recover lets Claude Code pick a
long-running task back up after a quota or rate-limit pause. It is off by default — enable
it per run with --session-recover, or for every run with recover = true in the config. When
enabled, the container entrypoint runs the tool’s install-into-project.sh against the project
before launching claude, which sets up its SessionStart and Stop hooks and a HANDOFF.md.
The tool is vendored into the image as a git clone (not the npm package) at
/opt/cc-session-recover, with PR #2
(safer watcher argument handling and the opt-in CC_REMIND_MODE prompt-injection limit) applied
on top. Two further patches keep the installer tidy: it no longer copies settings.example.json
into the project, and its own .gitignore handling is disabled. The installer writes into the
project’s .claude/ (hooks and settings.local.json, merged with jq) and a HANDOFF.md;
because the project is bind-mounted read-write, those files land in your real repository and
persist, which is why this is opt-in. Afterwards the entrypoint appends the recovery artifacts
(HANDOFF.md, auto-continue.md, session-recover.js, session-recover.yaml,
standing-instructions.md, statusline-quota-cache.sh, and the three hook scripts) to the
project’s .gitignore, each only when absent. If the install
fails, the box aborts rather than starting a session that silently lacks recovery.
opencode
--agent opencode (config key agent) runs opencode in the box instead of
Claude Code. The host opencode on PATH is mounted read-only, as claude is. When no opencode
is on PATH, sbclaude downloads the latest Linux release for the host architecture from GitHub into
~/.cache/sbclaude/opencode and mounts that copy. Later boxes reuse the download. Delete
~/.cache/sbclaude/opencode to fetch a newer release.
opencode has no equivalent of --dangerously-skip-permissions. The box instead sets
OPENCODE_PERMISSION to {"*":"allow"}, a rule that allows every tool without a prompt. Override
the rule with -e OPENCODE_PERMISSION=.... The mounted binary is read-only, and self-update is disabled
with OPENCODE_DISABLE_AUTOUPDATE=1.
opencode stores its configuration, provider credentials, sessions, and caches in four XDG
directories (~/.config/opencode, ~/.local/share/opencode, ~/.local/state/opencode, and
~/.cache/opencode). Each is created on the host when missing and mounted read-write, and a
sign-in made in the box with opencode auth login persists on the host. A directory the host
relocates with XDG_CONFIG_HOME or its siblings is mounted at the default path inside the box.
For opencode, ~/.claude is not mounted, settings.json is not patched, --no-fullscreen has no
effect, and --session-recover is ignored with a note. The box runs sbclaude:opencode, an image
without Claude Code’s managed settings or cc-session-recover.
Gentoo box (--gentoo)
--gentoo (config key gentoo) runs an image built from a Gentoo stage3, for editing ebuilds and
testing their builds. It ships pkgcheck, pkgdev, gentoolkit, portage-utils, git, and sudo. The
Debian image’s RE toolchain, webshot, Qt, and Rust are absent, although their run flags still
mount what they mount. sbclaude build --gentoo builds the image ahead of time, and the first
run --gentoo builds it otherwise. The images are sbclaude-gentoo:latest and
sbclaude-gentoo:opencode.
emerge and ebuild run as root, and --gentoo therefore implies --sudo.
sbclaude run --gentoo -p ~/dev/my-overlay
# inside: pkgcheck scan
# pkgdev manifest
# sudo ebuild dev-libs/foo/foo-1.0.ebuild clean test
# sudo emerge --oneshot =dev-libs/foo-1.0
Host Portage
On a Gentoo host, the box receives the following from the host:
/etc/portage, copied into the box on its first start. The host’s USE flags, CFLAGS, and profile are what its binary packages were built with, and emerge only takes a binary package whose settings match. After the copy, the box owns its configuration, and a change made in the box topackage.useorpackage.accept_keywordspersists with the saved box./var/db/repos, read-only.PKGDIR, read-only, with--usepkgadded toEMERGE_DEFAULT_OPTS. Dependencies install from the host’s binary packages instead of compiling.DISTDIR, read-write. A download made in the box is available to the host afterwards.
PKGDIR and DISTDIR are the paths portageq reports on the host. On a host without Portage, the
box uses the image’s configuration and a repository snapshot taken when the image was built.
The box registers the project as a repository when the project has profiles/repo_name. emerge and
ebuild then build from the working tree, and a checkout of gentoo.git replaces the host’s copy
of the tree.
The box turns off the four namespace sandboxes (ipc-sandbox, mount-sandbox, network-sandbox,
and pid-sandbox). Each needs CAP_SYS_ADMIN, and the box does not have it. sandbox,
usersandbox, and userpriv work as usual. buildpkg is off too, because PKGDIR is read-only.
Host packages with no binary package
sudo sbclaude-host-quickpkg ATOM... packages an ebuild installed on the host that PKGDIR does
not have, from the host’s installed files, and installs it in the box. The host’s /usr and
/var/db/pkg are mounted read-only for this, and quickpkg reads each package’s CONTENTS there.
Only the requested packages are copied. Their dependencies must already be installed in the box or
be requested in the same command. Files the host installed outside /usr (under /etc, for
example) are not mounted and are not included.
Saved state
A Gentoo box is not removed when its session ends. sbclaude commits its filesystem to
sbclaude-gentoo-state:<agent>-<project>-<digest>, and the next Gentoo box for the same project
and agent starts from that image. Packages emerged in one session are present in the next.
The container’s filesystem is saved, including /usr, /var/db/pkg, /etc/portage, and the
parts of the box’s home directory that are not mounted. Bind mounts (the project, ~/.claude, and
the host Portage directories) are not saved, and neither are /tmp (a tmpfs) and
/var/tmp/portage (a volume removed with the box).
docker commit copies a container’s environment into the image it writes. A saved box therefore
receives no -e at all. sbclaude writes the environment (including --keyring-keys secrets and
docker_args entries) to a file readable by the user alone and mounts it at /run/sbclaude/env.
The entrypoint and login shells export it, and the file is deleted when the session ends.
- The next Gentoo box for the project saves a box that sbclaude could not save (the terminal closed, or sbclaude was killed) before starting.
sbclaude stopstops a Gentoo box instead of removing it, and the stopped box is saved.- While one Gentoo box for a project is running, a second starts from the same state and is discarded on exit, with a note.
-i/imageturns saving off.- Each session adds one image layer. sbclaude warns past 100 layers, and when the Gentoo image was rebuilt after the state was saved.
sbclaude reset discards the project’s saved state, and sbclaude reset --all discards every
project’s. The next box starts from the image again and copies the host’s /etc/portage afresh.
sbclaude delete-image does not remove saved state.
MCP servers
MCP server configs live in your mounted ~/.claude.json, so they carry into the box — but
the server command must be runnable inside the container. A host Python venv won’t
work: its bin/python symlinks to a host-only interpreter (e.g. /usr/bin/python3.13),
which doesn’t exist in the box → ENOENT. The image ships Python 3 in a virtualenv at
/opt/venv (with the mcp SDK and pre-commit pre-installed) and uv, so point the
command at one of:
uv run /abs/path/to/server.py— best: reads the script’s PEP 723 deps, works on the host too. Example:claude mcp add ghidra -- uv run ~/dev/ghidra-mcp/bridge_mcp_ghidra.py./opt/venv/bin/python3 /abs/path/to/server.py— the container’s venv Python (hasmcppre-installed); container-only./opt/venv/binis first onPATH, so a barepython3resolves here too.
A server that talks to a process on the host (e.g. a Ghidra GUI on localhost) works out
of the box because the box defaults to host networking; pass --net bridge only if you
want to isolate it.
Git over SSH and commit signing
Pushing over SSH and signing commits need the host’s private keys, which are not mounted
by default (the box is a fully-autonomous, no-prompt agent — see Hardening).
Opt in per run, or globally with ssh = true and gpg = true under [tool.sbclaude]:
--sshbind-mounts~/.sshread-only, so SSH remotes authenticate with the host’s keys andknown_hosts. Read-only means newly-learnt host keys are not written back. It also forwards the host’s ssh-agent:$SSH_AUTH_SOCKis bind-mounted at its own path and set in the box, so keys that are passphrase-protected or held in a hardware token still work — the host agent does the signing and the secret never enters the container. Without this a box holding only encrypted key files would stall on a passphrase prompt nothing can answer. Symlinks inside~/.sshare followed too: aconfig(or key) linked into a dotfiles repository has its target mounted read-only at the same path, so the link does not dangle and everyHostalias keeps working.--gpgbind-mounts the host GnuPG home (read-write —gpgneeds to write lock files and the trustdb) and overlays the host’s live gpg-agent socket at both of the places the box’sgpgmay look for it:~/.gnupg/S.gpg-agentand/run/user/<uid>/gnupg/S.gpg-agent. Which one applies depends on whether/run/user/<uid>exists in the box, and--waylandand--sshboth make it exist, so both are mounted rather than guessed. The host agent performs the signing and owns the secret keys, so a cached passphrase carries over and any pinentry prompt appears on the host. The image shipsgnupgandopenssh-client; your mounted~/.gitconfig(withuser.signingkey/commit.gpgsign) does the rest.
sbclaude run --ssh --gpg -p ~/dev/foo # inside: git push, git commit -S both work
Your ~/.gitconfig is always mounted read-only, along with every file it pulls in via an
[include] path = ... directive (resolved with git config --includes), so a split config
carries into the box intact.
Root inside the box (--sudo)
The box runs as your mirrored user, and so does sbclaude shell — it opens a login shell as
that user, in the project directory. sbclaude shell --root gives a root shell instead; that one
asks the Docker daemon to start the process as root, so it works on any box, escalating nothing
inside it.
sudo is a different matter, because it is what lets the agent become root — to
apt-get install a missing library, say. It is off by default and enabled with --sudo (or
sudo = true). The box then gets a NOPASSWD: ALL sudoers drop-in for your user, so sudo su
and sudo <anything> work without a password:
sbclaude run --sudo -p ~/dev/foo # inside: sudo su, sudo apt-get install ... both work
The catch, and why this is a flag rather than the default: sudo is setuid-root, and the
no_new_privs kernel flag that --security-opt no-new-privileges sets makes the kernel ignore
the setuid bit on every execve — sudo detects this and refuses to run. The two cannot coexist,
so --sudo drops no-new-privileges from the hardening set. Everything else stays: the
capability set is still ALL dropped plus the same six, so container root here has no
CAP_SYS_ADMIN, no CAP_MKNOD, and no CAP_NET_ADMIN. What it does gain is CAP_DAC_OVERRIDE
over every mounted path, i.e. the file permissions on your read-write mounts stop being a
boundary. Read-only mounts stay read-only — that is enforced by the kernel, not by permissions.
The image ships sudo but still strips every setuid bit at build time; the entrypoint
restores it on sudo alone, and only for a box started with --sudo. A box started without it
therefore contains no setuid binary at all.
Host Docker (--docker)
--docker (config key docker) mounts the host Docker daemon socket at its own path and sets
DOCKER_HOST to match. The image ships the Docker CLI with the buildx and compose plugins. The
socket comes from DOCKER_HOST when DOCKER_HOST is a unix:// address, and /var/run/docker.sock
otherwise. A tcp:// or ssh:// DOCKER_HOST is forwarded unchanged. Forward its TLS certificates
or SSH keys separately (pass_env, ro, or --ssh).
Containers started from the box run on the host daemon, beside the box rather than inside it. A bind
mount such as docker run -v "$PWD:/src" is resolved on the host. Project paths work because the
box mounts them at their host paths. A path that exists only inside the box does not. With the
default host networking, a port a container publishes is on the box’s localhost too.
--docker grants root on the host. An agent that controls the daemon can start
docker run --privileged -v /:/host and bypass every restriction under Hardening.
The agent can also stop or enter every other container, including other boxes. Containers the agent
starts are not removed by sbclaude stop. To limit the damage, run a
rootless Docker daemon under a separate user
for sbclaude and point DOCKER_HOST at its socket. A breakout then gets the separate user’s rights,
not root.
The box joins the group that owns the socket. A socket owned by the root group cannot be opened, and sbclaude warns about it.
RE toolchain (--re)
Mounted from the host (your exact versions): Ghidra (analyzeHeadless, ghidraRun),
Android SDK (adb, emulator, sdkmanager, avdmanager), jadx, apktool,
plus ~/.android (adb keys + AVDs) and /dev/kvm for emulator acceleration.
Installed in the image: Temurin JDK 21 (Ghidra 12 needs it), build-essential +
binutils, frida + frida-tools (pinned to the host version), mitmproxy,
dex2jar, baksmali/smali, CLI audio tools (ffmpeg, sox, flac, vorbis-tools,
opus-tools, lame, mpg123, wavpack, shntool, plus vgmstream-cli for game audio), CLI
image tools (ImageMagick, zbar, deark, pngdefry for -iphone PNGs),
czkawka-cli (duplicate/similar finder), and the X11/GL/audio libs the mounted GUI
binaries need.
sbclaude run --re --x11 -p ~/dev/some-apk-re
# inside: jadx -d work/jadx-out base/classes*.dex
# apktool d base -o work/axml-decoded
# analyzeHeadless ~/dev/x-re proj -import lib/arm64-v8a/foo.so
# adb devices ; emulator -avd ford-x86_64-api35 -writable-system &
# frida -U -f com.x.y -l hook.js
--x11 mounts /tmp/.X11-unix + your session xauth cookie and sets DISPLAY/
XAUTHORITY. Java/Swing (Ghidra) renders through XWayland (:0). If a GUI fails with a
cookie error, run on the host: xhost +SI:localuser:$USER.
--wayland is the better option when the app speaks Wayland: it forwards the compositor
socket alone, and a Wayland client cannot read other windows or inject input into them, whereas
an X11 cookie grants exactly that over the whole session. The socket is re-homed under the
box’s own /run/user/<uid> and WAYLAND_DISPLAY/XDG_RUNTIME_DIR are set to match.
--desktop exposes the host desktop session itself to the agent. The agent can then see
and control the session. It forwards the Wayland socket, the D-Bus session bus, and the
PipeWire sockets together, and the ScreenCast and RemoteDesktop portals provide capture and
input over those sockets. The image ships a host-desktop helper driving those portals:
host-desktop check verifies the forwarding, host-desktop shot screen.png captures the
desktop, host-desktop click X Y presses a pointer button, and host-desktop serve retains
one approved session open behind a loopback HTTP API for repeated work. Each fresh session
shows one approval dialog on the host. Approve the dialog there and the command proceeds. On
KDE Plasma the approval can persist. --desktop is the widest desktop grant sbclaude
offers, since an approved session injects input across the whole host session. Prefer a
dedicated host user for unattended sessions. Without --desktop, --wayland only shows
the box’s own windows on the host. It never exposes host windows to the box.
--gpu uses the NVIDIA container runtime when it is present and also passes through the DRM
render nodes (/dev/dri/renderD*), so Mesa on AMD/Intel and Vulkan/VA-API work too. The
entrypoint re-adds the device groups Docker granted, and synthesises the glvnd/Vulkan/GBM
manifests the NVIDIA toolkit does not install, so GL, EGL, and Vulkan clients get the real
driver instead of a software fallback.
--ios targets an iOS device attached to the host. The host runs usbmuxd (it owns
the USB device), and frida’s usbmux backend reaches the device through that daemon’s
socket — so instead of claiming raw USB (which would clash with the host usbmuxd),
--ios bind-mounts /var/run/usbmuxd plus the host’s /var/lib/lockdown pairing
records. Combine with --re so frida is present:
sbclaude run --re --ios -p ~/dev/some-ios-re
# inside: frida-ls-devices # the host's device shows up over usbmux
# frida -U -f com.x.y -l hook.js
The host needs usbmuxd running and the device already paired (trusted). If frida sees
no device, confirm idevice_id -l works on the host first.
Hardening
This deliberately removes Claude’s own sandbox and permission prompts, so the box leans on Docker for confinement instead. Every run is hardened by default (defense-in-depth — it limits blast radius, it is not a guarantee):
- Build time: minimal Debian slim,
--no-install-recommends+ cleaned apt lists, no secrets baked in (theclaudebinary and all auth are bind-mounted), OCI provenance labels, and all setuid/setgid bits stripped from the image. - Run time:
--security-opt no-new-privileges(unless--sudoasked for the opposite — see Root inside the box),--cap-drop ALLplus only the six caps the root entrypoint needs to create the mapped user, drop to it via gosu, and lettini(PID 1) forward signals such asSIGWINCHto the non-root child (CHOWN,DAC_OVERRIDE,FOWNER,KILL,SETUID,SETGID), a--pids-limit, a non-root mapped user, and the default seccomp/AppArmor profiles (never disabled). The claude process itself ends up with an empty effective capability set. - Can’t lock the host:
--pids-limitstops fork bombs, and--memorywith an equal--memory-swapbounds RAM with no extra swap, so a runaway box is OOM-killed instead of thrashing the host into a freeze. The default cap is derived from host RAM (reserving the larger of 2 GiB or an eighth for the host); setmemoryto override or"0"to disable, andcpusto cap CPU (uncapped by default — saturation slows but does not lock). These are enforced by cgroups, i.e. the systemd cgroup driver on a systemd host — a separatesystemd-runwrapper is unnecessary (and would not bound the container, which runs under the Docker daemon’s cgroup, not the CLI’s).
Disable per run with --no-harden, globally with harden = false in config (this also
drops the resource caps), and add your own Docker flags (e.g. --read-only) via
docker_args = [...].
Still: the containerized Claude can run any command and read/write every mounted path
without asking, with unrestricted network. Keep writable mounts minimal (the config
defaults to read-only for everything but the project) and don’t mount secrets you don’t
want a fully-autonomous agent to touch. This is why --ssh, --gpg, and --sudo are opt-in:
the first two expose your private SSH and GPG key material (the GnuPG home read-write) to that
agent, and the third hands it container root. --docker is opt-in for the same reason and is the
widest grant of all, because it hands over host root (see Host Docker).
Forwarding an agent socket does not hand over the secret itself, but it does let the box ask the
host agent to sign with any key it has, for as long as the box runs.