Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

76 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sandbox-run: run command in a secure OS sandbox

Build status Language: shell / Bash Source lines of code Script size Issues Sponsors

Problem statement

Running other people's programs is inherently insecure. Rogue dependencies* 🎯 or hacked library code 🏴‍☠️ (et cet. ⚠️) can wreak havoc, including access all your private parts ‼️—think all current user's credentials and more personal bits like:

  • ~/.ssh,
  • ~/.pki/nssdb/,
  • ~/.mozilla/firefox/<profile>/key4.db,
  • ~/.mozilla/firefox/<profile>/formhistory.sqlite ...

✱ Running any Electron app relies on impeccability of hundreds or thousands of dependencies, NodeJS and Chromium to say the least! 😬

Solution

Run scary software in separate secure containers:

podman run --rm -it -v "$PWD:$PWD" --workdir="$PWD" \
           --net=host debian:stable-slim ./scary-binary

or you can simply:

sandbox-run scary-binary

(e.g. sandbox-run npx @google/gemini-cli) which relies on unshare (from util-linux package) to spawn your native OS "container" under the hood, and (in case of npx @google/gemini-cli), after downloading almost 500 MB ❗ of JavaScript sources, executes this untrusted third-party's Node/NPM package securely sandboxed, with its CWD in $PWD and new root filesystem (/) in $PWD/.sandbox.

This script implements most of the functionality of bubblewrap and firejail (bubblejail, docker (podman), etc.—all well-known Linux sandboxing tools that provide secure, isolated environments for running untrusted programs) in about ~400 lines of pure POSIX shell.

You're on a terminal. There's nothing to build. You run it. It works. The one major dependency chain is package util-linux, but if you can't trust Linux, can you even trust yourself?

Note

The repo also contains Bubblewrap wrapper script sandbox-run.bwrap. If you trust Bubblewrap, the wrapper script is shorter, slightly faster, and maybe easier to look-over/review, but feature parity is limited and could not be strictly maintained. Contributions welcome!

Installation

On Linux, there are no dependencies other than a POSIX shell with its standard set of conventions and utilities. The installation process might be similar on Windos/WSL. For macOS, see section Alternatives below.

# Install the unlikely-to-be-missing dependencies
sudo apt install mount coreutils util-linux

# Optionally install slirp4netns for separate network namespace.
# If unavailable, the network is shared with the host.
sudo apt install slirp4netns

# Optionally install `enosys` tool for automatic seccomp filtering
sudo apt install util-linux-extra

# Linux user namespaces need to be enabled
sudo sysctl -w kernel.unprivileged_userns_clone=1
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

# Download the script and put it somewhere on PATH.
# If you prefer the Bubblewrap wrapper, use
# URL 'https://bit.ly/sandbox-run-bwrap' instead.
curl -vL 'https://bit.ly/sandbox-run' | sudo tee /usr/local/bin/sandbox-run
sudo chmod +x /usr/local/bin/sandbox-run  # Mark executable

sandbox-run
# Usage: sandbox-run ARG...
sandbox-run ls /

Usage

Whenever you want to run an untrusted executable, simply run:

sandbox-run scary-app args

to run scary-app in a secure native sandbox.

Filesystem mounts

"$PWD/.sandbox" contains the sandbox root filesystem (/).

When different from $HOME, the current working directory ($PWD) is mounted with read-write permissions while everything else required for a successful run (e.g. /usr, /lib) is mounted read-only. Directories matching $PWD/**/.git are also mounted read-only.

To mount extra endpoints, use RO_BIND= and RW_BIND= environment variables. Anything else not explicitly mounted is either not there or lost upon namespace termination.

Environment variables

File $PWD/.env (dotenv) is respected, sourced, and exported to the sandbox environment.

