Skip to content

Commit e02e36f

Browse files
authored
Merge branch 'main' into feat/282-dead-code-detection-gate
2 parents 384efa8 + 27d7959 commit e02e36f

7 files changed

Lines changed: 265 additions & 60 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { Node } from 'constructs';
21+
22+
/**
23+
* Single source of truth for the Bedrock **foundation-model IDs** the agent
24+
* runtime may invoke. Both grant sites — the AgentCore runtime in
25+
* `stacks/agent.ts` and the ECS task role in `constructs/ecs-agent-cluster.ts`
26+
* — derive their `grantInvoke` / IAM ARNs from this one list, so the two
27+
* backends can never drift (they were previously two hand-synced arrays; #433).
28+
*
29+
* Scoping is intentionally per-model (explicit foundation-model +
30+
* cross-Region inference-profile ARNs), NOT a `Resource: '*'` wildcard — that
31+
* hardening is preserved. Account-level Bedrock model access remains the outer
32+
* gate; this list only controls the IAM grant.
33+
*/
34+
export const DEFAULT_BEDROCK_MODEL_IDS: readonly string[] = [
35+
'anthropic.claude-sonnet-4-6',
36+
'anthropic.claude-opus-4-20250514-v1:0',
37+
'anthropic.claude-haiku-4-5-20251001-v1:0',
38+
];
39+
40+
/** CDK context key whose value (a string array) overrides the model set. */
41+
export const BEDROCK_MODELS_CONTEXT_KEY = 'bedrockModels';
42+
43+
/**
44+
* Resolves the invocable foundation-model IDs: CDK context `bedrockModels`
45+
* (an array of **bare foundation-model IDs**) when provided, else
46+
* {@link DEFAULT_BEDROCK_MODEL_IDS}. Set via `cdk.json` `context` or
47+
* `-c bedrockModels='["anthropic.claude-opus-4-8", …]'`, then redeploy, to add
48+
* a model the runtime may invoke — no construct edits needed.
49+
*
50+
* **Use the bare foundation-model ID (`anthropic.claude-…`), NOT the
51+
* `us.`-prefixed inference-profile ID.** Both grant sites derive the US
52+
* inference-profile ARN by prefixing `us.`, so passing `us.anthropic.…` here
53+
* would produce an invalid `us.us.anthropic.…` ARN. The resolver rejects a
54+
* `us.`/`eu.`/`apac.`-prefixed entry to catch that early.
55+
*
56+
* Throws on a malformed override (non-array, non-string / empty entries, or a
57+
* region-prefixed ID) so a typo fails synth loudly instead of silently
58+
* granting nothing or an invalid ARN.
59+
*/
60+
export function resolveBedrockModelIds(node: Node): readonly string[] {
61+
const override = node.tryGetContext(BEDROCK_MODELS_CONTEXT_KEY);
62+
if (override === undefined || override === null) {
63+
return DEFAULT_BEDROCK_MODEL_IDS;
64+
}
65+
if (!Array.isArray(override) || override.length === 0) {
66+
throw new Error(
67+
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' must be a non-empty array of foundation-model IDs `
68+
+ `(e.g. ["anthropic.claude-sonnet-4-6"]); got ${JSON.stringify(override)}.`,
69+
);
70+
}
71+
for (const id of override) {
72+
if (typeof id !== 'string' || id.trim().length === 0) {
73+
throw new Error(
74+
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' entries must be non-empty strings; got ${JSON.stringify(id)}.`,
75+
);
76+
}
77+
if (/^(us|eu|apac)\./.test(id)) {
78+
throw new Error(
79+
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' expects bare foundation-model IDs, not region-prefixed `
80+
+ `inference-profile IDs — got '${id}'. Use '${id.replace(/^(us|eu|apac)\./, '')}'; `
81+
+ 'the US inference-profile ARN is derived automatically.',
82+
);
83+
}
84+
}
85+
return override as string[];
86+
}

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
2828
import { NagSuppressions } from 'cdk-nag';
2929
import { Construct } from 'constructs';
3030
import { AgentSessionRole } from './agent-session-role';
31+
import { resolveBedrockModelIds } from './bedrock-models';
3132

