Skip to content

feat(compute): Lambda MicroVMs P2 — smoke parity foundations (#645) - #733

Draft
dreamorosi wants to merge 8 commits into
aws-samples:mainfrom
dreamorosi:feat/645-lambda-microvm-p2
Draft

feat(compute): Lambda MicroVMs P2 — smoke parity foundations (#645)#733
dreamorosi wants to merge 8 commits into
aws-samples:mainfrom
dreamorosi:feat/645-lambda-microvm-p2

Conversation

@dreamorosi

Copy link
Copy Markdown
Member

Implements Phase P2 of ADR-021 short of the live smoke run: the agent is now fully launchable and observable on the lambda-microvm backend. Follows #689 (P1). Honest scope note: the clone→change→PR smoke run itself (plus the deferred empirical items: suspend TTL >1h, SUSPENDED-vs-quota, microvmImageHooks API spelling, NO_INGRESS ARN) executes against a live account after this lands and is tracked on #645 — this PR is the foundations, not the completion claim.

Platform config travels with the task, not the image

MicroVM env vars are image-version-frozen, and external images can't receive stack env at all — so deployment identifiers (table names, secret ARNs, session-role ARN; 13-key allowlist, 4 required) now ride the /run envelope as a platform_config block. The agent installs only allowlisted keys into its environment before any credential/pipeline initialization and fails closed on unknown keys (env installation from a network payload = injection surface). The allowlist lives once in contracts/constants.json; both the agent and the CDK strategy derive from it and check-constants-sync forbids literal re-declarations. Canonical wire shapes are documented in ADR-021 §3.

Snapshot credential hygiene (real defect found and fixed)

/ready was spinning up the CloudWatch debug writer, which resolved a credential chain and pinned the build-time region into boto3.DEFAULT_SESSION — state the image snapshot would replay into every MicroVM. Build hooks and pre-install /run logging are now stdout-only; a poisoned-seam test suite (every AWS/credential entry point armed to throw) plus a subprocess regression test lock the property, mutation-checked.

Full hook set + IAM parity + dual-signal liveness

The image now declares exactly what the agent serves: ready+validate (image hooks; /validate makes zero AWS calls — it runs under the build role) and run+terminate (runtime; /terminate returns 200 for any body and never writes terminal task status — the orchestrator owns terminal state). The execution role gains feature-based runtime parity (GitHub PAT + channel-OAuth secrets, scoped Bedrock, Memory grantReadWrite, AZ describe — still no direct DynamoDB; tenant data flows through the SessionRole). Heartbeat-staleness liveness now covers lambda-microvm (exhaustive per-backend switch; agentcore byte-identical, ECS excluded, RUNNING-scoped so P3 suspends stay immune) — closing the hung-pipeline-behind-healthy-substrate blind spot.

Docs

COMPUTE.md gains the Lambda MicroVMs column + explicit classic-Lambda distinction (the #645 acceptance criterion) with live-verified values only; ORCHESTRATOR.md gains the dual-signal liveness + lifecycle sections; SECURITY.md covers all three compute roles; ADR-021 amended in place (proposed).

Verification: cdk 3 825 / agent 1 590 (82.8% cov) / cli untouched; tsc, eslint, ruff/ty/vulture clean; drift-prevention green; docs sync idempotent + astro check clean; gitleaks clean. security:sast runs in CI (semgrep unavailable locally).

Refs #645

dreamorosi and others added 2 commits August 6, 2026 14:06
…ples#645)

Implement Phase P2 of ADR-021 short of the live smoke run: the agent
is now fully launchable and observable on the lambda-microvm backend.

Agent (agent/src/server.py):
- platform_config delivery: deployment-specific, non-secret
  identifiers (13-key allowlist, 4 required) arrive in the /run
  envelope and are installed into the environment before any
  credential or pipeline initialization; unknown keys fail closed
  (400 MICROVM_RUN_PLATFORM_CONFIG_INVALID / _INCOMPLETE). Decided
  over image-baked env by configuration lifetime: platform values
  belong to the deployment, image versions to packaging - and
  external images cannot receive stack env at all
- /validate (image hook): shallow, zero AWS calls - it runs under
  the build role; /terminate (runtime hook): best-effort flush,
  returns 200 for any body (raw-Request handler, structural guard
  against reintroducing a typed body model), never writes terminal
  task status
- snapshot credential hygiene: /ready no longer spins up the
  CloudWatch writer (it pinned a build-time region + resolved
  credential chain into boto3.DEFAULT_SESSION - state a snapshot
  would replay into every MicroVM); /run pre-install logging is
  stdout-only until platform_config is installed
  (poisoned-seam + subprocess regression tests, mutation-checked)
- the /run S3 payload fetch now uses the attributed client factory

Infra (cdk):
- platform_config producer in the strategy: closed map over the
  contract keys, env read at call time, required-key guard with
  remedy, boundary math includes the block, size-check before
  PutObject, key-names-only logging
- execution-role runtime IAM parity (feature-based, not an ECS
  copy): GitHub PAT + channel-OAuth-prefix secrets, scoped Bedrock
  invocation, AgentCore Memory grantReadWrite, AZ describe; still no
  direct DynamoDB (tenant data flows through the SessionRole)
- heartbeat liveness extended to lambda-microvm (exhaustive
  per-backend switch; agentcore byte-identical, ecs excluded;
  RUNNING-scoped so P3 suspends stay immune) - closes the
  hung-in-guest-pipeline blind spot behind a healthy substrate
- hooks declared to match what the agent serves: ready+validate
  (image, 60s) and run+terminate (runtime, 60s/15s), with
  both-direction exact-set tests; packaging script hooks JSON updated

Contracts: microvm_platform_config in contracts/constants.json is
the single source of truth; both the agent and the CDK strategy
derive from it and check-constants-sync validates shape and forbids
literal re-declarations.

Docs: ADR-021 amended in place (canonical wire shapes, per-phase
hook table, platform-config delivery, dual-signal liveness);
COMPUTE.md gains the Lambda MicroVMs column + classic-Lambda
distinction (aws-samples#645 acceptance criterion) and backend overview;
ORCHESTRATOR.md gains the dual-signal liveness and lifecycle
sections; SECURITY.md covers all three compute roles.

Remaining for P2 completion: the live smoke run (clone -> change ->
PR with bgagent watch) and the deferred empirical items (suspend
TTL >1h, SUSPENDED-vs-quota, microvmImageHooks API spelling,
NO_INGRESS ARN) - tracked on aws-samples#645.

Verification: cdk 3825, agent 1590 (coverage 82.8%), cli untouched;
tsc/eslint/ruff/ty/vulture clean; drift-prevention (constants-sync,
types-sync, pins) green; docs sync idempotent; docs:check clean.
security:sast runs in CI (semgrep unavailable locally).

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
'sk-ant-secret' pattern-matches Anthropic key detectors (Code
Defender warned; CI gitleaks could fail). Replaced with a
non-matching dummy; test intent unchanged (secret values must not
leak into session-start logs). Full P2 diff swept for other
detector-pattern lookalikes: none. gitleaks local scan clean.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
@dreamorosi dreamorosi closed this Aug 6, 2026
@dreamorosi dreamorosi reopened this Aug 6, 2026
dreamorosi and others added 2 commits August 6, 2026 19:55
…#645)

The Stage D live smoke run (evidence: aws-samples#645 thread) failed at turn 0
and surfaced five live-contract defects invisible to synth, unit
tests, and cdk-nag. This lands all corrections:

- P2-F1/F3: drop aws:SourceAccount from all MicroVM-facing role
  trust policies - the service presents no source key when assuming
  them (deterministic connector CREATE_FAILED; misleading caller-
  side PassRole denial proven by elimination). ADR security table
  states the limitation honestly with per-role passer accounting
  (CloudFormation passes build/operator roles; only the orchestrator
  passes the execution role) and the qualified Resource:* exceptions
- P2-F5: cold 225 MiB claude binary killed every task (10s version
  probe vs lazy snapshot hydration). /ready now warms claude
  (required, 120s) then git/node (optional, shared 240s ceiling
  inside the 300s hook budget; hung optional can never starve the
  snapshot); runner probe raised to 60s. Fake-clock budget tests
- P2-F2: CFN enforces API enums at change-set time, refuting the
  string-shape reasoning - ARM_64 + ENABLED hook states; routes live
  only in MICROVM_AGENT_HOOK_ROUTES; negative test keeps route
  strings out of the image resource; new drift-guard test diffs the
  script's actual flags against the synthesized template
- P2-F4: execution role granted CreateLogStream/PutLogEvents on the
  application log group whose name travels in platform_config
- P2-F6: ADR corrected - the service reaps run-hook FAILURES
  (4xx -> ~12s terminate); active terminate retained for success
  paths (after a 200 the service has no view of the guest)
- P2-F8: empty terminate-hook microvmId is expected-normal;
  artifacts/trace bucket sameness documented as intentional

Fixed-but-not-re-exercised: the CDK-managed image path and the
warm-up's effect on snapshot warmth are proven against the run's
verbatim errors; the follow-up live re-run converts them.

cdk 3832, agent 1609, cli 736; build/drift-prevention/docs green.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
Clears GHSA-5p4m-2wfm-xmqj (High) flagged by osv-scanner. The root
resolutions range ^4.2.0 already admits 4.3.1; minimal single-entry
lock refresh under the CI toolchain (Node 22.23.2 + Yarn 1.22.22),
byte-identical across repeated installs. osv clean; cdk 3832 and
cli 736 green.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@c927a20). Learn more about missing BASE report.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #733   +/-   ##
=======================================
  Coverage        ?   91.87%           
=======================================
  Files           ?      294           
  Lines           ?    82848           
  Branches        ?     9037           
=======================================
  Hits            ?    76120           
  Misses          ?     6728           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…-samples#645)

The Stage D-redux smoke run achieved the P2 acceptance criterion
(clone -> change -> PR on the lambda-microvm backend:
dreamorosi/batch-sync-triage#6, 153s, $0.28, 12 turns) and converted
four of five Stage E fixes live. It also disproved run 1's
exoneration of the identity-side PassRole condition via a controlled
two-arm experiment - run 1's control was contaminated by its own
temporary unconditioned grant. This lands the residual fixes:

- MicrovmPassExecutionRole: drop iam:PassedToService (denied on the
  RunMicrovm path; two-arm evidence in the reversed comment); the
  exact execution-role ARN remains the scoping. Tests assert no
  Condition, exact ARN, no wildcard
- Bootstrap 1.4.0: new MicrovmPassRoles statement in the
  backend-conditional compute-lambda-microvm policy so CloudFormation
  can pass the build/connector-operator roles for the CDK-managed
  image path (three-leg evidence: byte-identical live policy,
  simulate allowed-with/implicitDeny-without, same-role out-of-band
  control). Execution role deliberately excluded; infrastructure's
  allowlisted IAMPassRole untouched and now test-pinned. Golden
  DEPLOYMENT_ROLES block + re-bootstrap callout; operators must
  re-bootstrap to >= 1.4.0
- agent_heartbeat_at now projected through toTaskDetail and shown in
  bgagent status/detail renderers (its absence caused run 1's wrong
  liveness conclusion while DynamoDB held a 6s-old value)
- ADR: per-role passer accounting (orchestrator passes the execution
  role in practice; the deploy role's prefix grant technically
  matches it), smoke status corrected to record the passing run and
  the two fixes still awaiting live re-exercise, template-size
  consequence at 98.6% (aws-samples#735)

Honest residuals: P2r2-F9/F10 fixes are evidence-based but not yet
re-exercised live; suspend TTL beyond 1h and SUSPENDED-vs-quota
remain open (AWS-side observability gaps).

cdk 3841, cli 745, agent 1609; build/drift-prevention/bootstrap
determinism/docs all green.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
@dreamorosi
dreamorosi marked this pull request as ready for review August 7, 2026 05:55
@dreamorosi
dreamorosi requested review from a team as code owners August 7, 2026 05:55
dreamorosi and others added 3 commits August 7, 2026 00:24
Two branches: the exhaustive unknown-compute-type guard in the
heartbeat liveness check (rejects rather than bypassing), and
run_agent's wiring of the extracted claude version probe. Patch
coverage 100% on both files. cdk 3842, agent 1610.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
…mples#645)

Pre-review of PR aws-samples#733 per .abca/commands/review_pr.md surfaced six
blockers and a set of nits; all addressed:

- B2: the no-platform_config /run branch logged via _warn_cw before
  anything was installed, violating the PR's own pre-install
  stdout-only EARS rule (the P2 build-hook defect one phase later);
  routed through _pre_config_log
- B3: the seam-guard test could not catch B2 - it disarmed on any
  _install_platform_config return including the vacuous no-config
  early-return; now disarms only on non-empty installs, with
  _extract_invocation_params as the documented legacy-path phase
  marker, and the no-config case joined the armed parametrization
- B4: the diagnostics-only claude version probe could still kill a
  task (TimeoutExpired/OSError propagated); now warns and continues,
  with parametrized non-fatality tests
- B5: the /ready budget invariant lived as a hardcoded 300 in a
  Python test; both budgets now derive from contracts/constants.json
  (microvm_hook_budgets) with ordering invariants and no-literal
  redeclaration checks in check-constants-sync
- B6: SECURITY.md now states the MicroVM compute-role delta
  explicitly (no confused-deputy trust condition - service
  limitation, evidenced) with the complete grant enumeration
- B1: ADR-021 section 4 is evidence-self-sufficient (verbatim
  CREATE_FAILED, simulate-principal-policy both arms, out-of-band
  control, contaminated-control chronology); the P1+P2 verification
  runbooks are now COMMITTED under docs/verification/ with IAM
  unique-IDs, account IDs, emails, and VM-specific endpoints
  redacted (gitleaks + Code Defender clean)
- Nits: iam:PassRole recorded in resource-action-map for the two
  MicroVM CFN types; bedrock-models JSDoc reattached; /terminate
  active count uses None-for-unknown; /validate 503 documented as a
  refactor tripwire; trust-comment block trimmed to conclusion +
  ADR pointer (evidence lives once); frozen warning-id note;
  whitespace; two cdk/AGENTS.md Common-mistakes bullets (L1 string
  enums validate only at change-set time; statement-level bootstrap
  additions still need re-bootstrap + MINOR bump)

cdk 3843, cli 745, agent 1622 (83.17% cov); build, drift-prevention,
bootstrap determinism, docs sync all green.

Refs aws-samples#645

Co-authored-by: Claude <noreply@anthropic.com>
… into feat/645-lambda-microvm-p2

Upstream gained one commit — c927a20 "feat(orchestration): admission queue
with deferred pickup (aws-samples#441) (aws-samples#544)" — which overlaps this branch in the same
13 files the heartbeat/MicroVM work touches. Both change sets are kept; only
three files needed manual resolution.

Auto-merged, verified by hand (disjoint regions, no re-seating needed):

- shared/orchestrator.ts — aws-samples#544 adds `queueTask()` between `finalizeTask` and
  `failTask`; it does NOT touch `PollState` or `pollTaskStatus`, so
  `heartbeatLivenessApplies`, `buildComputeMetadata` and
  `reconcileMicrovmSubstrateState` stay exactly where they were seated.
- orchestrate-task.ts — aws-samples#544 rewrites the `admission-control` step (queue
  instead of fail) and the Linear/Jira cap-feedback copy; ours owns
  `start-session`, the MicroVM poll cross-check and `finalize`. Import list
  unions `queueTask` with `buildComputeMetadata` /
  `reconcileMicrovmSubstrateState`.
- shared/types.ts + cli/src/types.ts — `TaskDetail` carries BOTH additions in
  the same order on both sides (`agent_heartbeat_at` in the timestamp block,
  `queued_at` / `queue_position` / `estimated_wait_s` appended after
  `awaiting_approval_request_id`), so `check:types-sync` still matches
  exactly. `toTaskDetail` keeps its new `queueInfo` parameter and maps the
  heartbeat.
- cli/src/format.ts — both renderers show both fields: `Queue: position N`
  with the pre-start config lines (after `Branch`), `Heartbeat:` with the
  temporal block (after `Completed` / next to `Last event:`). The two are
  mutually exclusive in practice — a QUEUED task has no heartbeat, and a
  beating task has no queue position.
- cancel-task.ts — the new `QUEUED` cancel path cannot reach the MicroVM
  `TerminateMicrovm` branch: that block is gated on `wasRunning &&
  runtimeSessionId`, neither of which a queued task has.

Resolved manually:

- docs/design/ORCHESTRATOR.md (+ Starlight mirror) — transition table takes
  aws-samples#544's four `QUEUED` rows plus its reworded `SUBMITTED -> FAILED`, and keeps
  our backend-agnostic `HYDRATING -> RUNNING` wording; admission-control step
  takes aws-samples#544's queue semantics for the concurrency cap and rate limiting, and
  keeps our "configured system limit and selected-backend quotas" for system
  concurrency. `mise //docs:sync` reproduces the mirror byte-for-byte.
- yarn.lock — the js-yaml descriptor follows the root `resolutions` bump
  ^4.2.0 -> ^4.3.1 that came with aws-samples#544; the pinned 4.3.1 version/integrity was
  already identical on both sides, and the MicroVM SDK entries merged clean.
  `yarn install --check-files` reports the lock already up to date.

Verification: `mise run build` (agent 1622, cdk 183 suites / 3885, cli 56
suites / 750, docs build + link check, types/constants/coverage/transitive-pin
drift checks) green; `mise run drift-prevention` green; `mise //docs:sync` and
`mise //cdk:bootstrap:generate` both no-ops; `mise //cdk:eslint` and
`mise //cli:eslint` (--fix) produce no mutation.
@dreamorosi

Copy link
Copy Markdown
Member Author

Pre-review pass (self-commissioned, per .abca/commands/review_pr.md) + housekeeping since the smoke run, for reviewer context:

Pre-review found 6 blockers; all fixed in 38ecc66. The two worth highlighting: the no-platform_config /run branch violated this PR's own pre-install stdout-only EARS rule (_warn_cw → CW thread → pinned boto3.DEFAULT_SESSION — the P2 build-hook defect one phase later), and the seam-guard test structurally couldn't catch it (disarmed on the vacuous no-config early-return — asserting what the code does, not what it should). Also: the diagnostics-only claude probe could still kill a task on TimeoutExpired (now non-fatal), the /ready budget invariant moved from a hardcoded test literal into contracts/constants.json with ordering invariants, and SECURITY.md now states the MicroVM trust-condition delta explicitly.

The verification runbooks are now committed (docs/verification/, ~4,100 lines, redacted: IAM unique-IDs, account IDs, emails, VM endpoints) — every IAM relaxation this PR ships cites evidence that is now reviewable in-repo, and ADR-021 §4 additionally inlines the load-bearing pieces (two-arm PassRole experiments, verbatim denials, the contaminated-control chronology).

One PR-body correction (pre-review caught it): the body said security:sast runs in CI — it actually runs on the weekly schedule, not on PRs (security-pr.yml is gitleaks/osv/zizmor only). The new error-handling code was hand-checked against .semgrep/silent-success-masking.yaml; a workflow_dispatch of security.yml on this branch pre-merge is cheap if you want the full SAST suite first.

08b3091 — merged current main (#544 admission queue; 13 overlapping files, 3 textual conflicts, no semantic re-seating needed — PollState/pollTaskStatus were untouched by #544; both TaskDetail additions coexist in types-sync-exact order; Queue: and Heartbeat: render in different blocks and are mutually exclusive in practice). Full matrix green post-merge (cdk 3885 / cli 750 / agent 1622).

Also filed #736 so the "re-tighten IAM when AWS exposes usable condition keys" revisit has a handle.

@krokoko this is the fourth main-merge this PR has absorbed (#695, #711-#718, #345+#704, #544) — grateful for a prompt look when you have one, or auto-merge-on-approval if that's easier.

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

Strong architecture, security posture, and test discipline — CI is green (4/4), the bootstrap bundle verified clean by execution, and platform_config is a genuine security improvement over image-frozen env. Gated on seven items, all small and local. Four of them sit on the diagnosis path, and one hits the failure mode this backend hits most often.

The unifying theme: this PR is excellent at failing closed, and weaker at explaining why it failed. Rejections are correct, distinctly coded, and tested — then the code either discards the reason, routes it to a classifier that renames it, or documents a mechanism that isn't real.


Blocking

B1. stateReason is discarded, so the most common failure gets a fabricated cause

cdk/src/handlers/shared/strategies/lambda-microvm-strategy.ts:691-744

pollSession reads result.state and ignores stateReason. Your own runbook (docs/verification/645-p2-smoke-runbook.md:771) records what that field says on the dominant runtime failure:

state       = TERMINATED
stateReason = Run lifecycle hook returned HTTP status 400. Please check your hook endpoint...

TERMINATED → {status:'completed'} has no error slot, so orchestrator.ts:451 builds detail = "substrate state completed" and the operator gets:

MicroVM substrate terminated before the agent wrote a terminal status: substrate state completed

…with a remedy naming "session duration cap, host fault, or an external terminate" — none of which happened. Every distinct wire code /run carefully emits (MICROVM_RUN_PLATFORM_CONFIG_INVALID, TASK_RECORD_INCOMPLETE) is thrown away one layer up. Per this repo's own standard, a plausible-but-wrong result is a defect.

Fix: thread stateReason into the reconcile detail (minimum: logger.warn it plus append to the message).

B2. The new required-key throw is classified TRANSIENT and auto-retried

cdk/src/handlers/shared/strategies/lambda-microvm-strategy.ts:282-291

I ran the real classifier regexes. This message carries no MICROVM_ERROR_MARKER, so it falls to the /Session start failed/i catch-all (error-classifier.ts:320) → retryable: true, remedy "Check AgentCore Runtime or ECS cluster health." A hand-edited-Lambda-environment fault, which retrying provably cannot fix, gets retried and misattributed to the wrong substrate. This is exactly what the marker section's own comment calls "mandatory, not decorative." Tests assert the message text thoroughly but never its classification.

Fix: wrapMicrovmError('platform config', …) or a classifier entry — plus a test asserting classification, not just message.

B3. orchestrator.ts:82-85 documents an invariant the code does not have — in the direction that breaks ECS

"The agent writes that timestamp UNCONDITIONALLY on every substrate… so this predicate decides only whether the ORCHESTRATOR acts on it."

Verified false. pipeline.py:919 writes once; the only periodic writer is _heartbeat_worker (server.py:272, 45 s), started solely from _run_task_background — reachable only via /invocations or the MicroVM /run hook. ECS bypasses uvicorn entirely (ecs-strategy.ts:245: "This bypasses the uvicorn server entirely").

So ecs => false is a hard correctness constraint, not the tuning preference the comment frames it as. Anyone consolidating those "two independently-tuned kill paths" would flip it to true and fail every ECS task after ~6 minutes (grace 120 s + stale 240 s). The comment invites the change that breaks the backend.

Fix: lead the ecs bullet with the hard constraint — "ECS never starts _heartbeat_worker; enabling this fails every ECS task after ~6 min."

B4. The boto3.DEFAULT_SESSION region-pinning claim is factually wrong

agent/src/server.py:1084-1088, 1105-1107, 1122-1123

Tested in this repo's own venv (botocore 1.43.42), controlled for ambient AWS_PROFILE/~/.aws/config:

BUILD  session.region_name: us-west-2 | logs client: us-west-2
AFTER  session.region_name: eu-west-1 | logs client: eu-west-1
creds frozen to build role: AKIAbuild

A pre-existing DEFAULT_SESSION freezes credentials only. Region and AWS_SDK_UA_APP_ID re-resolve per client, because botocore's EnvironmentProvider holds a live os.environ reference.

Worth fixing rather than shrugging at, because the credential half is real and is the stronger argument: build-role credentials baked into a snapshot every MicroVM restores from is a security property; a stale region is a bug. This claim is the sole justification for three helpers and a deliberately awkward branch at server.py:1839 — a maintainer who tests the region claim, finds it false, and concludes the discipline was cargo-cult would delete helpers whose real rationale is sound.

Fix: narrow all three comments to credentials.

B5. GITHUB_TOKEN_SECRET_ARN redirect reads another workspace's OAuth token

agent/src/server.py:1074 + cdk/src/constructs/lambda-microvm-compute.ts:1168-1184

_install_platform_config validates keys rigorously and values not at all — no ARN-shape, account, partition, or region check before os.environ[env_name] = value. Most keys are contained by IAM (a foreign agent_session_role_arn AccessDenies → SessionScopingError → fails closed; tables/buckets are LeadingKeys/prefix-scoped). One is not:

The execution role holds GetSecretValue on bgagent-linear-oauth-* / bgagent-jira-oauth-*. config.py:55-65 fetches whatever ARN GITHUB_TOKEN_SECRET_ARN names and caches the raw SecretString into os.environ["GITHUB_TOKEN"], from which shell.py passes the environment to every repo subprocess — i.e. into the model's tool surface. So a /run payload naming another workspace's channel-OAuth secret succeeds: allowlisted key, unvalidated value, matching grant.

The prefix grant is at ECS parity. The asymmetry that makes it reachable is new: on ECS the ARN arrives as deploy-time container env; here it arrives in a network payload. The comment at server.py:874 ("secrets are still fetched at /run time … using the ARNs delivered here") is precisely where the ARN needs to stop being free-form.

Fix: pin *_secret_arn / *_role_arn values to the MicroVM's own partition/account/region before install, rejecting with the existing …_INVALID code. A per-key regex table beside env_by_key keeps it contract-sourced.

Related, and worth recording: NO_INGRESS reachability was never negatively verified. 645-p2-smoke-runbook.md:745-749 notes a NO_INGRESS VM still returns a public <vm-id>.lambda-microvm.<region>.on.aws hostname and warns that "endpoint exists" isn't evidence of reachability — but nobody probed it. /run has no application-layer auth (no Depends/HTTPBearer; lambda:CreateMicrovmAuthToken granted to no role, by design per sub-decision 3), so the entire posture rests on that untested inference. A bounded probe belongs in the P3 runbook.

B6. A real AWS account ID is committed

cdk/test/bootstrap/policies.test.ts:518-519704224321915

Verified new in this PR (absent from main), against a repo-wide convention of 123456789012 (8 uses in the same tree). Both 2,000-line runbooks in this PR correctly redact to <account> — the runbooks were scrubbed, the test wasn't. Not a secret, but this is a public sample repo. The test asserts prefix-glob survival across CFN's 64-char truncation, which depends only on the role-name segment, so the placeholder loses nothing.

B7. The iam:PassRole map entries are inert, and the comment names a guard that doesn't exist

cdk/src/bootstrap/resource-action-map.ts:89-100

"listing the action here is what makes its removal a test failure instead of a redeploy failure."

It doesn't. collectBootstrapAllowActions() collects action strings only, discarding Resource and Condition — and infrastructure.ts's IAMPassRole already contributes bare iam:PassRole to every bundle. The requirement is therefore satisfied by the conditioned statement, which is exactly the one P2r2-F9 proved is denied on this path. Empirically confirmed: deleting MicrovmPassRoles leaves the check green (missing for MicrovmImage: []). Compounding it, synth-coverage.test.ts synthesizes only the default context, where AWS::Lambda::MicrovmImage is never emitted, so these entries are never consulted.

Not an open hole — the new policies.test.ts SID assertions do catch removal. But it's a security-relevant comment misdescribing which mechanism protects the fix.

Fix: point the comment at policies.test.ts, or make the map condition-aware.


Non-blocking

  1. Null byte → 500 instead of a structured 400. server.py:1074. Verified: os.environ['X']='a\x00b' raises ValueError, which escapes _install_platform_config (raises only _PlatformConfigError) and bypasses the handler's except at :1814. Newlines are accepted silently (log injection into your structured lines). Keys get regex validation; values don't. Untested.
  2. MicroVM poll failures never escalate. orchestrate-task.ts:405-415 warns forever; the ECS sibling 40 lines up fails at 3 consecutive failures. A permanent fault (missing lambda:GetMicrovm, Region gap) is indistinguishable from a hiccup → ~1020 identical warns and a full 8.5 h billed reservation — the cost posture this PR's own heartbeat work exists to prevent.
  3. Payload retention regresses ECS parity. deleteEcsPayload is called at finalize (orchestrate-task.ts:458); MicroVM has no equivalent and relies solely on MICROVM_PAYLOAD_TTL_DAYS = 1. With bucket-wide grantRead and <taskId>/payload.json keys, any running MicroVM can read another task's hydrated prompt/issue thread — window widens from minutes to ~24 h.
  4. ADR-021's own EARS requirement is unimplemented. :52 requires CSPRNG reseeding on /run; :378 calls missing it "a silent security defect." No random.seed/os.urandom anywhere in agent/src. Exposure is small (only progress_writer.py:75's getrandbits(80) ULID, a sort key under a task_id partition — a collision needs same task and millisecond). Implement the one-liner or amend the ADR.
  5. Unreachable FINALIZING arm. finalizeTask acts on sessionUnhealthy for RUNNING or FINALIZING, but pollTaskStatus can only set it for RUNNING — dead for all three backends.
  6. _debug_cw_failures is incremented, never read. Three docstrings describe an alarm operators can watch; no metric, alarm, or /ping exposure exists. Pre-existing — but B4's rationale leans on it ("would poison the signal"), and there's no signal to poison.
  7. /validate secret detection reports into a void. server.py:1629. warnings: ["secret_env_present_in_snapshot:GITHUB_TOKEN"] rides a 200 nothing parses. One _build_hook_log line would land it in the build log group. Report-only is the right call; discarding it isn't.
  8. _READY_WARMUP_TOTAL_BUDGET_SECONDS: float = 240.0 evades the drift regex (\d+\b fails on 240.0), and check-constants-sync.ts gained ~140 lines with no test file anywhere.
  9. Producer misattributed. server.py:879 sends readers to orchestrator.ts for the platform_config producer; it's buildMicrovmPlatformConfig in the strategy (grep finds 0 hits in orchestrator.ts).
  10. agent_heartbeat_at on toTaskDetail but not toTaskSummarybgagent list still can't show liveness. Worth a deliberate decision given the PR's own framing.
  11. logs:CreateLogGroup on the execution role (:1482) — the group is pre-created at :1018, so this is a create right the runtime never uses on the role that runs untrusted repo code. Splitting build/runtime grants costs three lines.
  12. PR description understates the artifacts. The body says the smoke run "executes against a live account after this lands," while ADR-021:403 and the synth warning both record run 2 passing on 2026-08-07 (2 PRs, 12 turns, $0.279, heartbeat observed). The artifacts are more accurate than the description — worth reconciling so the honest scope note is trusted.

Vision alignment — strongly aligned

  • Bounded blast radiusplatform_config removes the image snapshot as a source of deployment truth. Rejecting the whole block on an unknown key rather than filtering correctly treats unrecognized keys as LD_PRELOAD/AWS_ENDPOINT_URL injection, not a compatibility gap. I verified the 13-key allowlist contains no process-hijacking variable.
  • Bounded cost — heartbeat liveness on lambda-microvm closes a real 8-hour-reservation burn; live evidence shows a hung guest sits in RUNNING indefinitely with no stateReason.
  • Reviewable outcomes — the unconditional, non-suppressible synth warning telling operators to keep production on agentcore/ecs is exactly right, as is freezing the warning id across phases.
  • Tenet trades documented — the dropped iam:PassedToService / aws:SourceAccount conditions are evidenced by a controlled two-arm experiment with per-role compensating controls in ADR-021 §4. The ADR retracts its own earlier false negative and diagnoses the contaminated control that caused it. That's the standard.

Documentation — complete

  • Mirror in sync (verified): ran node scripts/sync-starlight.mjs → exit 0, git status clean.
  • COMPUTE.md adds the Lambda MicroVMs column and the explicit classic-Lambda distinction (COMPUTE.md:24) — the #645 acceptance criterion, satisfied.
  • ORCHESTRATOR.md, SECURITY.md (all three roles), DEPLOYMENT_ROLES.md golden baseline, contracts/constants.md, ADR-021 amended as proposed.
  • Gap: the mandatory re-bootstrap to ≥1.4.0 appears in the ADR, the synth warning, and the packaging script — but no docs/guides/ file mentions lambda-microvm at all. An operator following a guide won't learn they must re-bootstrap.

Tests & CI

CI 4/4 green. cdk 3,825 / agent 1,590 (82.8%).

Bootstrap synth-coverage: PASS, verified by running it. Reframing worth noting: the constructs pre-existed this PR, so there are no new CFN resource types — the addition is one MicrovmPassRoles statement. A context-enabled synth showed UNMAPPED TYPES: [], MISSING ACTIONS: {}. Artifact regeneration was byte-identical (no hand-editing). 1.3.0 → 1.4.0 is the correct minor bump. test/bootstrap: 113/113. The ARN-truncation risk is pinned by a test, not just a comment — ConnectorOperatorRole truncates to …ComputeConnectorOp-, still inside the glob, and the modeled BuildRoleF0 truncation reproduces the live ARN quoted in the comment.

Rule #366 (synth perf): not violated — no test re-enables bundling; new synths are beforeAll-cached.

The poisoned-seam suite is real, not theater. It sets LOG_GROUP_NAME before asserting silence (without which every assertion passes trivially), and install_phase_done flips only on a non-empty install list — the difference between a real test and a hole, since _install_platform_config(None) returning [] would otherwise disarm the guard across the exact legacy path where the bug lived. Mutation claims spot-checked and held: reverting _build_hook_log_debug_cw, platform_clientboto3.client, and heartbeatLivenessApplies=== 'agentcore' are each caught.

Real gaps: no \x00/\n value test; /terminate's active: None branch untested despite a comment arguing it's load-bearing; and the heartbeat→TerminateMicrovm composition is unasserted for any backendfakeContext ignores waitStrategy, so the billing outcome the change exists for isn't covered end-to-end. The runbook admits the switch "never got a long-enough RUNNING window to exercise."

Review agents run

All seven dispatched and completed: code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer, comment-analyzer, plus security/IAM and bootstrap-coverage reviews. None omitted.

Two agents disagreed on B4's boto3.DEFAULT_SESSION behavior, so I settled it by experiment rather than vote — and my first five measurements all agreed with the wrong answer because ambient AWS_PROFILE/~/.aws/config silently confounded them, S3 masks region behind a us-east-1 fallback, and this botocore reads AWS_DEFAULT_REGION rather than AWS_REGION. Same shape as the contaminated control ADR-021 documents and retracts. Flagging because it means any reviewer re-checking B4 needs a clean-room env to see it.

Human heuristics

  • Proportionality — concern. server.py 1,892 lines, lambda-microvm-compute.ts 1,534, ~4,100 lines of in-tree runbooks. Most length is load-bearing evidence (measured values, the probe that established them, the wrong value replaced) — genuinely unrecoverable once lost. But two ~30-line docstrings document an "unreachable in practice today" branch twice (server.py:1550, 1600), and phase-status prose is narrated in three files needing hand-updates per phase.
  • Coherence — pass. Correct routing. New backend reuses ComputeStrategy unchanged (verified: empty diff on compute-strategy.ts) — no optional member forced on the other two. Divergences from ecs-strategy (NotFound→completed) are explained by substrate behavior, not convenience.
  • Clarity — concern. Names are good; the exhaustive never switch is mutation-verified compile-breaking. But B1/B2 hide real causes behind plausible defaults (AI004), and B3/B4/B7 are comments asserting mechanisms that don't exist — the highest-cost comment defect, since maintainers act on them.
  • Appropriateness — mostly pass. Verified against real AWS behavior, not self-written mocks (AI001) — the 4,096-vs-documented-16,384 runHookPayload cap with its verbatim ValidationException is exactly the grounding I want, as is the shell-parsing test that replaced a prose "keep these in step" comment which had already failed once. Two exceptions: the inner/outer platform_config precedence tests pin a branch the producer's own contract makes unobservable, and contracts/constants.json derivation is rightly preferred over hand-copied literals.

Genuinely good work — the live-evidence discipline, the self-retracting ADR, and the poisoned-seam suite are all above the bar for this repo. The gate is narrow: make the failures say what actually happened (B1, B2), fix three comments that would mislead a maintainer into breaking ECS or deleting sound credential hygiene (B3, B4, B7), pin ARN values (B5), and scrub the account ID (B6).

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline follow-up to my review above — the same findings anchored to specific lines, with committable suggestion blocks where the fix is unambiguous.

Directly committable via the GitHub UI: B6 (account ID), B3 (heartbeat docstring), B4 (credential-vs-region wording), B7 (map-entry comment), and the control-character nit.

Code sketches rather than one-click suggestions — because they touch a type or span non-adjacent lines: B1 (stateReason needs an optional reason?: string on SessionStatus plus the orchestrator.ts:451 detail string), B2 (wrapMicrovmError — verify the escaped apostrophe survives your lint), and B5 (ARN pinning).

B1 is anchored slightly above pollSession because that function falls outside the diff hunks; the comment names the real line range.

Everything asserted here I verified by running it — the classifier regexes, the botocore region/credential behavior in this repo's own venv, the os.environ null-byte ValueError, and the bootstrap action-collection check with MicrovmPassRoles deleted.

Comment on lines +518 to +519
'arn:aws:iam::704224321915:role/backgroundagent-dev-LambdaMicrovmComputeBuildRoleF0-9FxjQbiJC3px',
'arn:aws:iam::704224321915:role/backgroundagent-dev-LambdaMicrovmComputeConnectorOp-Ab12Cd34Ef56',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B6 (blocking, 30-second fix): a real 12-digit AWS account ID in a public sample repo.

704224321915 is new in this PR (absent from main), against a repo-wide convention of 123456789012 — 8 uses elsewhere in cdk/test. Both 2,000-line runbooks in this same PR correctly redact to <account>: the runbooks were scrubbed, this test wasn't.

The assertion's purpose — that the prefix globs survive CFN's 64-char logical-id truncation — depends only on the role-name segment, so the placeholder loses nothing.

Suggested change
'arn:aws:iam::704224321915:role/backgroundagent-dev-LambdaMicrovmComputeBuildRoleF0-9FxjQbiJC3px',
'arn:aws:iam::704224321915:role/backgroundagent-dev-LambdaMicrovmComputeConnectorOp-Ab12Cd34Ef56',
'arn:aws:iam::123456789012:role/backgroundagent-dev-LambdaMicrovmComputeBuildRoleF0-9FxjQbiJC3px',
'arn:aws:iam::123456789012:role/backgroundagent-dev-LambdaMicrovmComputeConnectorOp-Ab12Cd34Ef56',

Comment on lines +282 to +291
if (missing.length > 0) {
throw new Error(
'Cannot start a lambda-microvm session: the orchestrator environment is missing platform '
+ `configuration the in-guest agent cannot run without (${missing
.map(key => `${key} <- ${PLATFORM_CONFIG_ENV_VARS[key]}`)
.join(', ')}). A MicroVM snapshot must not bake these in (ADR-021 sub-decision 3), so the `
+ '/run payload is the only channel for them. TaskOrchestrator injects every one of these '
+ 'from stack-level values, so this indicates the orchestrator function\'s environment was '
+ 'edited outside CDK — redeploy the stack to restore it.',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2 (blocking): this throw escapes without MICROVM_ERROR_MARKER, so it is classified TRANSIENT and auto-retried with a remedy naming the wrong substrate.

I ran the real classifier patterns against this message. It matches none of the MicroVM entries (they require MicroVM [\w ]+failed), so it falls through to the generic catch-all at error-classifier.ts:320:

pattern:  /Session start failed/i
remedy:   "Check AgentCore Runtime or ECS cluster health."
retryable: true

So a hand-edited-Lambda-environment misconfiguration — which retrying provably cannot fix — gets retried, and the operator is pointed at AgentCore/ECS for a lambda-microvm fault. This file's own marker docstring calls the marker "mandatory, not decorative" for exactly this reason.

Definitive fix — reuse the existing wrapper so the marker and cause chain both land:

Suggested change
if (missing.length > 0) {
throw new Error(
'Cannot start a lambda-microvm session: the orchestrator environment is missing platform '
+ `configuration the in-guest agent cannot run without (${missing
.map(key => `${key} <- ${PLATFORM_CONFIG_ENV_VARS[key]}`)
.join(', ')}). A MicroVM snapshot must not bake these in (ADR-021 sub-decision 3), so the `
+ '/run payload is the only channel for them. TaskOrchestrator injects every one of these '
+ 'from stack-level values, so this indicates the orchestrator function\'s environment was '
+ 'edited outside CDK — redeploy the stack to restore it.',
);
throw wrapMicrovmError(
'platform config',
new Error(
'the orchestrator environment is missing platform configuration the in-guest agent '
+ `cannot run without (${missing
.map(key => `${key} <- ${PLATFORM_CONFIG_ENV_VARS[key]}`)
.join(', ')}). A MicroVM snapshot must not bake these in (ADR-021 sub-decision 3), so `
+ 'the /run payload is the only channel for them. TaskOrchestrator injects every one of '
+ 'these from stack-level values, so this indicates the orchestrator function\'s '
+ 'environment was edited outside CDK - redeploy the stack to restore it.',
),
);

Please also add a test asserting the classification, not just the message text — the current tests at lambda-microvm-strategy.test.ts:663-684 would pass either way, which is why this slipped. Note assertImageArn and the substrate-missing throw above have the same gap; both predate this PR, so I'm not gating on them.

platform_config_keys: Object.keys(platformConfig),
});

return {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1 (blocking): stateReason is discarded, so the most common runtime failure reports a fabricated cause.

Anchoring here because pollSession (~:691-744) is outside the diff hunks.

pollSession reads result.state and ignores stateReason, which GetMicrovmResponse carries. This PR's own runbook (docs/verification/645-p2-smoke-runbook.md:771) records what that field says on the dominant failure mode:

state       = TERMINATED
stateReason = Run lifecycle hook returned HTTP status 400. Please check your hook endpoint...

TERMINATED -> { status: 'completed' } has no error slot, so orchestrator.ts:451 builds detail = "substrate state completed" and the operator sees:

MicroVM substrate terminated before the agent wrote a terminal status: substrate state completed

…with a classifier remedy citing "session duration cap, host fault, or an external terminate" — none of which happened. Every distinct wire code /run carefully emits (MICROVM_RUN_PLATFORM_CONFIG_INVALID, MICROVM_RUN_PAYLOAD_UNREADABLE, TASK_RECORD_INCOMPLETE) is discarded one layer up. The runbook also notes this path self-terminates in ~12 s, so it is the fast, common case — not an edge.

Per this repo's standard, a plausible-but-wrong result is a defect.

Minimal fix (no type change) — capture it in pollSession and surface it:

// in pollSession, after `state = result.state;`
const stateReason = result.stateReason;
// ...
case MicrovmState.TERMINATING:
case MicrovmState.TERMINATED:
  if (stateReason && stateReason !== 'Success.') {
    logger.warn('MicroVM terminated with a substrate reason', {
      microvm_id: microvmId, state, state_reason: stateReason,
    });
  }
  return { status: 'completed', reason: stateReason };

Then widen SessionStatus with an optional reason?: string and append it at orchestrator.ts:451:

const detail = substrate.status === 'failed'
  ? substrate.error
  : `substrate state ${substrate.status}${substrate.reason ? ` (${substrate.reason})` : ''}`;

That single change turns the misleading message into substrate state completed (Run lifecycle hook returned HTTP status 400…), which points the operator at the guest logs where your structured 400 body already is.

Comment on lines +82 to +86
* Whether a backend's liveness is (partly) inferred from `agent_heartbeat_at`.
*
* The agent writes that timestamp UNCONDITIONALLY on every substrate — it is a
* DynamoDB write from the pipeline, with no backend awareness — so this predicate
* decides only whether the ORCHESTRATOR acts on it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B3 (blocking): this comment asserts an invariant the code does not have — and the error direction breaks ECS.

"The agent writes that timestamp UNCONDITIONALLY on every substrate … so this predicate decides only whether the ORCHESTRATOR acts on it."

Verified against the code, and it is not true:

  • agent/src/pipeline.py:919 writes the timestamp once, right after write_running. Backend-agnostic as claimed, but it never repeats.
  • The only periodic writer is _heartbeat_worker (agent/src/server.py:272, 45 s cadence), started solely from _run_task_background — reachable only via /invocations (AgentCore) or the MicroVM /run hook.
  • ECS bypasses the server entirely. ecs-strategy.ts:245: "This bypasses the uvicorn server entirely — no HTTP, no OTEL noise."

So on ECS agent_heartbeat_at is written once and never refreshed.

Why the wording matters: it frames ecs => false as a tuning preference ("two independently-tuned kill paths") when it is a hard correctness constraint. Anyone acting on this comment to consolidate those paths would flip ecs to true and fail every ECS task after ~6 minutes (AGENT_HEARTBEAT_GRACE_SEC 120 + AGENT_HEARTBEAT_STALE_SEC 240). The comment invites the change that breaks the backend.

Suggested change
* Whether a backend's liveness is (partly) inferred from `agent_heartbeat_at`.
*
* The agent writes that timestamp UNCONDITIONALLY on every substrate it is a
* DynamoDB write from the pipeline, with no backend awareness so this predicate
* decides only whether the ORCHESTRATOR acts on it.
/**
* Whether a backend's liveness is (partly) inferred from `agent_heartbeat_at`.
*
* The *periodic* writer is `_heartbeat_worker` (`agent/src/server.py`, 45 s), which
* runs ONLY on substrates that go through the FastAPI server `/invocations`
* (AgentCore) and the `/run` hook (MicroVMs). `pipeline.py` also writes the
* timestamp once at RUNNING on every substrate, but that single write goes stale by
* design, so this predicate decides which backends may treat staleness as signal.

Please also retitle the ecs bullet below to lead with the constraint rather than the preference — e.g. "ecs — no, and NOT a tuning choice: the ECS boot command bypasses uvicorn, so _heartbeat_worker never starts and the timestamp is written exactly once. Enabling this would fail every ECS task after ~6 min (grace + stale)." That also makes the never guard genuinely useful: a fourth backend's correct answer then turns on a checkable fact — does it serve HTTP? — rather than taste.

Comment thread agent/src/server.py
Comment on lines +1084 to +1088
``_debug_cw`` / ``_warn_cw``: those writers spawn a daemon thread that builds a
CloudWatch Logs client whenever ``LOG_GROUP_NAME`` is set, which drags in AWS
credential resolution and — worse — populates ``boto3.DEFAULT_SESSION``, a
module global holding a resolved credential chain plus the region that was
current when it was created.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B4 (blocking): the region half of this mechanism is factually wrong, and it is the load-bearing half as written.

I tested this in this repo's own venv (botocore 1.43.42), controlling for ambient AWS_PROFILE / ~/.aws/config:

BUILD  session.region_name: us-west-2 | logs client: us-west-2
AFTER  session.region_name: eu-west-1 | logs client: eu-west-1
creds frozen to build role: AKIAbuild

A pre-existing boto3.DEFAULT_SESSION freezes credentials only. Region and AWS_SDK_UA_APP_ID are re-resolved from os.environ on every client construction, because botocore's EnvironmentProvider holds a live reference to os.environ (configprovider.py) and ConfigValueStore.get_config_variable calls provider.provide() per lookup. Credentials are memoized (botocore/session.py: if self._credentials is None).

This is worth correcting rather than shrugging at, because the true statement is the stronger one: build-role credentials frozen into a snapshot that every MicroVM restores from is a security property; a stale region is merely a bug. And this claim is the sole justification for three helpers plus the deliberately awkward branch at server.py:1839 — a maintainer who checks the region claim, finds it false, and concludes the whole AWS-silence discipline was cargo-cult would delete helpers whose credential rationale is real and correct.

Suggested change
``_debug_cw`` / ``_warn_cw``: those writers spawn a daemon thread that builds a
CloudWatch Logs client whenever ``LOG_GROUP_NAME`` is set, which drags in AWS
credential resolution andworsepopulates ``boto3.DEFAULT_SESSION``, a
module global holding a resolved credential chain plus the region that was
current when it was created.
hooks, and ``/run`` before ``platform_config`` is installed). Deliberately NOT
``_debug_cw`` / ``_warn_cw``: those writers spawn a daemon thread that builds a
CloudWatch Logs client whenever ``LOG_GROUP_NAME`` is set, which drags in AWS
credential resolution andworsepopulates ``boto3.DEFAULT_SESSION``, a
module global that MEMOIZES the resolved credentials for the life of the process
(``botocore.session.Session.get_credentials`` loads once, then caches). Region
and ``AWS_SDK_UA_APP_ID`` are NOT frozenbotocore re-reads those from
``os.environ`` per clientbut the credentials are, and on a build hook those
are the BUILD role's.

Two sibling comments need the same narrowing: _build_hook_log reason 2 (:1105-1107) should read "(the build role's credentials — region is re-resolved per client, credentials are not)", and _pre_config_log (:1122-1123) should drop "region, AWS_SDK_UA_APP_ID" and keep the real defect — pinning credentials resolved before AGENT_SESSION_ROLE_ARN is installed.

Note the surrounding argument survives fully intact, and _fetch_microvm_payload_from_s3's "platform_client does not touch the cached session" is already consistent and correct.

Comment thread agent/src/server.py
installed: list[str] = []
for key, value in sorted(resolved.items()):
env_name = MICROVM_PLATFORM_CONFIG_ENV_BY_KEY[key]
os.environ[env_name] = value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B5 (blocking): values reach os.environ unvalidated, and one allowlisted key lets a /run payload read another workspace's OAuth token.

The key allowlist here is genuinely well built — fails closed on unknown keys rather than filtering, rejects non-string values, enforces the 4 required keys, and I verified the 13 permitted env names contain no process-hijacking variable (PATH, LD_PRELOAD, AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, HTTPS_PROXY, NODE_OPTIONS, ANTHROPIC_BASE_URL — zero overlap). Values, though, are installed verbatim: no ARN-shape, partition, account, or region check anywhere on this path.

I traced each key against what the execution role's IAM actually permits. Most are contained: a foreign agent_session_role_arn AccessDenies (sts:AssumeRole is granted on the one SessionRole ARN) and aws_session.get_session raises SessionScopingErrorfails closed, no silent downgrade to ambient credentials. Tables and buckets are LeadingKeys/prefix-scoped to the caller's own task_id/user_id.

One is not contained:

lambda-microvm-compute.ts:1168-1184 grants the execution role secretsmanager:GetSecretValue on bgagent-linear-oauth-* and bgagent-jira-oauth-*. agent/src/config.py:55-65 fetches whatever ARN GITHUB_TOKEN_SECRET_ARN names and caches the raw SecretString into os.environ["GITHUB_TOKEN"] — from which shell.py passes the environment to every repo subprocess, i.e. into the model's tool surface. A /run payload naming another workspace's channel-OAuth secret therefore succeeds: allowlisted key, unvalidated value, matching grant.

The prefix grant itself is at ECS parity. The asymmetry that makes it reachable is new to this backend: on ECS the secret ARN arrives as deploy-time container env; here it arrives in a network payload. The block comment above ("secrets are still fetched at /run time from Secrets Manager using the ARNs delivered here") is exactly where the ARN must stop being free-form.

Suggested fix — pin ARN-shaped values to this MicroVM's own partition/account/region before install, reusing the existing failure code:

    # ARN-shaped values are installed into the env that resolves credentials and
    # fetches secrets, so a payload-supplied ARN pointing outside THIS deployment is
    # an exfiltration primitive, not a config choice (the execution role holds a
    # prefix grant on the channel-OAuth secrets). Pin partition/account/region.
    self_arn = os.environ.get("AGENT_SESSION_ROLE_ARN", "")
    bad_arns: list[str] = []
    for key, value in resolved.items():
        if not (key.endswith("_arn")):
            continue
        parts = value.split(":", 5)
        if len(parts) < 6 or parts[0] != "arn":
            bad_arns.append(f"{key}:malformed")
            continue
        if self_arn:
            mine = self_arn.split(":", 5)
            # partition + account must match; region may be empty for global services
            if parts[1] != mine[1] or parts[4] != mine[4]:
                bad_arns.append(f"{key}:foreign-account-or-partition")
    if bad_arns:
        raise _PlatformConfigError(
            "MICROVM_RUN_PLATFORM_CONFIG_INVALID",
            "platform_config ARN values must name resources in this deployment's own "
            f"partition and account; rejected {sorted(bad_arns)}",
        )

A per-key regex/validator table beside env_by_key in contracts/constants.json would be the cleaner long-term shape, keeping it contract-sourced like the rest.

Related, and worth recording in the runbook rather than fixing here: /run has no application-layer auth (no Depends/HTTPBearer; lambda:CreateMicrovmAuthToken is granted to no role, deliberately per sub-decision 3), so the whole posture rests on NO_INGRESS. Your own runbook (645-p2-smoke-runbook.md:745-749) notes a NO_INGRESS VM still returns a public <vm-id>.lambda-microvm.<region>.on.aws hostname and warns that "endpoint exists" is not evidence of reachability — and nobody probed it. A bounded negative probe (curl the hostname from outside, expect connection failure) belongs in P3. This finding is what that probe protects.

Comment thread agent/src/server.py
Comment on lines +1050 to +1053
continue
if value.strip():
resolved[key] = value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (untested failure path): a null byte in any value escapes as a 500 instead of your structured 400.

Verified in Python 3: os.environ['X'] = 'a\x00b' raises ValueError: embedded null byte. That is not a _PlatformConfigError, so it escapes _install_platform_config, bypasses the handler's except _PlatformConfigError at ~:1814, and becomes a FastAPI 500 — the one place this module doesn't produce the structured body it is otherwise careful about. A newline is accepted silently, which is a log-injection vector into the structured lines you emit.

Note the keys get regex validation (_PLATFORM_CONFIG_KEY_RE) and the values don't, and the tests cover non-string (test_server.py:1917) and blank (1925/1935) but no control character. Since this block is explicitly framed as injection defense, value content is the other half of the same threat model.

Suggested change
continue
if value.strip():
resolved[key] = value
if not isinstance(value, str):
bad_types.append(f"{key}:{type(value).__name__}")
continue
if "\x00" in value or "\n" in value or "\r" in value:
# These become process env vars: a NUL raises ValueError from os.environ
# (a 500 instead of this module's structured 400), and a newline forges
# extra lines in the structured logs emitted below.
bad_types.append(f"{key}:control-characters")
continue
if value.strip():
resolved[key] = value

Comment on lines +96 to +100
// the conditional `compute-lambda-microvm` policy; listing the action here is
// what makes its removal a test failure instead of a redeploy failure. Evidence
// inlined in ADR-021 §4.
'AWS::Lambda::MicrovmImage': ['lambda:CreateMicrovmImage', 'iam:PassRole'],
'AWS::Lambda::NetworkConnector': ['lambda:CreateNetworkConnector', 'iam:PassRole'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B7 (important): these entries are inert, and the comment above claims a guard that does not exist.

"listing the action here is what makes its removal a test failure instead of a redeploy failure."

It doesn't. collectBootstrapAllowActions() in this same file collects action strings onlyResource and Condition are discarded. infrastructure.ts's IAMPassRole statement already contributes the bare action iam:PassRole to every bundle, so this requirement is satisfied by the conditioned statement — precisely the statement P2r2-F9 proved is denied on this path.

Confirmed empirically with a throwaway test: with MicrovmPassRoles deleted, the check stays green —

missing for MicrovmImage: []
has iam:PassRole: true

Compounding it: synth-coverage.test.ts synthesizes only the default context, where AWS::Lambda::MicrovmImage is never emitted, so these two entries are never even consulted.

This is not an open hole — removal is caught, by your new policies.test.ts assertions on the SID list and the absent condition. So the fix is to stop the comment pointing at the wrong mechanism, since a future maintainer will read it when deciding whether these entries are load-bearing:

Suggested change
// the conditional `compute-lambda-microvm` policy; listing the action here is
// what makes its removal a test failure instead of a redeploy failure. Evidence
// inlined in ADR-021 §4.
'AWS::Lambda::MicrovmImage': ['lambda:CreateMicrovmImage', 'iam:PassRole'],
'AWS::Lambda::NetworkConnector': ['lambda:CreateNetworkConnector', 'iam:PassRole'],
// the conditional `compute-lambda-microvm` policy. NOTE: listing the action here
// does NOT guard that statement — `collectBootstrapAllowActions` compares action
// STRINGS only (Resource/Condition are discarded), and `infrastructure`'s
// conditioned `IAMPassRole` already satisfies a bare `iam:PassRole` requirement.
// The real guard against dropping the unconditioned pass is
// `test/bootstrap/policies.test.ts` ("MicrovmPassRoles"), which asserts the sid,
// the two name-prefix resources, the absent condition, and the execution-role
// exclusion. These entries document the create-time need; they do not enforce it.

If you'd rather make the map genuinely enforce it, findMissingBootstrapActions would need to require an unconditioned match for a declared subset of actions — a bigger change I would not gate this PR on.

@ayushtr-aws ayushtr-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

Second-pass review (principal-architect lens + the mandatory pr-review-toolkit agents, run against the checked-out branch). I re-verified @scottschreckengaust's earlier review against current HEAD and found some new issues; this comment does not restate B1–B7 — it confirms they're still live and adds three findings that review didn't cover.

Excellent, evidence-disciplined work overall — platform_config as deployment-truth-off-the-snapshot, the poisoned-seam suite, and the self-retracting ADR are all above this repo's bar. The gate is the same theme the first review named: this PR fails closed correctly, then loses or misreports why it failed.


Prior blockers are all still open

The earlier review landed on 08b3091, which is still HEAD — no fix commits since — so B1–B7 remain unaddressed. I independently reproduced every one; flagging that they still gate:

  • B1 pollSession (lambda-microvm-strategy.ts:696) discards stateReason → dominant failure renders "substrate state completed".
  • B2 required-key throw (lambda-microvm-strategy.ts:283) skips wrapMicrovmErrorwith a correction to the earlier framing (see below).
  • B3 orchestrator.ts:84-86 — confirmed false; ecs => false is a hard correctness constraint (see N-follow-up below).
  • B4 boto3.DEFAULT_SESSION freezes credentials only, not region.
  • B5 _install_platform_config validates key allowlist + value type, never value content → cross-workspace OAuth-secret ARN redirect.
  • B6 real account ID 704224321915 at policies.test.ts:518-519.
  • B7 resource-action-map.ts:96-97 — the iam:PassRole map entry is inert; the real guard is policies.test.ts.

New blocking-grade findings (not in the prior review)

N1 — S3 payload parse errors are misclassified as non-retryable 400. server.py:1246-1248: on the dominant pointer path, json.loads(body) (JSONDecodeError ⊂ ValueError) and the non-object guard raise bare ValueError, which hits /run's except ValueError400 MICROVM_RUN_PAYLOAD_INVALID ("retrying an identical body cannot help") before the except Exception500 …_UNREADABLE ("retrying CAN help"). A truncated / racing / half-written S3 object is retryable, and the operator is told the orchestrator built a bad envelope — it didn't; the S3 object was bad. Only the pre-fetch URI-shape ValueError at :1239 correctly belongs in the 400 branch, which is exactly why a blanket except ValueError is the wrong discriminator.
Fix: raise a dedicated non-ValueError (e.g. PayloadFetchError) for post-fetch content problems so they fall through to the 500 branch. Note the function-level ValueError on a non-object body is covered (test_server.py:1461), but no test drives that unreadable body through the /run handler to assert its classification — the handler-level S3-failure test (test_server.py:1464) mocks _fetch to raise RuntimeError, so the JSONDecodeError/non-object → 400 misroute is never exercised end-to-end.

N2 — the no-platform_config path silently skips the required-key check, so tenant isolation can be silently OFF. server.py:1025-1026: _install_platform_config(None) returns [] before the required-key validation at :1061. agent_session_role_arn is a required key precisely because aws_session downgrades to ambient compute-role credentials with tenant scoping silently off when AGENT_SESSION_ROLE_ARN is unset, and ADR-021 sub-decision 3 forbids baking that ARN into the snapshot. The docstring itself says the image and orchestrator "deploy on independent cadences" — so a pre-P2 orchestrator (no platform_config sibling) launching a P2 image produces a task that runs to completion with per-tenant isolation disabled, traced only by a stdout-only _pre_config_log line that never reaches the task's CloudWatch group. This is the sharpest failure the required-key list exists to prevent, and the None path routes around it.
Fix: on the None path, still enforce MICROVM_PLATFORM_CONFIG_REQUIRED_KEYS against the effective environment (payload-or-baked) and reject …_INCOMPLETE when a required key is neither delivered nor present; at minimum escalate the no-config branch beyond a stdout breadcrumb so a silently-unscoped run is auditable.

N3 — comment/code contradiction on the build-role Logs grant. server.py:1102-1104 states "The build role has no Logs grant, so the write can only FAIL" — but lambda-microvm-compute.ts:1094 calls grantMicrovmLogWrites(this.buildRole), granting scoped logs:CreateLogGroup/CreateLogStream/PutLogEvents on the /aws/lambda-microvms/* namespace. The accurate statement is that the grant is scoped to the service namespace, so a write to any other LOG_GROUP_NAME (e.g. a baked APPLICATION_LOGS group) fails. Reason #2 in that same docstring (boto3.DEFAULT_SESSION freezing the build session into the snapshot) is accurate and load-bearing, so the code behavior is fine — only the stated IAM fact is wrong, and it's the kind a maintainer would act on.


Follow-ups reinforcing the prior review

  • B2 — correction to the earlier framing (I traced the classifier paths). The earlier review said the missing-key error is "classified TRANSIENT and auto-retried." The auto-retry half doesn't hold: startSessionWithRetry classifies the raw error (session-start-retry.ts:116, deliberately, per #599 — the /Session start failed/i wrapper is itself a transient pattern), and the raw "Cannot start a lambda-microvm session: …" matches no transient pattern → falls to UNKNOWNUSERthrown immediately, not retried. But the wrong-remedy half is real: failTask persists "Session start failed: <raw>", and the operator/channel-facing failure-reply.ts:150 re-classifies that prefixed string, which now matches the /Session start failed/i TRANSIENT catch-all → the user is told "Check AgentCore Runtime or ECS cluster health / quota exhausted" for a hand-edited-orchestrator-env fault on the MicroVM path. So B2 still stands as a real defect (misattributed cause + errorClass: TRANSIENT), just via the failure-reply classification rather than an actual retry. wrapMicrovmError('platform config', …) still fixes it; the fix and its blocking status are unchanged.

  • B3 is a hard correctness constraint, restated for emphasis. pipeline.py:919 writes the heartbeat once; the periodic 45s refresh that staleness detection depends on is _heartbeat_worker (server.py:272), started only inside _run_task_background — the AgentCore /invocations and MicroVM /run paths. ECS launches run_task_from_payload directly (ecs-strategy.ts:246), bypassing server.py, so it has no periodic writer. A maintainer flipping ecs to true on the strength of the "predicate only decides whether the orchestrator acts" comment would fail every ECS task ~240s in. Please lead the ecs bullet with the constraint.

Non-blocking nits (concur with the prior review; spot-verified)

  1. Null-byte value → uncaught ValueError → 500 + partial env install (fail-closed contract broken); newlines accepted silently (log injection). server.py:1074. Untested.
  2. MicroVM payload has no deleteEcsPayload equivalent — relies solely on MICROVM_PAYLOAD_TTL_DAYS = 1; parity regression vs ECS (orchestrate-task.ts:458), widening the cross-task read window from minutes to ~24h given bucket-wide grantRead.
  3. ADR-021's CSPRNG-reseed EARS requirement is unimplemented (no random.seed/os.urandom in agent/src; only progress_writer.py:75). Implement the one-liner or amend the ADR.
  4. Unreachable FINALIZING arm in the sessionUnhealthy gate (orchestrator.ts:990) — pollTaskStatus only sets it for RUNNING.
  5. Heartbeat-stale failure message hard-codes "container" (finalizeTask), now emitted for a MicroVM substrate that has none — parameterize by computeType.
  6. formatTaskDetail heartbeat age calls Date.now() directly (cli/src/format.ts), non-deterministic under test, unlike its formatStatusSnapshot sibling which threads an injected now.

Docs

Mirror verified in sync; COMPUTE.md's Lambda MicroVMs column + classic-Lambda distinction satisfies the #645 criterion. One gap stands from the prior review: the mandatory re-bootstrap to bundle ≥1.4.0 appears in the ADR, the synth warning, and the packaging script, but no docs/guides/ file mentions lambda-microvm — an operator following a guide won't learn they must re-bootstrap.

Tests & CI

CI 4/4 green. Bootstrap synth-coverage PASS / no new CFN types (constructs pre-existed; 1.3.0→1.4.0 is the correct minor bump). Rule #366 not violated (bundling stays off; new synths beforeAll-cached; the two per-test synths carry explicit constructor-throw exemption comments). Real gaps: no \x00/\n value test; /terminate's active:None branch is never actually exercised (the best-effort-failure test assigns active before the patched raise); and the heartbeat → TerminateMicrovm composition is unasserted end-to-end for any backend.

Review agents run

All five pr-review-toolkit agents dispatched against the checked-out branch and completed: code-reviewer (no ≥80-confidence blockers; confirmed type-sync of agent_heartbeat_at across cdk/cli, UA attribution #319 on the S3 fetch, bootstrap regen consistency, contract sourcing), silent-failure-hunter (N1, N2), comment-analyzer (N3, confirmed B3/B7), pr-test-analyzer (test gaps above). type-design-analyzer omitted — the diff adds no substantive new types (only optional reason? / agent_heartbeat_at fields on existing records, verified type-synced). Security/IAM + bootstrap judgment applied by hand.


Net: the seven prior blockers stand, and I'd add N1 (retryability inversion on the dominant payload path) and N2 (silent tenant-isolation-off on version skew) as blocking-grade, plus N3 as a must-fix misleading comment. The live-evidence discipline throughout is genuinely strong — the gate is narrow and local.

@dreamorosi
dreamorosi marked this pull request as draft August 8, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants