Files
ai-sandbox/tasks/ai/sbx
T
mroberts 93dc61a024 Provision repository registry credentials as sandbox secrets
Repositories need registry tokens to install dependencies, and those live
behind 1Password or a keychain that only exists on the host. A secrets file in
the user's per-repository config names each variable, the hosts it
authenticates to, and a command that prints it; the command runs on the host
from the repository root and its output becomes an sbx custom secret.

Custom secrets keep the value out of the sandbox entirely: the environment
variable is set to a placeholder and the proxy substitutes the real secret into
outbound request headers for the declared hosts. A committed .npmrc using
${VAR} interpolation therefore works unchanged while the agent sees only the
placeholder. Placeholders are derived from the repository and variable name so
re-running setup does not invalidate one already exported into a running
sandbox, and the value is piped rather than passed as --value, which would put
it in the process list.

The declaration lives in user config rather than the repository for the same
reason AWS profile approval does: a checkout must not choose which host
commands run or which credentials resolve.

A failed resolver has its own stderr surfaced, since it names where to obtain
the credential, and the remaining secrets still provision.

sbx secret set-custom was measured to overwrite silently and has no --force
flag, so the non-interactive test now matches sbx secret set precisely rather
than by prefix.
2026-07-31 11:42:36 -05:00

1255 lines
36 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
PROGRAM="ai:sbx"
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
DEFAULT_AGENT="${AI_SBX_AGENT:-claude}"
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}"
# Only these leave the host. ~/.claude also holds OAuth credentials, shell
# snapshots and conversation transcripts, so this is an allowlist rather than
# a list of exclusions: anything added to ~/.claude later stays put by default.
#
# skills is absent deliberately: sbx mounts its own shared skills store over
# that path, so it is seeded with "sbx skills import" instead of copied.
CLAUDE_CONFIG_ALLOW=(
CLAUDE.md
AGENTS.md
agents
commands
hooks
)
# GitHub accepts these as query parameters on the token creation form. A write
# level implies read, so only the highest level is listed. "workflows" is
# required to push any commit touching .github/workflows and is separate from
# "actions".
TOKEN_URL_PERMISSIONS=(
metadata=read
contents=write
pull_requests=write
issues=write
workflows=write
actions=write
statuses=read
security_events=write
secret_scanning_alerts=read
vulnerability_alerts=read
)
# Fine-grained tokens cannot reach the Checks API at all: GitHub's permission
# reference has no Checks section and lists no check-run endpoint, so there is
# no box to tick. Both calls below degrade to empty output rather than a
# permission error, which reads as broken CI unless it is called out.
TOKEN_LIMITATIONS=(
"gh pr checks shows only commit statuses, not check runs"
"gh run view returns no annotations"
)
usage() {
cat <<'EOF'
Usage:
mise run ai:sbx -- setup [options]
mise run ai:sbx -- token
mise run ai:sbx -- refresh
mise run ai:sbx -- config
mise run ai:sbx -- run [-- agent arguments...]
mise run ai:sbx -- status
mise run ai:sbx -- remove
Setup opens a pre-filled GitHub token form in your browser. Use "token" on
its own to replace an expired or revoked token later.
Set AI_SBX_TEMPLATE in your mise config to reuse one custom image across
every repository without repeating --template.
For the claude agent, setup copies your user-level configuration (CLAUDE.md,
AGENTS.md, agents, commands, hooks, skills) into the sandbox and installs the
marketplaces and plugins your host has enabled. Credentials, transcripts and
history are never copied. Use "config" to re-apply after the host changes.
Registry credentials are declared per repository in the user's config
directory as a "secrets" file, one line of VAR|host|command each. The command
runs on the host and its output becomes an sbx custom secret, so the sandbox
sees a placeholder and the proxy substitutes the real value.
AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
to bun because several Claude plugins run their hooks under it. Set it to an
empty string to install none.
Setup and run install mise in the sandbox and resolve the repository's
pinned tools, so the agent runs the same versions you do. A personal
mise config that must stay out of the repository goes in the per-repository
config directory as mise.local.toml; it is copied to the workspace root on
every run. Repositories without any mise configuration are left alone.
Setup options:
--aws-profile NAME Host AWS profile to expose inside the sandbox.
May be supplied more than once. A trailing
-readonly is stripped from the profile name
written into the sandbox.
--agent NAME Sandbox agent. Default: claude
--direct Mount the host working tree read-write.
--clone Give the agent a private in-container clone
of the repository, mounted read-only.
Its commits reach the host through the
sandbox-<name> git remote. This is the default.
--template REF Custom sandbox image. Defaults to
AI_SBX_TEMPLATE when set.
--stock-template Ignore AI_SBX_TEMPLATE and use the agent's
stock image.
--kit PATH Mixin kit to apply. May be supplied more
than once.
--replace Replace the existing sandbox.
Examples:
mise run ai:sbx -- setup \
--aws-profile api-portal-readonly \
--aws-profile prod-readonly
mise run ai:sbx -- run
mise run ai:sbx -- run -- --dangerously-bypass-approvals-and-sandbox \
"Review the Terraform plan"
EOF
}
die() {
printf '%s: %s\n' "$PROGRAM" "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 ||
die "Required command not found: $1"
}
url_encode() {
local string="$1" index character encoded=""
for ((index = 0; index < ${#string}; index++)); do
character="${string:index:1}"
case "$character" in
[a-zA-Z0-9.~_-])
encoded+="$character"
;;
*)
printf -v character '%%%02X' "'$character"
encoded+="$character"
;;
esac
done
printf '%s' "$encoded"
}
token_url() {
local owner="${REPOSITORY%%/*}"
local name="${REPOSITORY#*/}"
local url="https://github.com/settings/personal-access-tokens/new"
url+="?name=$(url_encode "ai-sbx $name")"
url+="&description=$(url_encode "AI agent sandbox for $REPOSITORY")"
url+="&target_name=$(url_encode "$owner")"
url+="&expires_in=$(url_encode "$DEFAULT_TOKEN_DAYS")"
local permission
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
url+="&$permission"
done
printf '%s' "$url"
}
open_browser() {
local url="$1" opener
for opener in "${BROWSER:-}" xdg-open open; do
[[ -n "$opener" ]] || continue
if command -v "$opener" >/dev/null 2>&1; then
"$opener" "$url" >/dev/null 2>&1 &
return 0
fi
done
return 1
}
read_token() {
local token
# -s keeps the token off the terminal; it never reaches shell history
# because it is read into a variable rather than typed as an argument.
IFS= read -rsp 'Paste token: ' token </dev/tty
printf '\n' >&2
[[ -n "$token" ]] ||
die "No token entered."
case "$token" in
github_pat_*) ;;
ghp_*)
die "That is a classic token. Generate a fine-grained token from the link above."
;;
*)
die "That does not look like a fine-grained token (expected a github_pat_ prefix)."
;;
esac
printf '%s' "$token"
}
install_github_token() {
local url
url="$(token_url)"
cat >&2 <<EOF
Create a fine-grained token for $REPOSITORY.
Everything except the repository is pre-filled. On the page:
1. Repository access -> Only select repositories -> ${REPOSITORY#*/}
2. Generate token, then paste it below.
Every permission is pre-filled. Fine-grained tokens cannot read check runs,
so inside the sandbox:
EOF
local limitation
for limitation in "${TOKEN_LIMITATIONS[@]}"; do
printf ' %s\n' "$limitation" >&2
done
cat >&2 <<'EOF'
A 403 will name what it wanted in the X-Accepted-GitHub-Permissions header.
EOF
if open_browser "$url"; then
printf 'Opened your browser.\n\n' >&2
else
printf 'Open this link:\n\n%s\n\n' "$url" >&2
fi
# --force is mandatory: without it a second write prompts for confirmation,
# reads the prompt from the already-consumed stdin, cancels, and still
# exits 0 - leaving the previous, expired token in place.
read_token |
sbx secret set --force "$SANDBOX_NAME" github >/dev/null
printf 'Stored the token for %s in sandbox %s.\n' "$REPOSITORY" "$SANDBOX_NAME" >&2
}
# Terraform and provider blocks reference the account profile name, while the
# host distinguishes the read-only grant with a -readonly suffix. The suffix is
# a host-side naming convention, so it is stripped on the way into the sandbox.
sandbox_profile_name() {
local profile="$1"
local mapped="${profile%-readonly}"
[[ -n "$mapped" ]] ||
die "AWS profile name is empty after stripping -readonly: $profile"
printf '%s' "$mapped"
}
# A task included from the global mise config runs with the config root as its
# working directory ($HOME), not the directory the user invoked it from, so the
# repository would otherwise be undiscoverable from anywhere.
enter_invocation_directory() {
local invoked_from="${MISE_ORIGINAL_CWD:-$PWD}"
cd "$invoked_from" ||
die "Could not enter the invoking directory: $invoked_from"
}
repository_context() {
enter_invocation_directory
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" ||
die "This command must be run inside a Git repository."
local remote
remote="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null)" ||
die "The repository has no origin remote."
case "$remote" in
[email protected]:*)
REPOSITORY="${remote#[email protected]:}"
;;
ssh://[email protected]/*)
REPOSITORY="${remote#ssh://[email protected]/}"
;;
https://github.com/*)
REPOSITORY="${remote#https://github.com/}"
;;
http://github.com/*)
REPOSITORY="${remote#http://github.com/}"
;;
*)
die "Unsupported GitHub origin: $remote"
;;
esac
REPOSITORY="${REPOSITORY%.git}"
REPOSITORY="${REPOSITORY%/}"
[[ "$REPOSITORY" =~ ^[^/]+/[^/]+$ ]] ||
die "Could not derive owner/repository from origin: $remote"
local slug
slug="$(
printf '%s' "$REPOSITORY" |
tr '[:upper:]' '[:lower:]' |
tr '/_' '--' |
tr -cd 'a-z0-9.-'
)"
# Include a short digest to avoid collisions caused by normalization.
local digest
digest="$(
printf '%s' "$REPOSITORY" |
sha256sum |
cut -c1-10
)"
SANDBOX_NAME="ai-${slug}-${digest}"
REPO_CONFIG_DIR="$CONFIG_ROOT/repos/$digest"
REPO_CONFIG_FILE="$REPO_CONFIG_DIR/config"
}
sandbox_exists() {
sbx ls --quiet 2>/dev/null |
grep -Fxq "$SANDBOX_NAME"
}
load_config() {
[[ -f "$REPO_CONFIG_FILE" ]] ||
die "Repository is not configured. Run: mise run ai:sbx -- setup"
# This file is user-owned, mode 600, and contains no credentials.
# shellcheck disable=SC1090
source "$REPO_CONFIG_FILE"
[[ "${CONFIG_REPOSITORY:-}" == "$REPOSITORY" ]] ||
die "Repository configuration does not match the current origin."
[[ -n "${CONFIG_AGENT:-}" ]] ||
die "Agent is missing from $REPO_CONFIG_FILE"
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
CONFIG_AWS_PROFILES=()
declare -p CONFIG_KITS >/dev/null 2>&1 ||
CONFIG_KITS=()
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-}"
}
save_config() {
local agent="$1"
local mode="$2"
local template="$3"
local kit_count="$4"
shift 4
local -a kits=("${@:1:kit_count}")
local -a profiles=("${@:kit_count + 1}")
mkdir -p "$REPO_CONFIG_DIR"
chmod 700 "$CONFIG_ROOT" "$CONFIG_ROOT/repos" "$REPO_CONFIG_DIR" 2>/dev/null || true
{
printf 'CONFIG_REPOSITORY=%q\n' "$REPOSITORY"
printf 'CONFIG_SANDBOX=%q\n' "$SANDBOX_NAME"
printf 'CONFIG_AGENT=%q\n' "$agent"
printf 'CONFIG_MODE=%q\n' "$mode"
printf 'CONFIG_TEMPLATE=%q\n' "$template"
printf 'CONFIG_KITS=('
local kit
for kit in ${kits[@]+"${kits[@]}"}; do
printf ' %q' "$kit"
done
printf ' )\n'
printf 'CONFIG_AWS_PROFILES=('
local profile
for profile in "${profiles[@]}"; do
printf ' %q' "$profile"
done
printf ' )\n'
} >"$REPO_CONFIG_FILE"
chmod 600 "$REPO_CONFIG_FILE"
}
validate_aws_profile() {
local profile="$1"
aws configure list-profiles | grep -Fxq "$profile" ||
die "AWS profile does not exist on the host: $profile"
printf 'Validating AWS profile %s...\n' "$profile" >&2
if ! aws sts get-caller-identity \
--profile "$profile" \
--output json \
>/dev/null; then
printf '\nAWS authentication failed for profile %s.\n' "$profile" >&2
printf 'Run:\n\n aws sso login --profile %q\n\n' "$profile" >&2
exit 1
fi
}
# Two host profiles mapping to the same sandbox name would silently write two
# sections with one identity, so reject it before any credentials are exported.
validate_profile_mapping() {
local -A claimed_by=()
local profile mapped
for profile in "$@"; do
mapped="$(sandbox_profile_name "$profile")"
if [[ -n "${claimed_by[$mapped]:-}" ]]; then
die "AWS profiles ${claimed_by[$mapped]} and $profile both map to sandbox profile $mapped"
fi
claimed_by["$mapped"]="$profile"
done
}
write_aws_files() {
load_config
local output_dir="$1"
local config_file="$output_dir/config"
local credentials_file="$output_dir/credentials"
validate_profile_mapping "${CONFIG_AWS_PROFILES[@]}"
mkdir -p "$output_dir"
chmod 700 "$output_dir"
: >"$config_file"
: >"$credentials_file"
local profile
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
validate_aws_profile "$profile"
local sandbox_profile
sandbox_profile="$(sandbox_profile_name "$profile")"
local credential_json
credential_json="$(
aws configure export-credentials \
--profile "$profile" \
--format process
)"
local access_key secret_key session_token expiration region output
access_key="$(jq -er '.AccessKeyId' <<<"$credential_json")"
secret_key="$(jq -er '.SecretAccessKey' <<<"$credential_json")"
session_token="$(jq -er '.SessionToken' <<<"$credential_json")"
expiration="$(jq -er '.Expiration // empty' <<<"$credential_json" || true)"
region="$(
aws configure get region --profile "$profile" 2>/dev/null ||
true
)"
output="$(
aws configure get output --profile "$profile" 2>/dev/null ||
true
)"
region="${region:-us-east-1}"
output="${output:-json}"
cat >>"$config_file" <<EOF
[profile $sandbox_profile]
region = $region
output = $output
EOF
cat >>"$credentials_file" <<EOF
[$sandbox_profile]
aws_access_key_id = $access_key
aws_secret_access_key = $secret_key
aws_session_token = $session_token
EOF
printf 'Exported %-30s as %-30s expires %s\n' \
"$profile" \
"$sandbox_profile" \
"${expiration:-unknown}" >&2
unset credential_json access_key secret_key session_token
done
chmod 600 "$config_file" "$credentials_file"
}
install_sandbox_aws_files() {
load_config
if ((${#CONFIG_AWS_PROFILES[@]} == 0)); then
printf 'No AWS profiles configured; skipping AWS credential refresh.\n'
return
fi
require_command aws
require_command jq
local temporary_directory
temporary_directory="$(mktemp -d)"
trap 'rm -rf "$temporary_directory"' RETURN
write_aws_files "$temporary_directory"
# The agent user differs between sandbox images, so ask rather than assume.
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" \
bash -c 'mkdir -p "$HOME/.aws" && chmod 700 "$HOME/.aws"'
sbx cp \
"$temporary_directory/config" \
"$SANDBOX_NAME:$sandbox_home/.aws/config"
sbx cp \
"$temporary_directory/credentials" \
"$SANDBOX_NAME:$sandbox_home/.aws/credentials"
# Every expansion below belongs to the sandbox shell, not the host.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
chmod 700 "$HOME/.aws"
chmod 600 "$HOME/.aws/config" "$HOME/.aws/credentials"
persistent=/etc/sandbox-persistent.sh
marker="# BEGIN ai-sbx AWS configuration"
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
exit 0
fi
cat >>"$persistent" <<'"'"'EOF'"'"'
# BEGIN ai-sbx AWS configuration
export AWS_CONFIG_FILE="$HOME/.aws/config"
export AWS_SHARED_CREDENTIALS_FILE="$HOME/.aws/credentials"
export AWS_SDK_LOAD_CONFIG=1
export AWS_EC2_METADATA_DISABLED=true
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
unset AWS_SECURITY_TOKEN
# END ai-sbx AWS configuration
EOF
'
rm -rf "$temporary_directory"
trap - RETURN
printf 'Installed isolated AWS profiles in sandbox %s:\n' "$SANDBOX_NAME"
local profile
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
printf ' %s -> %s\n' "$profile" "$(sandbox_profile_name "$profile")"
done
}
# Not @tsv: tab is an IFS whitespace character, so read collapses the empty
# field an entry without a repo produces and shifts the URL into it.
host_marketplaces() {
jq -r '
to_entries[]
| [
.key,
(.value.source.source // ""),
(.value.source.repo // ""),
(.value.source.url // "")
]
| join("|")
' "$1"
}
host_enabled_plugins() {
jq -r '(.enabledPlugins // {}) | to_entries[] | select(.value) | .key' "$1"
}
marketplace_url() {
local source_kind="$1"
local repo="$2"
local url="$3"
case "$source_kind" in
github)
[[ -n "$repo" ]] || return 1
printf 'https://github.com/%s.git' "$repo"
;;
git)
[[ -n "$url" ]] || return 1
printf '%s' "$url"
;;
*)
return 1
;;
esac
}
# sbx cp places a source directory *inside* an existing destination directory,
# so a repeat copy would nest hooks/hooks. Clearing the target first keeps this
# idempotent.
copy_claude_config_item() {
local item="$1"
local sandbox_home="$2"
local target="$sandbox_home/.claude/$item"
# sbx mounts parts of ~/.claude from its own stores. Those paths belong to
# sbx, and removing one fails with EBUSY partway through the copy.
if sbx exec "$SANDBOX_NAME" \
bash -c "mountpoint -q $(printf '%q' "$target")" 2>/dev/null; then
printf 'Skipping %s: managed by sbx inside the sandbox.\n' "$item" >&2
return 0
fi
sbx exec "$SANDBOX_NAME" \
bash -c "rm -rf $(printf '%q' "$target")"
sbx cp "$CLAUDE_HOME/$item" "$SANDBOX_NAME:$sandbox_home/.claude/"
}
install_sandbox_claude_config() {
if [[ "$CONFIG_AGENT" != claude ]]; then
printf 'Agent is %s, not claude; skipping Claude configuration.\n' \
"$CONFIG_AGENT"
return 0
fi
if [[ ! -d "$CLAUDE_HOME" ]]; then
printf 'No %s on the host; skipping Claude configuration.\n' \
"$CLAUDE_HOME" >&2
return 0
fi
require_command jq
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.claude"'
local item
for item in "${CLAUDE_CONFIG_ALLOW[@]}"; do
[[ -e "$CLAUDE_HOME/$item" ]] || continue
copy_claude_config_item "$item" "$sandbox_home"
printf 'Copied %s into %s.\n' "$item" "$SANDBOX_NAME"
done
# The store is shared by every sandbox, so this seeds all of them at once.
if [[ -d "$CLAUDE_HOME/skills" ]]; then
# --force is mandatory: the store is shared, so a second setup finds
# skills already there and prompts per skill. With output redirected
# the prompt is invisible and setup hangs on stdin forever.
sbx skills import --force </dev/null >/dev/null 2>&1 ||
printf 'Could not import skills into the shared store.\n' >&2
fi
install_sandbox_claude_plugins
}
install_sandbox_claude_plugins() {
local known="$CLAUDE_HOME/plugins/known_marketplaces.json"
local settings="$CLAUDE_HOME/settings.json"
if [[ ! -f "$known" || ! -f "$settings" ]]; then
printf 'No plugin manifest on the host; skipping plugin install.\n' >&2
return 0
fi
# A fresh sandbox knows no marketplaces at all, including the official one
# the host acquires on first run, so every marketplace is added explicitly.
# Not @tsv: tab is an IFS whitespace character, so read collapses the empty
# field an entry without a repo produces and shifts the URL into it.
local name source_kind repo url
while IFS='|' read -r name source_kind repo url; do
[[ -n "$name" ]] || continue
local marketplace
if ! marketplace="$(marketplace_url "$source_kind" "$repo" "$url")"; then
printf 'Skipping marketplace %s: unsupported source %s\n' \
"$name" "$source_kind" >&2
continue
fi
# Only github.com is reachable under the default network policy.
local host
host="${marketplace#https://}"
host="${host%%/*}"
if [[ "$host" != "github.com" ]]; then
sbx policy allow network \
--sandbox "$SANDBOX_NAME" "$host" \
</dev/null >/dev/null 2>&1 || true
fi
# Without </dev/null sbx exec drains the loop's input and only the
# first marketplace is ever processed.
sbx exec "$SANDBOX_NAME" \
claude plugin marketplace add "$marketplace" \
</dev/null >/dev/null 2>&1 ||
printf 'Could not add marketplace %s (%s).\n' \
"$name" "$marketplace" >&2
done < <(host_marketplaces "$known")
local plugin
while read -r plugin; do
[[ -n "$plugin" ]] || continue
if sbx exec "$SANDBOX_NAME" \
claude plugin install "$plugin" </dev/null >/dev/null 2>&1; then
printf 'Installed plugin %s\n' "$plugin"
else
printf 'Could not install plugin %s\n' "$plugin" >&2
fi
done < <(host_enabled_plugins "$settings")
}
# A repository declares which registry credentials it needs, but the declaration
# lives in the user's own config rather than the repository, so a checkout can
# never choose which host commands run or which secrets get resolved.
#
# VAR | host[,host...] | command printing the value on stdout
#
# The command runs on the host, from the repository root, where 1Password and
# the developer's keychain are available.
read_secret_declarations() {
local file="$REPO_CONFIG_DIR/secrets"
[[ -f "$file" ]] || return 0
local line var hosts command
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
[[ -n "${line//[[:space:]]/}" ]] || continue
IFS='|' read -r var hosts command <<<"$line"
var="$(printf '%s' "$var" | xargs)"
hosts="$(printf '%s' "$hosts" | xargs)"
command="$(printf '%s' "$command" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -n "$var" && -n "$hosts" && -n "$command" ]] || {
printf 'Ignoring malformed secret declaration: %s\n' "$line" >&2
continue
}
printf '%s|%s|%s\n' "$var" "$hosts" "$command"
done <"$file"
}
# Derived rather than random so re-running setup does not invalidate the value
# already exported inside a running sandbox. The placeholder is not a secret;
# it is the stand-in the proxy swaps for one.
secret_placeholder() {
local var="$1" digest
digest="$(printf '%s' "$REPOSITORY/$var" | sha256sum | cut -c1-16)"
printf 'sbx-cs-%s' "$digest"
}
install_sandbox_secrets() {
local declarations
declarations="$(read_secret_declarations)" || return 0
[[ -n "$declarations" ]] || return 0
local var hosts command value placeholder
local -a host_args
while IFS='|' read -r var hosts command; do
[[ -n "$var" ]] || continue
# The resolver prints guidance to stderr naming where to obtain the
# credential, which is more useful than anything this task could add.
if ! value="$(cd "$REPO_ROOT" && eval "$command" 2>&1)"; then
printf 'Could not resolve %s:\n%s\n' "$var" "$value" >&2
continue
fi
[[ -n "$value" ]] || {
printf 'Resolver for %s printed nothing.\n' "$var" >&2
continue
}
placeholder="$(secret_placeholder "$var")"
host_args=()
local host
for host in ${hosts//,/ }; do
host_args+=(--host "$host")
done
# Piped rather than --value: the secret would otherwise be visible in
# the process list to anything running as this user.
if printf '%s' "$value" |
sbx secret set-custom "$SANDBOX_NAME" \
"${host_args[@]}" \
--env "$var" \
--placeholder "$placeholder" >/dev/null 2>&1; then
printf 'Provisioned %s for %s\n' "$var" "${hosts//,/, }"
else
printf 'Could not store %s in the sandbox.\n' "$var" >&2
continue
fi
# A sandbox that already exists keeps whatever environment it was
# created with, so the placeholder is exported explicitly.
sbx exec "$SANDBOX_NAME" bash -c "
persistent=/etc/sandbox-persistent.sh
marker=$(printf '%q' "# ai-sbx secret $var")
grep -Fq \"\$marker\" \"\$persistent\" 2>/dev/null && exit 0
printf '%s\nexport %s=%s\n' \
\"\$marker\" $(printf '%q' "$var") $(printf '%q' "$placeholder") \
>>\"\$persistent\"
" </dev/null >/dev/null 2>&1 || true
unset value
done <<<"$declarations"
}
# Plugins bring their own runtime requirements - claude-mem and others run
# their hooks under bun, which the sandbox image does not carry - and a missing
# one surfaces as a hook error on every prompt rather than at install time.
install_sandbox_tools() {
local sandbox_home="$1"
[[ -n "$DEFAULT_TOOLS" ]] || return 0
local -a tools
read -r -a tools <<<"$DEFAULT_TOOLS"
((${#tools[@]})) || return 0
printf 'Installing sandbox tools: %s\n' "${tools[*]}"
# mise resolves these from GitHub releases, which the default network
# policy already allows. $HOME and $@ belong to the sandbox shell.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -lc '
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
mise use -g "$@" && mise reshim
' _ "${tools[@]}" </dev/null >/dev/null 2>&1 ||
printf 'Could not install sandbox tools: %s\n' "${tools[*]}" >&2
}
install_sandbox_mise() {
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# mise.jdx.dev sits outside the default network policy, so the sandbox gets
# the host binary rather than the network installer. This also pins the
# agent to the same mise version the host runs.
# shellcheck disable=SC2016
if ! sbx exec "$SANDBOX_NAME" \
bash -c 'command -v mise >/dev/null || [[ -x "$HOME/.local/bin/mise" ]]'; then
local host_mise
if ! host_mise="$(command -v mise)"; then
printf 'mise is not on the host PATH; skipping sandbox mise setup.\n' >&2
return 0
fi
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.local/bin"'
sbx cp "$host_mise" "$SANDBOX_NAME:$sandbox_home/.local/bin/mise"
fi
install_sandbox_tools "$sandbox_home"
local personal="$REPO_CONFIG_DIR/mise.local.toml"
if [[ -f "$personal" ]]; then
sbx cp "$personal" "$SANDBOX_NAME:$REPO_ROOT/mise.local.toml"
printf 'Installed personal mise.local.toml in %s.\n' "$SANDBOX_NAME"
fi
# Shims rather than "mise activate": the agent runs non-interactive shells,
# which never fire the activation hook, and would silently get the system
# toolchain instead of the pinned one.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
persistent=/etc/sandbox-persistent.sh
marker="# BEGIN ai-sbx mise configuration"
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
exit 0
fi
cat >>"$persistent" <<'"'"'EOF'"'"'
# BEGIN ai-sbx mise configuration
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"
# END ai-sbx mise configuration
EOF
'
# A repository with no mise configuration is normal, and a broken config is
# the repository's problem, not a reason to refuse to start the agent.
# Only the workspace path below is expanded by the host shell.
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
export PATH="$HOME/.local/bin:$PATH"
cd '"$(printf '%q' "$REPO_ROOT")"' 2>/dev/null || exit 0
for candidate in \
mise.toml mise.local.toml .mise.toml .mise.local.toml \
.config/mise.toml .config/mise/config.toml; do
[[ -f "$candidate" ]] && found=true && break
done
[[ "${found:-false}" == true ]] || exit 0
mise trust . >/dev/null 2>&1 || true
if mise install; then
mise reshim >/dev/null 2>&1 || true
else
printf "mise install failed; the agent starts without pinned tools.\n" >&2
fi
'
}
create_sandbox() {
load_config
local -a create_args=(
create
--name "$SANDBOX_NAME"
)
# Clone mode gives the agent its own in-container clone, so its commits
# never land on whatever the host has checked out.
if [[ "$CONFIG_MODE" == "clone" ]]; then
create_args+=(--clone)
fi
if [[ -n "$CONFIG_TEMPLATE" ]]; then
create_args+=(--template "$CONFIG_TEMPLATE")
fi
local kit
for kit in ${CONFIG_KITS[@]+"${CONFIG_KITS[@]}"}; do
create_args+=(--kit "$kit")
done
create_args+=(
"$CONFIG_AGENT"
"$REPO_ROOT"
)
sbx "${create_args[@]}"
}
setup_command() {
local agent="$DEFAULT_AGENT"
local mode="$DEFAULT_MODE"
local template="$DEFAULT_TEMPLATE"
local replace=false
local -a aws_profiles=()
local -a kits=()
while (($#)); do
case "$1" in
--aws-profile)
(($# >= 2)) || die "--aws-profile requires a value"
aws_profiles+=("$2")
shift 2
;;
--agent)
(($# >= 2)) || die "--agent requires a value"
agent="$2"
shift 2
;;
--clone)
mode="clone"
shift
;;
--direct)
mode="direct"
shift
;;
--template)
(($# >= 2)) || die "--template requires a value"
template="$2"
shift 2
;;
--stock-template)
template=""
shift
;;
--kit)
(($# >= 2)) || die "--kit requires a value"
kits+=("$2")
shift 2
;;
--replace)
replace=true
shift
;;
-h | --help)
usage
exit 0
;;
*)
die "Unknown setup option: $1"
;;
esac
done
validate_profile_mapping "${aws_profiles[@]}"
local profile
for profile in "${aws_profiles[@]}"; do
validate_aws_profile "$profile"
done
local kit
for kit in ${kits[@]+"${kits[@]}"}; do
[[ -e "$kit" ]] ||
die "Kit does not exist: $kit"
done
save_config "$agent" "$mode" "$template" "${#kits[@]}" \
${kits[@]+"${kits[@]}"} ${aws_profiles[@]+"${aws_profiles[@]}"}
if sandbox_exists; then
if [[ "$replace" == true ]]; then
printf 'Removing existing sandbox %s...\n' "$SANDBOX_NAME"
sbx rm --force "$SANDBOX_NAME" </dev/null
else
printf 'Using existing sandbox %s.\n' "$SANDBOX_NAME"
fi
fi
if ! sandbox_exists; then
printf 'Creating sandbox %s for %s...\n' \
"$SANDBOX_NAME" "$REPOSITORY"
create_sandbox
fi
install_github_token
install_sandbox_aws_files
install_sandbox_claude_config
install_sandbox_secrets
install_sandbox_mise
cat <<EOF
Setup complete.
Repository: $REPOSITORY
Sandbox: $SANDBOX_NAME
Agent: $agent
Mode: $mode
Run it with:
mise run ai:sbx -- run
EOF
}
token_command() {
load_config
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_github_token
}
refresh_command() {
load_config
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_sandbox_aws_files
}
# The sandbox home survives stop/start, so this is a setup-time job. It exists
# as its own command for the case where the host configuration changed and the
# sandbox should catch up without being recreated.
config_command() {
load_config
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_sandbox_claude_config
install_sandbox_secrets
}
run_command() {
load_config
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
# The GitHub token is long-lived and stays in the sbx secret store; only
# the AWS credentials expire between sessions.
install_sandbox_aws_files
# Tool pins change with the branch the agent is about to work on, so this
# runs every time rather than only at setup.
install_sandbox_mise
if (($#)) && [[ "$1" == "--" ]]; then
shift
fi
if (($#)); then
exec sbx run "$SANDBOX_NAME" -- "$@"
else
exec sbx run "$SANDBOX_NAME"
fi
}
status_command() {
load_config
printf 'Repository: %s\n' "$REPOSITORY"
printf 'Root: %s\n' "$REPO_ROOT"
printf 'Sandbox: %s\n' "$SANDBOX_NAME"
printf 'Agent: %s\n' "$CONFIG_AGENT"
printf 'Mode: %s\n' "$CONFIG_MODE"
printf 'Template: %s\n' "${CONFIG_TEMPLATE:-stock}"
if ((${#CONFIG_KITS[@]})); then
printf 'Kits:\n'
printf ' %s\n' "${CONFIG_KITS[@]}"
fi
printf 'Token days: %s\n' "$DEFAULT_TOKEN_DAYS"
printf 'AWS profiles (host -> sandbox):\n'
if ((${#CONFIG_AWS_PROFILES[@]})); then
local profile
for profile in "${CONFIG_AWS_PROFILES[@]}"; do
printf ' %s -> %s\n' "$profile" "$(sandbox_profile_name "$profile")"
done
else
printf ' none\n'
fi
printf 'Sandbox exists: '
if sandbox_exists; then
printf 'yes\n'
else
printf 'no\n'
fi
printf '\nConfigured sandbox secrets:\n'
sbx secret ls
}
remove_command() {
load_config
if sandbox_exists; then
sbx rm --force "$SANDBOX_NAME" </dev/null
fi
rm -rf "$REPO_CONFIG_DIR"
printf 'Removed sandbox and local configuration for %s.\n' "$REPOSITORY"
}
main() {
local command="${1:-}"
if (($#)); then
shift
fi
# These work outside a repository and without the sandbox toolchain.
case "$command" in
-h | --help | help | "")
usage
return
;;
esac
require_command git
require_command sbx
require_command sha256sum
repository_context
case "$command" in
setup)
setup_command "$@"
;;
token)
token_command "$@"
;;
refresh)
refresh_command "$@"
;;
config)
config_command "$@"
;;
run)
run_command "$@"
;;
status)
status_command "$@"
;;
remove)
remove_command "$@"
;;
*)
die "Unknown command: $command"
;;
esac
}
# Sourcing the task exposes its functions for tests without running a command.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi