From 0a6a0b8a0253021d8b2a8fc80c704790b79bf048 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Sun, 22 Feb 2026 22:52:20 +0100 Subject: [PATCH] Update sandbox.sh Co-authored-by: Cursor --- sandbox.sh | 107 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 34 deletions(-) diff --git a/sandbox.sh b/sandbox.sh index 8993fdc..4390fde 100755 --- a/sandbox.sh +++ b/sandbox.sh @@ -70,6 +70,9 @@ write_dockerfile() { FROM ubuntu:24.04 ARG DEBIAN_FRONTEND=noninteractive +# Match host uid/gid so bind-mounted config dirs are writable inside the container +ARG AGENT_UID=1000 +ARG AGENT_GID=1000 # System packages RUN apt-get update -qq && apt-get install -y --no-install-recommends \ @@ -98,22 +101,30 @@ 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 +# Sandbox user — uid/gid matches the host user so bind-mounted config dirs are writable. +# First evict any existing user/group that occupies the target uid/gid (ubuntu:24.04 +# ships a pre-created 'ubuntu' user at uid/gid 1000 which must be removed if AGENT_UID=1000). +RUN \ + eu=$(getent passwd "${AGENT_UID}" | cut -d: -f1); \ + [ -n "$eu" ] && userdel -r "$eu" 2>/dev/null || true; \ + eg=$(getent group "${AGENT_GID}" | cut -d: -f1); \ + [ -n "$eg" ] && groupdel "$eg" 2>/dev/null || true; \ + groupadd -g "${AGENT_GID}" agent && \ + useradd -u "${AGENT_UID}" -g agent -m -d /home/agent agent # Workspace dir -RUN mkdir -p /workspace && chown agent:agent /workspace +RUN mkdir -p /workspace && chown "${AGENT_UID}:${AGENT_GID}" /workspace USER agent ENV HOME=/home/agent -# Stub dirs for bind/tmpfs mounts (must exist before runtime mounts) +# Stub dirs — must exist in the image so bind/tmpfs mounts have a mountpoint RUN mkdir -p \ /home/agent/.claude \ /home/agent/.cache \ /home/agent/.config/gh \ /home/agent/.config/gemini \ + /home/agent/.gemini \ /home/agent/.local/state # gh Copilot extension — baked into image; read-only at runtime is fine @@ -169,11 +180,16 @@ cmd_setup() { 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)" + # 3. Optional extra hardening on Linux + if [[ "$PLATFORM" == "linux" ]]; then + if has_firejail; then + ok "firejail available: $(firejail --version 2>&1 | head -1)" + elif has_nsjail; then + ok "nsjail available: $(nsjail --version 2>&1 | head -1)" + else + warn "No extra hardening tool found (optional)." + warn " Install firejail: sudo apt install firejail" + fi fi # 4. Create sandbox dirs @@ -184,16 +200,26 @@ cmd_setup() { mkdir -p \ "$CONFIG_DIR/claude" \ "$CONFIG_DIR/gh" \ - "$CONFIG_DIR/gemini" - chmod 700 "$CONFIG_DIR" "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" "$CONFIG_DIR/gemini" + "$CONFIG_DIR/gemini" \ + "$CONFIG_DIR/gemini_home" + chmod 700 "$CONFIG_DIR" \ + "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" \ + "$CONFIG_DIR/gemini" "$CONFIG_DIR/gemini_home" + # claude also writes a top-level ~/.claude.json (outside ~/.claude/) — pre-create it + # so Docker bind-mounts a file, not a directory + [[ -f "$CONFIG_DIR/claude.json" ]] || echo '{}' > "$CONFIG_DIR/claude.json" + chmod 600 "$CONFIG_DIR/claude.json" ok "Config dir: $CONFIG_DIR (chmod 700)" - # 5. Build Docker image + # 5. Build Docker image (pass host uid/gid so bind mounts are writable) 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 + docker build -t "$SANDBOX_IMAGE" \ + --build-arg AGENT_UID="$(id -u)" \ + --build-arg AGENT_GID="$(id -g)" \ + "$build_dir" --quiet rm -rf "$build_dir" ok "Image built: $SANDBOX_IMAGE" @@ -234,6 +260,9 @@ cmd_run() { *) runner="" ;; # executable or unknown esac + local _uid _gid + _uid="$(id -u)"; _gid="$(id -g)" + local docker_args=( docker run --rm @@ -243,33 +272,36 @@ cmd_run() { --memory-swap "$MEM_LIMIT" # no swap --read-only # read-only rootfs --tmpfs /tmp:size=128m,noexec # writable tmp, no exec - --tmpfs /home/agent/.cache:size=128m,uid=999,gid=999 - --tmpfs /home/agent/.local/state:size=64m,uid=999,gid=999 + "--tmpfs=/home/agent/.cache:size=128m,uid=${_uid},gid=${_gid}" + "--tmpfs=/home/agent/.local/state:size=64m,uid=${_uid},gid=${_gid}" --cap-drop ALL # drop all Linux capabilities --security-opt no-new-privileges # no privilege escalation --security-opt seccomp=unconfined --pids-limit 128 # no fork bombs -v "${script_dir}:/workspace:ro" # mount script dir read-only -v "${CONFIG_DIR}/claude:/home/agent/.claude:rw" + -v "${CONFIG_DIR}/claude.json:/home/agent/.claude.json:rw" -v "${CONFIG_DIR}/gh:/home/agent/.config/gh:rw" -v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw" + -v "${CONFIG_DIR}/gemini_home:/home/agent/.gemini:rw" --workdir /workspace "$SANDBOX_IMAGE" ) - # Optional nsjail wrapper on Linux - if has_nsjail && [[ "$PLATFORM" == "linux" ]]; then - info " Extra: nsjail wrapping enabled" - # nsjail wraps the entire docker run call for an extra namespace layer - # Note: this requires nsjail in PATH and appropriate permissions - exec nsjail \ - --mode o \ - --quiet \ - --rlimit_nofile 64 \ - --rlimit_nproc 32 \ - --time_limit 300 \ - --disable_clone_newcgroup \ - -- "${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}" + # Optional extra hardening wrapper on Linux (firejail preferred, nsjail fallback) + if [[ "$PLATFORM" == "linux" ]]; then + if has_firejail; then + info " Extra: firejail wrapping enabled" + exec firejail --quiet --noprofile \ + -- "${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}" + elif has_nsjail; then + info " Extra: nsjail wrapping enabled" + exec nsjail \ + --mode o --quiet \ + --rlimit_nofile 64 --rlimit_nproc 32 \ + --time_limit 300 --disable_clone_newcgroup \ + -- "${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}" + fi fi "${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}" @@ -286,7 +318,11 @@ cmd_shell() { [[ "$NETWORK_MODE" == "none" ]] && warn " Internet is BLOCKED (set NETWORK_MODE=bridge to allow)" mkdir -p "$WORKDIR_HOST" - mkdir -p "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" "$CONFIG_DIR/gemini" + mkdir -p "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" "$CONFIG_DIR/gemini" "$CONFIG_DIR/gemini_home" + [[ -f "$CONFIG_DIR/claude.json" ]] || echo '{}' > "$CONFIG_DIR/claude.json" + + local _uid _gid + _uid="$(id -u)"; _gid="$(id -g)" docker run \ --rm -it \ @@ -296,16 +332,19 @@ cmd_shell() { --memory-swap "$MEM_LIMIT" \ --read-only \ --tmpfs /tmp:size=128m,noexec \ - --tmpfs /home/agent/.cache:size=128m,uid=999,gid=999 \ - --tmpfs /home/agent/.local/state:size=64m,uid=999,gid=999 \ - --tmpfs /workspace:size=256m,uid=999,gid=999 \ + --tmpfs "/home/agent/.cache:size=128m,uid=${_uid},gid=${_gid}" \ + --tmpfs "/home/agent/.local/state:size=64m,uid=${_uid},gid=${_gid}" \ + --tmpfs "/workspace:size=256m,uid=${_uid},gid=${_gid}" \ --cap-drop ALL \ --security-opt no-new-privileges \ --pids-limit 128 \ + -e "TERM=${TERM:-xterm-256color}" \ -v "${WORKDIR_HOST}:/mnt/workspace:rw" \ -v "${CONFIG_DIR}/claude:/home/agent/.claude:rw" \ + -v "${CONFIG_DIR}/claude.json:/home/agent/.claude.json:rw" \ -v "${CONFIG_DIR}/gh:/home/agent/.config/gh:rw" \ -v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw" \ + -v "${CONFIG_DIR}/gemini_home:/home/agent/.gemini:rw" \ "$SANDBOX_IMAGE" \ /bin/bash @@ -375,7 +414,7 @@ cmd_help() { NETWORK_MODE=bridge ./sandbox.sh run fetch_data.py ${YELLOW}Platforms:${NC} - Linux: Docker + optional nsjail hardening + Linux: Docker + optional firejail hardening (sudo apt install firejail) macOS: Docker only EOF