Add repository-scoped AI sandbox mise task

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.
This commit is contained in:
2026-07-30 13:52:57 -05:00
commit d43abe693e
3 changed files with 950 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
# ai-sandbox
A shareable [mise](https://mise.jdx.dev/) task that runs an AI coding agent inside a
[Docker Sandbox](https://docs.docker.com/ai/sandboxes/), scoped to exactly one GitHub
repository and one set of read-only AWS roles.
## Description
Giving an agent your everyday credentials gives it your everyday blast radius. A
long-lived PAT reaches every repository you can reach; `~/.aws` reaches every role your
SSO session can assume, admin included. This task narrows both, and puts the boundary
somewhere the agent cannot edit.
It provides a single command, `ai:sbx`, which:
- **Derives the repository from `origin`.** No repository name is typed or configured,
so the sandbox identity cannot drift from the checkout you are standing in. The
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
- **Scopes GitHub access to one repository.** A fine-grained PAT restricted to that
repository is stored with `sbx secret set`. Docker's host-side proxy injects it into
outbound requests; the token is never placed in `GH_TOKEN`, never written into the
repository, and is not readable by the agent.
- **Keeps your AWS admin profiles out of the sandbox entirely.** Your `~/.aws`
directory and your SSO token cache are never mounted or copied. Instead, the host
runs `aws configure export-credentials` against named read-only profiles you approve
once, and only the resulting short-lived role credentials are written into the
sandbox. The agent cannot use your SSO session to discover or request other roles.
- **Renames profiles for Terraform.** Host profiles are commonly suffixed to mark the
grant — `api-portal-readonly` — while Terraform code references the account name,
`api-portal`. A trailing `-readonly` is stripped when the profile is written into the
sandbox, so unmodified Terraform resolves the read-only credentials.
- **Refreshes credentials on every launch,** since exported SSO credentials are
short-lived.
- **Requires nothing from the repository.** All state lives under
`~/.config/ai-sbx/`. Repositories that want first-class support can opt in with three
lines of `mise.toml`; repositories that do not are unaffected, and developers who do
not use mise never notice.
### What the sandbox receives
| Receives | Does not receive |
| --- | --- |
| Temporary read-only role credentials | Your SSO access or refresh token |
| Profile names, minus the `-readonly` suffix | Your host `~/.aws/config` or credentials |
| Proxy-injected GitHub auth for one repository | A readable GitHub token |
| The repository working tree | Admin profile names or role assignments |
## Installation
### Dependencies
Install these on the **host** — none of them are needed inside the sandbox.
| Tool | Purpose | Install |
| --- | --- | --- |
| [mise](https://mise.jdx.dev/) | Runs the task and distributes it | [Getting started](https://mise.jdx.dev/getting-started.html) |
| [Docker Sandboxes (`sbx`)](https://docs.docker.com/ai/sandboxes/) | Sandbox, secret store, credential proxy | Ships with [Docker Desktop](https://docs.docker.com/desktop/) |
| [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) | `aws configure export-credentials` | Required only when using `--aws-profile` |
| [`jq`](https://jqlang.org/) | Parses exported credentials | Required only when using `--aws-profile` |
| `git`, `sha256sum` | Repository identity | Already present on most systems |
Verify:
```bash
mise --version
sbx version
aws --version
jq --version
```
### User-level install (recommended)
Adding the task to your personal mise config makes `ai:sbx` available in every Git
repository on the machine, without touching a single repository.
Add to `~/.config/mise/config.toml`:
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
```
Pin `ref` to a tag or, better, a commit SHA. The task executes on your host with your
credentials — a moving `ref` means an unreviewed change runs the next time the cache
expires.
The repository is public, so `https` needs no credentials and works on a fresh machine
with no SSH agent. For a private fork, use the SSH form instead, which requires a key
registered with the forge:
```toml
includes = [
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
```
Optional personal defaults:
```toml
[env]
AI_SBX_AGENT = "codex"
AI_SBX_MODE = "clone"
AI_SBX_BRANCH = "ai-sbx"
```
Confirm it loaded:
```bash
mise tasks ls # expect: ai:sbx
```
mise caches the clone under `$MISE_CACHE_DIR/remote-git-tasks-cache`. Force a refetch
with `MISE_TASK_REMOTE_NO_CACHE=true`.
### Repository-level install (optional)
A repository whose team has adopted the workflow can add the same include to its
`mise.toml`:
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
```
Developers with mise get `ai:sbx`; developers without mise are unaffected. Do not put
AWS profile names or tokens in a repository config — the approved profile list is
per-user state, and no secret belongs in a repository.
Both installs can coexist. Nothing about the workflow requires the repository-level
one.
## Usage
### 1. Authenticate your read-only AWS profiles on the host
```bash
aws sso login --profile api-portal-readonly
aws sso login --profile prod-readonly
```
Setup fails fast with the exact `aws sso login` command if a profile is missing or its
session has expired.
### 2. Set up a repository, once
```bash
cd ~/src/api-portal
mise run ai:sbx -- setup \
--aws-profile api-portal-readonly \
--aws-profile prod-readonly
```
This derives the repository from `origin`, creates the sandbox, then prompts for a
fine-grained GitHub PAT. Create it at
[github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
scoped to that one repository:
```text
Resource owner: your user or organization
Repository access: Only select repositories
Selected repository: owner/api-portal
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
Expiration: the shortest period you will tolerate
```
Paste it at the prompt. It is not written to shell history.
### 3. Run the agent
```bash
mise run ai:sbx -- run
```
Refreshes AWS credentials, then attaches. Pass agent arguments after a second `--`:
```bash
mise run ai:sbx -- run -- "Review the Terraform plan for the staging workspace"
```
### 4. Inside the sandbox
`gh` is already authenticated through the proxy, for that repository only:
```bash
gh pr list
gh pr create --fill
```
AWS named profiles work as Terraform expects:
```bash
aws sts get-caller-identity --profile api-portal
AWS_PROFILE=api-portal terraform init
AWS_PROFILE=api-portal terraform plan
```
```hcl
provider "aws" {
profile = "api-portal"
region = "us-east-1"
}
```
### Commands
| Command | Effect |
| --- | --- |
| `setup [options]` | Configure the repository, create the sandbox, store the GitHub token, install AWS profiles |
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
| `refresh` | Refresh AWS credentials without attaching |
| `status` | Show repository, sandbox, agent, mode, profile mapping, stored secrets |
| `remove` | Remove the sandbox and this repository's local configuration |
### `setup` options
| Option | Default | Effect |
| --- | --- | --- |
| `--aws-profile NAME` | none | Host profile to expose. Repeatable. Trailing `-readonly` stripped inside the sandbox |
| `--agent NAME` | `codex` | Sandbox agent. See `sbx create --help` for the list |
| `--clone` | on | Give the agent a Git worktree on its own branch |
| `--direct` | off | Mount the host working tree read-write |
| `--branch NAME` | `ai-sbx` | Branch used by `--clone` |
| `--replace` | off | Destroy and recreate an existing sandbox |
### Environment defaults
| Variable | Default | Overrides |
| --- | --- | --- |
| `AI_SBX_AGENT` | `codex` | `--agent` |
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
| `AI_SBX_BRANCH` | `ai-sbx` | `--branch` |
## AWS profile naming
Only a trailing `-readonly` is removed. Everything else passes through:
| Host profile | Sandbox profile |
| --- | --- |
| `api-portal-readonly` | `api-portal` |
| `prod-readonly` | `prod` |
| `dev` | `dev` |
| `readonly-first` | `readonly-first` |
| `team-readonly-readonly` | `team-readonly` |
Two host profiles that collapse to the same sandbox name — `dev-readonly` and `dev` —
are rejected at setup, before any credentials are exported. Silently letting one
overwrite the other would hand Terraform the wrong identity under a name that looks
right.
Variants such as `_readonly`, `-ro`, and `-read-only` are **not** stripped.
## Where state lives
```text
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
```
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
profile names. Tokens live in the `sbx` secret store; AWS credentials exist only inside
the sandbox and only until they expire.
Inspect the current repository's state with `mise run ai:sbx -- status`.
## Security notes
- **The repository is not the boundary.** A repository-controlled file such as `.envrc`
or `mise.toml` could otherwise choose which credentials get loaded. Profile approval
lives in your user-owned config; the repository only supplies its own identity, which
is cross-checked against `origin` on every run.
- **Fine-grained PATs, one per repository, with an expiration.** A classic PAT reaches
every repository you can reach; that is the thing this design exists to prevent.
- **Read-only AWS roles.** The sandbox boundary limits reach, not intent. Grant roles
that cannot cause damage if the agent misbehaves. Terraform `plan` needs read access;
`apply` should stay outside the sandbox.
- **Rotation is manual.** Revoke a PAT at GitHub and re-run `setup` to replace it.
- **`--direct` weakens isolation.** The agent writes directly to your working tree.
Prefer the default `--clone`.
## Development
```bash
bash tests/profile-mapping.test.sh
shellcheck -x tasks/ai/sbx tests/profile-mapping.test.sh
```
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
registers as `ai:sbx`, whereas a file literally named `tasks/ai:sbx` registers as
`ai_sbx`. Task files must be executable.
Executable
+605
View File
@@ -0,0 +1,605 @@
#!/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
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
assert_maps() {
local input="$1" expected="$2" actual
actual="$(sandbox_profile_name "$input")"
if [[ "$actual" != "$expected" ]]; then
printf 'FAIL: %s -> %s, expected %s\n' "$input" "$actual" "$expected" >&2
failures=$((failures + 1))
fi
}
assert_dies() {
local description="$1"
shift
if (validate_profile_mapping "$@") 2>/dev/null; then
printf 'FAIL: %s was accepted\n' "$description" >&2
failures=$((failures + 1))
fi
}
assert_maps api-portal-readonly api-portal
assert_maps prod-readonly prod
assert_maps dev dev
assert_maps readonly-first readonly-first
assert_maps team-readonly-readonly team-readonly
validate_profile_mapping api-portal-readonly prod-readonly dev
assert_dies 'dev-readonly colliding with dev' dev-readonly dev
assert_dies 'empty profile name' -readonly
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All profile mapping assertions passed.\n'