Provides a shareable mise task, ai:sbx, that runs an AI coding agent in a Docker Sandbox scoped to a single GitHub repository and a set of read-only AWS roles. The repository is derived from origin rather than configured, so the sandbox identity cannot drift from the checkout in use. GitHub access is a repository-scoped fine-grained PAT held in the sbx secret store and injected by its host-side proxy, so the token is never exposed to the agent. The host ~/.aws directory and SSO token cache are never mounted; instead the host exports short-lived credentials for approved read-only profiles and only those land in the sandbox. Host profiles are commonly suffixed to mark the grant (api-portal-readonly) while Terraform references the account name (api-portal), so a trailing -readonly is stripped when the profile is written into the sandbox. Two host profiles that collapse to the same sandbox name are rejected during setup, before any credentials are exported, since a silent overwrite would hand Terraform the wrong identity under a plausible-looking name. All state lives under ~/.config/ai-sbx; repositories supply nothing and need no mise.toml.
606 lines
15 KiB
Bash
Executable File
606 lines
15 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:-codex}"
|
|
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
|
|
DEFAULT_BRANCH="${AI_SBX_BRANCH:-ai-sbx}"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
mise run ai:sbx -- setup [options]
|
|
mise run ai:sbx -- refresh
|
|
mise run ai:sbx -- run [-- agent arguments...]
|
|
mise run ai:sbx -- status
|
|
mise run ai:sbx -- remove
|
|
|
|
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: codex
|
|
--direct Mount the host working tree read-write.
|
|
--clone Give the agent a Git worktree on its own
|
|
branch. This is the default.
|
|
--branch NAME Branch used by --clone. Default: ai-sbx
|
|
--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"
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
repository_context() {
|
|
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"
|
|
|
|
CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}"
|
|
|
|
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
|
|
CONFIG_AWS_PROFILES=()
|
|
}
|
|
|
|
save_config() {
|
|
local agent="$1"
|
|
local mode="$2"
|
|
local branch="$3"
|
|
shift 3
|
|
local -a profiles=("$@")
|
|
|
|
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_BRANCH=%q\n' "$branch"
|
|
|
|
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
|
|
}
|
|
|
|
create_sandbox() {
|
|
load_config
|
|
|
|
local -a create_args=(
|
|
create
|
|
--name "$SANDBOX_NAME"
|
|
)
|
|
|
|
# Clone mode gives the agent a Git worktree on its own branch, so its
|
|
# commits never land on whatever the host has checked out.
|
|
if [[ "$CONFIG_MODE" == "clone" ]]; then
|
|
create_args+=(--branch "$CONFIG_BRANCH")
|
|
fi
|
|
|
|
create_args+=(
|
|
"$CONFIG_AGENT"
|
|
"$REPO_ROOT"
|
|
)
|
|
|
|
sbx "${create_args[@]}"
|
|
}
|
|
|
|
setup_command() {
|
|
local agent="$DEFAULT_AGENT"
|
|
local mode="$DEFAULT_MODE"
|
|
local branch="$DEFAULT_BRANCH"
|
|
local replace=false
|
|
local -a aws_profiles=()
|
|
|
|
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
|
|
;;
|
|
--branch)
|
|
(($# >= 2)) || die "--branch requires a value"
|
|
branch="$2"
|
|
shift 2
|
|
;;
|
|
--direct)
|
|
mode="direct"
|
|
shift
|
|
;;
|
|
--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
|
|
|
|
save_config "$agent" "$mode" "$branch" "${aws_profiles[@]}"
|
|
|
|
if sandbox_exists; then
|
|
if [[ "$replace" == true ]]; then
|
|
printf 'Removing existing sandbox %s...\n' "$SANDBOX_NAME"
|
|
sbx rm "$SANDBOX_NAME"
|
|
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
|
|
|
|
cat <<EOF
|
|
|
|
Configure a fine-grained GitHub token for this sandbox.
|
|
|
|
The token should be restricted to:
|
|
|
|
Repository: $REPOSITORY
|
|
Sandbox: $SANDBOX_NAME
|
|
|
|
Suggested permissions:
|
|
Metadata: Read
|
|
Contents: Read and write
|
|
Pull requests: Read and write
|
|
Actions: Read, if required
|
|
Issues: Only if required
|
|
Workflows: No access unless explicitly required
|
|
|
|
EOF
|
|
|
|
# Interactive prompt; the token is not placed in shell history.
|
|
sbx secret set "$SANDBOX_NAME" github
|
|
|
|
install_sandbox_aws_files
|
|
|
|
cat <<EOF
|
|
|
|
Setup complete.
|
|
|
|
Repository: $REPOSITORY
|
|
Sandbox: $SANDBOX_NAME
|
|
Agent: $agent
|
|
Mode: $mode
|
|
Branch: $branch
|
|
|
|
Run it with:
|
|
|
|
mise run ai:sbx -- run
|
|
EOF
|
|
}
|
|
|
|
refresh_command() {
|
|
sandbox_exists ||
|
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
|
|
|
install_sandbox_aws_files
|
|
}
|
|
|
|
run_command() {
|
|
load_config
|
|
|
|
sandbox_exists ||
|
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
|
|
|
# Refresh the short-lived credentials before every session.
|
|
install_sandbox_aws_files
|
|
|
|
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"
|
|
|
|
if [[ "$CONFIG_MODE" == "clone" ]]; then
|
|
printf 'Branch: %s\n' "$CONFIG_BRANCH"
|
|
fi
|
|
|
|
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 "$SANDBOX_NAME"
|
|
fi
|
|
|
|
rm -rf "$REPO_CONFIG_DIR"
|
|
|
|
printf 'Removed sandbox and local configuration for %s.\n' "$REPOSITORY"
|
|
}
|
|
|
|
main() {
|
|
local command="${1:-}"
|
|
if (($#)); then
|
|
shift
|
|
fi
|
|
|
|
# Usage must 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 "$@"
|
|
;;
|
|
refresh)
|
|
refresh_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
|