@deployfoundation/foundation-deploy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,938 @@
1
+ import * as cdk from "aws-cdk-lib";
2
+ import type * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import type * as ec2 from "aws-cdk-lib/aws-ec2";
4
+ import * as ecr from "aws-cdk-lib/aws-ecr";
5
+ import * as iam from "aws-cdk-lib/aws-iam";
6
+ import type * as kms from "aws-cdk-lib/aws-kms";
7
+ import type * as s3 from "aws-cdk-lib/aws-s3";
8
+ import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
9
+ import type { Construct } from "constructs";
10
+ import { agentImage, agentImageTagRequired, releaseImageRepositoryArn } from "../artifacts.ts";
11
+ import {
12
+ type Instance,
13
+ type InstanceNames,
14
+ WEB_SEARCH_TARGET,
15
+ namesFor,
16
+ provisionsIntegration,
17
+ requiresAuthenticatedQueue,
18
+ } from "../names.ts";
19
+
20
+ export interface FoundationAgentProps extends cdk.StackProps {
21
+ /** The deployment this stack belongs to; every name below comes from it. */
22
+ instance: Instance;
23
+ vpc: ec2.IVpc;
24
+ egressSubnets: ec2.SubnetSelection;
25
+ agentSecurityGroup: ec2.ISecurityGroup;
26
+ /** SG on the S3 Files mount targets (NFS from `agentSecurityGroup`). */
27
+ mountTargetSecurityGroup: ec2.ISecurityGroup;
28
+ /** `FileSystemId` of the FoundationData S3 Files file system. */
29
+ fileSystemId: string;
30
+ /** ARN of its access point — mounted at `/mnt/sky`. */
31
+ dataKey: kms.IKey;
32
+ bucket: s3.IBucket;
33
+ /** Dedicated bucket for durable per-channel documents. */
34
+ documentBucket: s3.IBucket;
35
+ table: dynamodb.ITable;
36
+ /** The `pk`+`sk` table holding todos and routines. */
37
+ itemsTable: dynamodb.ITable;
38
+ /** Deployment-owned CRM table; never directly readable by the shell-capable agent. */
39
+ crmTable?: dynamodb.ITable;
40
+ /** Proxy-only proposal drafts; explicitly denied to every agent-assumable role. */
41
+ upworkApprovalTable?: dynamodb.ITable;
42
+ secrets: {
43
+ runtime: secretsmanager.ISecret;
44
+ codex: secretsmanager.ISecret;
45
+ googleAiStudio: secretsmanager.ISecret;
46
+ googleDrive: secretsmanager.ISecret;
47
+ googleCalendar: secretsmanager.ISecret;
48
+ googleOauth: secretsmanager.ISecret;
49
+ githubApp: secretsmanager.ISecret;
50
+ /** Read-only Atlas connection string; a placeholder until someone fills it. */
51
+ mongodbReadonly: secretsmanager.ISecret;
52
+ /** Read-only Otter Enterprise Public API key, provisioned only for opted-in instances. */
53
+ otterApi?: secretsmanager.ISecret;
54
+ /** Knock public client and credential; the shell-capable role is denied both. */
55
+ knockOauthClient?: secretsmanager.ISecret;
56
+ knockCredential?: secretsmanager.ISecret;
57
+ /** Combined Upwork OAuth client and token secret, explicitly denied to the agent role. */
58
+ upwork?: secretsmanager.ISecret;
59
+ /** The connected mailbox. The agent role is explicitly DENIED read on it. */
60
+ googleEmail: secretsmanager.ISecret;
61
+ slackApp: secretsmanager.ISecret;
62
+ signing: secretsmanager.ISecret;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * FoundationAgent — the ECR repository holding the agent image and the AgentCore
68
+ * Runtime that runs it.
69
+ *
70
+ * There is no L2 construct for AgentCore, so the runtime is declared as the
71
+ * `AWS::BedrockAgentCore::Runtime` L1 resource.
72
+ *
73
+ * Two-phase bootstrap: the runtime references an image in the repo this same
74
+ * stack creates, and AgentCore validates the container URI at *create* time,
75
+ * so the first deploy must run with `-c deployRuntime=false` (repo + role
76
+ * only), push the image, then re-deploy with the flag on (the default).
77
+ * During phase one `agentRuntimeArn` is a syntactically valid `…-pending`
78
+ * placeholder so FoundationApi still synthesizes.
79
+ *
80
+ * AgentCore VPC mode supports only some AZs (us-east-1: use1-az1/az2/az4).
81
+ * CDK cannot check that at synth time, so `-c agentSubnetIds=subnet-a,subnet-b`
82
+ * is the escape hatch when the VPC's default egress subnets land in an
83
+ * unsupported zone.
84
+ */
85
+ export class FoundationAgent extends cdk.Stack {
86
+ public readonly repository: ecr.Repository;
87
+ public readonly executionRole: iam.Role;
88
+ /** The S3 Files access point the runtime mounts at /mnt/sky. */
89
+ public readonly accessPoint: cdk.CfnResource;
90
+ private readonly accessPointArn: string;
91
+ /** Real `AgentRuntimeArn` when deployed, else the `-pending` placeholder. */
92
+ public readonly agentRuntimeArn: string;
93
+ /** MCP endpoint of the tool gateway; rides the runtime secret to the container. */
94
+ public readonly webSearchUrl: string;
95
+ /** The instance's OWN account expressed as a role `aws-readonly` assumes. */
96
+ public readonly readOnlyRole: iam.Role;
97
+
98
+ /** `namesFor(props.instance)`; the private helpers below read it. */
99
+ private readonly names: InstanceNames;
100
+
101
+ constructor(scope: Construct, id: string, props: FoundationAgentProps) {
102
+ super(scope, id, props);
103
+ const names = namesFor(props.instance);
104
+ this.names = names;
105
+ const display = props.instance.displayName;
106
+
107
+ const deployRuntime = String(this.node.tryGetContext("deployRuntime") ?? "true") !== "false";
108
+ const imageTag: string | undefined = this.node.tryGetContext("agentImageTag");
109
+
110
+ this.repository = new ecr.Repository(this, "AgentRepository", {
111
+ repositoryName: names.ecrRepo,
112
+ imageScanOnPush: true,
113
+ // Immutable tags: every build pushes a fresh tag (the short commit sha)
114
+ // and passes it as `-c agentImageTag=<tag>`.
115
+ imageTagMutability: ecr.TagMutability.IMMUTABLE,
116
+ encryption: ecr.RepositoryEncryption.KMS,
117
+ encryptionKey: props.dataKey,
118
+ // Only one tag is ever referenced; five keeps the live image plus four
119
+ // builds' worth of rollback.
120
+ lifecycleRules: [{ maxImageCount: 5 }],
121
+ });
122
+
123
+ // The access point is runtime-specific (POSIX ids of the container user,
124
+ // root directory) and its ARN changes when those change — keeping it here
125
+ // avoids a cross-stack export that CloudFormation would refuse to update
126
+ // while the runtime imports it (2026-09-05).
127
+ const accessPoint = new cdk.CfnResource(this, "FoundationMountAccessPoint", {
128
+ type: "AWS::S3Files::AccessPoint",
129
+ properties: {
130
+ FileSystemId: props.fileSystemId,
131
+ PosixUser: { Uid: "10001", Gid: "10001" },
132
+ // A dedicated root that S3 Files CREATES with these POSIX ids: the file
133
+ // system root itself is root-owned (0755), so a 10001 client could
134
+ // mount but not write (probe 2026-09-05: EACCES on /mnt/sky).
135
+ RootDirectory: {
136
+ Path: "/sky",
137
+ CreationPermissions: { OwnerUid: "10001", OwnerGid: "10001", Permissions: "0755" },
138
+ },
139
+ },
140
+ });
141
+ accessPoint.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
142
+ this.accessPointArn = accessPoint.getAtt("AccessPointArn").toString();
143
+ new cdk.CfnOutput(this, "FoundationMountAccessPointArn", { value: this.accessPointArn });
144
+ this.accessPoint = accessPoint;
145
+ this.executionRole = this.buildExecutionRole(props);
146
+ this.readOnlyRole = this.buildReadOnlyRole(props);
147
+
148
+ // The gateway is independent of the runtime: it can (and does) exist
149
+ // during the phase-one bootstrap deploy.
150
+ const gateway = this.buildToolGateway(display);
151
+ this.webSearchUrl = gateway.getAtt("GatewayUrl").toString();
152
+ this.executionRole.addToPolicy(
153
+ new iam.PolicyStatement({
154
+ sid: "InvokeToolGateway",
155
+ actions: ["bedrock-agentcore:InvokeGateway"],
156
+ resources: [gateway.getAtt("GatewayArn").toString()],
157
+ }),
158
+ );
159
+
160
+ if (!deployRuntime) {
161
+ // Phase one. FoundationApi needs a syntactically valid ARN to put in the
162
+ // gateway's environment; nothing can invoke this one.
163
+ this.agentRuntimeArn = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}-pending`;
164
+ this.outputs();
165
+ return;
166
+ }
167
+
168
+ // A release names the image by digest, so there is no tag to pass and
169
+ // nothing local to have built first.
170
+ if (!imageTag && agentImageTagRequired(this)) {
171
+ throw new Error(
172
+ `${id}: -c agentImageTag=<tag> is required when deploying the runtime. Pass the tag of an image already pushed to the ${names.ecrRepo} repository, deploy a release with -c release=<version>, or run the bootstrap phase with -c deployRuntime=false.`,
173
+ );
174
+ }
175
+
176
+ const explicitSubnets = String(this.node.tryGetContext("agentSubnetIds") ?? "")
177
+ .split(",")
178
+ .map((s) => s.trim())
179
+ .filter(Boolean);
180
+ const runtimeSubnets =
181
+ explicitSubnets.length > 0
182
+ ? explicitSubnets
183
+ : props.vpc.selectSubnets(props.egressSubnets).subnetIds;
184
+
185
+ // One mount target per egress subnet: the runtime schedules session ENIs
186
+ // across both AZs and a mount only resolves in its own AZ.
187
+ const mountTargets = runtimeSubnets.map(
188
+ (subnetId, index) =>
189
+ new cdk.CfnResource(this, `MountTarget${index}`, {
190
+ type: "AWS::S3Files::MountTarget",
191
+ properties: {
192
+ FileSystemId: props.fileSystemId,
193
+ SubnetId: subnetId,
194
+ SecurityGroups: [props.mountTargetSecurityGroup.securityGroupId],
195
+ },
196
+ }),
197
+ );
198
+
199
+ const runtime = new cdk.CfnResource(this, "AgentRuntime", {
200
+ type: "AWS::BedrockAgentCore::Runtime",
201
+ properties: {
202
+ AgentRuntimeName: names.runtimeName,
203
+ Description: `${display} agent runtime (Codex + Slack, single workspace)`,
204
+ AgentRuntimeArtifact: {
205
+ ContainerConfiguration: {
206
+ ContainerUri: agentImage(this, this.repository, imageTag),
207
+ },
208
+ },
209
+ NetworkConfiguration: {
210
+ NetworkMode: "VPC",
211
+ NetworkModeConfig: {
212
+ Subnets: runtimeSubnets,
213
+ SecurityGroups: [props.agentSecurityGroup.securityGroupId],
214
+ },
215
+ },
216
+ // BYO mounts require VPC mode (above) and are attached at
217
+ // *invocation* time, not container init — anything reading /mnt/sky
218
+ // must do so lazily, per request.
219
+ FilesystemConfigurations: [
220
+ {
221
+ S3FilesAccessPoint: {
222
+ AccessPointArn: this.accessPointArn,
223
+ MountPath: "/mnt/sky",
224
+ },
225
+ },
226
+ ],
227
+ LifecycleConfiguration: {
228
+ IdleRuntimeSessionTimeout: 3600,
229
+ MaxLifetime: 28800,
230
+ },
231
+ RoleArn: this.executionRole.roleArn,
232
+ EnvironmentVariables: {
233
+ // The whole env map lives in this one secret, read at boot by
234
+ // ts/agent/src/server.ts — so changing it needs no redeploy.
235
+ RUNTIME_SECRET_ID: props.secrets.runtime.secretName,
236
+ AWS_REGION: this.region,
237
+ FOUNDATION_WORKSPACE_ROOT: "/workspace",
238
+ },
239
+ // The App-level tag aspect cannot reach a raw CfnResource.
240
+ Tags: { project: props.instance.naming.secretPrefix },
241
+ },
242
+ });
243
+ runtime.node.addDependency(this.executionRole);
244
+ runtime.node.addDependency(accessPoint);
245
+ // The mount must exist in both AZs before a session can attach it.
246
+ for (const mountTarget of mountTargets) runtime.node.addDependency(mountTarget);
247
+
248
+ this.agentRuntimeArn = runtime.getAtt("AgentRuntimeArn").toString();
249
+ this.outputs();
250
+ }
251
+
252
+ private outputs(): void {
253
+ new cdk.CfnOutput(this, "AgentRuntimeArn", { value: this.agentRuntimeArn });
254
+ new cdk.CfnOutput(this, "RepositoryUri", { value: this.repository.repositoryUri });
255
+ new cdk.CfnOutput(this, "WebSearchGatewayUrl", { value: this.webSearchUrl });
256
+ new cdk.CfnOutput(this, "ReadOnlyRoleArn", { value: this.readOnlyRole.roleArn });
257
+ }
258
+
259
+ /**
260
+ * The AgentCore Gateway that fronts managed tool connectors, plus its one
261
+ * target: Amazon's `web-search` connector.
262
+ *
263
+ * Inbound is `AWS_IAM` — the container signs its MCP calls with the runtime
264
+ * execution role's own SigV4 credentials, so there is no OAuth issuer and no
265
+ * token to store. Outbound is `GATEWAY_IAM_ROLE`: the gateway calls the
266
+ * search tool as the service role built here, which can reach nothing else.
267
+ *
268
+ * There is no L2 for either resource, so both are raw L1s with the
269
+ * CloudFormation property names verbatim.
270
+ */
271
+ private buildToolGateway(display: string): cdk.CfnResource {
272
+ const gatewayArnPattern = `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:gateway/*`;
273
+ const role = new iam.Role(this, "ToolGatewayRole", {
274
+ description: "AgentCore tool gateway service role (web search only)",
275
+ assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com", {
276
+ // Confused-deputy guard: only this account's gateways may assume it.
277
+ conditions: {
278
+ StringEquals: { "aws:SourceAccount": this.account },
279
+ ArnLike: { "aws:SourceArn": gatewayArnPattern },
280
+ },
281
+ }),
282
+ });
283
+ role.addToPolicy(
284
+ new iam.PolicyStatement({
285
+ sid: "GatewayInvoke",
286
+ actions: ["bedrock-agentcore:InvokeGateway"],
287
+ resources: [gatewayArnPattern],
288
+ }),
289
+ );
290
+ role.addToPolicy(
291
+ new iam.PolicyStatement({
292
+ sid: "WebSearchTool",
293
+ actions: ["bedrock-agentcore:InvokeWebSearch"],
294
+ // The managed tool is an AWS-owned resource, not one of ours. It is
295
+ // regional, so it follows the instance's region rather than naming one.
296
+ resources: [`arn:aws:bedrock-agentcore:${this.region}:aws:tool/web-search.v1`],
297
+ }),
298
+ );
299
+
300
+ const gateway = new cdk.CfnResource(this, "ToolGateway", {
301
+ type: "AWS::BedrockAgentCore::Gateway",
302
+ properties: {
303
+ Name: this.names.gatewayName,
304
+ Description: `${display} managed tool gateway (web search)`,
305
+ ProtocolType: "MCP",
306
+ AuthorizerType: "AWS_IAM",
307
+ RoleArn: role.roleArn,
308
+ },
309
+ });
310
+ gateway.node.addDependency(role);
311
+
312
+ const target = new cdk.CfnResource(this, "WebSearchTarget", {
313
+ type: "AWS::BedrockAgentCore::GatewayTarget",
314
+ properties: {
315
+ GatewayIdentifier: gateway.getAtt("GatewayIdentifier"),
316
+ Name: WEB_SEARCH_TARGET,
317
+ TargetConfiguration: {
318
+ Mcp: {
319
+ Connector: {
320
+ // The schema for ConnectorSource carries only ConnectorId, so
321
+ // the connector's current default version is what runs.
322
+ Source: { ConnectorId: "web-search" },
323
+ Configurations: [{ Name: "WebSearch", ParameterValues: {} }],
324
+ },
325
+ },
326
+ },
327
+ CredentialProviderConfigurations: [{ CredentialProviderType: "GATEWAY_IAM_ROLE" }],
328
+ },
329
+ });
330
+ target.node.addDependency(gateway);
331
+
332
+ return gateway;
333
+ }
334
+
335
+ private denyDirectCrmTableAccess(
336
+ role: iam.Role,
337
+ instance: Instance,
338
+ crmTable: dynamodb.ITable | undefined,
339
+ ): void {
340
+ if (!instance.integrations.crmStorage) return;
341
+ // CRM tables are RETAINed. Match every historical prefix so a supported
342
+ // prefix migration cannot make an older retained table readable.
343
+ const retainedTableArn = cdk.Stack.of(this).formatArn({
344
+ service: "dynamodb",
345
+ resource: "table",
346
+ resourceName: "*-CrmTable*",
347
+ });
348
+ const resources = [
349
+ retainedTableArn,
350
+ `${retainedTableArn}/index/*`,
351
+ ...(crmTable === undefined ? [] : [crmTable.tableArn, `${crmTable.tableArn}/index/*`]),
352
+ ];
353
+ role.addToPolicy(
354
+ new iam.PolicyStatement({
355
+ sid: "DenyDirectCrmTableAccess",
356
+ effect: iam.Effect.DENY,
357
+ actions: [
358
+ "dynamodb:GetItem",
359
+ "dynamodb:BatchGetItem",
360
+ "dynamodb:TransactGetItems",
361
+ "dynamodb:Query",
362
+ "dynamodb:Scan",
363
+ "dynamodb:PartiQLSelect",
364
+ "dynamodb:ExecuteStatement",
365
+ "dynamodb:BatchExecuteStatement",
366
+ "dynamodb:ExportTableToPointInTime",
367
+ ],
368
+ resources,
369
+ }),
370
+ );
371
+ }
372
+
373
+ /** Deny remains attached to both execution and assumed read-only roles. */
374
+ private denyDirectKnockSecretAccess(role: iam.Role, props: FoundationAgentProps): void {
375
+ if (!provisionsIntegration(props.instance, "knock")) return;
376
+ const { knockOauthClient, knockCredential } = props.secrets;
377
+ if (knockOauthClient === undefined || knockCredential === undefined)
378
+ throw new Error(
379
+ `${this.stackName}: integrations.knock is enabled but FoundationData supplied no Knock secrets`,
380
+ );
381
+ role.addToPolicy(
382
+ new iam.PolicyStatement({
383
+ sid: "DenyKnockSecrets",
384
+ effect: iam.Effect.DENY,
385
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
386
+ resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)],
387
+ }),
388
+ );
389
+ }
390
+
391
+ private denyDirectUpworkApprovalAccess(
392
+ role: iam.Role,
393
+ upworkApprovalTable: dynamodb.ITable | undefined,
394
+ ): void {
395
+ if (upworkApprovalTable === undefined) return;
396
+ role.addToPolicy(
397
+ new iam.PolicyStatement({
398
+ sid: "DenyDirectUpworkApprovalAccess",
399
+ effect: iam.Effect.DENY,
400
+ // Cover reads today and any future managed-policy or inline write grant.
401
+ actions: ["dynamodb:*"],
402
+ resources: [upworkApprovalTable.tableArn, `${upworkApprovalTable.tableArn}/index/*`],
403
+ }),
404
+ );
405
+ }
406
+
407
+ /**
408
+ * `<Prefix>ReadOnlyRole` — how the agent reads its OWN account.
409
+ *
410
+ * The instance's account is not a special case: `aws-readonly` reaches
411
+ * every account through an assumed role, and this is the one for this one.
412
+ * That keeps the execution role itself narrow (it can write exactly the
413
+ * handful of resources it owns, and reads nothing else), and it makes every
414
+ * read the agent performs land in CloudTrail under a session named for the
415
+ * instance and the label rather than under the runtime's own identity.
416
+ *
417
+ * `ReadOnlyAccess` is AWS's own managed read-only policy. The inline
418
+ * additions are the two things it does NOT cover which the observability
419
+ * tools need: starting a Logs Insights query, and reading trace summaries.
420
+ */
421
+ private buildReadOnlyRole(props: FoundationAgentProps): iam.Role {
422
+ const role = new iam.Role(this, "AgentReadOnlyRole", {
423
+ roleName: `${props.instance.naming.prefix}ReadOnlyRole`,
424
+ description: `${props.instance.displayName} read-only role assumed by the agent (aws-readonly)`,
425
+ // Only this instance's own execution role may assume it. Nothing else
426
+ // in the account can, and it grants no write anywhere.
427
+ assumedBy: new iam.ArnPrincipal(this.executionRole.roleArn),
428
+ maxSessionDuration: cdk.Duration.hours(1),
429
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("ReadOnlyAccess")],
430
+ });
431
+ this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
432
+ this.denyDirectKnockSecretAccess(role, props);
433
+ this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
434
+ role.addToPolicy(
435
+ new iam.PolicyStatement({
436
+ sid: "DenyAgentCoreBrowserMetadata",
437
+ effect: iam.Effect.DENY,
438
+ actions: ["bedrock-agentcore:*Browser*"],
439
+ resources: ["*"],
440
+ }),
441
+ );
442
+ role.addToPolicy(
443
+ new iam.PolicyStatement({
444
+ sid: "LogsInsights",
445
+ // StartQuery is a WRITE action in IAM's taxonomy, so ReadOnlyAccess
446
+ // omits it — without this the log tools can only ever list groups.
447
+ actions: [
448
+ "logs:StartQuery",
449
+ "logs:StopQuery",
450
+ "logs:GetQueryResults",
451
+ "logs:FilterLogEvents",
452
+ "logs:DescribeLogGroups",
453
+ "logs:DescribeLogStreams",
454
+ ],
455
+ resources: ["*"],
456
+ }),
457
+ );
458
+ role.addToPolicy(
459
+ new iam.PolicyStatement({
460
+ sid: "TraceRead",
461
+ actions: ["xray:GetTraceSummaries", "xray:BatchGetTraces"],
462
+ resources: ["*"],
463
+ }),
464
+ );
465
+ this.executionRole.addToPolicy(
466
+ new iam.PolicyStatement({
467
+ sid: "AssumeReadOnlyRole",
468
+ actions: ["sts:AssumeRole"],
469
+ resources: [role.roleArn],
470
+ }),
471
+ );
472
+ return role;
473
+ }
474
+
475
+ private buildExecutionRole(props: FoundationAgentProps): iam.Role {
476
+ const role = new iam.Role(this, "AgentExecutionRole", {
477
+ assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
478
+ description: `${props.instance.displayName} AgentCore execution role (least privilege)`,
479
+ });
480
+ this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
481
+ this.denyDirectKnockSecretAccess(role, props);
482
+ this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
483
+
484
+ // Foundation's own repository when a release names the image, because the
485
+ // runtime then pulls across accounts. The pull also needs Foundation's
486
+ // repository POLICY to allow this account (`release-account-stack.ts`);
487
+ // this side is the half the instance owns.
488
+ const releaseRepositoryArn = releaseImageRepositoryArn(this);
489
+ role.addToPolicy(
490
+ new iam.PolicyStatement({
491
+ sid: "EcrPull",
492
+ actions: [
493
+ "ecr:BatchGetImage",
494
+ "ecr:GetDownloadUrlForLayer",
495
+ "ecr:BatchCheckLayerAvailability",
496
+ ],
497
+ resources: [
498
+ this.repository.repositoryArn,
499
+ ...(releaseRepositoryArn === undefined ? [] : [releaseRepositoryArn]),
500
+ ],
501
+ }),
502
+ );
503
+ role.addToPolicy(
504
+ new iam.PolicyStatement({
505
+ sid: "EcrAuth",
506
+ // GetAuthorizationToken has no resource-level scoping.
507
+ actions: ["ecr:GetAuthorizationToken"],
508
+ resources: ["*"],
509
+ }),
510
+ );
511
+ role.addToPolicy(
512
+ new iam.PolicyStatement({
513
+ sid: "AgentLogs",
514
+ actions: [
515
+ "logs:CreateLogGroup",
516
+ "logs:CreateLogStream",
517
+ "logs:PutLogEvents",
518
+ "logs:DescribeLogStreams",
519
+ ],
520
+ resources: [
521
+ `arn:${this.partition}:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/${this.names.runtimeName}-*`,
522
+ ],
523
+ }),
524
+ );
525
+ // Spans: the runtime SigV4-posts OTLP to the X-Ray endpoint, which routes
526
+ // them into the log group `AgentLogs` above already covers. None of these
527
+ // actions take a resource ARN — X-Ray scopes ingestion by account.
528
+ role.addToPolicy(
529
+ new iam.PolicyStatement({
530
+ sid: "TraceIngest",
531
+ actions: [
532
+ "xray:PutTraceSegments",
533
+ "xray:PutTelemetryRecords",
534
+ "xray:PutSpans",
535
+ "xray:PutSpansForIndexing",
536
+ ],
537
+ resources: ["*"],
538
+ }),
539
+ );
540
+ role.addToPolicy(
541
+ new iam.PolicyStatement({
542
+ sid: "AgentSecrets",
543
+ actions: ["secretsmanager:GetSecretValue"],
544
+ resources: [
545
+ secretArnPattern(props.secrets.runtime),
546
+ secretArnPattern(props.secrets.codex),
547
+ secretArnPattern(props.secrets.googleAiStudio),
548
+ secretArnPattern(props.secrets.googleDrive),
549
+ secretArnPattern(props.secrets.googleCalendar),
550
+ secretArnPattern(props.secrets.googleOauth),
551
+ secretArnPattern(props.secrets.githubApp),
552
+ secretArnPattern(props.secrets.slackApp),
553
+ secretArnPattern(props.secrets.mongodbReadonly),
554
+ ],
555
+ }),
556
+ );
557
+ role.addToPolicy(
558
+ new iam.PolicyStatement({
559
+ sid: "CodexCredentialWriteback",
560
+ // SharedAuthCredentialStore writes the rotated Codex token back.
561
+ actions: ["secretsmanager:PutSecretValue"],
562
+ resources: [secretArnPattern(props.secrets.codex)],
563
+ }),
564
+ );
565
+ // The signing secret proves inbound Slack authenticity. If the agent
566
+ // could read it, a prompt injection could forge Slack events past
567
+ // verification. Removing the grant is what closes that today; an explicit
568
+ // Deny beats any future Allow, so it stays closed.
569
+ role.addToPolicy(
570
+ new iam.PolicyStatement({
571
+ sid: "DenySlackSigningSecret",
572
+ effect: iam.Effect.DENY,
573
+ actions: ["secretsmanager:GetSecretValue"],
574
+ resources: [secretArnPattern(props.secrets.signing)],
575
+ }),
576
+ );
577
+ // Email is the sharpest version of the same argument as the signing
578
+ // secret, and the reason the proxy is a separate Lambda. The scope Google
579
+ // grants the mailbox (`gmail.compose`) permits SENDING, so a container
580
+ // that could read this secret could send mail as the company — and the
581
+ // model's shell can reach the execution role's credentials. Denying the
582
+ // read is what makes "the teammate cannot send" true rather than merely intended.
583
+ role.addToPolicy(
584
+ new iam.PolicyStatement({
585
+ sid: "DenyEmailSecret",
586
+ effect: iam.Effect.DENY,
587
+ actions: ["secretsmanager:GetSecretValue"],
588
+ resources: [secretArnPattern(props.secrets.googleEmail)],
589
+ }),
590
+ );
591
+ // The whole of the agent's email permission: invoke the proxy. Not the
592
+ // Gmail API, not the secret, not the identity list.
593
+ // Built from the NAME rather than from FoundationApi's function object: FoundationApi
594
+ // already depends on this stack for the runtime ARN, so referring to its
595
+ // output here would close a cycle. The function name is derived from the
596
+ // instance file, so both stacks compute the same string.
597
+ role.addToPolicy(
598
+ new iam.PolicyStatement({
599
+ sid: "EmailProxyInvoke",
600
+ actions: ["lambda:InvokeFunction"],
601
+ resources: [
602
+ cdk.Stack.of(this).formatArn({
603
+ service: "lambda",
604
+ resource: "function",
605
+ resourceName: namesFor(props.instance).emailProxyFunctionName,
606
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
607
+ }),
608
+ ],
609
+ }),
610
+ );
611
+ // Defense in depth: the model shell shares this role. A future broad grant must not let it
612
+ // bypass the proxy's domain, operation, or user/profile checks through the Browser APIs.
613
+ role.addToPolicy(
614
+ new iam.PolicyStatement({
615
+ sid: "DenyDirectAgentCoreBrowser",
616
+ effect: iam.Effect.DENY,
617
+ actions: ["bedrock-agentcore:*Browser*"],
618
+ resources: ["*"],
619
+ }),
620
+ );
621
+ // AgentCore Browser follows the same isolation boundary: the agent can invoke this one
622
+ // policy-enforcing function, but it cannot start a browser session or connect to CDP itself.
623
+ // The deny above is unconditional; this ALLOW exists only where the instance file opted the
624
+ // browser capability's infrastructure in, so an instance without it names no function.
625
+ if (provisionsIntegration(props.instance, "browser")) {
626
+ role.addToPolicy(
627
+ new iam.PolicyStatement({
628
+ sid: "BrowserProxyInvoke",
629
+ actions: ["lambda:InvokeFunction"],
630
+ resources: [
631
+ cdk.Stack.of(this).formatArn({
632
+ service: "lambda",
633
+ resource: "function",
634
+ resourceName: this.names.browserProxyFunctionName,
635
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
636
+ }),
637
+ ],
638
+ }),
639
+ );
640
+ }
641
+ if (provisionsIntegration(props.instance, "otter")) {
642
+ const otterApi = props.secrets.otterApi;
643
+ if (otterApi === undefined)
644
+ throw new Error(
645
+ `${this.stackName}: integrations.otter is enabled but FoundationData supplied no Otter secret`,
646
+ );
647
+ // Defense in depth: even a future broad Secrets Manager Allow cannot
648
+ // expose the API key to the shell-capable agent execution role.
649
+ role.addToPolicy(
650
+ new iam.PolicyStatement({
651
+ sid: "DenyOtterSecret",
652
+ effect: iam.Effect.DENY,
653
+ actions: ["secretsmanager:GetSecretValue"],
654
+ resources: [secretArnPattern(otterApi)],
655
+ }),
656
+ );
657
+ // The entire Otter permission surface: invoke the fixed read-only
658
+ // proxy. The name is derived locally to avoid a FoundationAgent ↔ FoundationApi cycle.
659
+ role.addToPolicy(
660
+ new iam.PolicyStatement({
661
+ sid: "OtterProxyInvoke",
662
+ actions: ["lambda:InvokeFunction"],
663
+ resources: [
664
+ cdk.Stack.of(this).formatArn({
665
+ service: "lambda",
666
+ resource: "function",
667
+ resourceName: this.names.otterProxyFunctionName,
668
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
669
+ }),
670
+ ],
671
+ }),
672
+ );
673
+ }
674
+ if (provisionsIntegration(props.instance, "knock")) {
675
+ // Knock's entire agent-facing surface is one proxy invocation. Neither
676
+ // the public client nor the connected OAuth credential reaches the shell.
677
+ role.addToPolicy(
678
+ new iam.PolicyStatement({
679
+ sid: "KnockProxyInvoke",
680
+ actions: ["lambda:InvokeFunction"],
681
+ resources: [
682
+ cdk.Stack.of(this).formatArn({
683
+ service: "lambda",
684
+ resource: "function",
685
+ resourceName: this.names.knockProxyFunctionName,
686
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
687
+ }),
688
+ ],
689
+ }),
690
+ );
691
+ }
692
+ if (provisionsIntegration(props.instance, "upwork")) {
693
+ const upwork = props.secrets.upwork;
694
+ if (upwork === undefined)
695
+ throw new Error(
696
+ `${this.stackName}: integrations.upwork is enabled but FoundationData supplied no Upwork secret`,
697
+ );
698
+ // The full Upwork credential (including a refresh token) is never
699
+ // readable in the shell-capable runtime. The one Lambda invocation below
700
+ // is the entire agent-facing Upwork authority.
701
+ role.addToPolicy(
702
+ new iam.PolicyStatement({
703
+ sid: "DenyUpworkSecret",
704
+ effect: iam.Effect.DENY,
705
+ actions: ["secretsmanager:GetSecretValue"],
706
+ resources: [secretArnPattern(upwork)],
707
+ }),
708
+ );
709
+ role.addToPolicy(
710
+ new iam.PolicyStatement({
711
+ sid: "UpworkProxyInvoke",
712
+ actions: ["lambda:InvokeFunction"],
713
+ resources: [
714
+ cdk.Stack.of(this).formatArn({
715
+ service: "lambda",
716
+ resource: "function",
717
+ resourceName: this.names.upworkProxyFunctionName,
718
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
719
+ }),
720
+ ],
721
+ }),
722
+ );
723
+ }
724
+ if (provisionsIntegration(props.instance, "crm")) {
725
+ // The CRM permission surface is one named proxy Lambda. Derive the ARN
726
+ // locally so FoundationAgent never depends on FoundationApi's output and closes a
727
+ // stack cycle through the runtime ARN.
728
+ role.addToPolicy(
729
+ new iam.PolicyStatement({
730
+ sid: "CrmProxyInvoke",
731
+ actions: ["lambda:InvokeFunction"],
732
+ resources: [
733
+ cdk.Stack.of(this).formatArn({
734
+ service: "lambda",
735
+ resource: "function",
736
+ resourceName: this.names.crmProxyFunctionName,
737
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
738
+ }),
739
+ ],
740
+ }),
741
+ );
742
+ }
743
+ // Config and curated skills are deployment-owned: direct S3 API writes are
744
+ // limited to memory/*. The separate fs/* persistent mount remains writable
745
+ // through S3 Files for workspace state and learned skills.
746
+ role.addToPolicy(
747
+ new iam.PolicyStatement({
748
+ sid: "ConfigAndSkills",
749
+ actions: ["s3:GetObject"],
750
+ resources: [props.bucket.arnForObjects("*")],
751
+ }),
752
+ );
753
+ role.addToPolicy(
754
+ new iam.PolicyStatement({
755
+ sid: "MemoryWrite",
756
+ actions: ["s3:PutObject"],
757
+ resources: [props.bucket.arnForObjects("memory/*")],
758
+ }),
759
+ );
760
+ role.addToPolicy(
761
+ new iam.PolicyStatement({
762
+ sid: "ConfigList",
763
+ actions: ["s3:ListBucket"],
764
+ resources: [props.bucket.bucketArn],
765
+ }),
766
+ );
767
+ role.addToPolicy(
768
+ new iam.PolicyStatement({
769
+ sid: "DocumentsObjects",
770
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
771
+ resources: [props.documentBucket.arnForObjects("*")],
772
+ }),
773
+ );
774
+ role.addToPolicy(
775
+ new iam.PolicyStatement({
776
+ sid: "DocumentsList",
777
+ actions: ["s3:ListBucket"],
778
+ resources: [props.documentBucket.bucketArn],
779
+ }),
780
+ );
781
+ role.addToPolicy(
782
+ new iam.PolicyStatement({
783
+ sid: "PersistentMount",
784
+ actions: ["s3files:ClientMount", "s3files:ClientWrite"],
785
+ resources: [
786
+ `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`,
787
+ ],
788
+ // The file system carries exactly one access point today; the
789
+ // condition keeps that true from the role's side, so a second one
790
+ // added later is not silently mountable by the agent.
791
+ conditions: { ArnEquals: { "s3files:AccessPointArn": this.accessPointArn } },
792
+ }),
793
+ );
794
+ // AgentCore validates the access point at runtime create/update time and
795
+ // checks GetAccessPoint against the ACCESS POINT ARN itself (2026-09-05:
796
+ // "Execution role is missing required permissions. Ensure the role has
797
+ // s3files:GetAccessPoint" when it was granted only on the file system).
798
+ role.addToPolicy(
799
+ new iam.PolicyStatement({
800
+ sid: "PersistentMountDescribe",
801
+ // Read-only describe set: the validator also asks for ListMountTargets
802
+ // (and may add more); none of these grant data access.
803
+ actions: ["s3files:Get*", "s3files:List*", "s3files:Describe*"],
804
+ resources: [
805
+ this.accessPointArn,
806
+ `arn:${this.partition}:s3files:${this.region}:${this.account}:file-system/${props.fileSystemId}`,
807
+ ],
808
+ }),
809
+ );
810
+ role.addToPolicy(
811
+ new iam.PolicyStatement({
812
+ sid: "StateTable",
813
+ // DeleteItem releases the Codex credential lease.
814
+ actions: [
815
+ "dynamodb:GetItem",
816
+ "dynamodb:PutItem",
817
+ "dynamodb:UpdateItem",
818
+ "dynamodb:DeleteItem",
819
+ "dynamodb:Query",
820
+ ],
821
+ resources: [props.table.tableArn],
822
+ }),
823
+ );
824
+ role.addToPolicy(
825
+ new iam.PolicyStatement({
826
+ sid: "ItemsTable",
827
+ actions: [
828
+ "dynamodb:GetItem",
829
+ "dynamodb:PutItem",
830
+ "dynamodb:UpdateItem",
831
+ "dynamodb:DeleteItem",
832
+ "dynamodb:Query",
833
+ ],
834
+ resources: [props.itemsTable.tableArn],
835
+ }),
836
+ );
837
+ role.addToPolicy(
838
+ new iam.PolicyStatement({
839
+ sid: "DataKeyUsage",
840
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
841
+ resources: [props.dataKey.keyArn],
842
+ }),
843
+ );
844
+ role.addToPolicy(
845
+ new iam.PolicyStatement({
846
+ sid: "RoutineSchedules",
847
+ actions: [
848
+ "scheduler:CreateSchedule",
849
+ "scheduler:UpdateSchedule",
850
+ "scheduler:DeleteSchedule",
851
+ "scheduler:GetSchedule",
852
+ ],
853
+ // Only inside the instance's own group: the agent cannot touch a schedule it
854
+ // did not create, and cannot create the group either.
855
+ resources: [
856
+ `arn:${this.partition}:scheduler:${this.region}:${this.account}:schedule/${this.names.routineGroup}/*`,
857
+ ],
858
+ }),
859
+ );
860
+ role.addToPolicy(
861
+ new iam.PolicyStatement({
862
+ sid: "PassRoutineSchedulerRole",
863
+ actions: ["iam:PassRole"],
864
+ resources: [
865
+ `arn:${this.partition}:iam::${this.account}:role/${this.names.routineSchedulerRole}`,
866
+ ],
867
+ // Passing it anywhere but Scheduler would be privilege escalation.
868
+ conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
869
+ }),
870
+ );
871
+ if (requiresAuthenticatedQueue(props.instance)) {
872
+ role.addToPolicy(
873
+ new iam.PolicyStatement({
874
+ sid: "RoutineIngressInvoke",
875
+ actions: ["lambda:InvokeFunction"],
876
+ resources: [
877
+ cdk.Stack.of(this).formatArn({
878
+ service: "lambda",
879
+ resource: "function",
880
+ resourceName: this.names.slackGatewayFunctionName,
881
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
882
+ }),
883
+ ],
884
+ }),
885
+ );
886
+ } else {
887
+ role.addToPolicy(
888
+ new iam.PolicyStatement({
889
+ sid: "InvokeQueueSend",
890
+ // Legacy instances still let a fired `turn` routine re-enqueue.
891
+ actions: ["sqs:SendMessage"],
892
+ resources: [
893
+ `arn:${this.partition}:sqs:${this.region}:${this.account}:${this.names.api}-InvokeQueue*`,
894
+ ],
895
+ }),
896
+ );
897
+ }
898
+ role.addToPolicy(
899
+ new iam.PolicyStatement({
900
+ sid: "SessionEni",
901
+ // VPC-mode session ENIs; these actions do not support scoping.
902
+ actions: [
903
+ "ec2:CreateNetworkInterface",
904
+ "ec2:DescribeNetworkInterfaces",
905
+ "ec2:DeleteNetworkInterface",
906
+ "ec2:DescribeSubnets",
907
+ "ec2:DescribeSecurityGroups",
908
+ "ec2:DescribeVpcs",
909
+ ],
910
+ resources: ["*"],
911
+ }),
912
+ );
913
+ role.addToPolicy(
914
+ new iam.PolicyStatement({
915
+ sid: "WorkloadIdentity",
916
+ actions: [
917
+ "bedrock-agentcore:GetWorkloadAccessToken",
918
+ "bedrock-agentcore:GetWorkloadAccessTokenForJWT",
919
+ "bedrock-agentcore:GetWorkloadAccessTokenForUserId",
920
+ ],
921
+ resources: [
922
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/default*`,
923
+ ],
924
+ }),
925
+ );
926
+
927
+ return role;
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Secrets imported by name carry a partial ARN (no service-generated
933
+ * 6-character suffix), which IAM will not match — append the wildcard the
934
+ * same way CDK's own `grantRead` does.
935
+ */
936
+ function secretArnPattern(secret: secretsmanager.ISecret): string {
937
+ return secret.secretFullArn ?? `${secret.secretArn}-??????`;
938
+ }