Skip to content

Instantly share code, notes, and snippets.

@pgbezerra
Created June 9, 2026 00:03
Show Gist options
  • Select an option

  • Save pgbezerra/bd3f3fcafc1adf112b796114e3e9e2e1 to your computer and use it in GitHub Desktop.

Select an option

Save pgbezerra/bd3f3fcafc1adf112b796114e3e9e2e1 to your computer and use it in GitHub Desktop.
groundcrew sbx sandbox provisioning with proxy-isolated secrets (claude/codex/opencode)
# sbx proxy secrets — copy to config.env (gitignored) and fill in your values.
#
# cp config.env.example config.env
# $EDITOR config.env
# ./recreate-sandboxes.sh secrets # register at the proxy (ONCE; re-run to rotate)
# ./recreate-sandboxes.sh # create the sandboxes
#
# Values are registered at the sbx PROXY. Real tokens never enter a sandbox — the
# agent sees only sentinels, which the proxy substitutes on outbound traffic to the
# matching host.
#
# Each value is a plain token. To keep secrets in a manager instead, use a 1Password
# reference and resolve it at call time, e.g.
# AI_SANDBOX_LINEAR_TOKEN="op://<vault>/<item>/credential"
# op run --env-file=config.env -- ./recreate-sandboxes.sh secrets
#
# All optional — unset = that credential is skipped (GitHub falls back to sbx's
# built-in default; Claude falls back to interactive /login).
# GitHub PAT — overrides sbx's built-in github default.
# AI_SANDBOX_GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"
# Claude subscription session — from `claude setup-token` (subscription OAuth
# token, NOT an API key). Never set ANTHROPIC_API_KEY (switches to API billing).
AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat-xxxxxxxxxxxxxxxx"
# Linear MCP.
AI_SANDBOX_LINEAR_TOKEN="lin_api_xxxxxxxxxxxxxxxx"
# Slack MCP bot token (xoxb-…, read-only scopes).
# AI_SANDBOX_SLACK_MCP_XOXB_TOKEN="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx"
# Snowflake — PAT is secret; account is plain and selects the proxy host.
# AI_SANDBOX_SNOWFLAKE_ACCOUNT=org-account
# AI_SANDBOX_SNOWFLAKE_PASSWORD="xxxxxxxxxxxxxxxx"
# Datadog / pup — DD_SITE defaults to datadoghq.com (EU: datadoghq.eu).
# AI_SANDBOX_DD_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# AI_SANDBOX_DD_APP_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# AI_SANDBOX_DD_SITE=datadoghq.com
# npm read-only registry token (private package installs).
AI_SANDBOX_NPM_TOKEN="npm_xxxxxxxxxxxxxxxxxxxx"
# Buf Schema Registry token.
# AI_SANDBOX_BUF_TOKEN="xxxxxxxxxxxxxxxx"
#!/usr/bin/env bash
#
# recreate-sandboxes.sh — provision groundcrew sbx sandboxes with proxy-isolated
# secrets. Reproduces the sbx lifecycle groundcrew removed in commit 0669f6d,
# plus the proxy/sentinel secrets model from ../jamietm-ai-sandbox.
#
# USAGE
# # 1. One-time secret setup (register real tokens at the sbx proxy):
# cp config.env.example config.env && $EDITOR config.env # fill op:// refs
# op run --env-file=config.env -- ./recreate-sandboxes.sh secrets
#
# # 2. Create the groundcrew-<agent> sandboxes (pass your workspace dir —
# # where per-ticket worktrees are siblings; defaults to cwd):
# ./recreate-sandboxes.sh --dir ~/Documents/source/cbh-groundcrew
# ./recreate-sandboxes.sh --dir ~/path --force # drop + recreate
#
# # Rotate a secret later: re-run `secrets` (live, no recreate needed).
#
# SECRETS MODEL
# Real tokens are registered at the proxy with `sbx secret set-custom` and never
# enter a sandbox. Each credential's in-sandbox env var holds a deterministic
# non-secret SENTINEL (suffixed with a host-local id in .sbx-host-id); the proxy
# swaps the sentinel for the real value in outbound traffic to the matching host.
# GitHub/OpenAI use sbx's built-in services instead (no sentinel).
#
# COMMON OVERRIDES (env vars)
# PROJECT_DIR host dir mounted into the sandbox (or pass --dir; default: cwd)
# AGENTS agents to provision (default: "claude codex")
# AUTH_MODE "proxy" (default) | "interactive" (in-sandbox login)
# MIRROR_CONFIG copy curated host ~/.claude + ~/.codex into the box (default: true)
# TEMPLATE sbx --template override GIT_DEFAULTS (default: true)
#
set -euo pipefail
# ── Config ──────────────────────────────────────────────────────────────────
PROJECT_DIR="${PROJECT_DIR:-$PWD}" # overridable via --dir <path>; see Main
AGENTS="${AGENTS:-claude codex opencode}"
TEMPLATE="${TEMPLATE:-}"
KITS="${KITS:-}"
GIT_DEFAULTS="${GIT_DEFAULTS:-true}"
MIRROR_CONFIG="${MIRROR_CONFIG:-true}"
SANDBOX_HOME="${SANDBOX_HOME:-/home/agent}"
HOST_HOME="${HOST_HOME:-$HOME}"
AUTH_MODE="${AUTH_MODE:-proxy}"
CONFIG_ENV="${CONFIG_ENV:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.env}"
OVERLAY_KIT="${OVERLAY_KIT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.sbx-kit-local}"
HOST_ID_FILE="${HOST_ID_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.sbx-host-id}"
# plugins/ excluded: hundreds of MB of marketplace checkouts that overflow sbx
# cp's tar, with host-absolute symlinks that wouldn't resolve in the box.
CLAUDE_ALLOWLIST="CLAUDE.md MEMORY.md skills commands agents memory hooks"
# auth.json excluded — auth is proxy-managed.
CODEX_ALLOWLIST="config.toml AGENTS.md instructions.md prompts"
# opencode config lives under ~/.config/opencode; auth.json (and ~/.local/share)
# stays out — proxy-managed.
OPENCODE_ALLOWLIST="opencode.jsonc opencode.json AGENTS.md skills"
# ── Sentinels ─────────────────────────────────────────────────────────────────
# The same value is registered at the proxy (--placeholder) and injected into the
# sandbox (--env), so they must never drift. The host-local id keeps the sentinel
# unguessable and stable across rotations/recreates.
load_host_id() {
if [ -f "$HOST_ID_FILE" ]; then
HOST_ID="$(tr -d ' \t\r\n' < "$HOST_ID_FILE")"
fi
if [ -z "${HOST_ID:-}" ]; then
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
HOST_ID="$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
elif command -v uuidgen >/dev/null 2>&1; then
HOST_ID="$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' | cut -c1-16)"
else
echo "error: no source of randomness for .sbx-host-id" >&2; exit 1
fi
printf '%s\n' "$HOST_ID" > "$HOST_ID_FILE"
fi
SENTINEL_CLAUDE="claude-code-oauth-token-sentinel-${HOST_ID}"
SENTINEL_LINEAR="linear-api-token-sentinel-${HOST_ID}"
SENTINEL_SLACK="slack-bot-token-sentinel-${HOST_ID}"
SENTINEL_SNOWFLAKE="snowflake-pat-sentinel-${HOST_ID}"
SENTINEL_DD_API="datadog-api-key-sentinel-${HOST_ID}"
SENTINEL_DD_APP="datadog-app-key-sentinel-${HOST_ID}"
SENTINEL_NPM="npm-read-token-sentinel-${HOST_ID}"
SENTINEL_BUF="buf-token-sentinel-${HOST_ID}"
}
# ── Mode: secrets — register real tokens at the proxy ─────────────────────────
assert_resolved() {
local name="$1" val="${2-}"
if [ "${val#op://}" != "$val" ]; then
echo "error: $name is an unresolved op:// ref — wrap with: op run --env-file=config.env -- $0 secrets" >&2
exit 1
fi
}
register_proxy_secrets() {
load_host_id
if [ -n "${AI_SANDBOX_GITHUB_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_GITHUB_TOKEN "$AI_SANDBOX_GITHUB_TOKEN"
printf '%s' "$AI_SANDBOX_GITHUB_TOKEN" | sbx secret set -g github
echo "GitHub: proxy secret set (built-in github service)"
else
echo "GitHub: AI_SANDBOX_GITHUB_TOKEN unset — keeping sbx's built-in github default"
fi
# CLAUDE_CODE_OAUTH_TOKEN: subscription OAuth token from `claude setup-token`,
# NOT an API key. Never set ANTHROPIC_API_KEY — it outranks this and bills usage.
if [ -n "${AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN "$AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN"
sbx secret set-custom -g --host api.anthropic.com \
--env CLAUDE_CODE_OAUTH_TOKEN --placeholder "$SENTINEL_CLAUDE" \
--value "$AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN"
echo "Claude: proxy secret set (CLAUDE_CODE_OAUTH_TOKEN -> api.anthropic.com)"
else
echo "Claude: AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN unset — agent falls back to interactive /login"
fi
if [ -n "${AI_SANDBOX_LINEAR_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_LINEAR_TOKEN "$AI_SANDBOX_LINEAR_TOKEN"
sbx secret set-custom -g --host mcp.linear.app \
--env LINEAR_TOKEN --placeholder "$SENTINEL_LINEAR" \
--value "$AI_SANDBOX_LINEAR_TOKEN"
echo "Linear: proxy secret set (LINEAR_TOKEN -> mcp.linear.app)"
fi
if [ -n "${AI_SANDBOX_SLACK_MCP_XOXB_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_SLACK_MCP_XOXB_TOKEN "$AI_SANDBOX_SLACK_MCP_XOXB_TOKEN"
sbx secret set-custom -g --host slack.com \
--env SLACK_MCP_XOXB_TOKEN --placeholder "$SENTINEL_SLACK" \
--value "$AI_SANDBOX_SLACK_MCP_XOXB_TOKEN"
echo "Slack: proxy secret set (SLACK_MCP_XOXB_TOKEN -> slack.com)"
fi
# Snowflake account is non-secret but selects the proxy host.
if [ -n "${AI_SANDBOX_SNOWFLAKE_PASSWORD:-}" ]; then
assert_resolved AI_SANDBOX_SNOWFLAKE_PASSWORD "$AI_SANDBOX_SNOWFLAKE_PASSWORD"
: "${AI_SANDBOX_SNOWFLAKE_ACCOUNT:?set AI_SANDBOX_SNOWFLAKE_ACCOUNT alongside the PAT}"
sbx secret set-custom -g --host "$AI_SANDBOX_SNOWFLAKE_ACCOUNT.snowflakecomputing.com" \
--env SNOWFLAKE_PASSWORD --placeholder "$SENTINEL_SNOWFLAKE" \
--value "$AI_SANDBOX_SNOWFLAKE_PASSWORD"
echo "Snowflake: proxy secret set (SNOWFLAKE_PASSWORD -> $AI_SANDBOX_SNOWFLAKE_ACCOUNT.snowflakecomputing.com)"
fi
if [ -n "${AI_SANDBOX_DD_API_KEY:-}" ]; then
assert_resolved AI_SANDBOX_DD_API_KEY "$AI_SANDBOX_DD_API_KEY"
: "${AI_SANDBOX_DD_APP_KEY:?set AI_SANDBOX_DD_APP_KEY alongside AI_SANDBOX_DD_API_KEY}"
assert_resolved AI_SANDBOX_DD_APP_KEY "$AI_SANDBOX_DD_APP_KEY"
local dd_site="${AI_SANDBOX_DD_SITE:-datadoghq.com}"
sbx secret set-custom -g --host "api.$dd_site" \
--env DD_API_KEY --placeholder "$SENTINEL_DD_API" --value "$AI_SANDBOX_DD_API_KEY"
sbx secret set-custom -g --host "api.$dd_site" \
--env DD_APP_KEY --placeholder "$SENTINEL_DD_APP" --value "$AI_SANDBOX_DD_APP_KEY"
echo "Datadog: proxy secrets set (DD_API_KEY/DD_APP_KEY -> api.$dd_site)"
fi
if [ -n "${AI_SANDBOX_NPM_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_NPM_TOKEN "$AI_SANDBOX_NPM_TOKEN"
sbx secret set-custom -g --host registry.npmjs.org \
--env NPM_TOKEN --placeholder "$SENTINEL_NPM" --value "$AI_SANDBOX_NPM_TOKEN"
echo "npm: proxy secret set (NPM_TOKEN -> registry.npmjs.org)"
fi
if [ -n "${AI_SANDBOX_BUF_TOKEN:-}" ]; then
assert_resolved AI_SANDBOX_BUF_TOKEN "$AI_SANDBOX_BUF_TOKEN"
sbx secret set-custom -g --host buf.build \
--env BUF_TOKEN --placeholder "$SENTINEL_BUF" --value "$AI_SANDBOX_BUF_TOKEN"
echo "Buf: proxy secret set (BUF_TOKEN -> buf.build)"
fi
echo "Done. Proxy secrets are global and live — rotate by re-running this with new values."
}
# ── Overlay kit: bake sentinel env vars into the sandbox at create ────────────
# Reads config.env literally (no op resolution) to decide which env vars to inject;
# only configured services get one.
config_value() {
local key="$1" line val
[ -f "$CONFIG_ENV" ] || return 0
line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "$CONFIG_ENV" | tail -n1 || true)"
[ -n "$line" ] || return 0
val="${line#*=}"
case "$val" in
\"*\") val="${val#\"}"; val="${val%\"}" ;;
\'*\') val="${val#\'}"; val="${val%\'}" ;;
esac
printf '%s' "$val"
}
config_has() { [ -n "$(config_value "$1")" ]; }
OVERLAY_VARS_JSON=""
add_var() {
local sep=""
[ -n "$OVERLAY_VARS_JSON" ] && sep=","
OVERLAY_VARS_JSON="${OVERLAY_VARS_JSON}${sep}\"$1\":\"$2\""
}
render_overlay() {
load_host_id
OVERLAY_VARS_JSON=""
# Gating CLAUDE matters: it outranks /login, so a bogus always-on value would
# break `claude` for anyone who hasn't configured a token.
config_has AI_SANDBOX_CLAUDE_CODE_OAUTH_TOKEN && add_var CLAUDE_CODE_OAUTH_TOKEN "$SENTINEL_CLAUDE"
config_has AI_SANDBOX_LINEAR_TOKEN && add_var LINEAR_TOKEN "$SENTINEL_LINEAR"
config_has AI_SANDBOX_SLACK_MCP_XOXB_TOKEN && add_var SLACK_MCP_XOXB_TOKEN "$SENTINEL_SLACK"
config_has AI_SANDBOX_SNOWFLAKE_PASSWORD && add_var SNOWFLAKE_PASSWORD "$SENTINEL_SNOWFLAKE"
config_has AI_SANDBOX_DD_API_KEY && { add_var DD_API_KEY "$SENTINEL_DD_API"; add_var DD_APP_KEY "$SENTINEL_DD_APP"; }
config_has AI_SANDBOX_NPM_TOKEN && add_var NPM_TOKEN "$SENTINEL_NPM"
config_has AI_SANDBOX_BUF_TOKEN && add_var BUF_TOKEN "$SENTINEL_BUF"
# Non-secret values the tools need alongside the proxied secrets.
local acct user site
acct="$(config_value AI_SANDBOX_SNOWFLAKE_ACCOUNT)"; [ -n "$acct" ] && add_var SNOWFLAKE_ACCOUNT "$acct"
user="$(config_value AI_SANDBOX_SNOWFLAKE_USER)"; [ -n "$user" ] && add_var SNOWFLAKE_USER "$user"
site="$(config_value AI_SANDBOX_DD_SITE)"; [ -n "$site" ] && add_var DD_SITE "$site"
if [ -z "$OVERLAY_VARS_JSON" ]; then
echo "overlay: no configured secrets in $CONFIG_ENV — sandbox gets no sentinel env vars"
OVERLAY_KIT=""
return
fi
mkdir -p "$OVERLAY_KIT"
cat > "$OVERLAY_KIT/spec.yaml" <<EOF
{
"schemaVersion": "1",
"kind": "mixin",
"name": "groundcrew-sandbox-local",
"displayName": "groundcrew sandbox local secrets overlay",
"description": "Generated (gitignored): proxy-managed credential sentinels. Regenerated each create; do not edit.",
"environment": { "variables": { ${OVERLAY_VARS_JSON} } }
}
EOF
echo "overlay: wrote $OVERLAY_KIT/spec.yaml (env vars: $(printf '%s' "$OVERLAY_VARS_JSON" | grep -oE '"[A-Z_]+":' | tr -d '":' | paste -sd' ' -))"
}
# ── Create ────────────────────────────────────────────────────────────────────
sandbox_name_for() {
printf 'groundcrew-%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9.+-]+/-/g; s/-+/-/g; s/^-|-$//g'
}
sandbox_exists() {
sbx ls 2>/dev/null | awk '{print $1}' | grep -qxF "$1"
}
# Disable signing (no key in the box) and route github over HTTPS so the proxy's
# gh credential applies regardless of how the host cloned.
GIT_DEFAULT_COMMANDS='git config --global commit.gpgsign false && git config --global tag.gpgsign false && (git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true) && git config --global --add url."https://github.com/".insteadOf "git@github.com:" && git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"'
apply_git_defaults() {
local name="$1"
echo "${name}: applying git defaults..."
sbx exec "$name" sh -c "$GIT_DEFAULT_COMMANDS"
}
# rm -rf before each copy because `sbx cp` nests source INTO an existing dest
# (skills/ -> skills/skills/). Per-entry failures warn instead of aborting.
mirror_config() {
local name="$1" host_dir="$2" sandbox_dir="$3" allowlist="$4"
[ -d "$host_dir" ] || { echo "${name}: ${host_dir} absent, skipping mirror"; return; }
echo "${name}: mirroring ${host_dir} -> ${sandbox_dir}"
sbx exec "$name" mkdir -p "$sandbox_dir"
for entry in $allowlist; do
local host_path="${host_dir}/${entry}"
[ -e "$host_path" ] || continue
local sandbox_path="${sandbox_dir}/${entry}"
sbx exec "$name" rm -rf "$sandbox_path" || true
if ! sbx cp "$host_path" "${name}:${sandbox_path}"; then
echo "${name}: warning — failed to copy '${entry}', skipping" >&2
fi
done
}
mirror_agent_config() {
local agent="$1" name="$2"
case "$agent" in
claude) mirror_config "$name" "${HOST_HOME}/.claude" "${SANDBOX_HOME}/.claude" "$CLAUDE_ALLOWLIST" ;;
codex) mirror_config "$name" "${HOST_HOME}/.codex" "${SANDBOX_HOME}/.codex" "$CODEX_ALLOWLIST" ;;
opencode) mirror_config "$name" "${HOST_HOME}/.config/opencode" "${SANDBOX_HOME}/.config/opencode" "$OPENCODE_ALLOWLIST" ;;
*) echo "${name}: no config mirror for agent '${agent}', skipping" ;;
esac
}
# FORCE drops an existing sandbox first so the --kit overlay re-applies (the env
# vars are injected only at create).
ensure_sandbox() {
local agent="$1" name="$2"
if [ "${FORCE:-false}" = "true" ] && sandbox_exists "$name"; then
echo "${name}: --force, dropping existing sandbox..."
sbx rm --force "$name" || echo "${name}: warning — sbx rm failed" >&2
fi
if sandbox_exists "$name"; then
echo "${name}: already exists"
else
echo "${name}: creating (agent=${agent}, template=${TEMPLATE:-default})"
local args=(create --name "$name")
[ -n "$TEMPLATE" ] && args+=(--template "$TEMPLATE")
for kit in $KITS; do args+=(--kit "$kit"); done
args+=("$agent" "$PROJECT_DIR")
if ! sbx "${args[@]}"; then
sandbox_exists "$name" || { echo "${name}: create failed" >&2; exit 1; }
fi
echo "${name}: created"
fi
[ "$GIT_DEFAULTS" = "true" ] && apply_git_defaults "$name"
[ "$MIRROR_CONFIG" = "true" ] && mirror_agent_config "$agent" "$name"
}
auth_agent_interactive() {
local agent="$1" name="$2"
case "$agent" in
claude) echo "${name}: 'claude auth login'..."; sbx exec -it "$name" claude auth login ;;
codex) echo "${name}: 'codex login --device-auth'..."; sbx exec -it "$name" codex login --device-auth ;;
cursor) echo "${name}: 'cursor-agent login'..."; sbx exec -it -e NO_OPEN_BROWSER=1 "$name" cursor-agent login ;;
*) echo "${name}: no interactive recipe for '${agent}', skipping" >&2 ;;
esac
}
# ── Main ──────────────────────────────────────────────────────────────────────
command -v sbx >/dev/null || { echo "error: 'sbx' not on PATH" >&2; exit 1; }
MODE="create"
FORCE="false"
USAGE="usage: $0 [create|secrets] [--dir <path>] [--force]"
while [ $# -gt 0 ]; do
case "$1" in
--force) FORCE="true" ;;
--dir) shift; [ $# -gt 0 ] || { echo "$USAGE" >&2; exit 2; }; PROJECT_DIR="$1" ;;
--dir=*) PROJECT_DIR="${1#--dir=}" ;;
secrets|create) MODE="$1" ;;
*) echo "$USAGE" >&2; exit 2 ;;
esac
shift
done
if [ "$MODE" = "secrets" ]; then
register_proxy_secrets
exit 0
fi
[ -d "$PROJECT_DIR" ] || { echo "error: PROJECT_DIR '$PROJECT_DIR' not a dir" >&2; exit 1; }
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
echo "Project dir (mount): $PROJECT_DIR"
echo "Agents: $AGENTS Auth mode: $AUTH_MODE"
[ "$AUTH_MODE" = "proxy" ] && echo "(proxy mode: run '$0 secrets' first so tokens live at the proxy, not in the sandbox)"
if [ "$AUTH_MODE" = "proxy" ]; then
render_overlay
[ -n "$OVERLAY_KIT" ] && KITS="$OVERLAY_KIT${KITS:+ $KITS}"
fi
echo
for agent in $AGENTS; do
name="$(sandbox_name_for "$agent")"
echo "════ ${agent} -> ${name} ════"
ensure_sandbox "$agent" "$name"
if [ "$AUTH_MODE" = "interactive" ]; then
auth_agent_interactive "$agent" "$name"
else
echo "${name}: proxy auth — sentinels injected by registered proxy secrets, no in-sandbox login"
fi
echo
done
echo "Done. Verify with: sbx ls Proxy secrets: sbx secret ls"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment