Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ POSTGRES_USER=claude
POSTGRES_PASSWORD=changeme-generated-by-make-env
POSTGRES_DB=claude

# --- Agent Toolkit for AWS (optional; consumed via compose interpolation) ---
# Profile names are specific to YOUR AWS setup, so nothing is shipped by default.
# `gen-env.sh` never rewrites an existing .env, so lines you add here survive.
#
# Space-separated profiles from ~/.claude/aws/config. The FIRST is the MCP
# server's default; the rest are selectable per tool call (`aws_profile`).
# Put the LEAST privileged account first — it is what an unqualified call uses.
#AWS_MCP_PROFILES=sandbox development shared_services management production
#
# Region for the MCP endpoint + the skill catalog (default us-east-1).
#AWS_MCP_REGION=us-east-1
#
# 1 = read-only (describe, don't mutate). Default 0 — the MCP tools can create,
# modify and delete real resources in whichever profile is selected.
#AWS_MCP_READ_ONLY=1

# --- claude-code (libpq client vars + URL) ---
PGHOST=db
PGPORT=5432
Expand Down
27 changes: 23 additions & 4 deletions .devcontainer/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,29 @@ Claude that *uses* the sandbox, not the one editing this repo.)
raw symptom is undiagnosable. Absent credentials are normal and user-fixable, so
the probe never sets `status=degraded`. (Upstream docs claim credential-free
doc/skill-discovery still works over MCP — measured against 1.6.4, it does not.)
- **Read-only is the default posture** (`AWS_MCP_READ_ONLY=1` → `--read-only`): the
server acts as whatever IAM identity is in `~/.claude/aws/credentials`, so
mutating real infrastructure is opt-in. Both knobs are compose env vars and take
effect on the next container *start*.
- **`--profile` is variadic and that is the point.** `AWS_MCP_PROFILES` is a
space-separated list: the FIRST name is the server's default identity, the rest
become selectable per tool call via the proxy's `aws_profile` parameter — one MCP
server spanning an AWS Organization. The word-splitting in the script is
deliberate (`# shellcheck disable=SC2086`) and guarded by an emptiness test so an
unset value can never emit a bare `--profile`. The credential probe targets the
*default* profile, not the ambient chain — with a list configured those are
different identities and only the first governs the handshake.
- **WRITES ARE ON BY DEFAULT** (`AWS_MCP_READ_ONLY=0`). The tools act with the full
IAM permissions of the selected profile and can create/modify/delete real
resources in *any* account in `AWS_MCP_PROFILES` — not just the default. Order the
list least-privileged-first, since position 1 is what an unqualified call uses.
`AWS_MCP_READ_ONLY=1` restores `--read-only`. All three knobs are compose env vars
(set per host in `.env`) and take effect on the next container *start*.
- **`~/.aws` must stay a directory symlink into the volume** (`seed-claude.sh`).
`AWS_CONFIG_FILE`/`AWS_SHARED_CREDENTIALS_FILE` relocate only `config` and
`credentials`; the SSO/OIDC **token cache path is not configurable** — the CLI
always writes `~/.aws/cli/cache/session.db` and `~/.aws/sso/cache/`. Left on the
container layer those die with every rebuild, so config would survive but every
profile would need a fresh `aws sso login`. Same per-file-symlink trap as `~/.ssh`:
the CLI rewrites these caches via temp-file + atomic rename. Because `~/.aws` now
*is* `~/.claude/aws`, the toolkit's own bookkeeping deliberately lives in
`~/.claude/aws-toolkit/` instead, so it never shows up inside the user's AWS dir.
- Every failure path still emits `aws.toolkit.ready` and exits 0 — `make
boot-check` requires that event unconditionally, so it must stay unconditional.

