Skip to content

Commit 3291bbc

Browse files
authored
Merge branch 'main' into feat/117-dlq-cloudwatch-alarms
2 parents f0fb22f + aba8939 commit 3291bbc

26 files changed

Lines changed: 2406 additions & 575 deletions

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ repos:
9696

9797
- id: monorepo-tests-pre-push
9898
name: package tests (pre-push)
99-
entry: bash -lc 'cd "$(git rev-parse --show-toplevel)" && mise run hooks:pre-push:tests'
99+
entry: bash -lc 'cd "$(git rev-parse --show-toplevel)" && export TMPDIR="${HOME}/.cache/cdk-tmp" && mkdir -p "$TMPDIR" && mise run hooks:pre-push:tests'
100100
language: system
101101
pass_filenames: false
102102
stages: [pre-push]

cdk/mise.toml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ min_version = "2026.2.6"
33
[tools]
44
node = "22"
55

6+
# CDK synth and Jest tests write large cdk.out artifacts to TMPDIR.
7+
# Default /tmp is often tmpfs with limited space; redirect to disk-backed dir.
8+
# Path MUST be outside the repo tree — CDK asset fingerprinting walks TMPDIR
9+
# recursively and would loop if .tmp/ were inside the bundled directory.
10+
[env]
11+
TMPDIR = "{{env.HOME}}/.cache/cdk-tmp"
12+
613
[tasks.install]
714
description = "Install dependencies from repo root (Yarn workspaces)"
815
run = "cd .. && yarn install --check-files"
@@ -18,16 +25,16 @@ run = "yarn eslint"
1825
[tasks.test]
1926
description = "Jest tests"
2027
depends = [":compile"]
21-
run = "yarn test"
28+
run = ["mkdir -p $TMPDIR", "yarn test"]
2229

2330
[tasks.synth]
2431
description = "cdk synth"
25-
run = "yarn synth"
32+
run = ["mkdir -p $TMPDIR", "yarn synth"]
2633

2734
[tasks."synth:quiet"]
2835
description = "cdk synth (quiet)"
2936
depends = [":compile"]
30-
run = "yarn synth:quiet"
37+
run = ["mkdir -p $TMPDIR", "yarn synth:quiet"]
3138

3239
[tasks.deploy]
3340
description = "cdk deploy (pass args after --)"

cdk/src/constructs/linear-integration.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ export class LinearIntegration extends Construct {
183183
runtime: Runtime.NODEJS_24_X,
184184
architecture: Architecture.ARM_64,
185185
timeout: Duration.seconds(30),
186+
// Default 128 MB OOMs at module init since the attachment-screening
187+
// path (#176) bundles pdf-parse + URL-resolver libs alongside the
188+
// existing AWS SDK + bedrock-agentcore deps. 512 MB gives ~4× headroom
189+
// and lifts CPU enough that p99 startup stays under the API Gateway
190+
// 30s deadline on cold starts.
191+
memorySize: 512,
186192
environment: {
187193
...createTaskEnv,
188194
LINEAR_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName,
@@ -245,11 +251,32 @@ export class LinearIntegration extends Construct {
245251
LINEAR_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn,
246252
LINEAR_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName,
247253
LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName,
254+
// Per-workspace signing-secret lookup — selects the right
255+
// workspace's `webhook_signing_secret` from the OAuth secret
256+
// bundle so multi-workspace installs verify correctly. Receiver
257+
// falls back to LINEAR_WEBHOOK_SECRET_ARN when this lookup
258+
// misses (back-compat for single-workspace installs).
259+
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName,
248260
},
249261
bundling: commonBundling,
250262
});
251263
this.webhookSecret.grantRead(webhookFn);
252264
this.webhookDedupTable.grantReadWriteData(webhookFn);
265+
this.workspaceRegistryTable.grantReadData(webhookFn);
266+
// Read-only on the per-workspace OAuth secret prefix — we extract
267+
// `webhook_signing_secret` for verification but never mutate; the
268+
// CLI owns the lifecycle of these secrets.
269+
webhookFn.addToRolePolicy(new iam.PolicyStatement({
270+
actions: ['secretsmanager:GetSecretValue'],
271+
resources: [
272+
Stack.of(this).formatArn({
273+
service: 'secretsmanager',
274+
resource: 'secret',
275+
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
276+
resourceName: 'bgagent-linear-oauth-*',
277+
}),
278+
],
279+
}));
253280
webhookProcessorFn.grantInvoke(webhookFn);
254281

