Atlas's brain reads two secrets with plain os.getenv: OPENROUTER_API_KEY (inference, ADR-116) and LOGFIRE_TOKEN (observability, ADR-118). Today those values arrive because scripts/deploy-atlas-staging.sh resolves them with deployer creds and injects them as plaintext runtime env vars. ADR-109 makes Terraform the sole IaC and retires that imperative deploy script — and the Terraform runtime resource deliberately leaves the two secret values empty so they never land in tfstate.
The naïve fix — keep an out-of-band injector that POSTs the secrets onto the runtime after terraform apply (ADR-121 Option A) — detonates on three doctrines: it needs lifecycle { ignore_changes = [environment_variables] }, which blinds terraform plan (ADR-109's single drift detector) on exactly the surface most likely to drift; it resurrects the bespoke deploy script ADR-109 deletes; and it leaves secrets retrievable by anyone with DescribeAgentRuntime.
The decision (ADR-121 D3, Option B): the agent's entrypoint reads its own secrets from Secrets Manager at cold start and sets os.environ before the brain reads it. The brain is unchanged — still os.getenv-only. Plaintext then exists in exactly one place: this process's memory. This Story is the pramaan-agents half; it must exist before the Phase-2 cutover (enable_atlas_runtime=true).
cold start (main.py)
│ bootstrap_secrets() ← NEW, runs FIRST (before any os.getenv)
▼
for (ARN_env → target_env) in:
OPENROUTER_SECRET_ARN → OPENROUTER_API_KEY
LOGFIRE_SECRET_ARN → LOGFIRE_TOKEN
│
├─ ARN unset? → skip (local dev / not wired)
├─ target already set? → skip (local .env / preset env WINS)
└─ else GetSecretValue(ARN) → os.environ[target] = SecretString
│ any error (AccessDenied, SM down, empty) → leave UNSET, never raise
▼
configure_observability() ← now sees LOGFIRE_TOKEN if it was loaded
brain run (provider.py) ← now sees OPENROUTER_API_KEY if it was loaded
no key → existing fail-closed (provider raises / Logfire no-op)
One new module (secrets_bootstrap.py — the only boto3/Secrets Manager surface in Atlas), one call site (main.py cold start), one dep (boto3), plus the ADR-110 §6 CI grep — added here for the first time, together with the secretsmanager allowance it fences.
| Piece | What | WHY |
|---|---|---|
agents/atlas/secrets_bootstrap.py | Cold-start GetSecretValue by ARN env vars → set target env var, only-if-unset, per-process guard, never raises, never logs the value. | ADR-121 D3: the secret VALUE delivery is a boot-time read, not a deploy-time injection. boto3 lives only here (D5 fence 1). |
main.py wiring | bootstrap_secrets() called before configure_observability() and before any brain run. | The brain reads os.getenv at import/run; the env must be populated first or the read sees nothing. |
| only-if-unset | A target already set is never overwritten; SM is not even consulted for it. | Local .env/preset env always wins; idempotent on warm-container reuse (D3). |
| fail-safe | Missing ARN / AccessDenied / SM error / empty value ⇒ env stays unset, no crash. | D5 fence 3: the existing fail-closed path applies — provider=openrouter with no key raises a clear error; absent LOGFIRE_TOKEN makes Logfire a no-op. |
scripts/check_brain_egress.py + tests/test_brain_egress_check.py | Greps agents/<agent>/ for ORM imports + boto3.client('s3'|'sqs'|'dynamodb'|'secretsmanager'); allowlists secretsmanager in secrets_bootstrap.py ONLY. Wired into CI. | D5 fence 4: the ADR-110 §6 grep was specified but never present; it lands now with the capability it fences, so the carve-out cannot widen into the brain. |
boto3 in pyproject.toml | Added (botocore was already present). | The bootstrap module needs the boto3 client; the brain stays boto3-free. |
OPENROUTER_SECRET_ARN, LOGFIRE_SECRET_ARN) per ADR-121 D2 — so the bootstrap reads the right secret without a hardcoded name, and the ARN belongs in the plan diff. Only the resolved values are sensitive.observability.py's docstring literally says "touches no boto3 s3/sqs/dynamodb". The grep matches boto3.client("<svc>"), not the bare service name, so honest prose is not a false positive — verified by a dedicated test, plus find_violations() over the real tree returns empty.| Check | Result |
|---|---|
(cd agents/atlas && pytest tests -q) | 80 passed (incl. 8 new bootstrap tests: load, only-if-unset, missing-ARN no-op, SM-error non-fatal, empty value, idempotent guard, value-never-logged). |
pytest tests/ -m unit (governance) | 35 passed (incl. 11 brain-egress grep tests). |
ruff check agents/ tests/ scripts/ | All checks passed. |
python scripts/check_brain_egress.py | clean; planting a brain-side boto3.client("secretsmanager") ⇒ exit 1 (caught); secrets_bootstrap.py ⇒ allowed. |
provider.py/config.py/observability.py still os.getenv-only). The live staging runtime was NOT touched — the D6 role re-grant + the Terraform env map are pramaan-infra cutover steps (Story-1214), and Bug-1310 (runtime recreate) is in flight separately.