3233
export interface EcsAgentClusterProps {
3334
readonly vpc: ec2.IVpc;
@@ -50,18 +51,6 @@ export interface EcsAgentClusterProps {
5051
readonly agentSessionRole?: AgentSessionRole;
5152
}
5253

53-
/**
54-
* Bedrock model IDs the agent may invoke (kept in sync with the AgentCore
55-
* runtime grants in agent.ts). Used to scope the ECS task role's Bedrock
56-
* permissions to explicit foundation-model + inference-profile ARNs instead of
57-
* a `Resource: '*'` wildcard.
58-
*/
59-
const BEDROCK_MODEL_IDS = [
60-
'anthropic.claude-sonnet-4-6',
61-
'anthropic.claude-opus-4-20250514-v1:0',
62-
'anthropic.claude-haiku-4-5-20251001-v1:0',
63-
];
64-
6554
/** HTTPS port — the only egress allowed from the agent task ENIs. */
6655
const HTTPS_PORT = 443;
6756

@@ -162,10 +151,12 @@ export class EcsAgentCluster extends Construct {
162151

163152
// Bedrock model invocation — scoped to explicit foundation-model and
164153
// cross-region inference-profile ARNs (parity with the AgentCore runtime
165-
// grants in agent.ts), replacing the prior Resource: '*' wildcard.
154+
// grants in agent.ts), NOT a Resource: '*' wildcard. The model set is the
155+
// shared, context-overridable list (constructs/bedrock-models.ts) so the
156+
// ECS and AgentCore backends can't drift.
166157
const stack = Stack.of(this);
167158
const bedrockResources: string[] = [];
168-
for (const modelId of BEDROCK_MODEL_IDS) {
159+
for (const modelId of resolveBedrockModelIds(this.node)) {
169160
bedrockResources.push(
170161
stack.formatArn({
171162
service: 'bedrock',

cdk/src/handlers/shared/workflows.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,18 @@ export interface WorkflowDescriptor {
6868

6969
/**
7070
* Platform allow-list of Bedrock model ids a workflow may pin via
71-
* `agent_config.model.id` (WORKFLOWS.md rule 13 / §"Model selection"). Mirrors
72-
* the foundation models the agent runtime is granted to invoke (`BEDROCK_MODEL_IDS`
73-
* in `cdk/src/constructs/ecs-agent-cluster.ts`), accepting both the bare id and
74-
* the `us.`-prefixed cross-region inference-profile form the runtime resolves.
75-
* A future Phase 4 will source this from the repo Blueprint; until then it is a
76-
* single platform-wide list checked at admission.
71+
* `agent_config.model.id` (WORKFLOWS.md rule 13 / §"Model selection"). Should
72+
* mirror the foundation models the agent runtime is granted to invoke — the
73+
* shared list in `cdk/src/constructs/bedrock-models.ts`
74+
* (`DEFAULT_BEDROCK_MODEL_IDS` / the `bedrockModels` context, #433) — accepting
75+
* both the bare id and the `us.`-prefixed cross-region inference-profile form.
76+
*
77+
* NOTE (#433 follow-up): this is a SEPARATE, hand-maintained list from the IAM
78+
* grant source. The `repo onboard --model` path is NOT gated by it (repo
79+
* `model_id` isn't validated here), but a custom workflow pinning a model added
80+
* via the `bedrockModels` context would still be rejected at create-task until
81+
* it's added here too. A future Phase 4 will source this from the repo
82+
* Blueprint; consolidating it with `bedrock-models.ts` is tracked separately.
7783
*/
7884
export const WORKFLOW_MODEL_ALLOWLIST: readonly string[] = [
7985
'anthropic.claude-sonnet-4-6',

cdk/src/stacks/agent.ts

Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AgentSessionRole } from '../constructs/agent-session-role';
3535
import { AgentVpc } from '../constructs/agent-vpc';
3636
import { ApprovalMetricsPublisherConsumer } from '../constructs/approval-metrics-publisher-consumer';
3737
import { AttachmentsBucket } from '../constructs/attachments-bucket';
38+
import { resolveBedrockModelIds } from '../constructs/bedrock-models';
3839
import { Blueprint } from '../constructs/blueprint';
3940
import { CedarWasmLayer } from '../constructs/cedar-wasm-layer';
4041
import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler';
@@ -420,44 +421,24 @@ export class AgentStack extends Stack {
420421
applicationLogGroup.grantWrite(runtime);
421422
agentMemory.grantReadWrite(runtime);
422423

423-
const model = new bedrock.BedrockFoundationModel('anthropic.claude-sonnet-4-6', {
424-
supportsAgents: true,
425-
supportsCrossRegion: true,
426-
});
427-
428-
// Create a cross-region inference profile for Claude Sonnet 4.6
429-
const inferenceProfile = bedrock.CrossRegionInferenceProfile.fromConfig({
430-
geoRegion: bedrock.CrossRegionInferenceProfileRegion.US,
431-
model: model,
432-
});
433-
434-
const model3 = new bedrock.BedrockFoundationModel('anthropic.claude-opus-4-20250514-v1:0', {
435-
supportsAgents: true,
436-
supportsCrossRegion: true,
437-
});
438-
439-
const inferenceProfile3 = bedrock.CrossRegionInferenceProfile.fromConfig({
440-
geoRegion: bedrock.CrossRegionInferenceProfileRegion.US,
441-
model: model3,
442-
});
443-
444-
const model2 = new bedrock.BedrockFoundationModel('anthropic.claude-haiku-4-5-20251001-v1:0', {
445-
supportsAgents: true,
446-
supportsCrossRegion: true,
447-
});
448-
449-
// Create a cross-region inference profile for Claude Haiku 4.5
450-
const inferenceProfile2 = bedrock.CrossRegionInferenceProfile.fromConfig({
451-
geoRegion: bedrock.CrossRegionInferenceProfileRegion.US,
452-
model: model2,
453-
});
454-
455-
model.grantInvoke(runtime);
456-
inferenceProfile.grantInvoke(runtime);
457-
model3.grantInvoke(runtime);
458-
inferenceProfile3.grantInvoke(runtime);
459-
model2.grantInvoke(runtime);
460-
inferenceProfile2.grantInvoke(runtime);
424+
// Grant the runtime invoke on each configured foundation model + its
425+
// US cross-Region inference profile. The model set is a single source of
426+
// truth (constructs/bedrock-models.ts), shared with the ECS task role, and
427+
// overridable via the `bedrockModels` CDK context — add a model by config,
428+
// no construct edits. Scoping stays per-model (no Resource:'*'); account-
429+
// level Bedrock access remains the outer gate.
430+
for (const modelId of resolveBedrockModelIds(this.node)) {
431+
const foundationModel = new bedrock.BedrockFoundationModel(modelId, {
432+
supportsAgents: true,
433+
supportsCrossRegion: true,
434+
});
435+
const crossRegionProfile = bedrock.CrossRegionInferenceProfile.fromConfig({
436+
geoRegion: bedrock.CrossRegionInferenceProfileRegion.US,
437+
model: foundationModel,
438+
});
439+
foundationModel.grantInvoke(runtime);
440+
crossRegionProfile.grantInvoke(runtime);
441+
}
461442

462443
// --- Per-task SessionRole (#209) ---
463444
// Holds the tenant-data grants (the four task_id-partitioned tables, plus
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { App, Stack } from 'aws-cdk-lib';
21+
import {
22+
BEDROCK_MODELS_CONTEXT_KEY,
23+
DEFAULT_BEDROCK_MODEL_IDS,
24+
resolveBedrockModelIds,
25+
} from '../../src/constructs/bedrock-models';
26+
27+
function nodeWithContext(context?: Record<string, unknown>) {
28+
const app = new App({ context });
29+
return new Stack(app, 'TestStack').node;
30+
}
31+
32+
describe('resolveBedrockModelIds', () => {
33+
it('returns the default set when no context override is present', () => {
34+
const ids = resolveBedrockModelIds(nodeWithContext());
35+
expect(ids).toEqual(DEFAULT_BEDROCK_MODEL_IDS);
36+
});
37+
38+
it('returns the context override when provided', () => {
39+
const override = ['anthropic.claude-opus-4-8', 'anthropic.claude-sonnet-4-6'];
40+
const ids = resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: override }));
41+
expect(ids).toEqual(override);
42+
});
43+
44+
it('throws on a non-array override (typo guard)', () => {
45+
expect(() =>
46+
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: 'anthropic.claude-opus-4-8' })),
47+
).toThrow(/must be a non-empty array/);
48+
});
49+
50+
it('throws on an empty-array override', () => {
51+
expect(() =>
52+
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: [] })),
53+
).toThrow(/must be a non-empty array/);
54+
});
55+
56+
it('throws on a non-string / empty entry', () => {
57+
expect(() =>
58+
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['anthropic.claude-sonnet-4-6', ''] })),
59+
).toThrow(/non-empty strings/);
60+
});
61+
62+
it('throws on a region-prefixed (us./eu./apac.) inference-profile ID', () => {
63+
// Guards the us.us.… double-prefix footgun: both grant sites derive the
64+
// inference-profile ARN by prefixing `us.`, so the context wants the bare id.
65+
expect(() =>
66+
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['us.anthropic.claude-opus-4-8'] })),
67+
).toThrow(/bare foundation-model IDs/);
68+
expect(() =>
69+
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['eu.anthropic.claude-sonnet-4-6'] })),
70+
).toThrow(/bare foundation-model IDs/);
71+
});
72+
});

