Skip to content

fix(agent): harden MicroVM ARN pinning — same-account cross-workspace secret redirect still accepted; arn_keys outside the drift gate #815

Description

@isadeks

Context

#733 added _reject_foreign_arns (agent/src/server.py:1096-1146) to stop a /run payload from redirecting an allowlisted *_secret_arn / *_role_arn key at a foreign resource. The control is well-built and verified: the account anchor cannot be disarmed by omission, None, "", or whitespace (the blank-is-absent filter at :1212 plus the required-key check at :1222 run before the pin, and the import-time assert at :993-1007 makes the anchor's required status non-removable); parsing resists prefix-account, bare-secret-name, short-ARN, uppercase, and whitespace tricks; and "region deliberately not pinned" is sound, because the Secrets Manager grant renders region-scoped so a cross-Region ARN reaches strictly less than an in-Region one.

Three gaps remain.

Gap 1 (primary) — the cross-workspace read the finding was named for is still accepted

The original finding was a cross-workspace OAuth-secret read. Per docs/guides/LINEAR_SETUP_GUIDE.md:16, a single ABCA deployment serves multiple Linear workspaces, each with its own bgagent-linear-oauth-<slug> secret — in the same account. An account anchor therefore cannot close it.

Reproduced with a legitimate, untouched anchor:

platform_config:
  agent_session_role_arn  = arn:aws:iam::123456789012:role/…AgentSessionRole-XYZ
  github_token_secret_arn = arn:aws:secretsmanager:us-west-2:123456789012:secret:bgagent-linear-oauth-victimworkspace-a1B2c3
→ ACCEPTED

os.environ["GITHUB_TOKEN_SECRET_ARN"] is then set to the victim's secret. agent/src/config.py:55-65 fetches it via platform_client (aws_session.py:323 — the unscoped execution role, explicitly bypassing the tag-scoped session), caches the raw SecretString into os.environ["GITHUB_TOKEN"], and agent/src/shell.py:148 _clean_env() hands all of os.environ to every repo subprocess — i.e. into the model's tool surface.

Fix direction: a per-key expected-value or allowed-prefix check. The ARN is a synth-time constant — cdk/src/constructs/task-orchestrator.ts:436 injects props.githubTokenSecretArn — so the agent can pin the value, not merely its account.

Reachability: requires forging the /run payload. NO_INGRESS is the only barrier, and it has never been negatively probed (645-p2-smoke-runbook.md:745-749 notes a NO_INGRESS VM still returns a public hostname and warns "endpoint exists" is not evidence of reachability). That bounded probe was deferred to P3 and is more load-bearing now than when it was agreed — it should be treated as a prerequisite for closing this issue, not an unrelated task.

Gap 2 — arn_keys sits outside the drift gate, so the next ARN key defaults to unpinned

contracts/constants.json gained arn_keys + account_anchor_key, consumed by MICROVM_PLATFORM_CONFIG_ARN_KEYS (agent/src/server.py:925) and ..._ACCOUNT_ANCHOR_KEY (:940) — but neither was added to OWNED_PYTHON_PATTERNS in scripts/check-constants-sync.ts, which still covers only ..._ENV_BY_KEY and ..._REQUIRED_KEYS. The repo rule is that the allowlist lives once in the contract and literal re-declarations are forbidden.

Proven in two steps:

  1. Re-declared both constants as literals in server.pycheck-constants-sync printed Constants sync OK, exit 0; all 216 agent tests passed.
  2. With that drift in place, appended a fifth ARN key to the contract → the contract declared 5 arn_keys while the agent pinned 2, silently dropping linear_oauth_secret_arn and jira_oauth_secret_arn — precisely the prefix-granted secrets _reject_foreign_arns names as the sharp exfiltration case.

Separately, membership is not enforced in the other direction either: adding future_kms_key_arn to env_by_key only was accepted by the agent's _validate_platform_config_contract() and by check-constants-sync (exit 0), then installed unpinned with a foreign account. Both guards check arn_keys ⊆ env_by_key but never the converse, so a new ARN-carrying key defaults to unpinned — the opposite of the fail-closed posture the rest of this block holds.

The same gap exists on the TypeScript side: lambda-microvm-strategy.ts exports MICROVM_PLATFORM_CONFIG_KEYS/REQUIRED_KEYS from the contract but never reads arn_keys, so tsc provides no compile-time anchor.

Gap 3 — a comment states the wrong scoping

agent/src/server.py:1105-1110 justifies "region deliberately NOT pinned" by saying the execution role's "grants are account-scoped". They are region-scopedstack.formatArn(...) renders arn:${Partition}:secretsmanager:us-west-2:123456789012:secret:bgagent-linear-oauth-*. The conclusion is right (region-scoped grants mean a cross-Region ARN reaches less, so pinning region would only reject valid shapes), but the stated reason is wrong, and it is the kind a maintainer would act on.

Acceptance criteria

  • A same-account, different-workspace bgagent-*-oauth-* ARN supplied for github_token_secret_arn is rejected.
  • Legitimate deployment shapes still accepted: same-account cross-Region secrets, all four current ARN keys, the real synth-injected values.
  • arn_keys and account_anchor_key are covered by OWNED_PYTHON_PATTERNS; re-declaring either as a literal fails check-constants-sync.
  • Membership is enforced both ways: adding an *_arn-suffixed key to env_by_key without adding it to arn_keys fails a gate (agent import validation or check-constants-sync), so a new ARN key cannot default to unpinned.
  • The server.py:1105-1110 comment says region-scoped, not account-scoped.
  • The bounded negative NO_INGRESS reachability probe is run and recorded, since the whole posture rests on it.
  • Every new guard is mutation-tested — the existing 11 tests are genuinely pinned (removing the _reject_foreign_arns call fails 5), so hold the additions to the same bar.

Notes

Hardens #733's _reject_foreign_arns on the experimental lambda-microvm backend (non-suppressible synth warning against production use). Exploitation requires a forged /run payload.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1medium priorityagent-runtimePython agent container: pipeline, runner, hooks, prompts, tools, DockerfilebugSomething isn't workingsecurityCedar/HITL, IAM least-privilege, secrets, PII/DLP, guardrails, supply-chain/CVE

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions