101
sandbox.sh
101
sandbox.sh
@@ -70,6 +70,9 @@ write_dockerfile() {
|
|||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
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
|
# System packages
|
||||||
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
|
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 \
|
@anthropic-ai/claude-code \
|
||||||
@google/gemini-cli
|
@google/gemini-cli
|
||||||
|
|
||||||
# Non-root sandbox user (fixed uid/gid 999)
|
# Sandbox user — uid/gid matches the host user so bind-mounted config dirs are writable.
|
||||||
RUN groupadd -r -g 999 agent \
|
# First evict any existing user/group that occupies the target uid/gid (ubuntu:24.04
|
||||||
&& useradd -r -u 999 -g agent -m -d /home/agent agent
|
# 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
|
# Workspace dir
|
||||||
RUN mkdir -p /workspace && chown agent:agent /workspace
|
RUN mkdir -p /workspace && chown "${AGENT_UID}:${AGENT_GID}" /workspace
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
ENV HOME=/home/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 \
|
RUN mkdir -p \
|
||||||
/home/agent/.claude \
|
/home/agent/.claude \
|
||||||
/home/agent/.cache \
|
/home/agent/.cache \
|
||||||
/home/agent/.config/gh \
|
/home/agent/.config/gh \
|
||||||
/home/agent/.config/gemini \
|
/home/agent/.config/gemini \
|
||||||
|
/home/agent/.gemini \
|
||||||
/home/agent/.local/state
|
/home/agent/.local/state
|
||||||
|
|
||||||
# gh Copilot extension — baked into image; read-only at runtime is fine
|
# 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."
|
die "Docker daemon not running. Start Docker and retry."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Optional: nsjail on Linux
|
# 3. Optional extra hardening on Linux
|
||||||
if [[ "$PLATFORM" == "linux" ]] && ! command -v nsjail &>/dev/null; then
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
warn "nsjail not found (optional). For extra hardening: sudo apt install nsjail"
|
if has_firejail; then
|
||||||
|
ok "firejail available: $(firejail --version 2>&1 | head -1)"
|
||||||
elif has_nsjail; then
|
elif has_nsjail; then
|
||||||
ok "nsjail available: $(nsjail --version 2>&1 | head -1)"
|
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
|
fi
|
||||||
|
|
||||||
# 4. Create sandbox dirs
|
# 4. Create sandbox dirs
|
||||||
@@ -184,16 +200,26 @@ cmd_setup() {
|
|||||||
mkdir -p \
|
mkdir -p \
|
||||||
"$CONFIG_DIR/claude" \
|
"$CONFIG_DIR/claude" \
|
||||||
"$CONFIG_DIR/gh" \
|
"$CONFIG_DIR/gh" \
|
||||||
"$CONFIG_DIR/gemini"
|
"$CONFIG_DIR/gemini" \
|
||||||
chmod 700 "$CONFIG_DIR" "$CONFIG_DIR/claude" "$CONFIG_DIR/gh" "$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)"
|
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
|
local build_dir
|
||||||
build_dir="$(mktemp -d)"
|
build_dir="$(mktemp -d)"
|
||||||
write_dockerfile "$build_dir"
|
write_dockerfile "$build_dir"
|
||||||
info "Building sandbox image ($SANDBOX_IMAGE)..."
|
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"
|
rm -rf "$build_dir"
|
||||||
ok "Image built: $SANDBOX_IMAGE"
|
ok "Image built: $SANDBOX_IMAGE"
|
||||||
|
|
||||||
@@ -234,6 +260,9 @@ cmd_run() {
|
|||||||
*) runner="" ;; # executable or unknown
|
*) runner="" ;; # executable or unknown
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
local _uid _gid
|
||||||
|
_uid="$(id -u)"; _gid="$(id -g)"
|
||||||
|
|
||||||
local docker_args=(
|
local docker_args=(
|
||||||
docker run
|
docker run
|
||||||
--rm
|
--rm
|
||||||
@@ -243,33 +272,36 @@ cmd_run() {
|
|||||||
--memory-swap "$MEM_LIMIT" # no swap
|
--memory-swap "$MEM_LIMIT" # no swap
|
||||||
--read-only # read-only rootfs
|
--read-only # read-only rootfs
|
||||||
--tmpfs /tmp:size=128m,noexec # writable tmp, no exec
|
--tmpfs /tmp:size=128m,noexec # writable tmp, no exec
|
||||||
--tmpfs /home/agent/.cache:size=128m,uid=999,gid=999
|
"--tmpfs=/home/agent/.cache:size=128m,uid=${_uid},gid=${_gid}"
|
||||||
--tmpfs /home/agent/.local/state:size=64m,uid=999,gid=999
|
"--tmpfs=/home/agent/.local/state:size=64m,uid=${_uid},gid=${_gid}"
|
||||||
--cap-drop ALL # drop all Linux capabilities
|
--cap-drop ALL # drop all Linux capabilities
|
||||||
--security-opt no-new-privileges # no privilege escalation
|
--security-opt no-new-privileges # no privilege escalation
|
||||||
--security-opt seccomp=unconfined
|
--security-opt seccomp=unconfined
|
||||||
--pids-limit 128 # no fork bombs
|
--pids-limit 128 # no fork bombs
|
||||||
-v "${script_dir}:/workspace:ro" # mount script dir read-only
|
-v "${script_dir}:/workspace:ro" # mount script dir read-only
|
||||||
-v "${CONFIG_DIR}/claude:/home/agent/.claude: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}/gh:/home/agent/.config/gh:rw"
|
||||||
-v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw"
|
-v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw"
|
||||||
|
-v "${CONFIG_DIR}/gemini_home:/home/agent/.gemini:rw"
|
||||||
--workdir /workspace
|
--workdir /workspace
|
||||||
"$SANDBOX_IMAGE"
|
"$SANDBOX_IMAGE"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional nsjail wrapper on Linux
|
# Optional extra hardening wrapper on Linux (firejail preferred, nsjail fallback)
|
||||||
if has_nsjail && [[ "$PLATFORM" == "linux" ]]; then
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
info " Extra: nsjail wrapping enabled"
|
if has_firejail; then
|
||||||
# nsjail wraps the entire docker run call for an extra namespace layer
|
info " Extra: firejail wrapping enabled"
|
||||||
# Note: this requires nsjail in PATH and appropriate permissions
|
exec firejail --quiet --noprofile \
|
||||||
exec nsjail \
|
|
||||||
--mode o \
|
|
||||||
--quiet \
|
|
||||||
--rlimit_nofile 64 \
|
|
||||||
--rlimit_nproc 32 \
|
|
||||||
--time_limit 300 \
|
|
||||||
--disable_clone_newcgroup \
|
|
||||||
-- "${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}"
|
-- "${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
|
fi
|
||||||
|
|
||||||
"${docker_args[@]}" ${runner:+$runner} "$script_file" "${@:2}"
|
"${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)"
|
[[ "$NETWORK_MODE" == "none" ]] && warn " Internet is BLOCKED (set NETWORK_MODE=bridge to allow)"
|
||||||
|
|
||||||
mkdir -p "$WORKDIR_HOST"
|
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 \
|
docker run \
|
||||||
--rm -it \
|
--rm -it \
|
||||||
@@ -296,16 +332,19 @@ cmd_shell() {
|
|||||||
--memory-swap "$MEM_LIMIT" \
|
--memory-swap "$MEM_LIMIT" \
|
||||||
--read-only \
|
--read-only \
|
||||||
--tmpfs /tmp:size=128m,noexec \
|
--tmpfs /tmp:size=128m,noexec \
|
||||||
--tmpfs /home/agent/.cache:size=128m,uid=999,gid=999 \
|
--tmpfs "/home/agent/.cache:size=128m,uid=${_uid},gid=${_gid}" \
|
||||||
--tmpfs /home/agent/.local/state:size=64m,uid=999,gid=999 \
|
--tmpfs "/home/agent/.local/state:size=64m,uid=${_uid},gid=${_gid}" \
|
||||||
--tmpfs /workspace:size=256m,uid=999,gid=999 \
|
--tmpfs "/workspace:size=256m,uid=${_uid},gid=${_gid}" \
|
||||||
--cap-drop ALL \
|
--cap-drop ALL \
|
||||||
--security-opt no-new-privileges \
|
--security-opt no-new-privileges \
|
||||||
--pids-limit 128 \
|
--pids-limit 128 \
|
||||||
|
-e "TERM=${TERM:-xterm-256color}" \
|
||||||
-v "${WORKDIR_HOST}:/mnt/workspace:rw" \
|
-v "${WORKDIR_HOST}:/mnt/workspace:rw" \
|
||||||
-v "${CONFIG_DIR}/claude:/home/agent/.claude: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}/gh:/home/agent/.config/gh:rw" \
|
||||||
-v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw" \
|
-v "${CONFIG_DIR}/gemini:/home/agent/.config/gemini:rw" \
|
||||||
|
-v "${CONFIG_DIR}/gemini_home:/home/agent/.gemini:rw" \
|
||||||
"$SANDBOX_IMAGE" \
|
"$SANDBOX_IMAGE" \
|
||||||
/bin/bash
|
/bin/bash
|
||||||
|
|
||||||
@@ -375,7 +414,7 @@ cmd_help() {
|
|||||||
NETWORK_MODE=bridge ./sandbox.sh run fetch_data.py
|
NETWORK_MODE=bridge ./sandbox.sh run fetch_data.py
|
||||||
|
|
||||||
${YELLOW}Platforms:${NC}
|
${YELLOW}Platforms:${NC}
|
||||||
Linux: Docker + optional nsjail hardening
|
Linux: Docker + optional firejail hardening (sudo apt install firejail)
|
||||||
macOS: Docker only
|
macOS: Docker only
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user