Files
asandbox/asandbox
T

671 lines
20 KiB
Bash

#!/usr/bin/env bash
# asandbox — run any CLI coding agent inside Docker with host FS integration
# Works on macOS (Docker Desktop / OrbStack) and Linux.
set -euo pipefail
VERSION="1.1.0"
# Program root = folder where this script lives (e.g. /Users/macbook/asandbox)
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE_NAME="${ASANDBOX_IMAGE:-asandbox:latest}"
SHARE_DIR="${ASANDBOX_SHARE:-${ROOT_DIR}}"
CONFIG_DIR="${ASANDBOX_CONFIG_DIR:-${ROOT_DIR}/config}"
CONFIG_FILE="${CONFIG_DIR}/config.env"
DEFAULT_NETWORK="${ASANDBOX_NETWORK:-bridge}"
DEFAULT_MEMORY="${ASANDBOX_MEMORY:-4g}"
DEFAULT_CPUS="${ASANDBOX_CPUS:-4}"
# ── colors ──────────────────────────────────────────────────────────
if [[ -t 2 ]]; then
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YLW=$'\033[33m'
C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'; C_BLD=$'\033[1m'
else
C_RED=; C_GRN=; C_YLW=; C_BLU=; C_DIM=; C_RST=; C_BLD=
fi
die() { echo "${C_RED}asandbox:${C_RST} $*" >&2; exit 1; }
info() { echo "${C_BLU}asandbox:${C_RST} $*" >&2; }
ok() { echo "${C_GRN}asandbox:${C_RST} $*" >&2; }
warn() { echo "${C_YLW}asandbox:${C_RST} $*" >&2; }
# ── usage ───────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
asandbox — run CLI AI agents in Docker with host filesystem integration
USAGE
asandbox <command> [options] [--] [agent-args...]
COMMANDS
run <agent|cmd...> Run an agent or arbitrary command in the sandbox
shell Interactive bash inside the sandbox
installagent <name> Install an agent inside the sandbox (claude, cursor, …)
build Build (or rebuild) the sandbox Docker image
agents List known agent presets / installagent catalog
config Show / create config
doctor Check Docker, image, mounts
version Print version
help Show this help
OPTIONS (run / shell)
-w, --workdir DIR Host directory to mount (default: git root or cwd)
-n, --name NAME Container name (default: auto)
--network MODE none | bridge | host (default: bridge)
--memory SIZE Memory limit (default: 4g)
--cpus N CPU limit (default: 4)
--read-only Read-only root filesystem (+tmpfs /tmp)
--no-mount-home Do not mount agent config dirs from host home
--mount-ssh Mount ~/.ssh (read-only) for git over SSH
--mount-gitconfig Mount ~/.gitconfig (read-only)
--mount PATH[:CONT] Extra bind mount (repeatable). CONT defaults to PATH
--env KEY=VAL Pass env var (repeatable)
--env-file FILE Pass env file
--pass-env KEY Pass host env KEY if set (repeatable)
--image NAME Override image (default: asandbox:latest)
--rm / --keep Remove container on exit (default) / keep it
--detach, -d Run in background
-q, --quiet Less noise
--dry-run Print docker command and exit
AGENT PRESETS / installagent
claude, codex, gemini, antigravity (agy), cursor (agent),
opencode, aider, qwen, amp, goose, continue, bash
Inside shell: installagent list | installagent claude
Or any command: asandbox run -- node script.js
EXAMPLES
asandbox build
asandbox shell
asandbox installagent list
asandbox installagent claude
asandbox installagent cursor
asandbox installagent antigravity
asandbox installagent opencode
asandbox run claude
asandbox run claude "explain this project"
asandbox run -w ~/proj codex
asandbox run --network none --read-only -- rg "TODO"
asandbox run --mount-ssh -- git pull
asandbox run --env ANTHROPIC_API_KEY=sk-... claude
SECURITY
• No Docker socket is mounted (agents cannot escape via Docker).
• Runs as non-root user inside the container.
• Capabilities dropped; no-new-privileges enabled.
• Host project directory is bind-mounted (same absolute path on Mac).
• Agent configs (~/.claude, ~/.codex, …) mounted so auth/state persist.
• Use --network none for fully offline runs when possible.
CONFIG
Program folder: same directory as this script (default ~/asandbox)
config/config.env optional defaults
Dockerfile, installagent, persist/ live next to the script
EOF
}
# ── config ──────────────────────────────────────────────────────────
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$CONFIG_FILE"; set +a
fi
}
write_default_config() {
mkdir -p "$CONFIG_DIR"
if [[ -f "$CONFIG_FILE" ]]; then
info "config already exists: $CONFIG_FILE"
return 0
fi
cat > "$CONFIG_FILE" <<'EOF'
# asandbox defaults (bash-compatible env file)
# ASANDBOX_IMAGE=asandbox:latest
# ASANDBOX_NETWORK=bridge
# ASANDBOX_MEMORY=4g
# ASANDBOX_CPUS=4
# ASANDBOX_MOUNT_SSH=0
# ASANDBOX_MOUNT_GITCONFIG=1
# ASANDBOX_QUIET=0
# Extra env keys always forwarded when set on host:
# ASANDBOX_PASS_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GOOGLE_API_KEY XAI_API_KEY"
EOF
ok "wrote $CONFIG_FILE"
}
# ── helpers ─────────────────────────────────────────────────────────
require_docker() {
command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker Desktop or OrbStack."
docker info >/dev/null 2>&1 || die "Docker daemon not running. Start Docker Desktop / OrbStack."
}
image_exists() {
docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
}
resolve_workdir() {
local dir="${1:-}"
if [[ -n "$dir" ]]; then
(cd "$dir" && pwd -P)
return
fi
# Prefer git root when inside a repo
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
pwd -P
}
# Map agent name → argv inside container
resolve_agent_cmd() {
local agent="$1"
shift || true
case "$agent" in
claude|claude-code)
echo "claude"; printf '%s\n' "$@"
;;
codex|openai-codex)
echo "codex"; printf '%s\n' "$@"
;;
gemini|gemini-cli)
echo "gemini"; printf '%s\n' "$@"
;;
antigravity|agy|antigravity-cli)
echo "agy"; printf '%s\n' "$@"
;;
cursor|cursor-cli|cursor-agent)
echo "agent"; printf '%s\n' "$@"
;;
opencode|open-code)
echo "opencode"; printf '%s\n' "$@"
;;
aider|aider-chat)
echo "aider"; printf '%s\n' "$@"
;;
qwen|qwen-code)
echo "qwen"; printf '%s\n' "$@"
;;
amp)
echo "amp"; printf '%s\n' "$@"
;;
goose)
echo "goose"; printf '%s\n' "$@"
;;
continue|cn)
echo "cn"; printf '%s\n' "$@"
;;
installagent|install-agent)
echo "installagent"; printf '%s\n' "$@"
;;
bash|sh|shell)
if [[ $# -eq 0 ]]; then
echo "bash"; echo "-l"
else
echo "bash"; echo "-lc"; echo "$*"
fi
;;
*)
# Treat first token as command
echo "$agent"; printf '%s\n' "$@"
;;
esac
}
# Known host config dirs to bind-mount (host → container path under $HOME)
agent_home_mounts() {
# format: host_relpath
cat <<'EOF'
.claude
.codex
.config/gh
.gemini
.grok
.cursor
.local/share/claude
.npm
.antigravity
.agy
.config/opencode
.config/aider
EOF
}
# Persistent install roots (survive --rm containers)
ensure_persist_dirs() {
mkdir -p \
"${SHARE_DIR}/persist/.local/bin" \
"${SHARE_DIR}/persist/.npm-global/bin" \
"${SHARE_DIR}/persist/.cache"
}
# Env keys commonly needed by agents
default_pass_env_keys() {
echo "ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL GEMINI_API_KEY GOOGLE_API_KEY GOOGLE_GENAI_API_KEY XAI_API_KEY GROK_API_KEY CURSOR_API_KEY GITHUB_TOKEN GH_TOKEN TERM COLORTERM LANG LC_ALL EDITOR"
}
# ── build ───────────────────────────────────────────────────────────
cmd_build() {
require_docker
local no_cache=0 force=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-cache) no_cache=1; shift ;;
--force|-f) force=1; shift ;;
-h|--help) echo "Usage: asandbox build [--no-cache] [--force]"; return 0 ;;
*) die "unknown build option: $1" ;;
esac
done
[[ -f "$SHARE_DIR/Dockerfile" ]] || die "Dockerfile not found at $SHARE_DIR/Dockerfile"
local args=(build -t "$IMAGE_NAME" -f "$SHARE_DIR/Dockerfile" "$SHARE_DIR")
if [[ $no_cache -eq 1 ]]; then
args+=(--no-cache)
fi
# Pass host UID/GID so files created in mounts match macOS user
args+=(--build-arg "USER_UID=$(id -u)" --build-arg "USER_GID=$(id -g)")
info "building image ${C_BLD}${IMAGE_NAME}${C_RST} from $SHARE_DIR"
docker "${args[@]}"
ok "image ready: $IMAGE_NAME"
}
# ── doctor ──────────────────────────────────────────────────────────
cmd_doctor() {
echo "${C_BLD}asandbox doctor${C_RST} (v$VERSION)"
echo
if command -v docker >/dev/null 2>&1; then
ok "docker CLI: $(command -v docker)"
else
die "docker CLI missing"
fi
if docker info >/dev/null 2>&1; then
local ctx
ctx=$(docker context show 2>/dev/null || echo unknown)
ok "docker daemon: running (context: $ctx)"
else
die "docker daemon not running"
fi
if image_exists; then
ok "image: $IMAGE_NAME present"
else
warn "image: $IMAGE_NAME missing — run: asandbox build"
fi
echo
echo "paths:"
echo " share: $SHARE_DIR"
echo " config: $CONFIG_FILE"
echo " home: $HOME"
echo " cwd: $(pwd -P)"
if git rev-parse --show-toplevel >/dev/null 2>&1; then
echo " git: $(git rev-parse --show-toplevel)"
fi
echo
echo "platform: $(uname -s)/$(uname -m)"
echo "uid/gid: $(id -u):$(id -g)"
if [[ -f "$SHARE_DIR/Dockerfile" ]]; then
ok "Dockerfile found"
else
warn "Dockerfile missing at $SHARE_DIR"
fi
}
# ── agents list ─────────────────────────────────────────────────────
cmd_agents() {
cat <<'EOF'
Agent presets (run via asandbox <name> or asandbox run <name>):
claude Anthropic Claude Code
codex OpenAI Codex CLI
gemini Google Gemini CLI
antigravity Google Antigravity CLI (binary: agy)
cursor Cursor Agent CLI (binary: agent)
opencode OpenCode
aider Aider
qwen Qwen Code
amp Sourcegraph Amp
goose Block Goose
continue Continue CLI (binary: cn)
bash Interactive shell
Install inside sandbox (persisted under <program>/persist):
asandbox installagent list
asandbox installagent claude
asandbox installagent cursor
asandbox installagent antigravity
asandbox installagent opencode
asandbox installagent all
# or inside shell:
asandbox shell
$ installagent list
$ installagent gemini
Host config dirs auto-mounted (if present):
~/.claude ~/.codex ~/.gemini ~/.grok ~/.cursor ~/.config/gh
~/.antigravity ~/.agy ~/.config/opencode
EOF
}
# ── installagent (host wrapper → container) ─────────────────────────
cmd_installagent() {
if [[ $# -eq 0 ]]; then
set -- list
fi
# Run installagent inside sandbox with network (needed to download)
cmd_run -q --network bridge -- installagent "$@"
}
# ── run / shell ─────────────────────────────────────────────────────
cmd_run() {
require_docker
local workdir="" container_name="" network="$DEFAULT_NETWORK"
local memory="$DEFAULT_MEMORY" cpus="$DEFAULT_CPUS"
local read_only=0 mount_home=1 mount_ssh=0 mount_gitconfig=1
local rm_flag=1 detach=0 quiet=0 dry_run=0
local -a extra_mounts=() env_args=() pass_envs=() env_files=()
local -a raw_cmd=()
# defaults from env/config
[[ "${ASANDBOX_MOUNT_SSH:-0}" == "1" ]] && mount_ssh=1
[[ "${ASANDBOX_MOUNT_GITCONFIG:-1}" == "0" ]] && mount_gitconfig=0
[[ "${ASANDBOX_QUIET:-0}" == "1" ]] && quiet=1
# Parse ASANDBOX_PASS_ENV
if [[ -n "${ASANDBOX_PASS_ENV:-}" ]]; then
# shellcheck disable=SC2206
pass_envs+=($ASANDBOX_PASS_ENV)
fi
# shellcheck disable=SC2206
pass_envs+=($(default_pass_env_keys))
while [[ $# -gt 0 ]]; do
case "$1" in
-w|--workdir) workdir="${2:-}"; shift 2 ;;
-n|--name) container_name="${2:-}"; shift 2 ;;
--network) network="${2:-}"; shift 2 ;;
--memory) memory="${2:-}"; shift 2 ;;
--cpus) cpus="${2:-}"; shift 2 ;;
--read-only) read_only=1; shift ;;
--no-mount-home) mount_home=0; shift ;;
--mount-ssh) mount_ssh=1; shift ;;
--mount-gitconfig) mount_gitconfig=1; shift ;;
--no-mount-gitconfig) mount_gitconfig=0; shift ;;
--mount)
extra_mounts+=("${2:-}")
shift 2
;;
--env|-e)
env_args+=(--env "${2:-}")
shift 2
;;
--env-file)
env_files+=(--env-file "${2:-}")
shift 2
;;
--pass-env)
pass_envs+=("${2:-}")
shift 2
;;
--image) IMAGE_NAME="${2:-}"; shift 2 ;;
--rm) rm_flag=1; shift ;;
--keep) rm_flag=0; shift ;;
-d|--detach) detach=1; shift ;;
-q|--quiet) quiet=1; shift ;;
--dry-run) dry_run=1; shift ;;
-h|--help) usage; return 0 ;;
--) shift; raw_cmd+=("$@"); break ;;
-*)
die "unknown option: $1 (try --help)"
;;
*)
raw_cmd+=("$@")
break
;;
esac
done
if ! image_exists; then
warn "image $IMAGE_NAME not found — building now..."
cmd_build
fi
local host_wd
host_wd=$(resolve_workdir "$workdir")
[[ -d "$host_wd" ]] || die "workdir does not exist: $host_wd"
# Resolve command
local -a cmd=()
if [[ ${#raw_cmd[@]} -eq 0 ]]; then
cmd=(bash)
else
# read resolve_agent_cmd output into array
local line
while IFS= read -r line; do
cmd+=("$line")
done < <(resolve_agent_cmd "${raw_cmd[@]}")
fi
# Unique name
if [[ -z "$container_name" ]]; then
local base slug
base=$(basename "$host_wd" | tr -c 'a-zA-Z0-9._-' '-' | tr '[:upper:]' '[:lower:]')
slug=$(echo "${cmd[0]}" | tr -c 'a-zA-Z0-9' '-')
container_name="asandbox-${base}-${slug}-$$"
# docker name length limit
container_name="${container_name:0:60}"
fi
local -a docker_args=(
run
--name "$container_name"
--hostname "asandbox"
--network "$network"
--memory "$memory"
--cpus "$cpus"
--security-opt no-new-privileges:true
--cap-drop ALL
--cap-add CHOWN
--cap-add SETUID
--cap-add SETGID
--cap-add DAC_OVERRIDE
--cap-add FOWNER
--cap-add NET_BIND_SERVICE
# Interactive when stdin is a TTY and not detached
)
if [[ $rm_flag -eq 1 ]]; then
docker_args+=(--rm)
fi
if [[ $detach -eq 1 ]]; then
docker_args+=(-d)
else
docker_args+=(-i)
if [[ -t 0 && -t 1 ]]; then
docker_args+=(-t)
fi
fi
if [[ $read_only -eq 1 ]]; then
docker_args+=(--read-only --tmpfs /tmp:rw,exec,nosuid,size=1g --tmpfs /home/agent:rw,exec,nosuid,size=512m)
fi
# ── filesystem integration ──────────────────────────────────────
# 1) Mount workspace at the SAME absolute path (critical for Mac path fidelity)
docker_args+=(-v "${host_wd}:${host_wd}:rw")
# 2) Also expose as /workspace for convenience
docker_args+=(-v "${host_wd}:/workspace:rw")
docker_args+=(-w "$host_wd")
docker_args+=(-e "ASANDBOX_WORKDIR=$host_wd")
docker_args+=(-e "ASANDBOX_HOST_OS=$(uname -s)")
docker_args+=(-e "ASANDBOX_QUIET=$quiet")
# 2b) Persist installagent packages across container runs
ensure_persist_dirs
docker_args+=(-v "${SHARE_DIR}/persist/.local:/home/agent/.local:rw")
docker_args+=(-v "${SHARE_DIR}/persist/.npm-global:/home/agent/.npm-global:rw")
docker_args+=(-v "${SHARE_DIR}/persist/.cache:/home/agent/.cache:rw")
# 3) Agent config / state from host home
if [[ $mount_home -eq 1 ]]; then
local rel host_path cont_path
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
host_path="${HOME}/${rel}"
cont_path="/home/agent/${rel}"
if [[ -e "$host_path" ]]; then
# ensure parent exists in container via mount of the path itself
docker_args+=(-v "${host_path}:${cont_path}:rw")
fi
done < <(agent_home_mounts)
fi
# 4) SSH keys (optional, read-only)
if [[ $mount_ssh -eq 1 && -d "${HOME}/.ssh" ]]; then
docker_args+=(-v "${HOME}/.ssh:/home/agent/.ssh:ro")
fi
# 5) gitconfig
if [[ $mount_gitconfig -eq 1 && -f "${HOME}/.gitconfig" ]]; then
docker_args+=(-v "${HOME}/.gitconfig:/home/agent/.gitconfig:ro")
fi
# 6) Extra mounts
local m host_m cont_m
for m in "${extra_mounts[@]+"${extra_mounts[@]}"}"; do
if [[ "$m" == *:* ]]; then
host_m="${m%%:*}"
cont_m="${m#*:}"
else
host_m="$m"
cont_m="$m"
fi
# resolve host side if relative
if [[ "$host_m" != /* ]]; then
host_m="$(cd "$(dirname "$host_m")" && pwd -P)/$(basename "$host_m")"
fi
docker_args+=(-v "${host_m}:${cont_m}")
done
# 7) Pass through env (bash 3.2-compatible dedupe)
local key seen_env=" "
for key in "${pass_envs[@]+"${pass_envs[@]}"}"; do
[[ -z "$key" ]] && continue
case "$seen_env" in
*" ${key} "*) continue ;;
esac
seen_env+="${key} "
# indirect expansion works in bash 3.2
if [[ -n "${!key:-}" ]]; then
docker_args+=(-e "${key}=${!key}")
fi
done
# Git author from host
local gname gemail
gname=$(git config --global user.name 2>/dev/null || true)
gemail=$(git config --global user.email 2>/dev/null || true)
[[ -n "$gname" ]] && docker_args+=(-e "GIT_AUTHOR_NAME=$gname" -e "GIT_COMMITTER_NAME=$gname")
[[ -n "$gemail" ]] && docker_args+=(-e "GIT_AUTHOR_EMAIL=$gemail" -e "GIT_COMMITTER_EMAIL=$gemail")
docker_args+=("${env_args[@]+"${env_args[@]}"}")
docker_args+=("${env_files[@]+"${env_files[@]}"}")
# Label for cleanup
docker_args+=(--label "asandbox=1" --label "asandbox.workdir=${host_wd}")
docker_args+=("$IMAGE_NAME")
docker_args+=("${cmd[@]}")
if [[ $quiet -eq 0 ]]; then
info "workdir ${C_DIM}${host_wd}${C_RST}"
info "network ${C_DIM}${network}${C_RST} memory=${memory} cpus=${cpus}"
info "command ${C_DIM}${cmd[*]}${C_RST}"
fi
if [[ $dry_run -eq 1 ]]; then
echo "docker $(printf '%q ' "${docker_args[@]}")"
return 0
fi
# Never mount docker.sock — explicit safety check
local a
for a in "${docker_args[@]}"; do
if [[ "$a" == *docker.sock* ]]; then
die "refusing to mount docker.sock"
fi
done
exec docker "${docker_args[@]}"
}
cmd_shell() {
cmd_run "$@" -- bash
}
cmd_config() {
case "${1:-show}" in
show|cat)
if [[ -f "$CONFIG_FILE" ]]; then
echo "# $CONFIG_FILE"
cat "$CONFIG_FILE"
else
warn "no config yet — run: asandbox config init"
fi
;;
init|create)
write_default_config
;;
path)
echo "$CONFIG_FILE"
;;
*)
die "usage: asandbox config [show|init|path]"
;;
esac
}
# ── main ────────────────────────────────────────────────────────────
main() {
load_config
local cmd="${1:-}"
if [[ -z "$cmd" ]]; then
usage
exit 0
fi
shift || true
case "$cmd" in
run) cmd_run "$@" ;;
shell|sh|bash) cmd_shell "$@" ;;
installagent|install-agent|ia)
cmd_installagent "$@"
;;
build) cmd_build "$@" ;;
agents|list) cmd_agents "$@" ;;
config) cmd_config "$@" ;;
doctor) cmd_doctor "$@" ;;
version|--version|-V) echo "asandbox $VERSION" ;;
help|-h|--help) usage ;;
# Convenience: asandbox claude ... → asandbox run claude ...
claude|claude-code|codex|gemini|gemini-cli|aider|antigravity|agy|cursor|opencode|qwen|amp|goose)
cmd_run "$cmd" "$@"
;;
*)
# Unknown subcommand: treat as agent/command name
cmd_run "$cmd" "$@"
;;
esac
}
main "$@"