@deployfoundation/foundation-deploy 0.2.20 → 0.2.22

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.
@@ -7,6 +7,10 @@
7
7
  # Arrays in omp settings REPLACE — ship complete lists only here or in the
8
8
  # per-session project config, never split across layers.
9
9
  tools:
10
+ # Foundation intentionally defers a small set of authorized admin read tools.
11
+ # Fail hard if OMP cannot mount them; never expose their schemas top-level.
12
+ xdev: true
13
+ xdevDocs: catalog
10
14
  approvalMode: yolo
11
15
  # Global timeout ceiling in seconds — caps every resolved bash/eval timeout
12
16
  # (defaults included). NOTE: omp treats `timeout: 0` ("no deadline") as
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-rhg6yfht.js";
11
+ } from "../chunk-r6djsvkj.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-k2rxb77g.js";
17
+ } from "../chunk-xb5k71tp.js";
18
18
 
19
19
  // bin/app.ts
20
20
  import { execFileSync } from "node:child_process";
@@ -29,7 +29,7 @@ import {
29
29
  stackExists,
30
30
  stackOutput,
31
31
  toolVersion
32
- } from "../chunk-fjbc0ydk.js";
32
+ } from "../chunk-zpjm151m.js";
33
33
  import {
34
34
  BASE_SLACK_EVENTS,
35
35
  CAPABILITY_IDS,
@@ -45,7 +45,7 @@ import {
45
45
  requiredSlackScopes,
46
46
  skillsKey,
47
47
  slackCommandPrefix
48
- } from "../chunk-k2rxb77g.js";
48
+ } from "../chunk-xb5k71tp.js";
49
49
 
50
50
  // src/deploy/config-sync.ts
51
51
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
@@ -102,6 +102,8 @@ var MAX_GOOGLE_TOKEN_RESPONSE_BYTES = 128 * 1024;
102
102
 
103
103
  // ../connectors/src/gmail.ts
104
104
  var GMAIL_LIST_SUMMARY_MAX_BYTES = 1024 * 1024;
105
+ var MAX_GMAIL_ATTACHMENT_BYTES = 3.5 * 1024 * 1024;
106
+ var MAX_GMAIL_ATTACHMENT_RESPONSE_BYTES = 5 * 1024 * 1024;
105
107
  var GMAIL_SCOPES = [
106
108
  "https://www.googleapis.com/auth/gmail.compose",
107
109
  "https://www.googleapis.com/auth/gmail.modify"
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-fjbc0ydk.js";
5
- import"../chunk-k2rxb77g.js";
4
+ } from "../chunk-zpjm151m.js";
5
+ import"../chunk-xb5k71tp.js";
6
6
 
7
7
  // bin/release-account.ts
8
8
  import * as cdk2 from "aws-cdk-lib";
@@ -19,7 +19,7 @@ import {
19
19
  scheduledJobBindings,
20
20
  slackCommandPrefixes,
21
21
  webhookJobBindings
22
- } from "./chunk-k2rxb77g.js";
22
+ } from "./chunk-xb5k71tp.js";
23
23
 
24
24
  // src/stacks/agent-stack.ts
25
25
  import * as cdk from "aws-cdk-lib";
@@ -240,6 +240,21 @@ class FoundationAgent extends cdk.Stack {
240
240
  resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)]
241
241
  }));
242
242
  }
243
+ denyDirectEmailSecretAccess(role, sharedMailboxSecret, personalSecretPrefix) {
244
+ const stack = cdk.Stack.of(this);
245
+ const personalSecrets = stack.formatArn({
246
+ service: "secretsmanager",
247
+ resource: "secret",
248
+ resourceName: `${personalSecretPrefix}/personal/*-??????`,
249
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
250
+ });
251
+ role.addToPolicy(new iam.PolicyStatement({
252
+ sid: "DenyEmailSecret",
253
+ effect: iam.Effect.DENY,
254
+ actions: ["secretsmanager:*"],
255
+ resources: [secretArnPattern(sharedMailboxSecret), personalSecrets]
256
+ }));
257
+ }
243
258
  pagesPolicy(id, role, instance, options) {
244
259
  if (!provisionsIntegration(instance, "pages"))
245
260
  return;
@@ -342,6 +357,7 @@ class FoundationAgent extends cdk.Stack {
342
357
  });
