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:
@@ -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
@@ -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
|
||||||
Executable
+45
@@ -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'
|
||||||
Reference in New Issue
Block a user