Merge branch 'dev' into fix/promote-dev-to-main-push

This commit is contained in:
Adam Moussa 2026-06-28 20:45:59 -04:00 • committed by GitHub
commit 3d8128ee18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 72 additions and 49 deletions

View file

@ -158,34 +158,64 @@ while IFS=$'\t' read -r nb vb; do
done < <(jq -r '.Parameters[] | (.Name|@base64) + "\t" + (.Value|@base64)' <<<"$ssm_json") done < <(jq -r '.Parameters[] | (.Name|@base64) + "\t" + (.Value|@base64)' <<<"$ssm_json")
log "loaded ${ssm_count} config param(s) from SSM" log "loaded ${ssm_count} config param(s) from SSM"
# 2) Secrets Manager (sensitive values). batch-get-secret-value filters by name # 2) Secrets Manager (sensitive values). We request secrets by EXPLICIT id
# prefix; one secret per env var, SecretString = the value. The AWS CLI does NOT # (`batch-get-secret-value --secret-id-list ...`) rather than a name-prefix
# auto-paginate this operation (unlike list/get-parameters-by-path), and a single # `--filters` collection scan (OSWE-IAC-SECRETS-LIST-01). Two wins:
# response caps well under the full set (~10 items/page) — so we MUST follow # (a) Least privilege — an explicit id list lets the instance role scope
# NextToken ourselves or secrets on later pages are silently dropped (they then # BatchGetSecretValue to the per-secret ARN prefix and DROP the account-wide
# surface as FAIL-FAST "missing required var"). `--no-cli-pager` only disables the # `secretsmanager:ListSecrets` grant that a filtered scan unavoidably forces
# OUTPUT pager, not API pagination. Each page's records are base64(name<TAB>value). # (ListSecrets has no resource-level scoping). A filtered batch call also only
# authorizes against `*`; an id-list call authorizes per-secret ARN.
# (b) Deterministic set — the value set is the fixed SECRET_VARS shells created by
# the CDK ConfigStore, so we no longer depend on a list scan returning every
# page. Value-less shells and ids with no current value come back in the
# response `.Errors[]` (ResourceNotFound), never in `.SecretValues[]`, so a
# genuinely-missing REQUIRED secret is still caught by the FAIL-FAST check
# below — an absent optional secret is simply skipped.
# `--secret-id-list` is capped at 20 ids per call, so we chunk it. `--no-cli-pager`
# disables only the OUTPUT pager. Each record is base64(name<TAB>value).
#
# SOURCE OF TRUTH for this list: infra/lib/constructs/config-store.ts `SECRET_VARS`.
# Keep the two in lockstep — a new secret shell created there must be added here or it
# will never be fetched into the .env.
SECRET_VARS=(
ANTHROPIC_API_KEY CORRIDOR_API_TOKEN CORRIDOR_MCP_TOKEN CORRIDOR_TOKEN
DASHBOARD_JWT_SECRET DAYTONA_API_KEY EXA_API_KEY FIREWORKS_API_KEY
GITHUB_APP_CLIENT_SECRET GITHUB_APP_PRIVATE_KEY GITHUB_PAT GITHUB_WEBHOOK_SECRET
GOOGLE_API_KEY GROQ_API_KEY JUDGE_ANTHROPIC_API_KEY LANGSMITH_API_KEY
LANGSMITH_API_KEY_PROD LANGCHAIN_API_KEY LINEAR_API_KEY LINEAR_WEBHOOK_SECRET
OPENAI_API_KEY RUNLOOP_API_KEY SLACK_BOT_TOKEN SLACK_CLIENT_SECRET
SLACK_SIGNING_SECRET TOKEN_ENCRYPTION_KEY USER_ID_API_KEY_MAP X_SERVICE_AUTH_JWT_SECRET
)
batch_get_secrets_tsv() { batch_get_secrets_tsv() {
local token="" page local -a ids=()
while :; do local v
if [ -n "$token" ]; then for v in "${SECRET_VARS[@]}"; do ids+=("${SECRET_PREFIX}${v}"); done
page="$(aws secretsmanager batch-get-secret-value \
--filters "Key=name,Values=${SECRET_PREFIX}" \ local i page
--region "$REGION" --no-cli-pager --output json --next-token "$token")" local -a chunk
else for ((i = 0; i < ${#ids[@]}; i += 20)); do
page="$(aws secretsmanager batch-get-secret-value \ chunk=("${ids[@]:i:20}")
--filters "Key=name,Values=${SECRET_PREFIX}" \ # Capture the response into a variable FIRST so a non-zero `aws` exit (throttle,
--region "$REGION" --no-cli-pager --output json)" # AccessDenied, KMS DecryptionFailure) aborts under set -e instead of being
fi # silently swallowed — then we'd FAIL-FAST below as "missing secret" with a wrong
# root cause. (Value-less / absent shells come back in .Errors[], not .SecretValues[].)
page="$(aws secretsmanager batch-get-secret-value \
--secret-id-list "${chunk[@]}" \
--region "$REGION" --no-cli-pager --output json)"
printf '%s' "$page" \ printf '%s' "$page" \
| jq -r '.SecretValues[] | select(.SecretString != null) | (.Name|@base64) + "\t" + (.SecretString|@base64)' | jq -r '.SecretValues[] | select(.SecretString != null) | (.Name|@base64) + "\t" + (.SecretString|@base64)'
token="$(printf '%s' "$page" | jq -r '.NextToken // empty')"
[ -n "$token" ] || break
done done
} }
log "reading secrets under ${SECRET_PREFIX} ..." log "reading secrets under ${SECRET_PREFIX} ..."
secret_count=0 secret_count=0
# Capture into a variable (NOT `done < <(...)` process substitution) so a non-zero
# exit from batch_get_secrets_tsv propagates under set -e — process substitution hides
# the producer's exit status from the parent shell, which would let a failed AWS call
# fall through to a misleading "missing required var" FAIL-FAST. Mirrors the SSM read.
secrets_tsv="$(batch_get_secrets_tsv)"
while IFS=$'\t' read -r nb vb; do while IFS=$'\t' read -r nb vb; do
[ -n "$nb" ] || continue [ -n "$nb" ] || continue
name="$(printf '%s' "$nb" | b64d)" name="$(printf '%s' "$nb" | b64d)"
@ -198,7 +228,7 @@ while IFS=$'\t' read -r nb vb; do
[ -n "$key" ] || continue [ -n "$key" ] || continue
accept_var "$key" "$value" "Secrets" accept_var "$key" "$value" "Secrets"
secret_count=$((secret_count + 1)) secret_count=$((secret_count + 1))
done < <(batch_get_secrets_tsv) done <<<"$secrets_tsv"
log "loaded ${secret_count} secret(s) from Secrets Manager" log "loaded ${secret_count} secret(s) from Secrets Manager"
# --- Sea Haven DEFAULT_REPO_OWNER guard -------------------------------------- # --- Sea Haven DEFAULT_REPO_OWNER guard --------------------------------------

View file

@ -64,35 +64,28 @@ export class InstanceRole extends Construct {
// random 6-char suffix, hence the trailing `*`. This is the statement that // random 6-char suffix, hence the trailing `*`. This is the statement that
// actually gates which secret VALUES the box can read: prefix-scoped, so the // actually gates which secret VALUES the box can read: prefix-scoped, so the
// dev box can never read prod secret values (and vice versa). GetSecretValue is // dev box can never read prod secret values (and vice versa). GetSecretValue is
// checked per-secret even when the value is returned via the batch call below. // checked per-secret even when the value is returned via BatchGetSecretValue.
//
// BatchGetSecretValue is included here (prefix-scoped, NOT `*`) because
// fetch-config.sh now fetches by EXPLICIT `--secret-id-list` rather than a name
// `--filters` scan (OSWE-IAC-SECRETS-LIST-01). An id-list batch call authorizes
// against each referenced secret's ARN, so the prefix ARN satisfies it — and we
// no longer need the account-wide `secretsmanager:ListSecrets` grant that a
// filtered collection scan unavoidably forced (ListSecrets has no resource-level
// scoping). Net effect: the box can read open-swe-<env>/* secret values and
// nothing else — it can no longer enumerate secret names account-wide.
this.role.addToPolicy( this.role.addToPolicy(
new iam.PolicyStatement({ new iam.PolicyStatement({
sid: "ReadSecretValues", sid: "ReadSecretValues",
actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], actions: [
"secretsmanager:GetSecretValue",
"secretsmanager:BatchGetSecretValue",
"secretsmanager:DescribeSecret",
],
resources: [`arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:${p}/*`], resources: [`arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:${p}/*`],
}), }),
); );
// OPERATION-level grants for fetch-config.sh's prefix-FILTERED batch read
// (`batch-get-secret-value --filters Key=name,Values=open-swe-<env>/`). Both of
// these are collection operations that AWS authorizes against `*`, NOT a
// per-secret ARN: BatchGetSecretValue with a filter is a collection call (a
// prefix-scoped ARN does NOT satisfy it — it AccessDenies), and ListSecrets is a
// list action with no resource-level scoping at all. Neither returns or widens
// VALUE access: a secret's value is still only returned when the prefix-scoped
// GetSecretValue above allows it, so cross-env VALUE isolation is preserved. The
// residual is metadata-only (the box can ENUMERATE secret names account-wide).
// Future hardening to drop both `*` grants: switch fetch-config to an explicit
// `--secret-id-list` (no filter), which lets BatchGetSecretValue be prefix-scoped
// and needs no ListSecrets. Tracked as OSWE-IAC-SECRETS-LIST-01.
this.role.addToPolicy(
new iam.PolicyStatement({
sid: "SecretsBatchListOps",
actions: ["secretsmanager:BatchGetSecretValue", "secretsmanager:ListSecrets"],
resources: ["*"],
}),
);
// NOTE (T11): SSM SecureString + Secrets Manager here are assumed to use the // NOTE (T11): SSM SecureString + Secrets Manager here are assumed to use the
// AWS-managed keys (alias/aws/ssm, alias/aws/secretsmanager) for which the // AWS-managed keys (alias/aws/ssm, alias/aws/secretsmanager) for which the
// service grants Decrypt implicitly — so NO kms:Decrypt is granted. If T11 // service grants Decrypt implicitly — so NO kms:Decrypt is granted. If T11

View file

@ -10,7 +10,7 @@ dependencies = [
"fastapi>=0.136.3", "fastapi>=0.136.3",
"uvicorn>=0.48.0", "uvicorn>=0.48.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"PyJWT>=2.12.1", "PyJWT>=2.13.0",
"cryptography>=48.0.1", "cryptography>=48.0.1",
"langgraph-sdk>=0.4.2", "langgraph-sdk>=0.4.2",
"langchain>=1.3.9", "langchain>=1.3.9",

10
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.11" requires-python = ">=3.11"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14'", "python_full_version >= '3.14'",
@ -2045,7 +2045,7 @@ requires-dist = [
{ name = "langsmith", specifier = "==0.8.18" }, { name = "langsmith", specifier = "==0.8.18" },
{ name = "markdownify", specifier = ">=1.2.2" }, { name = "markdownify", specifier = ">=1.2.2" },
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
{ name = "pyjwt", specifier = ">=2.12.1" }, { name = "pyjwt", specifier = ">=2.13.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.15" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.15" },
@ -2618,11 +2618,11 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.12.1" version = "2.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]