343
358
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
344
359
  this.denyDirectKnockSecretAccess(role, props);
360
+ this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
345
361
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
346
362
  this.denyDirectEtradeAccess(role, props);
347
363
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -404,6 +420,7 @@ class FoundationAgent extends cdk.Stack {
404
420
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
405
421
  this.denyDirectKnockSecretAccess(role, props);
406
422
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
423
+ this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
407
424
  this.denyDirectEtradeAccess(role, props);
408
425
  this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
409
426
  const releaseRepositoryArn = releaseImageRepositoryArn(this);
@@ -472,12 +489,6 @@ class FoundationAgent extends cdk.Stack {
472
489
  actions: ["secretsmanager:GetSecretValue"],
473
490
  resources: [secretArnPattern(props.secrets.signing)]
474
491
  }));
475
- role.addToPolicy(new iam.PolicyStatement({
476
- sid: "DenyEmailSecret",
477
- effect: iam.Effect.DENY,
478
- actions: ["secretsmanager:GetSecretValue"],
479
- resources: [secretArnPattern(props.secrets.googleEmail)]
480
- }));
481
492
  role.addToPolicy(new iam.PolicyStatement({
482
493
  sid: "EmailProxyInvoke",
483
494
  actions: ["lambda:InvokeFunction"],
@@ -771,7 +782,6 @@ import * as scheduler from "aws-cdk-lib/aws-scheduler";
771
782
  import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
772
783
  import * as sns from "aws-cdk-lib/aws-sns";
773
784
  import * as sqs from "aws-cdk-lib/aws-sqs";
774
- import { parse as parseYaml } from "yaml";
775
785
  class FoundationApi extends cdk2.Stack {
776
786
  api;
777
787
  gatewayDlq;
@@ -788,6 +798,8 @@ class FoundationApi extends cdk2.Stack {
788
798
  super(scope, id, props);
789
799
  const names = instanceNames(props.instance);
790
800
  const display = props.instance.displayName;
801
+ const emailIdentities = emailIdentitiesFor(props.configPath);
802
+ const personalEmailIdentities = emailIdentities.filter((identity) => identity.owner !== undefined);
791
803
  const upwork = provisionsIntegration(props.instance, "upwork") ? upworkConfigForProvisioning(props.configPath) : undefined;
792
804
  const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
793
805
  const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
@@ -825,6 +837,15 @@ class FoundationApi extends cdk2.Stack {
825
837
  retentionPeriod: cdk2.Duration.hours(6),
826
838
  deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 }
827
839
  });
840
+ const personalEmailSecrets = personalEmailIdentities.map((identity) => new secretsmanager.Secret(this, `PersonalEmailSecret${identity.owner}`, {
841
+ secretName: `${instanceNames(props.instance).secretGoogleEmail}/personal/${identity.owner}`,
842
+ description: `${display} personal Gmail credential for Slack ${identity.owner}; gateway writes and email proxy reads`,
843
+ removalPolicy: cdk2.RemovalPolicy.DESTROY,
844
+ generateSecretString: {
845
+ secretStringTemplate: "{}",
846
+ generateStringKey: "placeholder"
847
+ }
848
+ }));
828
849
  const gatewayRole = new iam2.Role(this, "GatewayRole", {
829
850
  assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
830
851
  description: `${display} gateway Lambda role (least privilege)`,
@@ -851,7 +872,10 @@ class FoundationApi extends cdk2.Stack {
851
872
  gatewayRole.addToPolicy(new iam2.PolicyStatement({
852
873
  sid: "EmailIdentityConnect",
853
874
  actions: ["secretsmanager:PutSecretValue"],
854
- resources: [secretArnPattern2(props.secrets.googleEmail)]
875
+ resources: [
876
+ secretArnPattern2(props.secrets.googleEmail),
877
+ ...personalEmailSecrets.map(secretArnPattern2)
878
+ ]
855
879
  }));
856
880
  if (props.secrets.knockCredential !== undefined) {
857
881
  gatewayRole.addToPolicy(new iam2.PolicyStatement({
@@ -948,9 +972,16 @@ class FoundationApi extends cdk2.Stack {
948
972
  FOUNDATION_DISPLAY_NAME: props.instance.displayName,
949
973
  FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
950
974
  FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
951
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
975
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: instanceNames(props.instance).secretGoogleOauth,
952
976
  FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
953
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
977
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: instanceNames(props.instance).secretGoogleEmail,
978
+ ...personalEmailIdentities.length === 0 ? {} : {
979
+ FOUNDATION_PERSONAL_EMAIL_IDENTITIES: JSON.stringify(personalEmailIdentities.map(({ address, channel, owner }) => ({
980
+ address,
981
+ channel,
982
+ owner
983
+ })))
984
+ },
954
985
  ...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
955
986
  ...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
956
987
  ...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
@@ -1095,7 +1126,9 @@ class FoundationApi extends cdk2.Stack {
1095
1126
  actions: ["secretsmanager:GetSecretValue"],
1096
1127
  resources: [
1097
1128
  secretArnPattern2(props.secrets.googleEmail),
1098
- secretArnPattern2(props.secrets.googleOauth)
1129
+ secretArnPattern2(props.secrets.googleOauth),
1130
+ secretArnPattern2(props.secrets.signing),
1131
+ ...personalEmailSecrets.map(secretArnPattern2)
1099
1132
  ]
1100
1133
  }));
1101
1134
  emailProxyRole.addToPolicy(new iam2.PolicyStatement({
@@ -1112,9 +1145,11 @@ class FoundationApi extends cdk2.Stack {
1112
1145
  timeout: cdk2.Duration.seconds(30),
1113
1146
  memorySize: 256,
1114
1147
  environment: {
1115
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
1116
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
1117
- FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentitiesFor(props.configPath))
1148
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: instanceNames(props.instance).secretGoogleEmail,
1149
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: instanceNames(props.instance).secretGoogleOauth,
1150
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1151
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1152
+ FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentities)
1118
1153
  }
1119
1154
  });
1120
1155
  const browserDomains = provisionsIntegration(props.instance, "browser") ? browserDomainsFor(props.configPath) : [];
@@ -1622,11 +1657,8 @@ function configTimezoneFor(configPath) {
1622
1657
  return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
1623
1658
  }
1624
1659
  function emailIdentitiesFor(configPath) {
1625
- const doc = parseYaml(readFileSync(configPath, "utf8"));
1626
- const email = doc.capabilities?.email;
1627
- if (email?.enabled !== true || !Array.isArray(email.identities))
1628
- return [];
1629
- return email.identities;
1660
+ const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
1661
+ return email.enabled ? email.identities : [];
1630
1662
  }
1631
1663
  var WEBHOOK_JOBS_MAX_ENV_BYTES = 2048;
1632
1664
  function webhookJobsFor(configPath) {
@@ -223,10 +223,26 @@ var RawAwsReadonlyCapability = z.object({
223
223
  }).strict().default({});
224
224
  var RawEmailIdentity = z.object({
225
225
  address: z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i),
226
- channel: z.string().regex(/^[CG][A-Z0-9]{8,}$/),
226
+ channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
227
+ owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id").optional(),
227
228
  signature: z.string().min(1).max(2000),
228
229
  sendAsAddresses: z.array(z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i)).max(20).default([]).refine((addresses) => new Set(addresses.map((address) => address.toLowerCase())).size === addresses.length, "duplicate send-as address")
229
- }).strict();
230
+ }).strict().superRefine((identity, ctx) => {
231
+ if (identity.channel.startsWith("D") && identity.owner === undefined) {
232
+ ctx.addIssue({
233
+ code: z.ZodIssueCode.custom,
234
+ path: ["owner"],
235
+ message: "personal email identities in D channels require an owner"
236
+ });
237
+ }
238
+ if (!identity.channel.startsWith("D") && identity.owner !== undefined) {
239
+ ctx.addIssue({
240
+ code: z.ZodIssueCode.custom,
241
+ path: ["owner"],
242
+ message: "shared email identities in C/G channels cannot have an owner"
243
+ });
244
+ }
245
+ });
230
246
  var RawEmailCapability = z.object({
231
247
  enabled: z.boolean().default(false),
232
248
  identities: z.array(RawEmailIdentity).default([]),
@@ -246,6 +262,19 @@ var RawEmailCapability = z.object({
246
262
  seen.add(key);
247
263
  }
248
264
  }
265
+ const personalOwners = new Set;
266
+ for (const [index, identity] of value.identities.entries()) {
267
+ if (identity.owner === undefined)
268
+ continue;
269
+ if (personalOwners.has(identity.owner)) {
270
+ ctx.addIssue({
271
+ code: z.ZodIssueCode.custom,
272
+ path: ["identities", index, "owner"],
273
+ message: `duplicate personal email identity owner: ${identity.owner}`
274
+ });
275
+ }
276
+ personalOwners.add(identity.owner);
277
+ }
249
278
  const owners = new Map;
250
279
  for (const [index, identity] of value.identities.entries()) {
251
280
  for (const address of [identity.address, ...identity.sendAsAddresses]) {
@@ -6,7 +6,7 @@ import {
6
6
  manifestKey,
7
7
  releaseCacheDir,
8
8
  verifyManifest
9
- } from "./chunk-k2rxb77g.js";
9
+ } from "./chunk-xb5k71tp.js";
10
10
 
11
11
  // src/deploy/release.ts
12
12
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-rhg6yfht.js";
13
+ } from "../chunk-r6djsvkj.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -43,7 +43,7 @@ import {
43
43
  skillsKey,
44
44
  skipBundle,
45
45
  verifyManifest
46
- } from "../chunk-k2rxb77g.js";
46
+ } from "../chunk-xb5k71tp.js";
47
47
  export {
48
48
  BUILD_TIMEOUT,
49
49
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -400,6 +400,33 @@ export class FoundationAgent extends cdk.Stack {
400
400
  }),
401
401
  );
402
402
  }
403
+ /**
404
+ * The execution role and its ReadOnlyAccess escape hatch are both
405
+ * shell-reachable. Keep Gmail credentials behind the proxy even if a future
406
+ * broad grant is added: the shared secret and all owner-derived personal
407
+ * secret names are explicitly denied here.
408
+ */
409
+ private denyDirectEmailSecretAccess(
410
+ role: iam.Role,
411
+ sharedMailboxSecret: secretsmanager.ISecret,
412
+ personalSecretPrefix: string,
413
+ ): void {
414
+ const stack = cdk.Stack.of(this);
415
+ const personalSecrets = stack.formatArn({
416
+ service: "secretsmanager",
417
+ resource: "secret",
418
+ resourceName: `${personalSecretPrefix}/personal/*-??????`,
419
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
420
+ });
421
+ role.addToPolicy(
422
+ new iam.PolicyStatement({
423
+ sid: "DenyEmailSecret",
424
+ effect: iam.Effect.DENY,
425
+ actions: ["secretsmanager:*"],
426
+ resources: [secretArnPattern(sharedMailboxSecret), personalSecrets],
427
+ }),
428
+ );
429
+ }
403
430
 
404
431
  /**
405
432
  * Hosted pages belong to the conversations that made them, and every read
@@ -558,6 +585,11 @@ export class FoundationAgent extends cdk.Stack {
558
585
  });
559
586
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
560
587
  this.denyDirectKnockSecretAccess(role, props);
588
+ this.denyDirectEmailSecretAccess(
589
+ role,
590
+ props.secrets.googleEmail,
591
+ namesFor(props.instance).secretGoogleEmail,
592
+ );
561
593
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
562
594
  this.denyDirectEtradeAccess(role, props);
563
595
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -652,6 +684,11 @@ export class FoundationAgent extends cdk.Stack {
652
684
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
653
685
  this.denyDirectKnockSecretAccess(role, props);
654
686
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
687
+ this.denyDirectEmailSecretAccess(
688
+ role,
689
+ props.secrets.googleEmail,
690
+ namesFor(props.instance).secretGoogleEmail,
691
+ );
655
692
  this.denyDirectEtradeAccess(role, props);
656
693
  this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
657
694
 
@@ -749,20 +786,6 @@ export class FoundationAgent extends cdk.Stack {
749
786
  resources: [secretArnPattern(props.secrets.signing)],
750
787
  }),
751
788
  );
752
- // Email is the sharpest version of the same argument as the signing
753
- // secret, and the reason the proxy is a separate Lambda. The scope Google
754
- // grants the mailbox (`gmail.compose`) permits SENDING, so a container
755
- // that could read this secret could send mail as the company — and the
756
- // model's shell can reach the execution role's credentials. Denying the
757
- // read is what makes "the teammate cannot send" true rather than merely intended.
758
- role.addToPolicy(
759
- new iam.PolicyStatement({
760
- sid: "DenyEmailSecret",
761
- effect: iam.Effect.DENY,
762
- actions: ["secretsmanager:GetSecretValue"],
763
- resources: [secretArnPattern(props.secrets.googleEmail)],
764
- }),
765
- );
766
789
  // The whole of the agent's email permission: invoke the proxy. Not the
767
790
  // Gmail API, not the secret, not the identity list.
768
791
  // Built from the NAME rather than from FoundationApi's function object: FoundationApi
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import {
3
3
  type CrmOutreachConfig,
4
+ type EmailIdentity,
4
5
  type ScheduledJobBinding,
5
6
  type ScheduledJobConfig,
6
7
  type WebhookJobBinding,
@@ -132,6 +133,10 @@ export class FoundationApi extends cdk.Stack {
132
133
  super(scope, id, props);
133
134
  const names = namesFor(props.instance);
134
135
  const display = props.instance.displayName;
136
+ const emailIdentities = emailIdentitiesFor(props.configPath);
137
+ const personalEmailIdentities = emailIdentities.filter(
138
+ (identity): identity is EmailIdentity & { owner: string } => identity.owner !== undefined,
139
+ );
135
140
  const upwork = provisionsIntegration(props.instance, "upwork")
136
141
  ? upworkConfigForProvisioning(props.configPath)
137
142
  : undefined;
@@ -190,6 +195,23 @@ export class FoundationApi extends cdk.Stack {
190
195
  deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 },
191
196
  });
192
197
 
198
+ // Personal grants are separate deployment-owned shells with exact proxy
199
+ // reads. Removing a personal identity deletes its stored refresh token;
200
+ // re-enabling it requires fresh Google consent instead of adopting a stale
201
+ // retained credential. Google-side revocation remains the account owner's.
202
+ const personalEmailSecrets = personalEmailIdentities.map(
203
+ (identity) =>
204
+ new secretsmanager.Secret(this, `PersonalEmailSecret${identity.owner}`, {
205
+ secretName: `${namesFor(props.instance).secretGoogleEmail}/personal/${identity.owner}`,
206
+ description: `${display} personal Gmail credential for Slack ${identity.owner}; gateway writes and email proxy reads`,
207
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
208
+ generateSecretString: {
209
+ secretStringTemplate: "{}",
210
+ generateStringKey: "placeholder",
211
+ },
212
+ }),
213
+ );
214
+
193
215
  const gatewayRole = new iam.Role(this, "GatewayRole", {
194
216
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
195
217
  description: `${display} gateway Lambda role (least privilege)`,
@@ -229,11 +251,15 @@ export class FoundationApi extends cdk.Stack {
229
251
  gatewayRole.addToPolicy(
230
252
  new iam.PolicyStatement({
231
253
  sid: "EmailIdentityConnect",
232
- // The same shape, and here it matters more: the mailbox credential is
233
- // the one thing in the system that could SEND mail. A read grant would
234
- // put it one injection away from an internet-facing Lambda.
254
+ // The same shape, and here it matters more: a mailbox credential is
255
+ // the one thing in the system that could SEND mail. The gateway may
256
+ // write the shared mailbox and these exact configured personal shells,
257
+ // but never read either one.
235
258
  actions: ["secretsmanager:PutSecretValue"],
236
- resources: [secretArnPattern(props.secrets.googleEmail)],
259
+ resources: [
260
+ secretArnPattern(props.secrets.googleEmail),
261
+ ...personalEmailSecrets.map(secretArnPattern),
262
+ ],
237
263
  }),
238
264
  );
239
265
  if (props.secrets.knockCredential !== undefined) {
@@ -376,9 +402,22 @@ export class FoundationApi extends cdk.Stack {
376
402
  FOUNDATION_DISPLAY_NAME: props.instance.displayName,
377
403
  FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
378
404
  FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
379
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
405
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: namesFor(props.instance).secretGoogleOauth,
380
406
  FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
381
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
407
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: namesFor(props.instance).secretGoogleEmail,
408
+ ...(personalEmailIdentities.length === 0
409
+ ? {}
410
+ : {
411
+ // The gateway needs no signatures or aliases; this compact mapping
412
+ // is enough to bind a slash command and callback to one owner/DM.
413
+ FOUNDATION_PERSONAL_EMAIL_IDENTITIES: JSON.stringify(
414
+ personalEmailIdentities.map(({ address, channel, owner }) => ({
415
+ address,
416
+ channel,
417
+ owner,
418
+ })),
419
+ ),
420
+ }),
382
421
  ...(props.secrets.knockOauthClient === undefined
383
422
  ? {}
384
423
  : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
@@ -593,11 +632,10 @@ export class FoundationApi extends cdk.Stack {
593
632
  }
594
633
 
595
634
  // --- email proxy -----------------------------------------------------
596
- // The permission barrier for Gmail, and the reason the design puts it in
597
- // a Lambda rather than a broker inside the agent container: this role is
598
- // the ONLY principal in the account that can read the mailbox secret, and
599
- // the agent's execution role carries an explicit Deny on it. A fully
600
- // hijacked session can invoke the four operations and nothing more.
635
+ // The permission barrier for Gmail. Only this role can read the mailbox
636
+ // grants; the agent's execution and assumed read-only roles explicitly
637
+ // deny those secrets. Personal access also verifies a short-lived signed
638
+ // owner/DM/session claim before reading its separate credential.
601
639
  const emailProxyRole = new iam.Role(this, "EmailProxyRole", {
602
640
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
603
641
  description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
@@ -609,11 +647,13 @@ export class FoundationApi extends cdk.Stack {
609
647
  new iam.PolicyStatement({
610
648
  sid: "EmailProxyCredentials",
611
649
  actions: ["secretsmanager:GetSecretValue"],
612
- // Exactly two: the mailbox, and the OAuth client its token is minted
613
- // with. Nothing else in the account is readable by this role.
650
+ // Exact shared, OAuth, signing, and configured personal secret ARNs:
651
+ // a config sync cannot grant a future owner's mailbox.
614
652
  resources: [
615
653
  secretArnPattern(props.secrets.googleEmail),
616
654
  secretArnPattern(props.secrets.googleOauth),
655
+ secretArnPattern(props.secrets.signing),
656
+ ...personalEmailSecrets.map(secretArnPattern),
617
657
  ],
618
658
  }),
619
659
  );
@@ -638,12 +678,14 @@ export class FoundationApi extends cdk.Stack {
638
678
  // below its minimum", 2026-09-06). Gmail-side rate is bounded
639
679
  // by the hourly routine and one turn per channel at a time instead.
640
680
  environment: {
641
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
642
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
681
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: namesFor(props.instance).secretGoogleEmail,
682
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: namesFor(props.instance).secretGoogleOauth,
683
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
684
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
643
685
  // Read at SYNTH from the instance's config file. The identity list a
644
686
  // turn can draft as is therefore fixed at deploy time and cannot be
645
687
  // widened by anything the agent sends or by a config sync alone.
646
- FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentitiesFor(props.configPath)),
688
+ FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentities),
647
689
  },
648
690
  });
649
691
 
@@ -1297,21 +1339,16 @@ function configTimezoneFor(configPath: string): string {
1297
1339
  }
1298
1340
 
1299
1341
  /**
1300
- * The mailbox identities to bake into the proxy, read from the instance's own
1301
- * config file at synth.
1342
+ * The mailbox identities to bake into the proxy and personal OAuth gateway
1343
+ * policy, parsed with the runtime schema at synth.
1302
1344
  *
1303
- * A plain file read: the CDK app must synthesize with no credentials and no
1304
- * network. A disabled capability yields an empty list, and so does a config
1305
- * that does not mention email at all — the proxy then refuses every identity,
1306
- * which is the correct behaviour for a capability nobody has turned on.
1345
+ * A disabled capability yields an empty list. Parsing here deliberately makes
1346
+ * malformed owner/channel bindings a deployment failure rather than allowing a
1347
+ * looser raw YAML shape into an IAM policy or Lambda environment.
1307
1348
  */
1308
- export function emailIdentitiesFor(configPath: string): unknown[] {
1309
- const doc = parseYaml(readFileSync(configPath, "utf8")) as {
1310
- capabilities?: { email?: { enabled?: unknown; identities?: unknown } };
1311
- };
1312
- const email = doc.capabilities?.email;
1313
- if (email?.enabled !== true || !Array.isArray(email.identities)) return [];
1314
- return email.identities;
1349
+ export function emailIdentitiesFor(configPath: string): EmailIdentity[] {
1350
+ const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
1351
+ return email.enabled ? email.identities : [];
1315
1352
  }
1316
1353
 
1317
1354
  /**