Skip to content

Commit 069d364

Browse files
committed
feat(linear): authenticated attachment fetch at admission (mirror Jira aws-samples#619) [P4.1]
ADR-016 P4.1: Linear runs deterministically (no MCP), so the agent can't fetch uploads.linear.app images at runtime. New cdk/src/handlers/shared/linear-attachments.ts mirrors jira-attachments.ts: at webhook admission, fetch uploads.linear.app images (extracted from the issue description markdown) AUTHENTICATED with the workspace @bgagent OAuth bearer, validate magic bytes, screen through the same Bedrock Guardrail pipeline, upload cleaned bytes to S3, and return passed AttachmentRecords injected via createTaskCore's preScreenedAttachments seam. Fail-closed (LinearAttachmentError → reject task with a comment); SSRF-guarded (HTTPS-only, uploads-host-only, private-IP rejection); per-file + total + count caps on real bytes; batch cleanup of uploaded objects on any mid-batch failure. Wired into linear-webhook-processor single-task path (mints taskId up-front, fail-closed when ATTACHMENTS_BUCKET/GUARDRAIL unconfigured, cleanup on non-201). Public-CDN markdown images stay on the existing unauthenticated URL path; only uploads.linear.app comes through the authenticated path. +17 unit tests (linear-attachments.test.ts, 88.76% stmt cov). Updated the 2 webhook-processor tests that encoded the retired "skip uploads.linear.app" behavior to the new fetch/fail-closed contract. CDK build green: 163 suites/3230.
1 parent 4efd208 commit 069d364

4 files changed

Lines changed: 866 additions & 18 deletions

File tree

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

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@
1818
*/
1919

2020
import * as crypto from 'crypto';
21+
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
2122
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
23+
import { S3Client } from '@aws-sdk/client-s3';
2224
import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
25+
import { ulid } from 'ulid';
26+
import type { ScreeningConfig } from './shared/attachment-screening';
2327
import { buildClarifyResumeDescription, isClarifyHold } from './shared/clarify-resume';
2428
import { createTaskCore } from './shared/create-task-core';
2529
import { renderMaturingReply } from './shared/iteration-reply';
30+
import { cleanupPreScreenedAttachments, downloadScreenAndStoreLinearAttachments, LinearAttachmentError } from './shared/linear-attachments';
2631
import {
2732
deleteComment,
2833
reactToComment,
@@ -107,7 +112,7 @@ import { computeEpicRetryPlan } from './shared/orchestration-reconcile';
107112
import { readConcurrencyBudget, releaseReadyChildren } from './shared/orchestration-release';
108113
import { upsertEpicPanel } from './shared/orchestration-rollup';
109114
import { claimCommentAck, clearRollupClaim, deriveOrchestrationId, loadOrchestration, setStatusCommentId, type OrchestrationReleaseContext } from './shared/orchestration-store';
110-
import type { Attachment } from './shared/types';
115+
import type { Attachment, PassedAttachmentRecord } from './shared/types';
111116
import { CODING_WORKFLOW_ID } from './shared/workflows';
112117

113118
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
@@ -124,6 +129,20 @@ const DEFAULT_LABEL_FILTER = 'bgagent';
124129
// budget. Unset → release all roots (back-compat; admission still gates).
125130
const USER_CONCURRENCY_TABLE = process.env.USER_CONCURRENCY_TABLE_NAME;
126131
const MAX_CONCURRENT = Number(process.env.MAX_CONCURRENT_TASKS_PER_USER ?? '10');
132+
// Attachment enrichment (ADR-016): fetch uploads.linear.app images with the
133+
// workspace OAuth token at admission time, screen, store, inject as
134+
// preScreenedAttachments — Linear has no MCP so the agent can't fetch them.
135+
// Mirrors the Jira processor (#619). Absent env → the authenticated-fetch path
136+
// is off (the public-URL image path in extractImageUrlAttachments still runs).
137+
const ATTACHMENTS_BUCKET = process.env.ATTACHMENTS_BUCKET_NAME;
138+
const GUARDRAIL_ID = process.env.GUARDRAIL_ID;
139+
const GUARDRAIL_VERSION = process.env.GUARDRAIL_VERSION;
140+
const attachmentsS3Client = ATTACHMENTS_BUCKET ? new S3Client({}) : undefined;
141+
const attachmentsBedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? new BedrockRuntimeClient({}) : undefined;
142+
const attachmentsScreeningConfig: ScreeningConfig | undefined =
143+
attachmentsBedrockClient && GUARDRAIL_ID && GUARDRAIL_VERSION
144+
? { bedrockClient: attachmentsBedrockClient, guardrailId: GUARDRAIL_ID, guardrailVersion: GUARDRAIL_VERSION }
145+
: undefined;
127146
// #299 Mode B: TTL (seconds) for a persisted pending plan awaiting approval. A
128147
// week is ample for a human to approve; the row self-expires after.
129148
const PENDING_PLAN_TTL_SECONDS = 604_800;
@@ -883,10 +902,69 @@ export async function handler(event: ProcessorEvent): Promise<void> {
883902

884903
const taskDescription = buildTaskDescription(issue, contextHint);
885904

886-
// Extract embedded image URLs from the issue description markdown.
887-
// These become URL attachments that are fetched and screened during context hydration.
905+
// Extract embedded image URLs from the issue description markdown. Non-Linear
906+
// (public CDN) images become URL attachments fetched+screened during context
907+
// hydration; uploads.linear.app images are handled below (they need auth).
888908
const attachments = extractImageUrlAttachments(issue.description);
889909

910+
// Mint the taskId up-front so pre-screened attachment S3 keys match the
911+
// eventual task record (createTaskCore honors ctx.taskId). Mirrors Jira #619.
912+
const taskId = ulid();
913+
914+
// ADR-016: fetch uploads.linear.app images with the workspace OAuth token,
915+
// screen, store, and inject as preScreenedAttachments — the agent has no
916+
// Linear MCP to fetch them at runtime. Fail-closed: a selected-but-
917+
// unscreenable attachment rejects the whole task (mirrors the Jira processor).
918+
let preScreenedAttachments: PassedAttachmentRecord[] = [];
919+
if (resolvedAccessToken && issue.description && issue.description.includes('uploads.linear.app')) {
920+
if (!attachmentsS3Client || !ATTACHMENTS_BUCKET || !attachmentsScreeningConfig) {
921+
logger.error('Linear issue has uploads.linear.app attachments but screening/storage is not configured (fail-closed)', {
922+
issue_id: issue.id,
923+
linear_workspace_id: workspaceId,
924+
has_bucket: Boolean(ATTACHMENTS_BUCKET),
925+
has_guardrail: Boolean(attachmentsScreeningConfig),
926+
});
927+
await safeReportIssueFailure(
928+
issue.id,
929+
workspaceId,
930+
'❌ This Linear issue has uploaded image attachments, but ABCA attachment screening is not configured. Contact your ABCA admin.',
931+
);
932+
return;
933+
}
934+
// Combined cap: public-URL image attachments already consume slots.
935+
const remainingSlots = 10 - attachments.length;
936+
try {
937+
preScreenedAttachments = await downloadScreenAndStoreLinearAttachments(
938+
issue.description,
939+
remainingSlots,
940+
{
941+
s3Client: attachmentsS3Client,
942+
bucketName: ATTACHMENTS_BUCKET,
943+
screeningConfig: attachmentsScreeningConfig,
944+
userId: platformUserId,
945+
taskId,
946+
accessToken: resolvedAccessToken,
947+
linearWorkspaceId: workspaceId,
948+
},
949+
);
950+
} catch (err) {
951+
if (err instanceof LinearAttachmentError) {
952+
logger.warn('Rejecting Linear task: attachment could not be safely processed', {
953+
issue_id: issue.id,
954+
linear_workspace_id: workspaceId,
955+
error: err.message,
956+
});
957+
await safeReportIssueFailure(
958+
issue.id,
959+
workspaceId,
960+
`❌ ABCA couldn't safely process an attachment on this issue: ${err.message} Remove or fix the attachment and re-apply the trigger label.`,
961+
);
962+
return;
963+
}
964+
throw err;
965+
}
966+
}
967+
890968
const requestId = crypto.randomUUID();
891969
const result = await createTaskCore(
892970
{
@@ -903,6 +981,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
903981
userId: platformUserId,
904982
channelSource: 'linear',
905983
channelMetadata,
984+
taskId,
985+
...(preScreenedAttachments.length > 0 && { preScreenedAttachments }),
906986
},
907987
requestId,
908988
);
@@ -913,6 +993,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
913993
body: result.body,
914994
issue_id: issue.id,
915995
});
996+
// Don't orphan the attachment objects we uploaded before this call failed —
997+
// createTaskCore only rolls back its own inline uploads, not ours.
998+
if (preScreenedAttachments.length > 0 && attachmentsS3Client && ATTACHMENTS_BUCKET) {
999+
await cleanupPreScreenedAttachments(attachmentsS3Client, ATTACHMENTS_BUCKET, preScreenedAttachments);
1000+
}
9161001
await safeReportIssueFailure(
9171002
issue.id,
9181003
workspaceId,

0 commit comments

Comments
 (0)