GitHub - nzjrs/sandbubble

5 min read Original article ↗

A virtualenv first, single-file Python sandbox for running untrusted code (LLM/agent use) with reasonable isolation using Bubblewrap (bwrap) and AppArmor.

Drop sandbox.py into your project. Run it once as a script: it works out the AppArmor profile it needs for your virtualenv, prints the cp + apparmor_parser commands to install it, and once that profile is loaded it self-checks that the isolation actually holds. After that, import it as a module and execute code in a minimal, controlled filesystem view. Basically no startup time-cost or virtualisation overhead.

It is designed to also isolate a python virtualenv and dependencies. This means it is trivial to have a virtualenv with private dependencies used as an execution sandbox for python code, wihout requiring a build step to containerise things (ala docker or similar)

sandbox_extra_tests.py is an optional companion: copy it next to sandbox.py to unlock the extended --extra-tests security suite. Omit it and --extra-tests just runs the base suite -- sandbox.py alone is a complete drop-in.

NOTE: THIS IS DEFENSIBLY NOT INSECURE. IT IS NOT PERFECT. IT IS GREAT FOR YOUR INTERNAL APPLICATION STACK. AN ADVERSERIAL USER MIGHT ESCAPE THE SANDBOX. THERE ARE MORE SECURE OPTIONS AVAILABLE FOR PUBLIC CONSUMPTION.

What it does

  • Runs Python in a fresh bubblewrap container:
    • tmpfs root (/)
    • read-only binds for system dirs (/usr, /lib, /bin, /sbin, ...)
    • isolated /proc, /dev, /tmp
    • read-only access to the virtualenv associated with the Python executable
    • network on by default; --disable-network turns it off
    • a host working directory bind-mounted read-write at /mnt for I/O (must live under the system temp dir -- see Practical guidance)
  • Constrains bwrap and the payload with AppArmor (aa-exec).
  • Ships a built-in test suite that validates the isolation properties.

Requirements

  • Linux with AppArmor enabled
  • bubblewrap (bwrap)
  • aa-exec (AppArmor userspace tools)
  • A Python virtualenv (and any dependencies you want) for the payload interpreter

Quick start

sandbox.py needs a venv to run and an AppArmor profile name. Pass them as flags:

python sandbox.py --venv /path/to/venv --apparmor-profile-name my_sandbox_profile

or set them once in the environment:

export CODEEXEC_SANDBOX_VENV=/path/to/venv
export CODEEXEC_SANDBOX_AA_PROFILE_NAME=my_sandbox_profile
python sandbox.py

If the AppArmor profile isn't installed/loaded, the script prints the exact profile contents and the cp + apparmor_parser commands to install it. Once it's installed and the tests pass, call run_sandboxed() / run_sandboxed_async() from your code.

CLI flags

  • --venv PATH / --apparmor-profile-name NAME -- override the env vars (pass both together; supplying only one is ignored and the env vars are used, which must then be set)
  • --enable-network -- run with network on (this is the default; redundant with the absence of --disable-network)
  • --disable-network -- run with no network (default: network on)
  • --verbose -- print every test result, not just failures
  • --extra-tests -- also run the extended suite (needs sandbox_extra_tests.py)
  • --ignore-profile-diffs -- continue past profile content mismatches (e.g. Docker adds extra venv paths)
  • --interactive -- drop into a sandboxed Python shell (uses an ephemeral /mnt; changes are not persisted)

Usage examples

Run a snippet (no persistent host I/O)

from sandbox import get_default_sandbox_config, run_sandboxed

conf = get_default_sandbox_config()

res, _files = run_sandboxed(conf, code="print('hello from sandbox')", timeout=5)

print(res.returncode)
print(res.stdout)
print(res.stderr)

Writable host dir at /mnt, collect outputs

import tempfile
from pathlib import Path
from sandbox import get_default_sandbox_config, run_sandboxed

conf = get_default_sandbox_config()

with tempfile.TemporaryDirectory(prefix="agent_io_") as host_dir:
    res, changed = run_sandboxed(
        conf,
        code="open('/mnt/out.txt','w').write('result\n')",
        mnt_dir=host_dir,
        timeout=5,
    )

    print("rc:", res.returncode)
    print("changed files:", changed)
    print("host out:", (Path(host_dir) / "out.txt").read_text())

Run a script from /mnt

import tempfile
from pathlib import Path
from sandbox import get_default_sandbox_config, run_sandboxed

conf = get_default_sandbox_config()

with tempfile.TemporaryDirectory(prefix="agent_io_") as host_dir:
    p = Path(host_dir) / "code.py"
    p.write_text("print('script ran')\n")

    res, changed = run_sandboxed(conf, script_path="/mnt/code.py", mnt_dir=host_dir, timeout=5)

    print(res.stdout)              # "script ran\n"
    print("changed files:", changed)   # the script file itself is excluded by design

Disable network

from sandbox import SandboxConfig, run_sandboxed

conf = SandboxConfig(
    venv_path="/path/to/venv",
    apparmor_profile_name="my_sandbox_profile",
    enable_network=False,
)

res, _ = run_sandboxed(conf, code="import socket; socket.getaddrinfo('example.com', 80)")
print("rc:", res.returncode)   # non-zero: networking is off

Security model (read this)

A defensible isolation layer, not a hardened sandbox.

What you get

  • Filesystem isolation via bubblewrap: tmpfs root, explicit read-only system binds, one writable bind (/mnt).
  • Namespace isolation (user/pid/ipc/uts; optional net).
  • Policy enforcement via AppArmor over the launcher and payload.
  • Self-tests for the common assumptions (no host home mounted, read-only binds hold, etc.).

Intentionally exposed (read-only)

  • /etc/passwd and /etc/group -- bound so name resolution works. This reveals host usernames, uids/gids, home paths, and login shells (the home directories themselves are not mounted).
  • The entire venv directory -- it is the payload's interpreter and libraries, so all of its contents are readable. Don't store secrets under it.
  • Fixed public DNS (8.8.8.8 / 8.8.4.4 / 1.1.1.1) -- internal/split-horizon names will not resolve, and DNS queries egress to third parties. Use --disable-network to turn networking off.

What you don't get

  • Protection against kernel vulnerabilities or novel container escapes.
  • A formally verified policy. Small changes to mounts, devices, or the profile can change the attack surface materially.
  • DoS resistance by default -- no CPU/memory/disk/output limits unless you add cgroups/rlimits/output caps.

Not attempted

  • Stopping code that only needs network exfiltration (network is on by default; disable it if you need it off).
  • Kernel 0-days -- like any namespace sandbox, the kernel stays in the TCB.
  • Resource exhaustion -- add cgroups / rlimits / output limits for multi-tenant safety.

Practical guidance

  • /mnt is the only host interface and it is read-write. Pass the host directory via mnt_dir; it must be the system temp dir or a subdirectory of it (normally /tmp, honoring $TMPDIR) or you get a ValueError. Use a fresh tempfile.TemporaryDirectory() per run and put only inputs you can afford to lose there: the payload can overwrite and delete anything under it, and deletions are not reflected in the returned changed-files list.
  • Run the built-in checks in CI or at deploy time on the target host.
  • For stronger isolation add: cgroups (memory/pids/cpu), output size limits, seccomp, a tighter AppArmor surface (/proc, /dev).

License

MIT -- see LICENSE.