#!/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:-}" 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. 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- 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 &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 < 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 git@github.com:*) REPOSITORY="${remote#git@github.com:}" ;; ssh://git@github.com/*) REPOSITORY="${remote#ssh://git@github.com/}" ;; 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" <>"$credentials_file" <&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 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 2>&1 || true fi # Without /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 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") } 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 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" 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"