255282
// --- Account linking (Cognito-authenticated) ---
@@ -319,7 +346,11 @@ export class LinearIntegration extends Construct {
319346
},
320347
{
321348
id: 'AwsSolutions-IAM5',
322-
reason: 'Wildcard permissions are scoped by DynamoDB index ARN patterns',
349+
reason:
350+
'Wildcards cover (a) DynamoDB index ARN patterns from CDK grant helpers, '
351+
+ 'and (b) the Secrets Manager `bgagent-linear-oauth-*` prefix grant — '
352+
+ 'the per-workspace OAuth secret name is not known at synth time '
353+
+ '(operators add workspaces by slug at runtime via `bgagent linear add-workspace`).',
323354
},
324355
], true);
325356
}

cdk/src/constructs/slack-integration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,17 @@ export class SlackIntegration extends Construct {
238238
}));
239239

240240
// --- Slash Command Processor (async worker) ---
241+
// Memory bumped from default 128 MB → 512 MB after module-init OOM
242+
// surfaced in dev. Bundle grew past the 128 MB cap once createTaskCore's
243+
// transitive dependency graph (Cedar, attachment-screening) imported
244+
// here through the shared task-creation path.
241245
const commandProcessorFn = new lambda.NodejsFunction(this, 'CommandProcessorFn', {
242246
entry: path.join(handlersDir, 'slack-command-processor.ts'),
243247
handler: 'handler',
244248
runtime: Runtime.NODEJS_24_X,
245249
architecture: Architecture.ARM_64,
246250
timeout: Duration.seconds(30),
251+
memorySize: 512,
247252
environment: {
248253
...createTaskEnv,
249254
SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName,

cdk/src/handlers/linear-link.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,19 @@ const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!;
3232

3333
interface LinkRequest {
3434
readonly code: string;
35+
/** Preview-only: return what would be linked without writing. */
36+
readonly dry_run?: boolean;
3537
}
3638

3739
/**
38-
* POST /v1/linear/link — Complete Linear account linking.
40+
* POST /v1/linear/link — Complete Linear account linking, or preview it.
3941
*
4042
* Called from the CLI (`bgagent linear link <code>`) with a Cognito JWT.
41-
* Looks up the pending link record, maps the Linear identity to the
42-
* authenticated platform user, and cleans up the pending record.
43+
* Looks up the pending link record. With `dry_run: true`, returns the
44+
* Linear identity attached to the code without writing — the CLI uses
45+
* this to render a "you're about to link X (email: Y)" preview before
46+
* the teammate confirms. Without `dry_run`, writes the mapping and
47+
* deletes the pending record.
4348
*/
4449
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
4550
const requestId = ulid();
@@ -55,7 +60,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
5560
return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId);
5661
}
5762

58-
const code = body.code.trim().toUpperCase();
63+
// Codes from `bgagent linear invite-user` are case-sensitive
64+
// (see generateInviteCode in the CLI — `link-<8-char-hex>` shape,
65+
// hex is lowercase). Don't uppercase the incoming value — that
66+
// would break codes generated post-2.0b.
67+
const code = body.code.trim();
5968

6069
const pending = await ddb.send(new GetCommand({
6170
TableName: USER_MAPPING_TABLE,
@@ -67,7 +76,23 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
6776
}
6877

6978
const workspaceId = pending.Item.linear_workspace_id as string;
79+
const workspaceSlug = (pending.Item.linear_workspace_slug as string | undefined) ?? '';
7080
const linearUserId = pending.Item.linear_user_id as string;
81+
const linearUserName = (pending.Item.linear_user_name as string | undefined) ?? '';
82+
const linearUserEmail = (pending.Item.linear_user_email as string | undefined) ?? '';
83+
84+
// Dry-run preview: return identity without writing.
85+
if (body.dry_run === true) {
86+
return successResponse(200, {
87+
dry_run: true,
88+
linear_workspace_id: workspaceId,
89+
linear_workspace_slug: workspaceSlug,
90+
linear_user_id: linearUserId,
91+
linear_user_name: linearUserName,
92+
linear_user_email: linearUserEmail,
93+
}, requestId);
94+
}
95+
7196
const now = new Date().toISOString();
7297

7398
await ddb.send(new PutCommand({
@@ -97,7 +122,10 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
97122
return successResponse(200, {
98123
message: 'Linear account linked successfully.',
99124
linear_workspace_id: workspaceId,
125+
linear_workspace_slug: workspaceSlug,
100126
linear_user_id: linearUserId,
127+
linear_user_name: linearUserName,
128+
linear_user_email: linearUserEmail,
101129
linked_at: now,
102130
}, requestId);
103131
} catch (err) {

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,27 @@ export async function handler(event: ProcessorEvent): Promise<void> {
248248

249249
// Phase 2.0b-O2: resolve the workspace's OAuth secret ARN ONCE here
250250
// and stash it on the task record. The agent runtime reads it directly
251-
// (no registry lookup at task-execution time). If the workspace isn't
252-
// onboarded the agent's outbound Linear MCP simply skips.
251+
// (no registry lookup at task-execution time).
252+
//
253+
// When the registry table IS configured but resolution returns null —
254+
// workspace not in registry, status not active, or token unreadable —
255+
// the receiver only let this through because the stack-wide fallback
256+
// verified. Creating a task against a workspace ABCA doesn't recognize
257+
// is the wrong behaviour: outbound Linear comments would silently
258+
// skip, the user mapping lookup would fail, and we'd burn agent
259+
// quota for no observable result. Drop the event explicitly here
260+
// rather than rely on downstream lookups to incidentally block it.
253261
if (WORKSPACE_REGISTRY_TABLE) {
254262
const resolved = await resolveLinearOauthToken(workspaceId, WORKSPACE_REGISTRY_TABLE);
255-
if (resolved) {
256-
channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn;
257-
channelMetadata.linear_workspace_slug = resolved.workspaceSlug;
258-
} else {
259-
logger.warn('Linear workspace not in registry — agent will run without Linear MCP', {
263+
if (!resolved) {
264+
logger.warn('Linear workspace not resolvable from registry — dropping event', {
260265
linear_workspace_id: workspaceId,
261266
issue_id: issue.id,
262267
});
268+
return;
263269
}
270+
channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn;
271+
channelMetadata.linear_workspace_slug = resolved.workspaceSlug;
264272
}
265273

266274
// Extract embedded image URLs from the issue description markdown.

cdk/src/handlers/linear-webhook.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client
2121
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
2222
import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
2323
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
24-
import { isWebhookTimestampFresh, verifyLinearRequest } from './shared/linear-verify';
24+
import {
25+
isWebhookTimestampFresh,
26+
verifyLinearRequest,
27+
verifyLinearRequestForWorkspace,
28+
} from './shared/linear-verify';
2529
import { logger } from './shared/logger';
2630

2731
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
@@ -30,6 +34,10 @@ const lambdaClient = new LambdaClient({});
3034
const WEBHOOK_SECRET_ARN = process.env.LINEAR_WEBHOOK_SECRET_ARN!;
3135
const DEDUP_TABLE_NAME = process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME!;
3236
const PROCESSOR_FUNCTION_NAME = process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME!;
37+
/** Optional. When unset, the per-workspace signing-secret path is skipped
38+
* and only the stack-wide secret is consulted (back-compat for installs
39+
* predating per-workspace secrets). */
40+
const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME;
3341

3442
/**
3543
* Dedup window (seconds). Must exceed Linear's full retry horizon: first
@@ -79,11 +87,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
7987
return jsonResponse(401, { error: 'Missing signature' });
8088
}
8189

82-
if (!await verifyLinearRequest(WEBHOOK_SECRET_ARN, signature, event.body)) {
83-
logger.warn('Invalid Linear webhook signature');
84-
return jsonResponse(401, { error: 'Invalid signature' });
85-
}
86-
90+
// Parse body ONCE — we peek at the orgId before signature verification
91+
// so we can pick the right per-workspace signing secret. The orgId is
92+
// untrusted at this point; it only selects WHICH secret to verify
93+
// against. An attacker can claim any orgId but still needs the
94+
// matching signing secret to forge a valid signature.
8795
let payload: LinearWebhookEnvelope;
8896
try {
8997
payload = JSON.parse(event.body) as LinearWebhookEnvelope;
@@ -94,6 +102,57 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
94102
return jsonResponse(400, { error: 'Invalid JSON' });
95103
}
96104

105+
// Try the per-workspace secret first. Falls through to the stack-wide
106+
// path if (a) registry table not configured, (b) no orgId in body,
107+
// (c) workspace not in registry, or (d) workspace's stored secret
108+
// lacks `webhook_signing_secret`. Per-workspace MISMATCH is fatal —
109+
// do NOT fall back, that would let an attacker bypass per-workspace
110+
// signatures by also matching the stack-wide one. REVOKED is also
111+
// fatal: a workspace that has been deactivated must not be able to
112+
// ride the stack-wide fallback (which `setup` mirrored from the
113+
// first workspace's signing secret) back into a verified state.
114+
let verified = false;
115+
if (WORKSPACE_REGISTRY_TABLE && payload.organizationId) {
116+
const result = await verifyLinearRequestForWorkspace(
117+
WORKSPACE_REGISTRY_TABLE,
118+
payload.organizationId,
119+
signature,
120+
event.body,
121+
);
122+
if (result === 'verified') {
123+
verified = true;
124+
} else if (result === 'mismatch') {
125+
logger.warn('Linear webhook signature mismatch against per-workspace secret', {
126+
linear_workspace_id: payload.organizationId,
127+
});
128+
return jsonResponse(401, { error: 'Invalid signature' });
129+
} else if (result === 'revoked') {
130+
logger.warn('Linear webhook from revoked workspace — rejecting without stack-wide fallback', {
131+
linear_workspace_id: payload.organizationId,
132+
});
133+
return jsonResponse(401, { error: 'Workspace not active' });
134+
}
135+
// 'no-per-workspace-secret' falls through to the stack-wide path
136+
// below — back-compat for installs predating per-workspace secrets.
137+
}
138+
139+
if (!verified) {
140+
if (!await verifyLinearRequest(WEBHOOK_SECRET_ARN, signature, event.body)) {
141+
logger.warn('Invalid Linear webhook signature', {
142+
linear_workspace_id: payload.organizationId,
143+
});
144+
return jsonResponse(401, { error: 'Invalid signature' });
145+
}
146+
// Stack-wide fallback succeeded. Log positively so operators
147+
// diagnosing a per-workspace verification regression have a
148+
// breadcrumb that says "this workspace is verifying via the
149+
// back-compat path" rather than its own per-workspace secret.
150+
logger.info('Linear webhook verified via stack-wide fallback secret', {
151+
linear_workspace_id: payload.organizationId,
152+
per_workspace_registry_configured: Boolean(WORKSPACE_REGISTRY_TABLE),
153+
});
154+
}
155+
97156
if (!isWebhookTimestampFresh(payload.webhookTimestamp)) {
98157
logger.warn('Linear webhook timestamp outside replay window', {
99158
webhook_timestamp: payload.webhookTimestamp,

0 commit comments

Comments
 (0)