Story-1317 · pramaan-docs ADR-121 · D3/D5

Atlas: the entrypoint reads its own secrets at cold start

Branch issue-1317-secret-bootstrap-module · Story: pramaan-io/pramaan-spec#1317 · Provenance: pramaan-docs ADR-121 (env delivery, D3 boot read + D5 §6 carve-out), amends ADR-110 §6 (thin brain), refines ADR-116 (OpenRouter key) & ADR-118 (Logfire token). Gates Story-1214 Phase-2 cutover.

🛰️ 1 · Satellite — why this exists

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).

✈️ 2 · Airplane — the shape of the change

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.

🔬 3 · Microscope — what was added, and WHY each piece

PieceWhatWHY
agents/atlas/secrets_bootstrap.pyCold-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 wiringbootstrap_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-unsetA 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-safeMissing 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.pyGreps 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.tomlAdded (botocore was already present).The bootstrap module needs the boto3 client; the brain stays boto3-free.
ARNs are not secret The secret ARNs arrive as ordinary Terraform env vars (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.
Why the grep matches the call form, not the word 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.

✅ 4 · Verification

CheckResult
(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.pyclean; planting a brain-side boto3.client("secretsmanager") ⇒ exit 1 (caught); secrets_bootstrap.py ⇒ allowed.
Scope honoured Code + tests only. No brain-logic change (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.