Super simple, fast and effective sandbox to run commands in a lightweight bubblewrap + nix sandbox. No docker/podman containers needed
It's useful to run AI agent harness like opencode isolated from the main system and you can also install packages as normal user without mess the host system.
Isolates Nix operations from your host filesystem while preserving access to /nix/store and /nix/var/nix. Useful for safely experimenting with Nix builds, running OpenCode in a sandbox, or containing AI coding tools.
./start-sandbox.shThis drops you into a sandboxed bash shell with Nix, git, bun, uv, jcode, and other tools pre-installed. The sandbox-home directory acts as $HOME.
Add a persistent alias to your shell config (e.g. ~/.bashrc or ~/.zshrc):
alias sss=/path/to/sandbox-bwrap-nix/start-sandbox.shReplace /path/to/sandbox-bwrap-nix with the absolute path to this directory (use pwd to get it). After adding the line, reload with source ~/.bashrc (or open a new terminal), then launch with sss.
➜ demo git:(master) ✗ ls ../
- examples ggml.h main rec.wav
bench extra ggml-metal.h Makefile run.sh
bindings ggml-alloc.c ggml-metal.m models samples
build ggml-alloc.h ggml-metal.metal openvino stream
cmake ggml-alloc.o ggml.o quantize tests
CMakeLists.txt ggml.c ggml-opencl.cpp README.md whisper.cpp
coreml ggml-cuda.cu ggml-opencl.h rec16.wav whisper.h
demo ggml-cuda.h LICENSE rec16.wav.wts whisper.o
➜ demo git:(master) ✗ sss ls ../
=== sandbox-bwrap-nix development shell ===
nixpkgs: nixpkgs-unstable (20260809 - 7525d99)
XKB_CONFIG_ROOT=/nix/store/xxx-xkeyboard-config/share/X11/xkb
demo
➜ demo git:(master) ✗ opencode --version
1.18.4
➜ demo git:(master) ✗ sss opencode --version
=== sandbox-bwrap-nix development shell ===
nixpkgs: nixpkgs-unstable (20260809 - 7525d99)
XKB_CONFIG_ROOT=/nix/store/xxx-xkeyboard-config/share/X11/xkb
1.18.3
➜ demo git:(master) ✗ sss
=== sandbox-bwrap-nix development shell ===
nixpkgs: nixpkgs-unstable (20260809 - 7525d99)
XKB_CONFIG_ROOT=/nix/store/xxx-xkeyboard-config/share/X11/xkb
● demo $ ls ../
demo
start-sandbox.sh invokes bwrap with:
| Mount | Type | Purpose |
|---|---|---|
/nix/store, /nix/var/nix |
bind (rw) | Nix store access (shared with the host) |
/nix/var/nix/builds |
tmpfs | Isolate builds |
/bin/sh, /bin/bash, /usr/bin/nix |
bind (ro) | Essential binaries |
/usr/lib, /usr/lib64 |
bind (ro) | Shared libraries |
/etc/resolv.conf, /etc/hosts, /etc/nsswitch.conf |
bind (ro) | DNS / name resolution |
| SSL certs | bind (ro) | HTTPS support |
sandbox-home/ |
bind | Isolated home dir |
/tmp |
tmpfs | Temporary files |
/proc |
procfs | Process access |
/dev |
tmpfs + device binds + fresh devpts | Only null/zero/full/random/urandom/tty bound from host; pty support via a fresh devpts instance mounted inside (/dev/ptmx → pts/ptmx) |
The environment is cleared (--clearenv), networking is shared (--share-net), and PID namespace is unshared (--unshare-pid).
Inside the sandbox, nix develop with the flake provisions a dev shell containing: nix, git, bun, uv, jcode, opencode, pi-coding-agent, deepseek-harness (dsh), codex, cargo, yt-dlp, gnumake, micro, less, btop, bash completion, and the clear/reset terminal commands (ncurses).
The sandbox is a blast-radius reduction for convenient everyday use, not a hard security boundary. It keeps AI agents and experiments away from your host filesystem, processes, and home, while deliberately sharing the things needed to be useful: the network, the nix store, and the current directory.
| Area | Mechanism | Effect |
|---|---|---|
| Filesystem | Mount namespace with explicit binds | Only the paths in the table above are visible. $HOME, /etc, /root, host mounts: all invisible |
| Writable surface | rw binds limited to $PWD, the repo dir, sandbox-home/, /nix/var/nix |
Outside the explicitly bound paths there is nothing to delete or modify |
| Home directory | --setenv HOME "$SCRIPT_DIR/sandbox-home" |
The real $HOME is not bound; the sandbox gets its own home |
| Environment | --clearenv |
No host variables leak; only HOME, PATH, TMPDIR, TERM are set |
| Processes | --unshare-pid + fresh /proc |
Host processes are invisible; they can't be inspected or signalled |
| Hostname | --unshare-uts |
Private hostname namespace |
/tmp, /dev/shm |
tmpfs | Private scratch space |
/dev |
tmpfs dev setup via --dev |
Only null/zero/full/random/urandom/tty bound from host plus a private devpts instance (/dev/ptmx → pts/ptmx). No block devices, no host ptys |
| Users | Synthetic /etc/passwd, /etc/group |
Only the current user exists (as nixuser); host accounts are absent |
| Nix build dirs | /nix/var/nix/builds tmpfs |
Build artifacts in that path never touch the host |
| Area | Mechanism | Consequence |
|---|---|---|
| Network | --share-net |
Same host IP; LAN, internet, and localhost are all reachable. No egress restrictions |
| User identity | Same uid/gid as the host user | Files in shared paths are owned by you; host permission checks apply |
| Nix store | /nix/store and /nix/var/nix bound read-write |
The store is the host store. Tools can read and (where permissions allow) write it; a hostile process could poison store paths |
| Nix daemon | Socket at /nix/var/nix/daemon-socket |
nix build through the daemon executes on the host, outside the sandbox |
| Current directory | --bind "$PWD" "$PWD" |
Everything in the directory you launched from is shared both ways |
sandbox-home/ |
Real directory on the host | Files persist and are visible from the host |
| OpenCode skills | ~/.config/opencode/skills bound in |
When present on the host, the sandbox reads and writes them |
| Kernel & capabilities | Shared kernel; bwrap runs unprivileged in a user namespace |
--dev setup (incl. the private devpts mount) is done by bwrap itself while it holds CAP_SYS_ADMIN inside the user namespace, so it works without setuid/setcap bwrap; it cannot touch host sysctls or devices |
Bottom line: this is containment for everyday use — run AI agents here so they can't read your SSH keys or delete files outside the shared paths — not a sandbox for running untrusted or adversarial code. Anything inside it has network access and reach into the nix store.
| Aspect | nix develop alone |
bwrap + nix develop |
|---|---|---|
| Filesystem | Full host access | Only explicitly bound paths visible |
| Environment | Inherits all host vars | --clearenv gives a clean slate |
| Process isolation | Shares host PID namespace | --unshare-pid hides host processes |
| Home directory | Real $HOME |
Isolated sandbox-home/ |
| Security | AI tools can read/write any file | Accidental rm -rf stays contained |
| Reproducibility | Host state leaks in | Minimal, controlled surface |
bwrap is also lighter than containers — no daemon, no image pulls, no layers, no sudo. Just namespaces and bind mounts.
jcode is provided as a prebuilt binary from the
grigio Nix binary cache
(install docs: https://github.com/grigio/jcode#install-flake). It is never compiled in
this repo: the cache publishes both the jcode binary and its crane cargoArtifacts
dependency layer, so nix develop only downloads the closure.
The cache is configured in three places, all of which must stay in sync:
nixConfiginflake.nix— applies to everynix develop,nix buildandnix flake checkrun in this reposandbox-home/.config/nix/nix.conf— the Nix config inside the sandbox (Nix runs withHOME=sandbox-home, so it reads this file).github/actions/setup-nix/action.yml— the composite action used by every workflow to install Nix with the cache configured; it is the single source of truth for CI
Paths the cache does not have fall back to building from source. The grigio cache only publishes x86_64-linux, so on that platform jcode is a pure download. This flake targets x86_64-linux only, and CI runs on x86_64 runners.
- Linux with bubblewrap (
bwrap) installed - Nix with flakes and
nix-commandenabled on the host
├── start-sandbox.sh # Entry point — invokes bwrap
├── flake.nix # Nix flake with dev shell definition
├── flake.lock # Pinned nixpkgs revision
├── badges/ # Version badge data (opencode, jcode, pi-coding-agent, deepseek-harness), updated by CI
├── vendor/dsh/ # Vendored package-lock.json for the deepseek-harness npm wrap
├── scripts/
│ ├── update-badges.sh # Regenerates badges/ from the versions pinned in the flake (used by CI, runnable locally)
│ └── update-dsh.sh # Bumps the dsh version/hash pins + vendored lock in flake.nix to the latest @deepseek-ai/dsh npm release (used by CI, runnable locally)
├── .github/
│ ├── actions/setup-nix/ # Composite action: Nix install + jcode binary cache for CI
│ └── workflows/ # GitHub Actions: sandbox CI, flake.lock updater, dsh pin updater, version badge updater
├── sandbox-home/ # Isolated home directory
│ ├── .bashrc # Colored prompt, git branch, aliases
│ ├── .bash_profile # Sources .bashrc
│ ├── SANDBOX # Marker file (empty)
│ └── .config/
│ ├── nix/nix.conf # Enables flakes + nix-command + jcode binary cache
│ └── opencode/ # OpenCode config (skills, AGENTS.md)
├── .gitignore # Ignores generated runtime artifacts
├── LICENSE # MIT License
└── README.md
A minimal home directory injected into the sandbox:
.bashrc— Custom PS1 with working directory and git branch, bash completion from the Nix store, colorized grep,ll/la/laliasesnix.conf—experimental-features = nix-command flakes, plus the grigio jcode binary cache substituter and trust keyopencode/— Pre-configured with skills, AGENTS.md prompt instructions, and an opencode.jsonc config- Runtime caches (
.cache,.npm,.local,.nix-profile, etc.) are gitignored
Edit flake.nix and add packages to the mkShell packages list, then run:
nix flake update # update nixpkgs (optional)nix flake updateThis updates flake.lock to the latest nixpkgs unstable commit and bumps the
jcode input (github:grigio/jcode, which provides the prebuilt jcode binary)
to the newest version.
CI also runs this automatically: the daily flake.lock update workflow runs
nix flake update every day, validates with nix flake check --all-systems,
smoke-tests the sandbox, regenerates the version badges from the new lock, and
merges everything through a PR. The badge workflow additionally refreshes the
badges on every push to master, so they always match the locked versions.
The dsh (deepseek-harness) pin is kept current the same way:
scripts/update-dsh.sh runs weekly, re-downloads the
published npm tarball, regenerates the vendored lock and its npmDepsHash,
validates with nix build .#deepseek-harness, and auto-merges a PR. To bump
the tool manually, edit the version and hash lines in flake.nix or run
bash scripts/update-dsh.sh.
deepseek-harness (the dsh
CLI) has no GitHub releases or prebuilt nix binaries. Upstream distributes it
as the public npm package @deepseek-ai/dsh,
which is what this flake wraps with nixpkgs' buildNpmPackage (see
deepseekHarnessFor in flake.nix).
The published tarball ships no package-lock.json, so a lock generated
from the tarball at the pinned version is vendored at
vendor/dsh/package-lock.json; the build copies
it into the source root before npm ci. Native addons (node-pty, koffi) are
compiled by npm's rebuild during the build, and the wrapper launches node with
--expose-internals because the harness's HMR plugin needs the Node internal
module loader. Both dsh and deepseek-harness resolve to the CLI in the
sandbox (deepseek-harness is a convenience alias to dsh).
MIT — see LICENSE.
