I wanted to stop by to add that if we begin using the MCP tools enabled in this branch, we should really consider adding sandboxing of some kind, gemini cli has some limited form of this, including tool exclusions
so you can exclude ShellCommand(rm -rf) from running for example, and add a "sandbox": true, which then runs the tool within a docker container.
I've been experimenting a bit with this using firecracker VMs, with openapi spec mcp generations
Its hard to believe how fast the tools have come in the last year, well maybe not considering how incredible aider was just 9 months ago, seems antiquated already.
This issue I believe also brings up a good example using cgroups via bubblewrap.
For the bubblewrap script above is that a script that runs in the same directory or is it intended to be something like:
./bubblewrap.sh -- aider-ce -config {file path} [...other arfs]
For the bubblewrap script above is that a script that runs in the same directory or is it intended to be something like: ./bubblewrap.sh -- aider-ce -config {file path} [...other arfs]
Yes, its just an example of using bubblewrap bwrap its meant to place a process into a cgroup, so it isolates any particular process, its a bit more lightweight than docker, which under the hood used to, or still does use crun, runc, which is just a layer over cgroups
although, container escapes are like a thing, that's why i've been looking at firecracker vms, docker is enough though for the various foot guns that get vibed up sometimes.
I'm actually putting together a python module for this called bubbleproc
Effectively it would do a lightweight cgroup bwrap by monkey patching subprocess by wrapping subprocess executions in bwrap
Here's a sketch of what I mean by that:
#!/usr/bin/env python3
"""
Examples and tests for the bubbleproc module.
Run with: python examples.py
"""
from bubbleproc import Sandbox, run, check_output, patch_subprocess, create_aider_sandbox
import subprocess
import os
import tempfile
def example_basic():
"""Basic usage - run a simple command."""
print("=== Basic Usage ===")
# Simple command (no filesystem access needed)
result = run("echo 'Hello from bubbleproc!'", capture_output=True)
print(f"Output: {result.stdout.strip()}")
print(f"Return code: {result.returncode}")
def example_filesystem():
"""Filesystem access control."""
print("\n=== Filesystem Access ===")
# Create a temp directory to work with
with tempfile.TemporaryDirectory() as tmpdir:
# Write a test file
test_file = os.path.join(tmpdir, "test.txt")
with open(test_file, "w") as f:
f.write("Hello World\n")
# Read-only access
result = run(f"cat {test_file}", ro=[tmpdir], capture_output=True)
print(f"Read file: {result.stdout.strip()}")
# Read-write access
result = run(
f"echo 'Modified' >> {test_file} && cat {test_file}",
rw=[tmpdir],
capture_output=True
)
print(f"After write: {result.stdout.strip()}")
# Verify the write persisted
with open(test_file) as f:
print(f"Actual content: {f.read().strip()}")
def example_secrets_blocked():
"""Demonstrate that secrets are blocked."""
print("\n=== Secrets Blocked ===")
home = os.environ.get("HOME", "/tmp")
# Try to read .ssh (should be empty/blocked)
result = run(
f"ls -la {home}/.ssh 2>&1 || echo 'Access blocked'",
share_home=True,
capture_output=True
)
print(f"Trying to access ~/.ssh: {result.stdout.strip()}")
# Try to read .aws (should be empty/blocked)
result = run(
f"ls -la {home}/.aws 2>&1 || echo 'Access blocked'",
share_home=True,
capture_output=True
)
print(f"Trying to access ~/.aws: {result.stdout.strip()}")
def example_network():
"""Network access control."""
print("\n=== Network Access ===")
# Without network (should fail)
result = run(
"curl -s --connect-timeout 2 https://example.com 2>&1 || echo 'Network blocked'",
capture_output=True
)
print(f"Without --network: {result.stdout.strip()[:50]}...")
# With network (should work)
result = run(
"curl -s --connect-timeout 5 https://example.com | head -c 100",
network=True,
capture_output=True
)
print(f"With --network: {result.stdout.strip()[:50]}...")
def example_sandbox_object():
"""Using the Sandbox class for reusable configuration."""
print("\n=== Sandbox Object ===")
with tempfile.TemporaryDirectory() as tmpdir:
# Configure once, use multiple times
sb = Sandbox(
rw=[tmpdir],
network=False,
env={"MY_VAR": "hello"},
)
# Run multiple commands with same config
sb.run(f"echo $MY_VAR > {tmpdir}/out.txt")
result = sb.run(f"cat {tmpdir}/out.txt", capture_output=True)
print(f"Environment variable passed: {result.stdout.strip()}")
# Check output helper
output = sb.check_output("echo 'check_output works'")
print(f"check_output: {output.strip()}")
def example_aider_sandbox():
"""Sandbox configured for Aider usage."""
print("\n=== Aider Sandbox ===")
with tempfile.TemporaryDirectory() as project_dir:
# Create a mock project
with open(os.path.join(project_dir, "main.py"), "w") as f:
f.write("print('hello')\n")
# Create sandbox for aider
sb = create_aider_sandbox(project_dir, network=True)
# Show what aider would see
result = sb.run(f"ls -la {project_dir}", capture_output=True)
print(f"Project files: {result.stdout.strip()}")
# Verify API key passthrough works (if set)
api_key = os.environ.get("ANTHROPIC_API_KEY", "not-set")
result = sb.run(
"echo ${ANTHROPIC_API_KEY:0:10}...", # Just first 10 chars
capture_output=True
)
print(f"API key passed: {result.stdout.strip()}")
def example_monkey_patch():
"""Monkey-patching subprocess for transparent sandboxing."""
print("\n=== Monkey Patch ===")
with tempfile.TemporaryDirectory() as tmpdir:
# Patch subprocess
patch_subprocess(rw=[tmpdir], network=False, share_home=True)
# Now regular subprocess.run with shell=True is sandboxed
result = subprocess.run(
f"echo 'sandboxed!' > {tmpdir}/test.txt && cat {tmpdir}/test.txt",
shell=True,
capture_output=True,
text=True
)
print(f"Patched subprocess.run: {result.stdout.strip()}")
# Non-shell commands still work normally
result = subprocess.run(["echo", "not sandboxed"], capture_output=True, text=True)
print(f"Non-shell command: {result.stdout.strip()}")
# Clean up
from bubbleproc import unpatch_subprocess
unpatch_subprocess()
def example_dangerous_commands():
"""Show that dangerous commands are contained."""
print("\n=== Dangerous Commands Contained ===")
with tempfile.TemporaryDirectory() as safe_dir:
# Create a file in the safe directory
safe_file = os.path.join(safe_dir, "important.txt")
with open(safe_file, "w") as f:
f.write("important data\n")
# Try to delete everything (should fail outside rw paths)
result = run(
"rm -rf / 2>&1 || echo 'Deletion blocked'",
rw=[safe_dir],
capture_output=True
)
print(f"rm -rf /: {result.stdout.strip()[:60]}")
# The safe file should still exist
print(f"Safe file exists: {os.path.exists(safe_file)}")
# Try to access /etc/passwd for writing (should be read-only)
result = run(
"echo 'hacked' >> /etc/passwd 2>&1 || echo 'Write blocked'",
capture_output=True
)
print(f"Write to /etc/passwd: {result.stdout.strip()}")
def main():
"""Run all examples."""
print("Sandbox Module Examples")
print("=" * 50)
examples = [
("Basic", example_basic),
("Filesystem", example_filesystem),
("Secrets Blocked", example_secrets_blocked),
("Network", example_network),
("Sandbox Object", example_sandbox_object),
("Aider Sandbox", example_aider_sandbox),
("Monkey Patch", example_monkey_patch),
("Dangerous Commands", example_dangerous_commands),
]
for name, func in examples:
try:
func()
except Exception as e:
print(f"\n=== {name} ===")
print(f"Error: {e}")
print("\n" + "=" * 50)
print("All examples complete!")
if __name__ == "__main__":
main()
Neat! So for this issue, are we good with docker, or did you want to try to integrate your library with the core so something like the patch_subprocess() method is available for configuration?
Neat! So for this issue, are we good with docker, or did you want to try to integrate your library with the core so something like the patch_subprocess() method is available for configuration?
I'll look into integrating the idea, i'll fork and feature branch if i come up with something feasible.
I'm not sure how it will go.
Might easier to just default to docker.
But, I've been struggling with the various cli agent tools on this front.
It's just docker containers everywhere.
It'd be nice to know that configuring something like Aider would allow these tools to just work with some simple guardrails, without all of the container management with extra steps.
So just for reference, these would be the places where subprocess would get patched:
It should also be noted, I haven't come up with a great solution for cross compatible sandboxing as cgroups don't exist in the same fashion for macintosh, and windows.
But, I've been struggling with the various cli agent tools on this front.
It's just docker containers everywhere.
It'd be nice to know that configuring something like Aider would allow these tools to just work with some simple guardrails, without all of the container management with extra steps.
I absolutely feel this. I use Docker, I don't dislike Docker, but... it's just too heavy for what I want in this situation.
and windows.
As a Windows user, I use WSL2 exclusively for projects, so we're probably not out entirely.
Activity
allen-munsch commented on Dec 16, 2025
Here's a vibe coded script of what is meant via bubblewrap:
#!/usr/bin/env bash ########################################### # sandbox - Simple command sandboxing via bubblewrap ########################################### # Usage: sandbox [options] -- <command> [args...] # # Examples: # sandbox --ro ~/projects -- grep -r "TODO" . # sandbox --profile aider --rw ~/code -- aider ########################################### set -euo pipefail VERSION="4.0.0" # --- Configuration --- declare -a RO_MOUNTS=() declare -a RW_MOUNTS=() declare -a ENV_VARS=() declare -a ENV_PASSTHROUGH=() declare -a ALLOW_SECRETS=() ALLOW_NETWORK=false ALLOW_GPU=false SHARE_HOME=false WORKDIR="" VERBOSE=false DRY_RUN=false PROFILE="" TIME_LIMIT="" # --- Colors --- if [[ -t 2 ]]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' NC='' fi # --- Forbidden paths --- FORBIDDEN_WRITE=("/" "/bin" "/boot" "/etc" "/lib" "/lib64" "/opt" "/root" "/sbin" "/sys" "/usr" "/var") # --- Logging --- log_info() { [[ "$VERBOSE" == "true" ]] && echo -e "${BLUE}[INFO]${NC} $*" >&2 || true; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } # --- Profiles --- apply_profile() { case "$1" in minimal) ;; dev) ALLOW_NETWORK=true SHARE_HOME=true ENV_PASSTHROUGH+=(EDITOR VISUAL TERM) ;; network) ALLOW_NETWORK=true ;; aider|mcp) # For Aider CLI + MCP tools ALLOW_NETWORK=true SHARE_HOME=true # With secrets blocked ENV_PASSTHROUGH+=( # API keys passed via env (not from files) ANTHROPIC_API_KEY OPENAI_API_KEY OPENROUTER_API_KEY GOOGLE_API_KEY AZURE_OPENAI_API_KEY GEMINI_API_KEY # Git identity GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL # Terminal TERM COLORTERM CLICOLOR FORCE_COLOR # Aider config AIDER_MODEL AIDER_DARK_MODE AIDER_AUTO_COMMITS AIDER_GIT_ROOT AIDER_VIM AIDER_VOICE_LANGUAGE ) ;; untrusted) TIME_LIMIT="${TIME_LIMIT:-60}" ;; *) die "Unknown profile: $1 (available: minimal, dev, network, aider, mcp, untrusted)" ;; esac log_info "Applied profile: $1" } # --- Path validation --- validate_path() { local path="${1/#\~/$HOME}" local mode="${2:-ro}" local resolved resolved="$(realpath -m "$path" 2>/dev/null || echo "$path")" if [[ "$mode" == "rw" ]]; then for forbidden in "${FORBIDDEN_WRITE[@]}"; do [[ "$resolved" == "$forbidden" ]] && die "Write access to '$resolved' forbidden" done fi [[ ! -e "$path" ]] && log_warn "Path does not exist: $path" echo "$resolved" } # --- Help --- show_help() { cat << 'EOF' sandbox - Run commands in an isolated bubblewrap sandbox USAGE: sandbox [options] -- <command> [args...] PROFILES: minimal No network, no extras (default) dev Network + read-only home (secrets blocked) network Network access only aider/mcp Aider + MCP tools (network, env-based API keys, secrets blocked) untrusted Maximum isolation with 60s timeout MOUNT OPTIONS: --ro <path> Mount path read-only --rw <path> Mount path read-write --share-home Mount $HOME read-only (secrets auto-blocked) --workdir, -C <path> Set working directory PERMISSIONS: --profile, -p <name> Apply a profile --net Allow network access --gpu Allow GPU access (/dev/dri, nvidia) ENVIRONMENT: --env <VAR=val> Set environment variable --env-pass <VAR> Pass variable from host environment SECURITY: --allow-secret <dir> Unblock a secret dir (e.g. .gnupg for signing) --time <seconds> Kill process after timeout OUTPUT: --dry-run Print bwrap command without executing --verbose, -v Show verbose output --help, -h Show this help BLOCKED SECRETS (with --share-home or aider/mcp profile): .ssh .gnupg .aws .azure .gcloud .kube .docker .helm .password-store .config/gh .config/hub browser profiles .bash_history .zsh_history and other shell/tool histories EXAMPLES: # Aider with API key from environment export ANTHROPIC_API_KEY=sk-ant-... sandbox --profile aider --rw ~/myproject -- aider # MCP filesystem server (sandboxed) sandbox --profile mcp --rw ~/data -- npx @modelcontextprotocol/server-filesystem ~/data # Allow GPG for signed commits sandbox --profile aider --allow-secret .gnupg --rw ~/project -- aider # Read-only code search sandbox --ro ~/code -- grep -r "TODO" . # Untrusted script with timeout sandbox --profile untrusted --ro ~/scripts -- ./suspicious.sh # Preview the bwrap command sandbox --profile aider --rw ~/proj --dry-run -- aider EOF } # --- Argument parsing --- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --profile|-p) shift; PROFILE="$1"; shift ;; --ro) shift; RO_MOUNTS+=("$(validate_path "$1" ro)"); shift ;; --rw) shift; RW_MOUNTS+=("$(validate_path "$1" rw)"); shift ;; --net) ALLOW_NETWORK=true; shift ;; --gpu) ALLOW_GPU=true; shift ;; --share-home) SHARE_HOME=true; shift ;; --env) shift; ENV_VARS+=("$1"); shift ;; --env-pass) shift; ENV_PASSTHROUGH+=("$1"); shift ;; --allow-secret) shift; ALLOW_SECRETS+=("$1"); shift ;; --workdir|-C) shift; WORKDIR="$1"; shift ;; --time) shift; TIME_LIMIT="$1"; shift ;; --dry-run) DRY_RUN=true; shift ;; --verbose|-v) VERBOSE=true; shift ;; --help|-h) show_help; exit 0 ;; --) shift; break ;; -*) die "Unknown option: $1" ;; *) break ;; esac done COMMAND=("$@") [[ ${#COMMAND[@]} -eq 0 ]] && die "No command specified" } # --- Build bwrap command --- build_bwrap_command() { local -a cmd=(bwrap) # Isolation cmd+=(--unshare-user --unshare-pid --unshare-uts --unshare-ipc --unshare-cgroup) [[ "$ALLOW_NETWORK" != "true" ]] && cmd+=(--unshare-net) # Security cmd+=(--cap-drop ALL --no-new-privs --new-session --die-with-parent) cmd+=(--hostname sandbox) # Proc and dev cmd+=(--proc /proc --dev /dev) for dev in /dev/null /dev/zero /dev/random /dev/urandom /dev/tty; do [[ -e "$dev" ]] && cmd+=(--dev-bind-try "$dev" "$dev") done # Base system (read-only) for dir in /usr /bin /sbin /lib /lib64 /lib32; do [[ -d "$dir" ]] && cmd+=(--ro-bind "$dir" "$dir") done # Essential /etc files for f in /etc/ld.so.cache /etc/ld.so.conf /etc/passwd /etc/group /etc/hosts \ /etc/resolv.conf /etc/localtime /etc/ssl /etc/ca-certificates \ /etc/terminfo /etc/alternatives; do [[ -e "$f" ]] && cmd+=(--ro-bind-try "$f" "$f") done # Ephemeral mounts cmd+=(--tmpfs /tmp --tmpfs /var/tmp --tmpfs /run) # === Home directory handling === if [[ "$SHARE_HOME" == "true" ]]; then cmd+=(--ro-bind "$HOME" "$HOME") # Sensitive dirs to block (overlay with tmpfs) local -a secret_dirs=( # Keys and credentials .ssh .gnupg .pki # Cloud providers .aws .azure .gcloud .config/gcloud # Container/k8s .kube .docker .helm # Package manager tokens .npmrc .yarnrc .pypirc .netrc .gem/credentials .cargo/credentials .cargo/credentials.toml .composer/auth.json # Password managers .password-store .local/share/keyrings .config/op .config/keybase # CLI tokens .config/gh .config/hub .config/netlify .config/heroku .config/doctl # Browsers (cookies, passwords) .mozilla .config/google-chrome .config/chromium .config/BraveSoftware .config/vivaldi # Generic secrets .secrets .credentials .private .env # History files (can leak secrets) .bash_history .zsh_history .python_history .psql_history .mysql_history .node_repl_history ) local blocked=0 for dir in "${secret_dirs[@]}"; do # Check if explicitly allowed local allowed=false for allow in "${ALLOW_SECRETS[@]}"; do [[ "$dir" == "$allow" || "$dir" == "${allow#$HOME/}" ]] && allowed=true && break done if [[ "$allowed" == "true" ]]; then log_info "Secret allowed: ~/$dir" elif [[ -e "$HOME/$dir" ]]; then cmd+=(--tmpfs "$HOME/$dir") ((blocked++)) || true fi done log_info "Home shared (ro), $blocked secret paths blocked" else cmd+=(--tmpfs "$HOME") cmd+=(--dir "$HOME/.cache" --dir "$HOME/.config" --dir "$HOME/.local/share") log_info "Home: ephemeral tmpfs" fi # User mounts for path in "${RO_MOUNTS[@]}"; do [[ -e "$path" ]] && cmd+=(--ro-bind "$path" "$path") done for path in "${RW_MOUNTS[@]}"; do [[ -e "$path" ]] && cmd+=(--bind "$path" "$path") done # GPU if [[ "$ALLOW_GPU" == "true" ]]; then [[ -d /dev/dri ]] && cmd+=(--dev-bind /dev/dri /dev/dri) for nv in /dev/nvidia*; do [[ -e "$nv" ]] && cmd+=(--dev-bind "$nv" "$nv") done fi # Environment cmd+=( --setenv HOME "$HOME" --setenv USER "${USER:-$(whoami)}" --setenv PATH "/usr/local/bin:/usr/bin:/bin" --setenv TERM "${TERM:-xterm-256color}" --setenv LANG "${LANG:-C.UTF-8}" --setenv TMPDIR "/tmp" ) for var in "${ENV_PASSTHROUGH[@]}"; do [[ -n "${!var:-}" ]] && cmd+=(--setenv "$var" "${!var}") done for envvar in "${ENV_VARS[@]}"; do cmd+=(--setenv "${envvar%%=*}" "${envvar#*=}") done # Working directory local workdir="${WORKDIR:-${RW_MOUNTS[0]:-${RO_MOUNTS[0]:-/tmp}}}" cmd+=(--chdir "$workdir") cmd+=(-- "${COMMAND[@]}") printf '%q ' "${cmd[@]}" } # --- Main --- main() { command -v bwrap &>/dev/null || die "bubblewrap not found. Install: apt install bubblewrap" parse_args "$@" [[ -n "$PROFILE" ]] && apply_profile "$PROFILE" local bwrap_cmd bwrap_cmd="$(build_bwrap_command)" if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}${BOLD}Dry-run:${NC}" echo "$bwrap_cmd" exit 0 fi if [[ "$VERBOSE" == "true" ]]; then echo -e "${CYAN}━━━ Sandbox ━━━${NC}" >&2 echo -e " Command: ${COMMAND[*]}" >&2 echo -e " Profile: ${PROFILE:-none}" >&2 echo -e " Network: $ALLOW_NETWORK" >&2 echo -e " RW: ${RW_MOUNTS[*]:-none}" >&2 echo -e "${CYAN}━━━━━━━━━━━━━━━${NC}" >&2 fi if [[ -n "$TIME_LIMIT" ]] && command -v timeout &>/dev/null; then exec timeout --signal=KILL "$((TIME_LIMIT + 5))" timeout --signal=TERM "$TIME_LIMIT" bash -c "$bwrap_cmd" else exec bash -c "$bwrap_cmd" fi } main "$@"dwash96 commented on Dec 16, 2025
So there is already a docker container that provides some sandboxing, the documentation in the README being
For the bubblewrap script above is that a script that runs in the same directory or is it intended to be something like:
./bubblewrap.sh -- aider-ce -config {file path} [...other arfs]
allen-munsch commented on Dec 16, 2025
Ah yep, Thanks!
Yes, its just an example of using bubblewrap bwrap its meant to place a process into a cgroup, so it isolates any particular process, its a bit more lightweight than docker, which under the hood used to, or still does use crun, runc, which is just a layer over cgroups
although, container escapes are like a thing, that's why i've been looking at firecracker vms, docker is enough though for the various foot guns that get vibed up sometimes.
I'm actually putting together a python module for this called bubbleproc
Effectively it would do a lightweight cgroup bwrap by monkey patching subprocess by wrapping subprocess executions in bwrap
Here's a sketch of what I mean by that:
#!/usr/bin/env python3 """ Examples and tests for the bubbleproc module. Run with: python examples.py """ from bubbleproc import Sandbox, run, check_output, patch_subprocess, create_aider_sandbox import subprocess import os import tempfile def example_basic(): """Basic usage - run a simple command.""" print("=== Basic Usage ===") # Simple command (no filesystem access needed) result = run("echo 'Hello from bubbleproc!'", capture_output=True) print(f"Output: {result.stdout.strip()}") print(f"Return code: {result.returncode}") def example_filesystem(): """Filesystem access control.""" print("\n=== Filesystem Access ===") # Create a temp directory to work with with tempfile.TemporaryDirectory() as tmpdir: # Write a test file test_file = os.path.join(tmpdir, "test.txt") with open(test_file, "w") as f: f.write("Hello World\n") # Read-only access result = run(f"cat {test_file}", ro=[tmpdir], capture_output=True) print(f"Read file: {result.stdout.strip()}") # Read-write access result = run( f"echo 'Modified' >> {test_file} && cat {test_file}", rw=[tmpdir], capture_output=True ) print(f"After write: {result.stdout.strip()}") # Verify the write persisted with open(test_file) as f: print(f"Actual content: {f.read().strip()}") def example_secrets_blocked(): """Demonstrate that secrets are blocked.""" print("\n=== Secrets Blocked ===") home = os.environ.get("HOME", "/tmp") # Try to read .ssh (should be empty/blocked) result = run( f"ls -la {home}/.ssh 2>&1 || echo 'Access blocked'", share_home=True, capture_output=True ) print(f"Trying to access ~/.ssh: {result.stdout.strip()}") # Try to read .aws (should be empty/blocked) result = run( f"ls -la {home}/.aws 2>&1 || echo 'Access blocked'", share_home=True, capture_output=True ) print(f"Trying to access ~/.aws: {result.stdout.strip()}") def example_network(): """Network access control.""" print("\n=== Network Access ===") # Without network (should fail) result = run( "curl -s --connect-timeout 2 https://example.com 2>&1 || echo 'Network blocked'", capture_output=True ) print(f"Without --network: {result.stdout.strip()[:50]}...") # With network (should work) result = run( "curl -s --connect-timeout 5 https://example.com | head -c 100", network=True, capture_output=True ) print(f"With --network: {result.stdout.strip()[:50]}...") def example_sandbox_object(): """Using the Sandbox class for reusable configuration.""" print("\n=== Sandbox Object ===") with tempfile.TemporaryDirectory() as tmpdir: # Configure once, use multiple times sb = Sandbox( rw=[tmpdir], network=False, env={"MY_VAR": "hello"}, ) # Run multiple commands with same config sb.run(f"echo $MY_VAR > {tmpdir}/out.txt") result = sb.run(f"cat {tmpdir}/out.txt", capture_output=True) print(f"Environment variable passed: {result.stdout.strip()}") # Check output helper output = sb.check_output("echo 'check_output works'") print(f"check_output: {output.strip()}") def example_aider_sandbox(): """Sandbox configured for Aider usage.""" print("\n=== Aider Sandbox ===") with tempfile.TemporaryDirectory() as project_dir: # Create a mock project with open(os.path.join(project_dir, "main.py"), "w") as f: f.write("print('hello')\n") # Create sandbox for aider sb = create_aider_sandbox(project_dir, network=True) # Show what aider would see result = sb.run(f"ls -la {project_dir}", capture_output=True) print(f"Project files: {result.stdout.strip()}") # Verify API key passthrough works (if set) api_key = os.environ.get("ANTHROPIC_API_KEY", "not-set") result = sb.run( "echo ${ANTHROPIC_API_KEY:0:10}...", # Just first 10 chars capture_output=True ) print(f"API key passed: {result.stdout.strip()}") def example_monkey_patch(): """Monkey-patching subprocess for transparent sandboxing.""" print("\n=== Monkey Patch ===") with tempfile.TemporaryDirectory() as tmpdir: # Patch subprocess patch_subprocess(rw=[tmpdir], network=False, share_home=True) # Now regular subprocess.run with shell=True is sandboxed result = subprocess.run( f"echo 'sandboxed!' > {tmpdir}/test.txt && cat {tmpdir}/test.txt", shell=True, capture_output=True, text=True ) print(f"Patched subprocess.run: {result.stdout.strip()}") # Non-shell commands still work normally result = subprocess.run(["echo", "not sandboxed"], capture_output=True, text=True) print(f"Non-shell command: {result.stdout.strip()}") # Clean up from bubbleproc import unpatch_subprocess unpatch_subprocess() def example_dangerous_commands(): """Show that dangerous commands are contained.""" print("\n=== Dangerous Commands Contained ===") with tempfile.TemporaryDirectory() as safe_dir: # Create a file in the safe directory safe_file = os.path.join(safe_dir, "important.txt") with open(safe_file, "w") as f: f.write("important data\n") # Try to delete everything (should fail outside rw paths) result = run( "rm -rf / 2>&1 || echo 'Deletion blocked'", rw=[safe_dir], capture_output=True ) print(f"rm -rf /: {result.stdout.strip()[:60]}") # The safe file should still exist print(f"Safe file exists: {os.path.exists(safe_file)}") # Try to access /etc/passwd for writing (should be read-only) result = run( "echo 'hacked' >> /etc/passwd 2>&1 || echo 'Write blocked'", capture_output=True ) print(f"Write to /etc/passwd: {result.stdout.strip()}") def main(): """Run all examples.""" print("Sandbox Module Examples") print("=" * 50) examples = [ ("Basic", example_basic), ("Filesystem", example_filesystem), ("Secrets Blocked", example_secrets_blocked), ("Network", example_network), ("Sandbox Object", example_sandbox_object), ("Aider Sandbox", example_aider_sandbox), ("Monkey Patch", example_monkey_patch), ("Dangerous Commands", example_dangerous_commands), ] for name, func in examples: try: func() except Exception as e: print(f"\n=== {name} ===") print(f"Error: {e}") print("\n" + "=" * 50) print("All examples complete!") if __name__ == "__main__": main()allen-munsch commented on Dec 16, 2025
alright i did it, here's the vibed up library: https://github.com/allen-munsch/bubbleproc
it should be basically a drop in replacement for subprocess.* stuff
dwash96 commented on Dec 16, 2025
Neat! So for this issue, are we good with docker, or did you want to try to integrate your library with the core so something like the patch_subprocess() method is available for configuration?
allen-munsch commented on Dec 16, 2025
I'll look into integrating the idea, i'll fork and feature branch if i come up with something feasible.
I'm not sure how it will go.
Might easier to just default to docker.
But, I've been struggling with the various cli agent tools on this front.
It's just docker containers everywhere.
It'd be nice to know that configuring something like Aider would allow these tools to just work with some simple guardrails, without all of the container management with extra steps.
So just for reference, these would be the places where subprocess would get patched:
It should also be noted, I haven't come up with a great solution for cross compatible sandboxing as cgroups don't exist in the same fashion for macintosh, and windows.
strawberrymelonpanda commented on Dec 26, 2025
I absolutely feel this. I use Docker, I don't dislike Docker, but... it's just too heavy for what I want in this situation.
As a Windows user, I use WSL2 exclusively for projects, so we're probably not out entirely.