The sandbox held no signing material, so its commits arrived unverified and a branch rule requiring signatures rejected them outright. AI_SBX_SIGNING_KEY copies an SSH signing key into the sandbox and points both git and jj at it. The private half genuinely lands in the sandbox, which is why this is opt-in and documented as signing-only: an agent that can read the key can sign as you. A signing key grants no repository access and is revocable on its own, so the exposure is forged attestation rather than reach. Forwarding an agent socket would avoid the copy, but a socket passed over virtiofs is visible and unconnectable from the guest, and the TCP workaround is a worse trade. Setup refuses a passphrase-protected key rather than letting the failure surface on the agent's first commit, and writes an allowed_signers entry so the sandbox can verify what it just signed. jj is configured through conf.d, which is read after config.toml and so overrides the host key path a copied dotfile carries.
1725 lines
53 KiB
Bash
Executable File
1725 lines
53 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}"
|
|
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
|
|
DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"
|
|
DEFAULT_SIGNING_KEY="${AI_SBX_SIGNING_KEY:-}"
|
|
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
|
|
|
|
# Rendered dotfiles are checked for these before the archive enters the sandbox.
|
|
# chezmoi archive decrypts as it renders, so this is the last line of defence
|
|
# behind the allowlist rather than the first.
|
|
DOTFILES_CREDENTIAL_PATTERNS=(
|
|
'gh[pousr]_[A-Za-z0-9]{16,}'
|
|
'github_pat_[A-Za-z0-9_]{20,}'
|
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
|
|
'AKIA[0-9A-Z]{16}'
|
|
'_authToken[[:space:]]*='
|
|
'aws_secret_access_key'
|
|
)
|
|
|
|
# VAR|host[,host...]|requirement. Provisioned for every repository when the
|
|
# variable is present in the host environment. "docker" skips the entry on a
|
|
# sandbox that cannot run containers, where the credential would be useless.
|
|
HOST_WIDE_SECRETS=(
|
|
"LOCALSTACK_AUTH_TOKEN|localstack.cloud,*.localstack.cloud|docker"
|
|
)
|
|
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 [--launch agent|tmux] [-- agent arguments...]
|
|
mise run ai:sbx -- status
|
|
mise run ai:sbx -- remove
|
|
|
|
Setup opens a pre-filled GitHub token form in your browser, unless a token is
|
|
already stored for the sandbox. The secret store outlives the sandbox, so
|
|
recreating one with --replace keeps its token. Use "token" on its own to
|
|
replace an expired or revoked token.
|
|
|
|
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.
|
|
LOCALSTACK_AUTH_TOKEN is provisioned for every repository when it is set on
|
|
the host and the sandbox has Docker to make use of it.
|
|
|
|
AI_SBX_NETWORK lists hosts to allow through the sandbox network policy, comma
|
|
or space separated. Hosts backing a provisioned secret are allowed
|
|
automatically, since a credential for a denied host can never be used.
|
|
|
|
AI_SBX_LAUNCH selects what "run" attaches to: "agent" starts the agent alone
|
|
and is the default, "tmux" attaches to a three-window workspace (agent, edit,
|
|
shell) that survives detaching. --launch overrides it for one run. Agent
|
|
arguments apply to "agent" only.
|
|
|
|
AI_SBX_DOTFILES=chezmoi renders the host's chezmoi dotfiles into the sandbox.
|
|
It requires an allowlist of target paths, one per line, in the user's config
|
|
directory as a "dotfiles" file. chezmoi decrypts as it renders, so setup
|
|
refuses to run when an encrypted file resolves inside the allowlist.
|
|
|
|
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.
|
|
|
|
AI_SBX_SIGNING_KEY is the path to an SSH signing key on the host. Its private
|
|
half is copied into the sandbox, so the agent can sign as you: use a key that
|
|
signs and nothing else, and never an authentication key. Unset, the default,
|
|
leaves the sandbox unable to sign and its commits arrive unverified. The key
|
|
must not be passphrase-protected, because nothing in the sandbox can answer
|
|
the prompt. git and jj are both pointed at it.
|
|
|
|
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"
|
|
}
|
|
|
|
# Only a token scoped to this sandbox counts. A global one would authenticate
|
|
# the agent too, but reaching every repository the token can reach is exactly
|
|
# what this task exists to prevent, so it is not treated as satisfying setup.
|
|
sandbox_has_github_token() {
|
|
sbx secret ls 2>/dev/null |
|
|
awk -v scope="$SANDBOX_NAME" '
|
|
$1 == scope && $2 == "service" && $3 == "github" { found = 1 }
|
|
END { exit !found }
|
|
'
|
|
}
|
|
|
|
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
|
|
allow_sandbox_host "$host"
|
|
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"
|
|
}
|
|
|
|
allow_sandbox_host() {
|
|
local host="$1"
|
|
|
|
[[ -n "$host" ]] || return 0
|
|
|
|
sbx policy allow network --sandbox "$SANDBOX_NAME" "$host" \
|
|
</dev/null >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# The default policy denies everything outside common development hosts, so a
|
|
# private registry is unreachable no matter what credential it holds.
|
|
install_sandbox_network() {
|
|
[[ -n "$DEFAULT_NETWORK" ]] || return 0
|
|
|
|
# A multi-line TOML string is a natural way to write a long list, and read
|
|
# stops at the first newline, so separators are flattened before splitting.
|
|
local normalized="${DEFAULT_NETWORK//,/ }"
|
|
normalized="${normalized//$'\n'/ }"
|
|
normalized="${normalized//$'\r'/ }"
|
|
|
|
local -a allow_hosts
|
|
read -r -a allow_hosts <<<"$normalized"
|
|
|
|
((${#allow_hosts[@]})) || return 0
|
|
|
|
local host
|
|
for host in "${allow_hosts[@]}"; do
|
|
allow_sandbox_host "$host"
|
|
done
|
|
|
|
printf 'Allowed network access to: %s\n' "${allow_hosts[*]}"
|
|
}
|
|
|
|
sandbox_has_docker() {
|
|
sbx exec "$SANDBOX_NAME" \
|
|
bash -lc 'command -v docker >/dev/null' \
|
|
</dev/null >/dev/null 2>&1
|
|
}
|
|
|
|
provision_secret() {
|
|
local var="$1" hosts="$2" value="$3"
|
|
local placeholder
|
|
placeholder="$(secret_placeholder "$var")"
|
|
|
|
local -a host_args=()
|
|
local host
|
|
for host in ${hosts//,/ }; do
|
|
host_args+=(--host "$host")
|
|
|
|
# A credential for a host the policy denies is dead weight: the request
|
|
# never leaves the sandbox, so the proxy never substitutes anything.
|
|
allow_sandbox_host "$host"
|
|
done
|
|
|
|
# Piped rather than --value: the secret would otherwise be visible in the
|
|
# process list to anything running as this user.
|
|
printf '%s' "$value" |
|
|
sbx secret set-custom "$SANDBOX_NAME" \
|
|
"${host_args[@]}" \
|
|
--env "$var" \
|
|
--placeholder "$placeholder" >/dev/null 2>&1 ||
|
|
{
|
|
printf 'Could not store %s in the sandbox.\n' "$var" >&2
|
|
return 1
|
|
}
|
|
|
|
# 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
|
|
|
|
printf 'Provisioned %s for %s\n' "$var" "${hosts//,/, }"
|
|
}
|
|
|
|
# Credentials worth provisioning for every repository rather than declaring per
|
|
# repository, taken straight from the host environment. LocalStack only matters
|
|
# when the agent can run containers, so it is skipped where Docker is absent.
|
|
install_host_wide_secrets() {
|
|
local entry var hosts requirement
|
|
|
|
for entry in "${HOST_WIDE_SECRETS[@]}"; do
|
|
IFS='|' read -r var hosts requirement <<<"$entry"
|
|
|
|
[[ -n "${!var:-}" ]] || continue
|
|
|
|
if [[ "$requirement" == docker ]] && ! sandbox_has_docker; then
|
|
printf 'Skipping %s: the sandbox has no Docker to run it.\n' "$var" >&2
|
|
continue
|
|
fi
|
|
|
|
provision_secret "$var" "$hosts" "${!var}" || true
|
|
done
|
|
}
|
|
|
|
install_sandbox_secrets() {
|
|
install_host_wide_secrets
|
|
|
|
local declarations
|
|
declarations="$(read_secret_declarations)" || return 0
|
|
|
|
[[ -n "$declarations" ]] || return 0
|
|
|
|
local var hosts command value
|
|
|
|
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
|
|
}
|
|
|
|
provision_secret "$var" "$hosts" "$value" || 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
|
|
'
|
|
}
|
|
|
|
# A sandbox image ships without a locale, which leaves LC_CTYPE at POSIX. Every
|
|
# multibyte glyph then degrades to a placeholder: Nerd Font icons in the editor
|
|
# render as underscores, and bash printf emits \uXXXX escapes literally. LANG
|
|
# alone is enough, and leaves a user free to override individual categories.
|
|
install_sandbox_locale() {
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c '
|
|
persistent=/etc/sandbox-persistent.sh
|
|
marker="# BEGIN ai-sbx locale"
|
|
|
|
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
|
|
# locale -a spells these inconsistently across distributions, so probe
|
|
# each one for usability rather than matching its name.
|
|
for candidate in C.UTF-8 en_US.UTF-8; do
|
|
if LC_ALL="$candidate" locale >/dev/null 2>&1; then
|
|
chosen="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
|
|
[[ -n "${chosen:-}" ]] || exit 0
|
|
|
|
cat >>"$persistent" <<EOF
|
|
# BEGIN ai-sbx locale
|
|
export LANG=$chosen
|
|
# END ai-sbx locale
|
|
EOF
|
|
' </dev/null >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# The in-container clone inherits origin verbatim from the host, which is
|
|
# commonly an SSH URL. Nothing in the sandbox can satisfy SSH - there is no key
|
|
# and port 22 is closed - and only HTTPS carries the Authorization header the
|
|
# proxy substitutes the GitHub token into. Rewriting globally covers the clone,
|
|
# any repository the agent clones later, and every submodule, while leaving the
|
|
# host's own .git/config untouched under --direct.
|
|
install_sandbox_git_https() {
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c '
|
|
key="url.https://github.com/.insteadOf"
|
|
|
|
# insteadOf is multi-valued, so a plain set would replace the first
|
|
# form with the second and a repeat setup would accumulate duplicates.
|
|
git config --global --unset-all "$key" 2>/dev/null
|
|
|
|
git config --global --add "$key" "[email protected]:"
|
|
git config --global --add "$key" "ssh://[email protected]/"
|
|
' </dev/null >/dev/null 2>&1 ||
|
|
printf 'Could not rewrite GitHub SSH remotes to HTTPS in %s.\n' \
|
|
"$SANDBOX_NAME" >&2
|
|
}
|
|
|
|
# The private half genuinely lands in the sandbox, which is why the key is
|
|
# opt-in and must be signing-only: an agent that can read it can sign as you.
|
|
# A signing key is separately revocable and grants no repository access, so the
|
|
# damage is forged attestation rather than reach.
|
|
install_sandbox_signing_key() {
|
|
[[ -n "$DEFAULT_SIGNING_KEY" ]] || return 0
|
|
|
|
local private="${DEFAULT_SIGNING_KEY/#\~/$HOME}"
|
|
local public="$private.pub"
|
|
|
|
[[ -f "$private" ]] ||
|
|
die "AI_SBX_SIGNING_KEY does not exist: $private"
|
|
|
|
[[ -f "$public" ]] ||
|
|
die "No public half beside $private. SSH signing needs both, and git names the signing key by its .pub."
|
|
|
|
ssh-keygen -y -P '' -f "$private" >/dev/null 2>&1 ||
|
|
die "$private is passphrase-protected. Nothing in the sandbox can answer the prompt, so every commit would fail at the moment of signing. Use a dedicated signing key with no passphrase."
|
|
|
|
local principal
|
|
principal="$(git -C "$REPO_ROOT" config user.email)" ||
|
|
die "The repository has no user.email, so signatures could not be attributed to a principal."
|
|
|
|
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."
|
|
|
|
local staging
|
|
staging="$(mktemp -d)"
|
|
trap 'rm -rf "$staging"' RETURN
|
|
|
|
local name
|
|
name="$(basename "$private")"
|
|
|
|
mkdir -p "$staging/.ssh"
|
|
install -m 600 "$private" "$staging/.ssh/$name"
|
|
install -m 644 "$public" "$staging/.ssh/$name.pub"
|
|
|
|
# Without a principal mapping git reports "No principal matched" for the
|
|
# signatures it just produced, so the sandbox cannot verify its own commits.
|
|
printf '%s %s\n' "$principal" "$(cat "$public")" \
|
|
>"$staging/.ssh/allowed_signers"
|
|
|
|
tar -C "$staging" -cf - .ssh |
|
|
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" ||
|
|
die "Could not copy the signing key into $SANDBOX_NAME."
|
|
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c '
|
|
set -e
|
|
name="$1"
|
|
|
|
chmod 700 "$HOME/.ssh"
|
|
chmod 600 "$HOME/.ssh/$name"
|
|
chmod 644 "$HOME/.ssh/$name.pub" "$HOME/.ssh/allowed_signers"
|
|
|
|
git config --global gpg.format ssh
|
|
git config --global user.signingkey "$HOME/.ssh/$name.pub"
|
|
git config --global commit.gpgsign true
|
|
git config --global tag.gpgsign true
|
|
git config --global gpg.ssh.allowedSignersFile "$HOME/.ssh/allowed_signers"
|
|
|
|
# jj reads conf.d after config.toml, so the host key path a copied
|
|
# dotfile carries is overridden without editing a file chezmoi owns.
|
|
mkdir -p "$HOME/.config/jj/conf.d"
|
|
cat >"$HOME/.config/jj/conf.d/10-ai-sbx-signing.toml" <<EOF
|
|
[signing]
|
|
backend = "ssh"
|
|
key = "$HOME/.ssh/$name.pub"
|
|
EOF
|
|
' _ "$name" </dev/null ||
|
|
die "Could not configure commit signing in $SANDBOX_NAME."
|
|
|
|
printf 'Installed the signing key %s into %s.\n' "$name" "$SANDBOX_NAME"
|
|
}
|
|
|
|
validate_launch_mode() {
|
|
case "$1" in
|
|
agent | tmux) ;;
|
|
*)
|
|
die "Unknown launch mode: $1 (expected agent or tmux)"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# The image may already carry the launcher. Its copy is built from this same
|
|
# file, so the two cannot drift, and skipping keeps a custom image authoritative
|
|
# about its own contents.
|
|
install_sandbox_workspace() {
|
|
local launcher
|
|
launcher="$(dirname "${BASH_SOURCE[0]}")/workspace"
|
|
|
|
[[ -f "$launcher" ]] ||
|
|
die "Workspace launcher is missing: $launcher"
|
|
|
|
if sbx exec "$SANDBOX_NAME" \
|
|
bash -c '[[ -x /usr/local/bin/ai-sbx-workspace ]]' \
|
|
</dev/null >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
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/.local/bin"'
|
|
|
|
sbx cp "$launcher" "$SANDBOX_NAME:$sandbox_home/.local/bin/ai-sbx-workspace"
|
|
|
|
# shellcheck disable=SC2016
|
|
sbx exec "$SANDBOX_NAME" bash -c 'chmod 755 "$HOME/.local/bin/ai-sbx-workspace"'
|
|
}
|
|
|
|
# One target path per line, relative to the home directory. Comments and blank
|
|
# lines are ignored.
|
|
read_dotfiles_allowlist() {
|
|
local file="$CONFIG_ROOT/dotfiles"
|
|
|
|
[[ -f "$file" ]] ||
|
|
die "AI_SBX_DOTFILES is set but $file does not exist. List one target path per line, for example .config/nvim"
|
|
|
|
local line
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line%%#*}"
|
|
line="$(printf '%s' "$line" | xargs)"
|
|
|
|
[[ -n "$line" ]] || continue
|
|
|
|
printf '%s\n' "$line"
|
|
done <"$file"
|
|
}
|
|
|
|
# chezmoi archive decrypts as it renders, so an encrypted file inside the
|
|
# allowlist would arrive in the sandbox as plaintext credentials - defeating the
|
|
# proxy-injected GitHub token in a single step. A denylist would rot as new
|
|
# encrypted files appear, and the failure mode is silent, so refuse instead.
|
|
assert_no_encrypted_targets() {
|
|
local source_dir
|
|
source_dir="$(chezmoi source-path)" ||
|
|
die "Could not determine the chezmoi source directory."
|
|
|
|
local encrypted target allowed
|
|
while IFS= read -r encrypted; do
|
|
[[ -n "$encrypted" ]] || continue
|
|
|
|
target="$(chezmoi target-path "$encrypted" 2>/dev/null)" || continue
|
|
target="${target#"$HOME/"}"
|
|
|
|
for allowed in "$@"; do
|
|
[[ "$target" == "$allowed" || "$target" == "$allowed"/* ]] ||
|
|
continue
|
|
|
|
die "Dotfiles allowlist entry $allowed contains the encrypted file $target, which chezmoi would render in plaintext. Narrow $CONFIG_ROOT/dotfiles."
|
|
done
|
|
done < <(find "$source_dir" -type f -name '*encrypted_*' 2>/dev/null)
|
|
}
|
|
|
|
scan_dotfiles_archive() {
|
|
local archive="$1" pattern
|
|
|
|
for pattern in "${DOTFILES_CREDENTIAL_PATTERNS[@]}"; do
|
|
grep -aEq -- "$pattern" "$archive" ||
|
|
continue
|
|
|
|
die "The rendered dotfiles archive contains something shaped like a credential (matching /$pattern/). Narrow $CONFIG_ROOT/dotfiles."
|
|
done
|
|
}
|
|
|
|
# Rendered on the host, where the age identity already lives, and copied in as a
|
|
# tar. The sandbox needs no dotfiles repository, no key and no network for this.
|
|
install_sandbox_dotfiles() {
|
|
[[ -n "$DEFAULT_DOTFILES" ]] || return 0
|
|
|
|
[[ "$DEFAULT_DOTFILES" == chezmoi ]] ||
|
|
die "Unknown AI_SBX_DOTFILES value: $DEFAULT_DOTFILES (expected chezmoi)"
|
|
|
|
require_command chezmoi
|
|
|
|
local -a targets
|
|
mapfile -t targets < <(read_dotfiles_allowlist)
|
|
|
|
((${#targets[@]})) ||
|
|
die "The dotfiles allowlist $CONFIG_ROOT/dotfiles is empty."
|
|
|
|
assert_no_encrypted_targets "${targets[@]}"
|
|
|
|
local -a target_paths=()
|
|
local target
|
|
for target in "${targets[@]}"; do
|
|
target_paths+=("$HOME/$target")
|
|
done
|
|
|
|
local archive
|
|
archive="$(mktemp)"
|
|
trap 'rm -f "$archive"' RETURN
|
|
|
|
# DEV_CONTAINER=1 is required, not cosmetic: .chezmoi.toml.tmpl branches on
|
|
# it to disable git.autoCommit and git.autoPush, and without it an agent in
|
|
# the sandbox could push to the dotfiles repository.
|
|
DEV_CONTAINER=1 chezmoi archive --format tar "${target_paths[@]}" >"$archive" ||
|
|
die "chezmoi could not render the dotfiles archive."
|
|
|
|
scan_dotfiles_archive "$archive"
|
|
|
|
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."
|
|
|
|
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" <"$archive" ||
|
|
die "Could not unpack the dotfiles archive in $SANDBOX_NAME."
|
|
|
|
rm -f "$archive"
|
|
trap - RETURN
|
|
|
|
printf 'Installed dotfiles into %s: %s\n' "$SANDBOX_NAME" "${targets[*]}"
|
|
}
|
|
|
|
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
|
|
|
|
# The secret store outlives the sandbox, so recreating one to change its
|
|
# image keeps the token. Prompting anyway would train the habit of minting
|
|
# replacement tokens and never revoking the old ones.
|
|
if sandbox_has_github_token; then
|
|
printf 'Keeping the GitHub token already stored for %s. Replace it with: mise run ai:sbx -- token\n' \
|
|
"$SANDBOX_NAME"
|
|
else
|
|
install_github_token
|
|
fi
|
|
|
|
install_sandbox_aws_files
|
|
|
|
install_sandbox_claude_config
|
|
|
|
install_sandbox_dotfiles
|
|
|
|
# After the dotfiles: the allowlist may carry a .gitconfig, which would
|
|
# otherwise land on top of the rewrite.
|
|
install_sandbox_git_https
|
|
|
|
install_sandbox_signing_key
|
|
|
|
install_sandbox_network
|
|
|
|
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_dotfiles
|
|
|
|
install_sandbox_git_https
|
|
|
|
install_sandbox_signing_key
|
|
|
|
install_sandbox_network
|
|
|
|
install_sandbox_secrets
|
|
}
|
|
|
|
run_command() {
|
|
local launch="$DEFAULT_LAUNCH"
|
|
|
|
# Everything after -- belongs to the agent, including anything that looks
|
|
# like an option of this task.
|
|
while (($#)); do
|
|
case "$1" in
|
|
--launch)
|
|
(($# >= 2)) || die "--launch requires a value"
|
|
launch="$2"
|
|
shift 2
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
validate_launch_mode "$launch"
|
|
|
|
if [[ "$launch" == tmux ]] && (($#)); then
|
|
die "Agent arguments are only supported with --launch agent; got: $*"
|
|
fi
|
|
|
|
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
|
|
|
|
install_sandbox_locale
|
|
|
|
if [[ "$launch" == tmux ]]; then
|
|
install_sandbox_workspace
|
|
|
|
# bash -lc is mandatory: /etc/sandbox-persistent.sh is where PATH, the
|
|
# mise shims, the AWS credentials and every secret placeholder live, and
|
|
# the tmux server inherits its environment from this shell.
|
|
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
|
|
bash -lc "ai-sbx-workspace $(printf '%q' "$CONFIG_AGENT")"
|
|
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
|