Expand Down
20 changes: 15 additions & 5 deletions .devcontainer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,22 @@
FIREWALL_MODE: ${FIREWALL_MODE:-strict}
TZ: ${TZ:-America/New_York}
# Agent Toolkit for AWS (init-aws-toolkit.sh). Region drives both the MCP
# endpoint host and the `aws agent-toolkit` skill-catalog calls. Read-only
# is the default posture: the AWS MCP Server acts as whatever IAM identity
# is in ~/.claude/aws/credentials, so writes are opt-in. Both take effect on
# the next container START (the MCP entry is re-registered every boot).
# endpoint host and the `aws agent-toolkit` skill-catalog calls. All three
# take effect on the next container START (the MCP entry is re-registered
# every boot), and all three are meant to be set per-host in .env.
AWS_MCP_REGION: ${AWS_MCP_REGION:-us-east-1}
AWS_MCP_READ_ONLY: ${AWS_MCP_READ_ONLY:-1}
# Space-separated AWS profile names. The FIRST is the server's default; the
# rest become selectable per tool call via the proxy's `aws_profile`
# parameter. Empty (the shipped default) omits --profile entirely, leaving
# the standard credential chain / AWS_PROFILE in charge. Profile names are
# host-specific, so set your own list in .env — see .env.example.
AWS_MCP_PROFILES: ${AWS_MCP_PROFILES:-}
# 1 = pass --read-only (server can describe but not mutate). Default 0:
# the MCP tools can CREATE, MODIFY and DELETE real AWS resources with the
# permissions of the selected profile. With a multi-profile list above,
# that includes every account in the list — order it so the least
# privileged account is first, since that one is the unqualified default.
AWS_MCP_READ_ONLY: ${AWS_MCP_READ_ONLY:-0}
volumes:
- workspace:/workspace # isolated code (no host path)
- config:/home/claude/.claude # Claude state/auth/gh/git/CLAUDE.md
Expand Down Expand Up @@ -98,7 +108,7 @@
timeout: 10s
retries: 3
start_period: 60s
# Optional guardrails so a runaway build can't starve the host (tune to taste).

Check warning on line 111 in .devcontainer/compose.yaml

View workflow job for this annotation

GitHub Actions / yamllint

111:5 [comments-indentation] comment not indented like content
# mem_limit: 8g
# cpus: 4

Expand Down
67 changes: 53 additions & 14 deletions .devcontainer/init-aws-toolkit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ command -v log_event >/dev/null 2>&1 || log_event() { :; }

CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
MANIFEST="${AWS_SKILLS_MANIFEST:-/usr/local/share/claude-seed/aws-skills.txt}"
STATE_DIR="$CONFIG_DIR/aws"
# Deliberately NOT $CONFIG_DIR/aws: seed-claude.sh symlinks ~/.aws at that
# directory, so anything we drop there would surface inside the user's AWS
# config dir. Our bookkeeping lives beside it, not in it.
STATE_DIR="$CONFIG_DIR/aws-toolkit"
STAMP="$STATE_DIR/.skills-stamp"

# Region drives BOTH the MCP endpoint host and the `aws agent-toolkit` calls. The
Expand All @@ -33,10 +36,17 @@ STAMP="$STATE_DIR/.skills-stamp"
# ~/.claude/aws/config would fail every skill install with `NoRegion`. Never drop
# the explicit --region.
AWS_MCP_REGION="${AWS_MCP_REGION:-us-east-1}"
# Read-only is the default posture: the MCP server runs with whatever IAM identity
# is in ~/.claude/aws/credentials, and this sandbox should not mutate real AWS
# infrastructure unless asked. Set AWS_MCP_READ_ONLY=0 (compose env) to allow writes.
AWS_MCP_READ_ONLY="${AWS_MCP_READ_ONLY:-1}"
# Space-separated profile names from ~/.claude/aws/config. The proxy takes a LIST:
# the first is the server's default identity, and the remainder become selectable
# per tool call through its `aws_profile` parameter — which is how one MCP server
# spans several accounts in an AWS Organization. Empty omits --profile entirely and
# falls back to the standard credential chain (AWS_PROFILE, env vars, SSO, ...).
AWS_MCP_PROFILES="${AWS_MCP_PROFILES:-}"
# WRITES ARE ENABLED BY DEFAULT (AWS_MCP_READ_ONLY=0). The MCP tools act with the
# full IAM permissions of the selected profile and can create, modify and delete
# real resources — in ANY account listed in AWS_MCP_PROFILES, not just the default
# one. Set AWS_MCP_READ_ONLY=1 to pass --read-only and restrict it to describes.
AWS_MCP_READ_ONLY="${AWS_MCP_READ_ONLY:-0}"
# Whole-step wall-clock budget for skill downloads, so a degraded network can't
# stretch boot by 23 sequential timeouts. An unfinished pass simply retries next boot.
AWS_SKILLS_BUDGET="${AWS_SKILLS_BUDGET:-180}"
Expand All @@ -59,6 +69,14 @@ fi

