Skip to content

Commit 964a547

Browse files
committed
fix(jira): address #619 review — orphan cleanup, real-byte cap, comment screening, idempotency (#577)
Addresses isadeks's review on PR #619: 1. Zero-byte attachment → JiraAttachmentError (fail-closed). A 0-byte body passed magic-byte + screening then tripped createAttachmentRecord's size guard with a plain Error outside the fail-closed conversion (no ❌ comment). Now rejected explicitly with a Jira comment. 2. 50 MB total-attachment cap enforced on REAL downloaded bytes, not the attacker-declared `size` metadata (which can under-report). Cumulative real bytes tracked in the download loop; throws once over the ceiling. 3. S3 orphan cleanup. downloadScreenAndStoreJiraAttachments now tracks uploaded keys and best-effort-deletes them if the batch throws mid-way; the processor deletes the returned pre-screened objects when createTaskCore returns non-201 (and on a 200 idempotent replay, whose objects the replayed task doesn't reference). New exported cleanupPreScreenedAttachments helper. 4. Comment content screened SEPARATELY and dropped fail-open. Third-party comment text that trips the guardrail no longer fails the reporter's task — screenCommentsOrDrop screens the folded comment block and drops it (task proceeds) on GUARDRAIL_INTERVENED / screening outage / no guardrail. 5. Deterministic idempotency key (`jira-<issueKey>-<webhookTimestamp>`) so an async webhook re-delivery dedupes instead of minting a duplicate task and re-downloading every attachment. A 200 idempotent replay is handled as success (no ❌ comment), cleaning up the round's re-uploaded objects. 6. Webhook-processor timeout 60s → 300s. 10 serial 10s attachment fetches can sum past 60s; a mid-loop kill would orphan objects and force a retry. Tests: +5 (zero-byte reject, real-byte total cap, mid-batch orphan cleanup, comment-blocked fail-open drop, deterministic idempotency key + 200-replay). mise run build green (cdk 2362).
1 parent 1475553 commit 964a547

5 files changed

Lines changed: 377 additions & 110 deletions

File tree

cdk/src/constructs/jira-integration.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,13 @@ const DEFAULT_TASK_RETENTION_DAYS = 90;
4040
* Webhook-processor Lambda timeout (seconds). The processor is invoked
4141
* asynchronously (not behind the API Gateway 30s deadline), so it can run
4242
* longer than the receiver. #577 added serial authenticated download +
43-
* Bedrock screening of up to 10 Jira attachments, so 60s leaves headroom over
44-
* the per-attachment fetch (10s) + screening-retry budget.
43+
* Bedrock screening of up to 10 Jira attachments; the per-attachment fetch
44+
* timeout alone (10s) can sum past 60s across a full batch, and a mid-loop
45+
* kill would orphan objects and force an idempotent retry. 300s covers the
46+
* worst-case serial batch with headroom. (A future optimization could
47+
* parallelize the download/screen loop and lower this again.)
4548
*/
46-
const WEBHOOK_PROCESSOR_TIMEOUT_SECONDS = 60;
49+
const WEBHOOK_PROCESSOR_TIMEOUT_SECONDS = 300;
4750

4851
/**
4952
* Marker key embedded in the auto-generated stack-wide webhook-secret

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

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import * as crypto from 'crypto';
21-
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
21+
import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bedrock-runtime';
2222
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2323
import { S3Client } from '@aws-sdk/client-s3';
2424
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
@@ -27,6 +27,7 @@ import type { ScreeningConfig } from './shared/attachment-screening';
2727
import { createTaskCore } from './shared/create-task-core';
2828
import { extractDescriptionMarkdown } from './shared/jira-adf';
2929
import {
30+
cleanupPreScreenedAttachments,
3031
downloadScreenAndStoreJiraAttachments,
3132
fetchRecentHumanComments,
3233
JiraAttachmentError,
@@ -46,6 +47,9 @@ const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!;
4647
const WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME;
4748
const DEFAULT_LABEL_FILTER = 'bgagent';
4849

50+
/** Max length of the idempotency key (matches validation's IDEMPOTENCY_KEY_PATTERN). */
51+
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
52+
4953
// Attachment enrichment (#577). The processor downloads Jira `media` file
5054
// attachments, screens them through the Bedrock Guardrail, and uploads the
5155
// cleaned bytes to S3 before creating the task. All three must be configured;
@@ -415,7 +419,13 @@ export async function handler(event: ProcessorEvent): Promise<void> {
415419
const tenantCtx = { cloudId, registryTableName: WORKSPACE_REGISTRY_TABLE };
416420

417421
// Recent human comments — advisory context, never gate task creation.
418-
comments = await fetchRecentHumanComments(tenantCtx, issue.key);
422+
const fetchedComments = await fetchRecentHumanComments(tenantCtx, issue.key);
423+
// Fail-OPEN on comment content policy: comments are third-party text the
424+
// reporter didn't write, so a policy-tripping comment must not fail the
425+
// reporter's task. Screen the rendered comment section on its own and drop
426+
// it (not the task) if the guardrail intervenes. (createTaskCore separately
427+
// screens the description, which the reporter authored.)
428+
comments = await screenCommentsOrDrop(fetchedComments, issue.key, cloudId);
419429

420430
const rawAttachments = issue.fields?.attachment;
421431
if (Array.isArray(rawAttachments) && rawAttachments.length > 0) {
@@ -481,17 +491,43 @@ export async function handler(event: ProcessorEvent): Promise<void> {
481491
channelSource: 'jira',
482492
channelMetadata,
483493
taskId,
494+
// Deterministic key so an async re-delivery of the same trigger event
495+
// dedupes instead of minting a second task (and re-downloading every
496+
// attachment). Keyed on issue + webhook timestamp, matching the
497+
// receiver's dedup key shape.
498+
idempotencyKey: buildIdempotencyKey(issue.key, payload.timestamp),
484499
...(preScreenedAttachments.length > 0 && { preScreenedAttachments }),
485500
},
486501
requestId,
487502
);
488503

504+
if (result.statusCode === 200) {
505+
// Idempotent replay: this is a duplicate delivery of the same trigger event
506+
// (createTaskCore matched the deterministic idempotency key to an existing
507+
// task). Not a failure — but the attachments we re-downloaded and uploaded
508+
// this round are keyed on a fresh taskId the replayed task doesn't
509+
// reference, so delete them rather than orphan them. No ❌ comment.
510+
logger.info('Jira-triggered task was an idempotent replay (duplicate delivery)', {
511+
issue_key: issue.key,
512+
request_id: requestId,
513+
});
514+
if (preScreenedAttachments.length > 0 && s3Client && ATTACHMENTS_BUCKET) {
515+
await cleanupPreScreenedAttachments(s3Client, ATTACHMENTS_BUCKET, preScreenedAttachments);
516+
}
517+
return;
518+
}
519+
489520
if (result.statusCode !== 201) {
490521
logger.warn('Jira-triggered task creation returned non-201', {
491522
status: result.statusCode,
492523
body: result.body,
493524
issue_key: issue.key,
494525
});
526+
// Don't orphan the attachment objects we uploaded before this call failed —
527+
// createTaskCore only rolls back its own inline uploads, not ours.
528+
if (preScreenedAttachments.length > 0 && s3Client && ATTACHMENTS_BUCKET) {
529+
await cleanupPreScreenedAttachments(s3Client, ATTACHMENTS_BUCKET, preScreenedAttachments);
530+
}
495531
await safeReportIssueFailure(
496532
issue.key,
497533
cloudId,
@@ -648,6 +684,71 @@ function truncateCommentSection(section: string, budget: number): string {
648684
return section.slice(0, room) + COMMENT_TRUNCATION_NOTICE;
649685
}
650686

687+
/**
688+
* Screen the rendered comment block through the Bedrock Guardrail on its own,
689+
* so third-party comment content that trips the policy is DROPPED (fail-open)
690+
* rather than gating the reporter's task. Returns the comments unchanged when
691+
* they pass, and `[]` when the guardrail intervenes or is unavailable — the
692+
* task still proceeds with the reporter-authored summary/description (which
693+
* createTaskCore screens separately). This keeps the comment-enrichment
694+
* contract fail-open end to end (issue #577 review, item 4).
695+
*/
696+
async function screenCommentsOrDrop(
697+
comments: RenderedComment[],
698+
issueKey: string,
699+
cloudId: string,
700+
): Promise<RenderedComment[]> {
701+
if (comments.length === 0) return comments;
702+
if (!bedrockClient || !GUARDRAIL_ID || !GUARDRAIL_VERSION) {
703+
// No guardrail configured — drop unscreened third-party text rather than
704+
// route it, unscreened, into the agent context.
705+
logger.warn('Dropping Jira comments: guardrail not configured to screen them', {
706+
issue_key: issueKey,
707+
jira_cloud_id: cloudId,
708+
});
709+
return [];
710+
}
711+
const text = renderCommentSection(comments);
712+
try {
713+
const result = await bedrockClient.send(new ApplyGuardrailCommand({
714+
guardrailIdentifier: GUARDRAIL_ID,
715+
guardrailVersion: GUARDRAIL_VERSION,
716+
source: 'INPUT',
717+
content: [{ text: { text } }],
718+
}));
719+
if (result.action === 'GUARDRAIL_INTERVENED') {
720+
logger.warn('Dropping Jira comments: blocked by content policy (task still proceeds)', {
721+
issue_key: issueKey,
722+
jira_cloud_id: cloudId,
723+
});
724+
return [];
725+
}
726+
return comments;
727+
} catch (err) {
728+
// Fail-open on a screening outage too — comments are advisory.
729+
logger.warn('Dropping Jira comments: screening unavailable (task still proceeds)', {
730+
issue_key: issueKey,
731+
jira_cloud_id: cloudId,
732+
error: err instanceof Error ? err.message : String(err),
733+
});
734+
return [];
735+
}
736+
}
737+
738+
/**
739+
* Deterministic idempotency key for a trigger event: `<issueKey>#<timestamp>`,
740+
* sanitized to the allowed key charset (`[A-Za-z0-9_-]{1,128}`). A webhook
741+
* re-delivery of the same event yields the same key so createTaskCore dedupes
742+
* instead of creating a duplicate task (and re-downloading attachments). Falls
743+
* back to undefined if we can't form a stable key, preserving prior behavior.
744+
*/
745+
function buildIdempotencyKey(issueKey: string, timestamp: number | undefined): string | undefined {
746+
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) return undefined;
747+
const raw = `jira-${issueKey}-${timestamp}`;
748+
const sanitized = raw.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, MAX_IDEMPOTENCY_KEY_LENGTH);
749+
return sanitized || undefined;
750+
}
751+
651752
/**
652753
* Extract image URLs from the rendered description markdown. Same limits
653754
* as the Linear processor: HTTPS only, capped at 10.

0 commit comments

Comments
 (0)