cdk/test/constructs/ecs-agent-cluster.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
2929
import { AgentSessionRole } from '../../src/constructs/agent-session-role';
3030
import { EcsAgentCluster } from '../../src/constructs/ecs-agent-cluster';
3131

32-
function createStack(overrides?: { memoryId?: string }): { stack: Stack; template: Template } {
33-
const app = new App();
32+
function createStack(overrides?: { memoryId?: string; bedrockModels?: string[] }): { stack: Stack; template: Template } {
33+
const app = new App({
34+
context: overrides?.bedrockModels ? { bedrockModels: overrides.bedrockModels } : undefined,
35+
});
3436
const stack = new Stack(app, 'TestStack');
3537

3638
const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 });
@@ -174,6 +176,29 @@ describe('EcsAgentCluster construct', () => {
174176
expect(serialized).toContain('anthropic.claude-haiku-4-5-20251001-v1:0');
175177
});
176178

179+
test('bedrockModels context override changes the granted model ARNs (#433)', () => {
180+
const template = createStack({ bedrockModels: ['anthropic.claude-opus-4-8'] }).template;
181+
const policies = template.findResources('AWS::IAM::Policy');
182+
let bedrockStatement: { Resource: unknown } | undefined;
183+
for (const policy of Object.values(policies)) {
184+
for (const s of policy.Properties.PolicyDocument.Statement) {
185+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
186+
if (actions.includes('bedrock:InvokeModel')) {
187+
bedrockStatement = s;
188+
}
189+
}
190+
}
191+
expect(bedrockStatement).toBeDefined();
192+
const serialized = JSON.stringify(bedrockStatement!.Resource);
193+
// The override model is granted...
194+
expect(serialized).toContain('foundation-model/anthropic.claude-opus-4-8');
195+
expect(serialized).toContain('inference-profile/us.anthropic.claude-opus-4-8');
196+
// ...and the defaults are NOT (the override replaces, not appends).
197+
expect(serialized).not.toContain('claude-sonnet-4-6');
198+
// Still scoped, never a wildcard.
199+
expect(bedrockStatement!.Resource).not.toEqual('*');
200+
});
201+
177202
test('container has required environment variables', () => {
178203
baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', {
179204
ContainerDefinitions: Match.arrayWith([

cdk/test/stacks/agent.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,50 @@ describe('AgentStack', () => {
230230
expect(serialized).toMatch(/"Fn::GetAtt":\["Runtime[0-9A-F]+","AgentRuntimeArn"\]/);
231231
});
232232

233+
test('runtime is granted the default Bedrock model set (#433)', () => {
234+
// Default (no bedrockModels context): the runtime execution role must hold
235+
// bedrock:InvokeModel on the three default foundation models + their US
236+
// inference profiles, scoped (never Resource: '*').
237+
const serialized = JSON.stringify(template.findResources('AWS::IAM::Policy'));
238+
expect(serialized).toContain('foundation-model/anthropic.claude-sonnet-4-6');
239+
expect(serialized).toContain('inference-profile/us.anthropic.claude-sonnet-4-6');
240+
expect(serialized).toContain('anthropic.claude-opus-4-20250514-v1:0');
241+
expect(serialized).toContain('anthropic.claude-haiku-4-5-20251001-v1:0');
242+
});
243+
244+
test('bedrockModels context override propagates to the runtime execution role (#433)', () => {
245+
// The other half of #433's acceptance criteria (the ECS side is covered in
246+
// ecs-agent-cluster.test.ts): a context override must replace the runtime's
247+
// granted models too — overridden model present, defaults absent, still scoped.
248+
const app = new App({ context: { bedrockModels: ['anthropic.claude-opus-4-8'] } });
249+
const stack = new AgentStack(app, 'OverrideAgentStack', {
250+
env: { account: '123456789012', region: 'us-east-1' },
251+
});
252+
const overridden = Template.fromStack(stack);
253+
254+
// Collect every bedrock:InvokeModel statement's Resource across IAM policies.
255+
const policies = overridden.findResources('AWS::IAM::Policy');
256+
const bedrockResources: unknown[] = [];
257+
for (const p of Object.values(policies)) {
258+
for (const s of (p.Properties?.PolicyDocument?.Statement ?? []) as Array<{ Action?: string | string[]; Resource?: unknown }>) {
259+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
260+
if (actions.some((a) => typeof a === 'string' && a.startsWith('bedrock:InvokeModel'))) {
261+
bedrockResources.push(s.Resource);
262+
}
263+
}
264+
}
265+
const serialized = JSON.stringify(bedrockResources);
266+
expect(bedrockResources.length).toBeGreaterThan(0);
267+
// Overridden model is granted...
268+
expect(serialized).toContain('foundation-model/anthropic.claude-opus-4-8');
269+
expect(serialized).toContain('inference-profile/us.anthropic.claude-opus-4-8');
270+
// ...defaults are NOT (override replaces, not appends)...
271+
expect(serialized).not.toContain('claude-sonnet-4-6');
272+
expect(serialized).not.toContain('claude-haiku-4-5');
273+
// ...and the grant is never a bare wildcard.
274+
expect(serialized).not.toContain('"*"');
275+
});
276+
233277
test('outputs ApiUrl', () => {
234278
template.hasOutput('ApiUrl', {
235279
Description: 'URL of the Task API',

0 commit comments

Comments
 (0)