Repository navigation
feat(credential-broker): bind runtime credentials to Gateway grants and retire grants when their use ends - #615
Conversation
jeromevdl
left a comment
There was a problem hiding this comment.
I examined the size and coupling of #615 and found no material defect requiring changes. Although broker authorization and grant retirement could be separate PRs, splitting now would mainly reorganize already-reviewed work. I would keep the PR together and clarify the recovery contract, rather than require stack surgery without a demonstrated engineering benefit.
Two improvements for maintainability and doc:
1. Document the grant-retirement recovery contract
The PR adds recovery behavior that operators currently need to reconstruct from implementation comments. Please extend
docs/development/recovery-ownership.md to explain:
- How failed retirement writes produce pendingGrantRetirements, and how consumers treat those grants.
- What must succeed before start, rewind, recompose, or repair can relaunch an execution.
- What the repairRelaunch cleanup/relaunch phases mean and how retries resume them.
- Which retirement failures return 503 before an operation changes state, versus after a synchronous command has already acted.
- The supported retry procedure and relevant failure log codes.
A compact table covering failure → persisted state → response → retry behavior would be sufficient.
2. Give HTTP retirement policy a clearer owner
In lambda/intents/index.js, the new helpers now handle retirement retries, pending-grant cleanup, relaunch guards, and HTTP refusal responses alongside the endpoint implementations.
Consider moving the existing helpers—particularly retireExecutionGrantsOrRefuse, retirePendingGrantsOrRefuse, relaunchWithoutPendingGrants, and issueSyncGatewayGrant—into a small module within lambda/intents/.
Keep endpoint authorization and route-specific state transitions in the handlers. Preserve the existing conditional writes, retry behavior, and response semantics. Keep the durable orchestrator adapter separate because it has different execution and retry requirements; no generic lifecycle framework is needed.
e3d6d53 to
16f2ff9
Compare
|
Both done. |
16f2ff9 to
1175c68
Compare
Complexity and test coverageAutomated quality report using code complexity and test coverage to highlight potential hot spots in this PR.
Show 7 more functions
🟢 Improving · 🟠 Needs attention · 🔴 High risk · 🟣 Highest risk — = unavailable · n/a = not applicable · * = incomplete tests |
1175c68 to
c87d5b3
Compare
With enforcement enabled the broker verifies the control-plane grant on source-control credential requests (engine mode, signed ids equal the request, execution record matches) before its existing execution, repository, binding and capability checks. Explicit unknown or malformed actions are now denied instead of falling through to source-control. refresh-intent receives an execution grant because it fetches through the broker.
With enforcement enabled the broker begins the invocation record before it returns credential material, from the verified credential grant (and the Gateway grant for execution, compose and discussion purposes), idempotent per grant and nonce. It returns a ticket bound to that invocation only; heartbeat and finish take nothing else. The runtime keeps the session lifetime observer but no longer writes accounting rows itself. Persistence moves to a shared module so local mode keeps identical transactions.
The credential broker resolves custom MCP secrets for the runtime under exactly one authority: a stage Gateway grant, with the configuration read from the execution record, or a short-lived verification grant the agents Lambda signs for verify-mcp over the exact config digest, surviving refs and editable tier. The broker derives SSM paths itself and never accepts a parameter name. With engine-data enabled the runtime uses the broker, never falls back to SSM, and its direct SSM client throws. The survivor and collision policy is shared so local mode is unchanged.
…structure The source-control function gains a runtime alias, selected by the qualified function ARN, that accepts only default-branch, pr-status and set-pr-draft with a verified Gateway grant for an active execution that lists the repository, and only for review numbers that execution itself recorded. With engine-data on, the runtime targets the alias and threads the execution id. Execution and repository checks are shared with the credential broker. Terraform gives the broker and source-control the grant public key, the broker the MCP secret reads, source-control process-table reads, publishes the alias and lets the runtime invoke it, and turns on verify-mcp verification grants in the agents Lambda. A runtime-closure test proves that in remote mode the runtime's direct DynamoDB, S3, SSM, websocket and Neptune clients are never used. The runtime role is unchanged.
…ransition With enable_engine_data on and enforce_runtime_grants off, the broker verifies every request that carries the new authority fields exactly as in enforcement, but still serves requests in the legacy shape from runtimes that predate engine data, logging them as LEGACY_RUNTIME_REQUEST so the drain can be watched. Turning enforce_runtime_grants on denies the legacy shape. The mode comes only from the broker's environment, and the flag requires enable_engine_data.
…n execution stops Each grant the orchestrator or the intents Lambda issues is retired when its stage settles or its command returns; cancel, rewind, recompose, repair, delete and the durable watchdog retire the whole execution, fenced on the run they observed. Every grant consumer rejects a retired grant and fails closed when the retirement table cannot be read. Retirement writes are retried; a grant that cannot be retired fails its stage or run and blocks any relaunch until it is.
JWThewes
left a comment
There was a problem hiding this comment.
Please see the 2 inline finding(s) below.
| const rewindRetirementRefused = await retireExecutionGrantsOrRefuse( | ||
| intentId, | ||
| 'rewound', | ||
| response, | ||
| { ifOrchestratorRunId: meta.orchestratorRunId ?? null }, | ||
| ); | ||
| if (rewindRetirementRefused) return rewindRetirementRefused; | ||
| // Artifact history is a hard precondition for a restart. Snapshot before | ||
| // META, gates, stage rows, lanes, or sessions are touched; a Neptune | ||
| // failure leaves the run exactly where it was and no relaunch occurs. | ||
| // failure leaves the run where it was (its grants already retired) and |
There was a problem hiding this comment.
[P1] Fence issuance before the final retirement snapshot
Rewind and recompose retire existing grants before replacing the old run ID, with artifact archival between those operations. A concurrent gate answer can still pass its run-ID CAS, resume the old durable execution, and obtain a new grant issued after retiredAt. Broker and source-control authorization do not compare that grant's run ID with the current execution, so the old runtime can continue obtaining credentials or performing allowed review operations after relaunch.
Suggested fix: After fencing and stopping the old run, perform another execution retirement conditioned on the retired run ID before relaunch, as lane repair does, or atomically combine the run fence and retirement. Add a concurrent gate-resume and grant-issuance regression.
Evidence: WAITING remains rewindable and recomposable, and the answer route conditions on the still-current run ID. The run ID is not replaced until lines 5289 and 5640, after the initial retirement and archival. Resumed runStage legs receive a new jti. isGatewayGrantRetired accepts a grant issued after retiredAt, while assertGatewayExecution and assertEngineGrantFor do not reject an old orchestratorRunId. This is distinct from the supplied recovery-documentation and helper-extraction feedback.
| state: 'ACTIVE', | ||
| startedAt: stamp(), | ||
| heartbeatAt: stamp(), | ||
| }; | ||
| const scopeWrites = scopeActivityWrites(tableName, [invocation]); |
There was a problem hiding this comment.
[P2] Expire abandoned invocation accounting records
Active invocation rows are created without agentAuthTtl, so their inventory references also have no TTL. If a runtime dies or completion cannot be recorded, the invocation and references remain permanently even though material inventory merely filters them after the 24-hour ticket lifetime.
Suggested fix: Set agentAuthTtl when creating an invocation to a deadline beyond the accounting ticket lifetime so the invocation and generated references eventually expire. Add coverage for an abandoned active invocation and its inventory references.
Evidence: The invocation object still contains state, startedAt, and heartbeatAt but no agentAuthTtl. finishCredentialInvocation adds that attribute only on successful completion. The process table enables TTL on agentAuthTtl, while isStaleInvocation only filters expired active rows and loadInventory still queries every reference and reads its target. The supplied complexity report names this function but does not report this retention defect.
… grant A tools/call to a target other than aidlc is looked up in the registry Terraform passes as MCP_GATEWAY_CUSTOM_TARGETS. Only stage grants reach a customer target; the grant's projectId, stageId and the tool must match the target's allow rule. Role, persona and agentRef are never read. An allowed call carries only x-aidlc-caller, a versioned envelope of grant-derived fields, and never Authorization. tools/list filters customer tools with the same predicate and keeps pagination. A malformed registry denies every request.
…gentCore Identity credentials
mcp_gateway_custom_targets (default {}) creates one remote MCP target per entry, with an OAuth client-credentials or API-key credential provider created out of band, an optional VPC Lattice private endpoint and x-aidlc-caller as the only allowed request header. The Gateway role gets GetWorkloadAccessToken, GetResourceOauth2Token/GetResourceApiKey and GetSecretValue on exactly the configured providers. The interceptor receives the allow rules (no endpoint, no credential) bounded to 2 KB; the runtime receives only a flag that customer targets exist. Requires enable_mcp_gateway.
…ugh proxy When the Gateway has customer targets (AIDLC_MCP_GATEWAY_CUSTOM_TARGETS=true), stage and persona sessions get one runtime-owned aidlc-gateway-tools entry for kiro, claude, codex and opencode. It relays over its own engine-owned socket to gateway-proxy.js in passthrough mode, which lists the Gateway's tools page by page without the aidlc___ ones and forwards calls and results unchanged under their qualified names, with the same grant and session as the built-in proxy. Discussion and conflict sessions get no entry. The entry carries no endpoint, grant or credential.
… under gateway_only runtime_mcp_credentials = gateway_only (env RUNTIME_MCP_CREDENTIALS, one helper in lambda/shared/runtime-mcp-policy.js): the space and platform custom MCP editors reject stdio and remote entries and secret values with an error pointing to Gateway targets, before any setting is written; reads and clears keep working. MCP verification is refused before a verification grant is issued or the runtime invoked. Intent launch re-reads a pinned snapshot with direct servers like an invalid one: still configured refuses the start, removed repairs it. GET /agents/settings names the profile only when it is on. Unset (allow) behaves as before; an unknown value fails loudly.
…of the runtime under gateway_only The orchestrator fails a stage whose pinned or legacy config names direct MCP servers inside the existing run-<attempt> dispatch step (no step renamed or added), so it never reaches the runtime and settles like any dispatch refusal. The credential broker refuses MCP secret resolution for both authorities (MCP_SECRETS_GATEWAY_ONLY) before any read when a request or the pinned config names servers. As defense in depth run-stage fails such a stage before secret resolution or launch, and verify-mcp refuses before probing. A sentinel secret is shown never to reach dispatch payloads, the child env or the process store.
…yment profile runtime_mcp_credentials = "allow" (default, unchanged) | "gateway_only". gateway_only requires enable_mcp_gateway, enable_engine_data and enforce_runtime_grants, sets RUNTIME_MCP_CREDENTIALS on the credential broker, projects, intents, orchestrator, agents and runtime (managed environments inherit the runtime environment), drops the runtime role's MCP secret read and adds an explicit Deny on MCP secret parameters, AgentCore Identity tokens and API keys and the customer providers' secrets.
…P editors When GET /agents/settings reports runtimeMcpCredentials = gateway_only, the platform and space MCP editors say that servers are added as Gateway targets by a platform administrator and that existing entries can be removed. Saves keep showing the API's refusal message.
…ity API returns The AgentCore Identity API reference documents credential provider ARNs under the acps service name while the IAM examples use bedrock-agentcore; validation accepts both and the ARN is used as returned.
…profile New admin page: creating the AgentCore Identity credential provider, declaring the target and its allow rules, the header contract a customer MCP server must follow (authenticate the Gateway credential, read x-aidlc-caller, trust it only on an authenticated request), what widening, rebinding, narrowing and rotation do to running stages, and the gateway_only profile. Architecture covers both features and which credentials still reach the runtime (Git tokens, model credentials).
c87d5b3 to
40de007
Compare
In short
Stacked on #614 (
feat/agentcore-engine-data).Moves the runtime's remaining credential and provider access behind the control-plane grant, retires grants as soon as their use ends, and lets customer MCP servers run as Gateway targets so their credentials never enter the agent's microVM. Active with
enable_engine_data; strict enforcement is a separate flag (enforce_runtime_grants, defaultfalse); keeping custom MCP out of the runtime is a third, opt-in profile (runtime_mcp_credentials = "gateway_only").Grants and the broker
runtimealias. Provider review operations (default branch, review status, set to draft) go to an alias of the source-control Lambda that requires a grant, accepts only those operations, and acts only on review numbers the execution's own unit review records list. Control-plane callers keep the unqualified function.enforce_runtime_grantsis off, requests that carry any grant field are verified exactly as under enforcement, and requests in the old shape (runtimes started before the flag) are served the old way and logged asLEGACY_RUNTIME_REQUEST. Turn it on once that count is zero; the old shapes are then denied.executiongrant is refused at the Gateway.Two small fixes ride along, found while testing (each its own commit): a Bedrock IAM renewal no longer requests an STS session past the signed renewal deadline, and a cancel that races the durable watchdog now completes instead of returning 500.
The HTTP retirement helpers live in
lambda/intents/grant-retirement.js; the recovery contract (failure, persisted state, response, retry) is documented indocs/development/recovery-ownership.md.Customer MCP servers as Gateway targets
mcp_gateway_custom_targets(default{}, requiresenable_mcp_gateway) declares one remote MCP target per entry: the HTTPS endpoint, an AgentCore Identity credential provider (OAuth client-credentials or API key) created out of band and referenced by ARN together with its secret ARN, an optional VPC Lattice private endpoint, and an allow rule by space, stage and tool. No secret enters Terraform state. The Gateway role getsGetWorkloadAccessToken,GetResourceOauth2TokenorGetResourceApiKey, andsecretsmanager:GetSecretValueon exactly the configured providers. The interceptor receives the allow rules only (no endpoint, no credential, bounded to 2 KB); the runtime learns only that customer targets exist.tools/list. Rotation happens in AgentCore Identity.agentRefare never read. An allowed call carriesx-aidlc-caller, a versioned envelope of grant-derived fields (space, intent, execution, stage, unit, grant id, expiry), and neverAuthorization: the provider's credential is the one the server receives. On every other request the interceptor allows (initialize,ping,tools/list, notifications, and calls to theaidlctarget) the header is set to a fixed{"v": 1, "none": true}value, so a caller-supplied header never reaches a server.tools/listfilters customer tools with the same predicate and keeps pagination. A malformed registry denies every request.aidlc-gateway-toolsentry for Kiro, Claude, Codex and OpenCode. It lists the Gateway's tools page by page without theaidlc___ones and forwards calls and results unchanged under their qualified names (<target>___<tool>), with the same grant and session as the built-in proxy. Discussion and conflict sessions get no entry. The entry carries no endpoint, grant or credential.x-aidlc-callerfor scoping, trust it only on an authenticated request, treat thenonevalue as no caller) is indocs/using-the-platform/customer-mcp-servers.md.runtime_mcp_credentials = "gateway_only"Opt-in profile (default
"allow", unchanged). It requiresenable_mcp_gateway,enable_engine_dataandenforce_runtime_grants, and setsRUNTIME_MCP_CREDENTIALSon the broker, projects, intents, orchestrator, agents and runtime (managed environments inherit it). One helper,lambda/shared/runtime-mcp-policy.js; an unknown value fails loudly at cold start.run-<attempt>dispatch step (no step renamed or added), so it never reaches the runtime; the broker refuses MCP secret resolution for both authorities before any read; run-stage fails the stage before secret resolution or launch as defense in depth.getExecution,getExecutionRecords, and the METAcreateExecutionandupdateExecutionreturn); the record itself is untouched.--strict-mcp-config, KiroincludeMcpJson: false, Codex with the workspace and its ancestors markeduntrusted, OpenCode with every server another layer defines written as a disabled stand-in. A sentinel secret is shown never to reach dispatch payloads, the child env or the process store.Scope and known limits
gateway_only: Git provider tokens during the engine's push and fetch windows, and each CLI's model credentials. The profile isolates the agent's MCP tools; it does not replace network egress control, since code the agent runs (a shell command,curl, a script in the repository) can still open connections from the microVM. Restricting the runtime's egress is the next hardening step.aidlc-gateway-toolsentry; it is written when a stage session starts on the updated runtime.Validation
main. On the stack tip:npm test7349 passed, 2 todo;test:release172 passed (including theruntime_mcp_credentialswiring test);terraform test49/49 (mcp-gateway-custom-targets.tftest.hclcovers the targets, the provider permissions, the registry bound, the profile's prerequisites and the runtime role's Denies); lint,format:check,dep:check,secretlintandsdk:checkpass. The suite includes a real durable-runner test of retirement retries and replay, broker handler tests in transition and strict modes, concurrency cases (stale rewind, cancel/watchdog race, same-nonce begins at the cap), static IAM assertions for the retirement table, the interceptor's customer-target and retirement suites, and the CLI isolation tests against the generated configs.x-aidlc-callerand the Identity-injected credential reach the target and that a caller-supplied header is overwritten; tool naming and sync (<target>___<tool>intools/list, filtered out for another space); the credential provider ARN service name the Identity API returns (acpsversusbedrock-agentcore, both accepted by validation); revocation latency after narrowing an allow rule; and the CLI isolation flags against the real Claude, Kiro, Codex and OpenCode binaries.Upgrade notes
Requires
enable_engine_data(and thereforeenable_mcp_gateway). Creates the grant-retirement table (TTL, not deletion-protected: entries are short-lived), the source-controlruntimealias, and broker/consumer IAM. Enableenforce_runtime_grantsonly afterLEGACY_RUNTIME_REQUESThas drained.Customer targets and
gateway_onlyare off by default. Adding a target creates it and the Gateway role's provider permissions; the interceptor picks up the allow rules on the same apply. Turning ongateway_onlyadds the runtime role Denies and refuses existing direct custom MCP entries at the next intent launch (remove them in the space or platform settings first; a snapshot from which they were removed is repaired on launch).