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:
- Re-declared both constants as literals in
server.py → check-constants-sync printed Constants sync OK, exit 0; all 216 agent tests passed.
- 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-scoped — stack.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
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.
Context
#733 added
_reject_foreign_arns(agent/src/server.py:1096-1146) to stop a/runpayload from redirecting an allowlisted*_secret_arn/*_role_arnkey 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:1212plus the required-key check at:1222run before the pin, and the import-time assert at:993-1007makes 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 ownbgagent-linear-oauth-<slug>secret — in the same account. An account anchor therefore cannot close it.Reproduced with a legitimate, untouched anchor:
os.environ["GITHUB_TOKEN_SECRET_ARN"]is then set to the victim's secret.agent/src/config.py:55-65fetches it viaplatform_client(aws_session.py:323— the unscoped execution role, explicitly bypassing the tag-scoped session), caches the rawSecretStringintoos.environ["GITHUB_TOKEN"], andagent/src/shell.py:148_clean_env()hands all ofos.environto 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:436injectsprops.githubTokenSecretArn— so the agent can pin the value, not merely its account.Reachability: requires forging the
/runpayload.NO_INGRESSis the only barrier, and it has never been negatively probed (645-p2-smoke-runbook.md:745-749notes aNO_INGRESSVM 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_keyssits outside the drift gate, so the next ARN key defaults to unpinnedcontracts/constants.jsongainedarn_keys+account_anchor_key, consumed byMICROVM_PLATFORM_CONFIG_ARN_KEYS(agent/src/server.py:925) and..._ACCOUNT_ANCHOR_KEY(:940) — but neither was added toOWNED_PYTHON_PATTERNSinscripts/check-constants-sync.ts, which still covers only..._ENV_BY_KEYand..._REQUIRED_KEYS. The repo rule is that the allowlist lives once in the contract and literal re-declarations are forbidden.Proven in two steps:
server.py→check-constants-syncprintedConstants sync OK, exit 0; all 216 agent tests passed.arn_keyswhile the agent pinned 2, silently droppinglinear_oauth_secret_arnandjira_oauth_secret_arn— precisely the prefix-granted secrets_reject_foreign_arnsnames as the sharp exfiltration case.Separately, membership is not enforced in the other direction either: adding
future_kms_key_arntoenv_by_keyonly was accepted by the agent's_validate_platform_config_contract()and bycheck-constants-sync(exit 0), then installed unpinned with a foreign account. Both guards checkarn_keys ⊆ env_by_keybut 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.tsexportsMICROVM_PLATFORM_CONFIG_KEYS/REQUIRED_KEYSfrom the contract but never readsarn_keys, sotscprovides no compile-time anchor.Gap 3 — a comment states the wrong scoping
agent/src/server.py:1105-1110justifies "region deliberately NOT pinned" by saying the execution role's "grants are account-scoped". They are region-scoped —stack.formatArn(...)rendersarn:${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
bgagent-*-oauth-*ARN supplied forgithub_token_secret_arnis rejected.arn_keysandaccount_anchor_keyare covered byOWNED_PYTHON_PATTERNS; re-declaring either as a literal failscheck-constants-sync.*_arn-suffixed key toenv_by_keywithout adding it toarn_keysfails a gate (agent import validation orcheck-constants-sync), so a new ARN key cannot default to unpinned.server.py:1105-1110comment says region-scoped, not account-scoped.NO_INGRESSreachability probe is run and recorded, since the whole posture rests on it._reject_foreign_arnscall fails 5), so hold the additions to the same bar.Notes
Hardens #733's
_reject_foreign_arnson the experimentallambda-microvmbackend (non-suppressible synth warning against production use). Exploitation requires a forged/runpayload.