install -d -m 700 "$STATE_DIR"

# One-time migration off the pre-`~/.aws`-symlink stamp location. Moving rather
# than deleting keeps the "skills already installed" state, so an existing
# container doesn't re-download all 23 skills just because the path changed.
if [ -f "$CONFIG_DIR/aws/.skills-stamp" ] && [ ! -f "$STAMP" ]; then
mv "$CONFIG_DIR/aws/.skills-stamp" "$STAMP" 2>/dev/null || true
fi
rm -f "$CONFIG_DIR/aws/.skills-stamp"

# ---------------------------------------------------------------------------
# 1. MCP server registration — REGENERATED every boot, not copy-if-missing.
# ---------------------------------------------------------------------------
Expand All @@ -73,14 +91,26 @@ proxy_args=("$MCP_ENDPOINT")
if [ "$AWS_MCP_READ_ONLY" != "0" ]; then
proxy_args+=(--read-only)
fi
# Word-splitting is intentional: AWS_MCP_PROFILES is a space-separated list and
# `--profile` is variadic. Guarded by the emptiness test so an unset value can
# never expand to a bare `--profile` with no argument.
default_profile=""
if [ -n "$AWS_MCP_PROFILES" ]; then
# shellcheck disable=SC2086
set -- $AWS_MCP_PROFILES
default_profile="$1"
proxy_args+=(--profile "$@")
fi
proxy_args+=(--metadata "AWS_REGION=${AWS_MCP_REGION}")

claude mcp remove "$MCP_NAME" --scope user >/dev/null 2>&1 || true
if claude mcp add --scope user "$MCP_NAME" -- \
mcp-proxy-for-aws "${proxy_args[@]}" >/dev/null 2>&1; then
echo "[aws-toolkit] registered MCP server '${MCP_NAME}' -> ${MCP_ENDPOINT} (read_only=${AWS_MCP_READ_ONLY})"
echo "[aws-toolkit] registered MCP server '${MCP_NAME}' -> ${MCP_ENDPOINT}" \
"(read_only=${AWS_MCP_READ_ONLY}, profiles=${AWS_MCP_PROFILES:-<credential-chain>})"
log_event aws aws.mcp.registered name "$MCP_NAME" region "$AWS_MCP_REGION" \
read_only "$AWS_MCP_READ_ONLY" endpoint "$MCP_ENDPOINT"
read_only "$AWS_MCP_READ_ONLY" endpoint "$MCP_ENDPOINT" \
profiles "${AWS_MCP_PROFILES:-}" default_profile "${default_profile:-}"
else
echo "[aws-toolkit] WARN: failed to register MCP server '${MCP_NAME}'" >&2
log_event aws aws.mcp.failed name "$MCP_NAME" region "$AWS_MCP_REGION"
Expand All @@ -93,14 +123,23 @@ fi
# `-32602: Invalid request parameters`. Detect it here and say the useful thing
# instead. Absent credentials are a normal, user-fixable state, NOT a degraded
# boot, so this never changes `status`; it is one bounded, read-only API call.
if timeout 15 aws sts get-caller-identity --region "$AWS_MCP_REGION" \
--no-cli-pager >/dev/null 2>&1; then
log_event aws aws.credentials.ok region "$AWS_MCP_REGION"
# Probe the profile the server will actually default to, not the ambient chain —
# with a profile list configured those are different identities, and only the
# default one determines whether the initial handshake succeeds.
probe_args=(--region "$AWS_MCP_REGION" --no-cli-pager)
[ -n "$default_profile" ] && probe_args+=(--profile "$default_profile")

if timeout 15 aws sts get-caller-identity "${probe_args[@]}" >/dev/null 2>&1; then
log_event aws aws.credentials.ok region "$AWS_MCP_REGION" \
profile "${default_profile:-<credential-chain>}"
else
echo "[aws-toolkit] NOTE: no usable AWS credentials — '${MCP_NAME}' will show as" >&2
echo "[aws-toolkit] 'Failed to connect' until you run \`aws sso login\` or" >&2
echo "[aws-toolkit] \`aws configure\`. Skills below work regardless." >&2
log_event aws aws.credentials.absent region "$AWS_MCP_REGION"
echo "[aws-toolkit] NOTE: no usable AWS credentials for" \
"${default_profile:-the default credential chain} — '${MCP_NAME}' will show" >&2
echo "[aws-toolkit] as 'Failed to connect' until you authenticate, e.g." >&2
echo "[aws-toolkit] \`aws sso login --profile ${default_profile:-<profile>}\`." >&2
echo "[aws-toolkit] Skills below work regardless." >&2
log_event aws aws.credentials.absent region "$AWS_MCP_REGION" \
profile "${default_profile:-<credential-chain>}"
fi

# ---------------------------------------------------------------------------
Expand Down
28 changes: 25 additions & 3 deletions .devcontainer/seed-claude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,26 @@ install -d -m 700 "$CONFIG_DIR"
# AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE (set in the Dockerfile) point here
# so `aws configure` / `aws sso login` persist into the ~/.claude volume. The CLI
# won't create the parent dir for a custom config path, so ensure it exists.
install -d -m 700 "$CONFIG_DIR/aws"
AWS_DIR="$CONFIG_DIR/aws"
install -d -m 700 "$AWS_DIR"

# ...but those two env vars only relocate `config` and `credentials`. The SSO/OIDC
# TOKEN cache is NOT configurable — the CLI always writes it under ~/.aws
# (`cli/cache/session.db`, `sso/cache/`), which lives in the container layer and is
# destroyed by every rebuild. Result without this: config survives, tokens don't,
# so `aws sso login` has to be re-run for every profile after each `make rebuild`.
# Same fix as ~/.ssh below — point the whole directory at the volume (see the note
# there on why a *directory* symlink, not per-file: the CLI rewrites these caches
# via temp-file + atomic rename, which would replace a per-file symlink).
if [ ! -L "$HOME/.aws" ] && [ -d "$HOME/.aws" ]; then
# One-time migration off the container layer. `-n` (no-clobber) so a live
# token cache is preserved without ever overwriting the volume's config.
cp -an "$HOME/.aws/." "$AWS_DIR/" 2>/dev/null || true
rm -rf "$HOME/.aws"
fi
ln -sfn "$AWS_DIR" "$HOME/.aws"
echo "[seed] linked ~/.aws -> $AWS_DIR"
log_event seed seed.aws.linked target "$AWS_DIR"

