#!/usr/bin/env bash # sandbox.sh - Safe AI coding agent sandbox # Usage: ./sandbox.sh [options] # Commands: setup, run, shell, status, clean set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── SANDBOX_IMAGE="${SANDBOX_IMAGE:-ai-sandbox:latest}" SANDBOX_DIR="${SANDBOX_DIR:-$HOME/.ai-sandbox}" WORKDIR_HOST="${SANDBOX_DIR}/workspace" CONFIG_DIR="${SANDBOX_DIR}/config" # persistent agent credentials (host-only, chmod 700) TMPDIR_SIZE="${TMPDIR_SIZE:-512m}" CPU_LIMIT="${CPU_LIMIT:-1.0}" MEM_LIMIT="${MEM_LIMIT:-512m}" NETWORK_MODE="${NETWORK_MODE:-none}" # none = no internet; bridge = allow # Colors RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${CYAN}[sandbox]${NC} $*"; } ok() { echo -e "${GREEN}[ok]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } # ── OS detection ────────────────────────────────────────────────────────────── OS="$(uname -s)" case "$OS" in Linux) PLATFORM=linux ;; Darwin) PLATFORM=macos ;; *) die "Unsupported OS: $OS" ;; esac # ── Helpers ─────────────────────────────────────────────────────────────────── require_docker() { command -v docker &>/dev/null || die "Docker not found. Run: ./sandbox.sh setup" docker info &>/dev/null || die "Docker daemon not running." } has_nsjail() { [[ "$PLATFORM" == "linux" ]] && command -v nsjail &>/dev/null } has_firejail() { command -v firejail &>/dev/null } # Verify that host networking/DNS was not changed by this script assert_host_untouched() { # We never modify /etc/hosts, /etc/resolv.conf, iptables, or network interfaces. # This function just documents that guarantee and does a quick sanity check. if [[ "$PLATFORM" == "linux" ]]; then local resolv_hash_before resolv_hash_after resolv_hash_before="${_RESOLV_HASH_BEFORE:-}" resolv_hash_after="$(md5sum /etc/resolv.conf 2>/dev/null | cut -d' ' -f1)" if [[ -n "$resolv_hash_before" && "$resolv_hash_before" != "$resolv_hash_after" ]]; then die "/etc/resolv.conf was modified unexpectedly! Aborting." fi fi } snapshot_host() { if [[ "$PLATFORM" == "linux" ]]; then export _RESOLV_HASH_BEFORE="$(md5sum /etc/resolv.conf 2>/dev/null | cut -d' ' -f1)" fi } # ── Dockerfile (embedded) ───────────────────────────────────────────────────── write_dockerfile() { local dir="$1" cat > "$dir/Dockerfile" <<'EOF' FROM ubuntu:24.04 ARG DEBIAN_FRONTEND=noninteractive # System packages RUN apt-get update -qq && apt-get install -y --no-install-recommends \ python3 python3-pip python3-venv \ git curl wget ca-certificates gnupg \ build-essential \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Node.js LTS (via NodeSource — newer than Ubuntu's bundled version) RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && apt-get clean && rm -rf /var/lib/apt/lists/* # GitHub CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) \ signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \ https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list \ && apt-get update -qq && apt-get install -y --no-install-recommends gh \ && apt-get clean && rm -rf /var/lib/apt/lists/* # AI CLI tools (npm global) RUN npm install -g --no-audit --no-fund \ @anthropic-ai/claude-code \ @google/gemini-cli # Non-root sandbox user (fixed uid/gid 999) RUN groupadd -r -g 999 agent \ && useradd -r -u 999 -g agent -m -d /home/agent agent # Workspace dir RUN mkdir -p /workspace && chown agent:agent /workspace USER agent ENV HOME=/home/agent # Stub dirs for bind/tmpfs mounts (must exist before runtime mounts) RUN mkdir -p \ /home/agent/.claude \ /home/agent/.cache \ /home/agent/.config/gh \ /home/agent/.config/gemini \ /home/agent/.local/state # gh Copilot extension — baked into image; read-only at runtime is fine RUN gh extension install github/gh-copilot 2>/dev/null || true USER root WORKDIR /workspace USER agent ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 CMD ["/bin/bash"] EOF } # ── Commands ────────────────────────────────────────────────────────────────── cmd_setup() { snapshot_host info "Setting up AI sandbox on $PLATFORM..." # 1. Docker if ! command -v docker &>/dev/null; then if [[ "$PLATFORM" == "linux" ]]; then info "Installing Docker (rootless)..." # Use official convenience script but only if user confirms read -rp " Install Docker via get.docker.com? [y/N] " ans [[ "$ans" =~ ^[Yy]$ ]] || die "Aborted. Install Docker manually: https://docs.docker.com/engine/install/" curl -fsSL https://get.docker.com | sh # Rootless setup (optional, avoids root daemon) if command -v dockerd-rootless-setuptool.sh &>/dev/null; then info "Configuring rootless Docker..." dockerd-rootless-setuptool.sh install 2>/dev/null || warn "Rootless setup skipped." fi elif [[ "$PLATFORM" == "macos" ]]; then if command -v brew &>/dev/null; then info "Installing Docker via Homebrew..." brew install --cask docker info "Please open Docker.app to finish installation, then re-run: ./sandbox.sh setup" open /Applications/Docker.app 2>/dev/null || true exit 0 else die "Install Docker Desktop from https://docs.docker.com/desktop/mac/install/" fi fi else ok "Docker already installed: $(docker --version)" fi # 2. Verify Docker daemon if ! docker info &>/dev/null; then die "Docker daemon not running. Start Docker and retry." fi # 3. Optional: nsjail on Linux if [[ "$PLATFORM" == "linux" ]] && ! command -v nsjail &>/dev/null; then warn "nsjail not found (optional). For extra hardening: sudo apt install nsjail" elif has_nsjail; then ok "nsjail available: $(nsjail --version 2>&1 | head -1)" fi # 4. Create sandbox dirs mkdir -p "$WORKDIR_HOST" ok "Workspace: $WORKDIR_HOST" # Persistent config dirs — host-only, never world-readable mkdir -p \ "$CONFIG_DIR/claude" \ "$CONFIG_DIR/gh" \ "$CONFIG_DIR/gemini" chmod 700 "$CONFIG_DIR" "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" "$CONFIG_DIR/gemini" ok "Config dir: $CONFIG_DIR (chmod 700)" # 5. Build Docker image local build_dir build_dir="$(mktemp -d)" write_dockerfile "$build_dir" info "Building sandbox image ($SANDBOX_IMAGE)..." docker build -t "$SANDBOX_IMAGE" "$build_dir" --quiet rm -rf "$build_dir" ok "Image built: $SANDBOX_IMAGE" assert_host_untouched echo "" ok "Setup complete. Host network/DNS: unchanged." info "Run agent code: ./sandbox.sh run " info "Interactive: ./sandbox.sh shell" } cmd_run() { require_docker snapshot_host local script="${1:-}" [[ -z "$script" ]] && die "Usage: ./sandbox.sh run