The following environment variables can be set to influence program behavior:

  • ROOT= Path to sandbox root filesystem (default: $PWD/.sandbox).
  • RO_BIND=, RW_BIND= Extra mount points to bind-mount read-only (or read-write respectively) inside the sandbox. Space- or newline-delimited (useful if argument paths themselves contain spaces). If any argument is like src:dst, path src is mounted as dst inside the sandbox. These variables also support glob wildcard patterns.
  • PORTS= Space- or comma-separated list of ports to forward from host to guest. Format like for Docker/podman -p switch: host_port:guest_port[/protocol]. Example: PORTS=8080:8080,8123:123/udp. This variable has no effect if host networking namespace is shared (i.e. slirp4netns is unavailable). See section Networking below.
  • SLIRP4NETNS_ARGS= Extra arguments passed to slirp4netns binary. Can be used especially to pass --disable-host-loopback and thus prevent guest-to-host connections via default 10.0.2.2 gateway.
  • CAPS= List of capabilities to maintain (default: NET_RAW, DAC_OVERRIDE, NET_BIND_SERVICE if run as root, NET_RAW otherwise).
  • DEFAULT_RO_BIND=, DEFAULT_RW_BIND= Override default mount points. Set clear to disable default mounts like /usr and /lib.
  • VERBOSE= Print to stderr verbose debug messages pertaining to sandbox initialization and cleanup.
  • DEBUG= Even more verbose debugging, useful in development of the wrapper script itself.
  • CLEANUP= If set, remove $ROOT after execution.

Symlinks

Symlinks to sandbox-run are resolved, e.g.:

ln -s /usr/local/bin/npm "$(which sandbox-run)"
which npm  # Confirm npm points to sandbox-run

# Now `npm` runs inside the sandbox everywhere
npm -v

Runtime monitoring

If environment variable VERBOSE= is set to a non-empty value, verbose/debug program output is emitted to stderr upon execution.

You can list sandboxed process namespaces using the command lsns or the following shell wrapper:

list_sandbox_namespaces () {
    lsns -u -W | {
        IFS= read header; echo "$header"
        grep --color=never "sandbox-run|slirp4netns"
    }
}

list_sandbox_namespaces  # Function call

You can run sandbox-run without arguments to spawn an interactive shell.

Linux Seccomp

When the filter file exists, seccomp filtering is set up using:

setpriv --seccomp-filter="$ROOT/seccomp_filter.bin" ...

Default filtering is automatically set up if enosys is available (package util-linux-extra on Debian/Ubuntu). Most syscalls are allowed by default, but the dangerous ones are filtered out, including all the syscalls blocked by Docker.

sudo apt install util-linux-extra  # For enosys
# Optionally generate custom seccomp filter file
enosyss --dump='$PWD/.sandbox/seccomp_filter.bin' --syscall ...

Firejail profiles

Firejail profile in $ROOT/firejail.profile is read, As this program is only a rudimentary Firejail approximation, only directives include, noblacklist, read-only are interpreted.

Debugging

To see what's failing, run the sandbox with something like colorstrace:

sandbox-run colorstrace -f -e '%file,%process' my-failing-prog

Networking

Network connection is enabled via a shared net namespace or, preferably, via a slirp4netns bridge when available.

Set PORTS= environment variable to enable port forwarding from host to guest.

Connections from guest to host (via 10.0.2.2) are enabled by default. Set SLIRP4NETNS_ARGS=--disable-host-loopback to disable this gateway.

See section Environment variables above.

Examples

To pass extra environment variables, other than those filtered by default, use an .env file:

echo 'OPENAI_API_KEY=1111111111' > .env
sandbox-run env | grep API_KEY  # Verify the variables are set
sandbox-run my-ai-prog

To run the sandboxed process as superuser (while still retaining most of the security functionality of the container sandbox), e.g. to open privileged ports, simply use sudo:

sudo sandbox-run python -m http.server 80

If extra capabilities are required:

CAPS=+SYS_PTRACE sandbox-run strace -f malware

CAPS=+SYS_ADMIN sandbox-run unshare --net id

To run GUI (X11) apps, some prior success was achieved using e.g.:

RO_BIND='/tmp/.X11-unix/X0' sandbox-run xterm

Use NVIDIA for CUDA/compute by exposing the various device handles and forwarding the relevant port:

RW_BIND='/dev/nvidia*' \
PORTS=8080:8080 \
  sandbox-run llama-server -m Qwen3

Hide files inside the sandbox by bind-mounting /dev/null over them. Hide directories by bind-mounting empty directories over them.

empty_dir="$(mktemp -d)"
RO_BIND="/dev/null:$PWD/hidden/file $empty_dir:$PWD/hidden/dir" \
    sandbox-run ...  # Won't see real $PWD/hidden/{file,dir}

Contributing

You see a mistake—you fix it. Thanks!

Alternatives

See a few alternatives discussed over at sister project sandbox-venv.