# Static, user-editable guidance: seed once, then leave alone.
if [ ! -e "$CONFIG_DIR/CLAUDE.md" ] && [ -f "$SEED_SRC" ]; then
Expand Down Expand Up @@ -80,9 +99,10 @@ Regenerated at container start by \`seed-claude.sh\`. See CLAUDE.md for guidance
## Agent Toolkit for AWS (registered at boot by \`init-aws-toolkit.sh\`)
- MCP server: \`aws-mcp\` -> https://aws-mcp.${AWS_MCP_REGION:-us-east-1}.api.aws/mcp
- proxy: $(uv tool list 2>/dev/null | grep -m1 "^mcp-proxy-for-aws" || echo "mcp-proxy-for-aws n/a")
- region: ${AWS_MCP_REGION:-us-east-1} | read-only: ${AWS_MCP_READ_ONLY:-1} (set AWS_MCP_READ_ONLY=0 + restart to allow writes)
- region: ${AWS_MCP_REGION:-us-east-1} | read-only: ${AWS_MCP_READ_ONLY:-0} ($([ "${AWS_MCP_READ_ONLY:-0}" = "0" ] && echo "WRITES ENABLED — MCP can mutate real AWS resources" || echo "describes only"))
- profiles: ${AWS_MCP_PROFILES:-<standard credential chain>} (first = default; others selectable per call via \`aws_profile\`)
- skills: pinned in /usr/local/share/claude-seed/aws-skills.txt, installed to $CONFIG_DIR/skills
- AWS API tools need credentials — run \`aws configure\` or \`aws sso login\`
- credentials: $(timeout 10 aws sts get-caller-identity --query Arn --output text 2>/dev/null || echo "NONE — run \`aws sso login\`; the MCP server cannot connect without them")

## Database (Postgres sidecar — opt-in via the \`db\` compose profile)
- DATABASE_URL: $([ -n "${DATABASE_URL:-}" ] && echo "set" || echo "unset (db profile not active / .env absent)")
Expand All @@ -102,6 +122,8 @@ Regenerated at container start by \`seed-claude.sh\`. See CLAUDE.md for guidance
## Persistent volumes (survive rebuilds; removed only by \`docker compose down -v\`)
- /workspace -> claude-workspace
- /home/claude/.claude -> claude-config (this file lives here)
- /home/claude/.aws (symlink) -> claude-config:/aws (AWS config + SSO tokens)
- /home/claude/.ssh (symlink) -> claude-config:/ssh (git key + known_hosts)
- /commandhistory -> claude-bashhistory
- /home/claude/.local/share/pnpm -> claude-pnpm-store
- (db sidecar) /var/lib/postgresql/data -> claude-pgdata (only with the \`db\` profile)
Expand Down
31 changes: 21 additions & 10 deletions .devcontainer/seed/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,14 @@ No host credentials are mounted. Authenticate once inside; it persists in the
`~/.claude` volume:
- `gh auth login` (config in `$GH_CONFIG_DIR=~/.claude/gh`).
- `git config --global user.name/user.email` (`$GIT_CONFIG_GLOBAL=~/.claude/gitconfig`).
- AWS: the `aws` CLI (v2) is baked into the image. `aws configure` / `aws sso
login` write to `$AWS_CONFIG_FILE=~/.claude/aws/config` +
`$AWS_SHARED_CREDENTIALS_FILE=~/.claude/aws/credentials`, so creds persist in the
volume across rebuilds. Runtime egress to AWS APIs is allowed via the firewall's
`@aws-ip-ranges` directive (see `config/extra-allowlist.txt`). The AWS MCP
server (§8) uses these same credentials.
- AWS: the `aws` CLI (v2) is baked into the image. **`~/.aws` is a directory symlink
to `~/.claude/aws`**, so config, credentials, AND the SSO token cache all live in
the persistent volume — `aws sso login` survives rebuilds and does not need
repeating. (`$AWS_CONFIG_FILE` / `$AWS_SHARED_CREDENTIALS_FILE` point at the same
place.) With SSO, each profile is logged in separately and expires separately:
`aws sso login --profile <name>`. Runtime egress to AWS APIs is allowed via the
firewall's `@aws-ip-ranges` directive (see `config/extra-allowlist.txt`). The AWS
MCP server (§8) uses these same credentials.
- SSH for git: keep the key in the persistent `~/.claude/ssh/` volume — `~/.ssh`
itself is NOT persistent, so `seed-claude.sh` symlinks the whole `~/.ssh` dir to
`~/.claude/ssh` on every boot. Make a passphrase-free key (so no ssh-agent is
Expand Down Expand Up @@ -155,10 +157,19 @@ Registered automatically at every boot by `init-aws-toolkit.sh` — no setup nee
the CLI (§7), then restart Claude Code. The boot log says so explicitly, and
`~/.claude/logs/boot-events.jsonl` records `aws.credentials.absent`.
**The skills (below) work with or without credentials** — they're just documents.
- **READ-ONLY by default.** The server runs with `--read-only`, so it can inspect,
describe, and price resources but not create or delete them. Use the `aws` CLI
for deliberate writes, or restart the container with `AWS_MCP_READ_ONLY=0`
(a host-side compose env var) to enable the write tools.
- **Multiple accounts, one server.** If the host set `AWS_MCP_PROFILES`, the first
profile is the default identity and the rest are selectable **per tool call** via
the `aws_profile` parameter — so you can read from one account and act in another
in the same conversation without restarting anything. `~/.claude/ENVIRONMENT.md`
lists the active profiles and which one is the default. Each profile needs its
own `aws sso login`; they expire independently.
- **⚠ WRITES ARE ENABLED by default** (`AWS_MCP_READ_ONLY=0`). The MCP tools can
create, modify, and **delete** real AWS resources with the full IAM permissions of
the selected profile — in any account in the list, including production if it is
listed. Treat every mutating call as production-affecting: confirm the target
account before acting, and prefer the default (least-privileged) profile unless
the user names another. The host can restore describe-only with
`AWS_MCP_READ_ONLY=1`.
- **Region:** `us-east-1` unless the host sets `AWS_MCP_REGION`. It drives both the
MCP endpoint and the skill catalog.
- **Skills:** the 23-skill `aws-core` set (CDK, CloudFormation, IAM, serverless,
Expand Down
Loading
Loading