Skip to content

Commit e5328ca

Browse files
author
bgagent
committed
chore(review): implement fixes from automated review
1 parent 5721600 commit e5328ca

14 files changed

Lines changed: 676 additions & 63 deletions

agent/src/pipeline.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,12 +473,27 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
473473
if config.attachments:
474474
from attachments import download_attachments
475475

476-
with task_span("task.attachment_download"):
477-
prepared_attachments = download_attachments(config.attachments, setup.repo_dir)
478-
progress.write_agent_milestone(
479-
"attachments_downloaded",
480-
f"count={len(prepared_attachments)}",
481-
)
476+
try:
477+
with task_span("task.attachment_download"):
478+
prepared_attachments = download_attachments(
479+
config.attachments, setup.repo_dir
480+
)
481+
progress.write_agent_milestone(
482+
"attachments_downloaded",
483+
f"count={len(prepared_attachments)}",
484+
)
485+
except RuntimeError as e:
486+
log("ERROR", f"Attachment integrity check failed: {e}")
487+
raise RuntimeError(
488+
f"Attachment download/verification failed: {e}. "
489+
"The task cannot proceed without valid attachments."
490+
) from e
491+
except Exception as e:
492+
err_type = type(e).__name__
493+
log("ERROR", f"Attachment download failed: {err_type}: {e}")
494+
raise RuntimeError(
495+
f"Failed to download task attachments from S3: {err_type}: {e}"
496+
) from e
482497

483498
# Log discovered repo-level project configuration
484499
# (all files loaded by setting_sources=["project"])

cdk/src/handlers/cleanup-pending-uploads.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
UpdateItemCommand,
4141
PutItemCommand,
4242
} from '@aws-sdk/client-dynamodb';
43-
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3';
43+
import { DeleteObjectsCommand, ListObjectVersionsCommand, S3Client } from '@aws-sdk/client-s3';
4444
import { ulid } from 'ulid';
4545
import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../constructs/attachments-bucket';
4646
import { logger } from './shared/logger';
@@ -187,41 +187,43 @@ async function cancelExpiredTask(task: ExpiredTask): Promise<boolean> {
187187
async function cleanupTaskAttachments(task: ExpiredTask): Promise<void> {
188188
const prefix = `${ATTACHMENT_OBJECT_KEY_PREFIX}${task.user_id}/${task.task_id}/`;
189189

190-
let continuationToken: string | undefined;
190+
let keyMarker: string | undefined;
191+
let versionIdMarker: string | undefined;
191192
let totalDeleted = 0;
192193

193194
do {
194-
const listResp = await s3.send(new ListObjectsV2Command({
195+
const listResp = await s3.send(new ListObjectVersionsCommand({
195196
Bucket: ATTACHMENTS_BUCKET,
196197
Prefix: prefix,
197-
ContinuationToken: continuationToken,
198+
KeyMarker: keyMarker,
199+
VersionIdMarker: versionIdMarker,
198200
}));
199201

200-
const objects = listResp.Contents;
201-
if (!objects || objects.length === 0) break;
202+
// Collect all versions and delete markers for deletion
203+
const objects = [
204+
...(listResp.Versions ?? []).map(v => ({ Key: v.Key!, VersionId: v.VersionId })),
205+
...(listResp.DeleteMarkers ?? []).map(d => ({ Key: d.Key!, VersionId: d.VersionId })),
206+
].filter(obj => obj.Key !== undefined);
202207

203-
const keys = objects
204-
.map(obj => obj.Key)
205-
.filter((key): key is string => key !== undefined);
208+
if (objects.length === 0) break;
206209

207-
if (keys.length > 0) {
208-
const deleteResp = await s3.send(new DeleteObjectsCommand({
209-
Bucket: ATTACHMENTS_BUCKET,
210-
Delete: { Objects: keys.map(Key => ({ Key })) },
211-
}));
212-
213-
if (deleteResp.Errors && deleteResp.Errors.length > 0) {
214-
logger.error('Partial S3 cleanup failure for expired pending-upload task', {
215-
task_id: task.task_id,
216-
failedKeys: deleteResp.Errors.map(e => e.Key),
217-
});
218-
}
210+
const deleteResp = await s3.send(new DeleteObjectsCommand({
211+
Bucket: ATTACHMENTS_BUCKET,
212+
Delete: { Objects: objects.map(({ Key, VersionId }) => ({ Key, VersionId })) },
213+
}));
219214

220-
totalDeleted += keys.length - (deleteResp.Errors?.length ?? 0);
215+
if (deleteResp.Errors && deleteResp.Errors.length > 0) {
216+
logger.error('Partial S3 cleanup failure for expired pending-upload task', {
217+
task_id: task.task_id,
218+
failedKeys: deleteResp.Errors.map(e => e.Key),
219+
});
221220
}
222221

223-
continuationToken = listResp.NextContinuationToken;
224-
} while (continuationToken);
222+
totalDeleted += objects.length - (deleteResp.Errors?.length ?? 0);
223+
224+
keyMarker = listResp.NextKeyMarker;
225+
versionIdMarker = listResp.NextVersionIdMarker;
226+
} while (keyMarker);
225227

226228
if (totalDeleted > 0) {
227229
logger.info('Cleaned up S3 objects for expired pending-upload task', {

cdk/src/handlers/confirm-uploads.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,10 @@ async function decrementConcurrency(userId: string): Promise<void> {
799799
error: err instanceof Error ? err.message : String(err),
800800
metric_type: 'concurrency_counter_leak',
801801
});
802+
throw new Error(
803+
`Concurrency counter decrement failed for user ${userId} after ${maxAttempts} attempts. ` +
804+
'Manual intervention may be required to reset the counter.',
805+
);
802806
}
803807
}
804808
}

cdk/src/handlers/shared/attachment-screening.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,18 @@ function assertImageDimensionsWithinLimits(
404404
} else if (contentType === 'image/jpeg') {
405405
dims = readJpegDimensions(content);
406406
if (!dims) {
407-
// Non-fatal: if we can't parse dimensions, let Bedrock handle rejection
407+
// Fail-closed for large JPEGs where dimensions cannot be verified (> 5 MB).
408+
// Smaller files are allowed through to Bedrock which will reject if oversized.
409+
if (content.length > 5 * 1024 * 1024) {
410+
throw new AttachmentScreeningError(
411+
`Image "${filename}" is ${(content.length / (1024 * 1024)).toFixed(1)} MB and its dimensions ` +
412+
'could not be verified. Please use a standard JPEG encoder or convert to PNG.',
413+
);
414+
}
415+
logger.warn('Could not parse JPEG dimensions — relying on Bedrock validation', {
416+
filename,
417+
size_bytes: content.length,
418+
});
408419
return;
409420
}
410421
} else {

cdk/src/handlers/shared/create-task-core.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { BedrockRuntimeClient, ApplyGuardrailCommand } from '@aws-sdk/client-bed
2525
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2626
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
2727
import { PutObjectCommand, DeleteObjectsCommand, S3Client } from '@aws-sdk/client-s3';
28-
import { DynamoDBDocumentClient, PutCommand, QueryCommand, GetCommand } from '@aws-sdk/lib-dynamodb';
28+
import { DynamoDBDocumentClient, PutCommand, QueryCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
2929
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
3030
import type { APIGatewayProxyResult } from 'aws-lambda';
3131
import { ulid } from 'ulid';
@@ -666,12 +666,31 @@ export async function createTaskCore(
666666
taskRecord, validatedAttachments, context.userId, taskId, s3Client,
667667
);
668668
} catch (presignErr) {
669-
logger.error('Failed to generate presigned upload instructions — task orphaned in PENDING_UPLOADS', {
669+
logger.error('Failed to generate presigned upload instructions — transitioning task to FAILED', {
670670
task_id: taskId,
671671
error: presignErr instanceof Error ? presignErr.message : String(presignErr),
672672
request_id: requestId,
673673
metric_type: 'presigned_post_generation_failure',
674674
});
675+
// Transition task to FAILED so it doesn't remain orphaned in PENDING_UPLOADS
676+
try {
677+
await ddb.send(new UpdateCommand({
678+
TableName: TABLE_NAME,
679+
Key: { task_id: taskId },
680+
UpdateExpression: 'SET #s = :failed, error_message = :err, updated_at = :now',
681+
ExpressionAttributeNames: { '#s': 'status' },
682+
ExpressionAttributeValues: {
683+
':failed': TaskStatus.FAILED,
684+
':err': 'Failed to generate upload instructions. Please try again.',
685+
':now': new Date().toISOString(),
686+
},
687+
}));
688+
} catch (cleanupErr) {
689+
logger.error('Failed to transition orphaned task to FAILED', {
690+
task_id: taskId,
691+
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
692+
});
693+
}
675694
return errorResponse(500, ErrorCode.INTERNAL_ERROR,
676695
'Failed to generate upload instructions. Please try again.', requestId);
677696
}

cdk/src/handlers/shared/orchestrator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B
483483
bucketName: ATTACHMENTS_BUCKET_NAME,
484484
screeningConfig,
485485
githubToken,
486+
githubInstallationDomain: process.env.GITHUB_INSTALLATION_DOMAIN,
486487
},
487488
);
488489
}

cdk/src/handlers/shared/resolve-url-attachments.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ const PRIVATE_IP_RANGES = [
6767
{ prefix: '169.254.', mask: null },
6868
{ prefix: '127.', mask: null },
6969
{ prefix: '0.', mask: null },
70-
{ prefix: '100.64.', mask: null }, // CGN / Shared Address Space (RFC 6598)
70+
{
71+
prefix: '100.',
72+
mask: (ip: string) => {
73+
const second = parseInt(ip.split('.')[1], 10);
74+
return second >= 64 && second <= 127; // 100.64.0.0/10 (RFC 6598)
75+
},
76+
},
7177
// IPv6
7278
{ prefix: '::1', mask: null },
7379
{ prefix: '::', mask: (ip: string) => ip === '::' }, // Unspecified address (could route to localhost)
@@ -131,14 +137,29 @@ async function resolveAndValidate(hostname: string): Promise<string> {
131137
try {
132138
// Try IPv4 first (more common for HTTP endpoints)
133139
addresses = await dns.resolve4(hostname);
134-
} catch (ipv4Err) {
135-
try {
136-
addresses = await dns.resolve6(hostname);
137-
} catch (ipv6Err) {
138-
throw new AttachmentResolutionError(
139-
`DNS resolution failed for '${hostname}'. Check that the URL is correct and the server is reachable.`,
140-
{ cause: new AggregateError([ipv4Err, ipv6Err], `Both IPv4 and IPv6 resolution failed for '${hostname}'`) },
141-
);
140+
} catch (ipv4Err: any) {
141+
// Only fall through to IPv6 for NODATA/NXDOMAIN — system errors should propagate
142+
const dnsNoRecordCodes = ['ENODATA', 'ENOTFOUND', 'NODATA'];
143+
if (!dnsNoRecordCodes.includes(ipv4Err?.code)) {
144+
// System-level DNS failure (ENOMEM, ESERVFAIL, etc.) — do not mask
145+
try {
146+
addresses = await dns.resolve6(hostname);
147+
} catch (ipv6Err) {
148+
throw new AttachmentResolutionError(
149+
`DNS resolution failed for '${hostname}': ${ipv4Err?.code ?? ipv4Err?.message ?? 'unknown error'}`,
150+
{ cause: new AggregateError([ipv4Err, ipv6Err], `Both IPv4 and IPv6 resolution failed for '${hostname}'`) },
151+
);
152+
}
153+
} else {
154+
// No IPv4 records — try IPv6
155+
try {
156+
addresses = await dns.resolve6(hostname);
157+
} catch (ipv6Err) {
158+
throw new AttachmentResolutionError(
159+
`DNS resolution failed for '${hostname}'. Check that the URL is correct and the server is reachable.`,
160+
{ cause: new AggregateError([ipv4Err, ipv6Err], `Both IPv4 and IPv6 resolution failed for '${hostname}'`) },
161+
);
162+
}
142163
}
143164
}
144165

@@ -207,7 +228,17 @@ async function pinnedHttpsRequest(
207228
},
208229
(res) => {
209230
const chunks: Buffer[] = [];
210-
res.on('data', (chunk: Buffer) => chunks.push(chunk));
231+
let totalBytes = 0;
232+
res.on('data', (chunk: Buffer) => {
233+
totalBytes += chunk.length;
234+
if (totalBytes > MAX_FETCH_SIZE_BYTES) {
235+
res.destroy();
236+
agent.destroy();
237+
reject(new Error(`Response exceeds ${MAX_FETCH_SIZE_BYTES} byte size limit`));
238+
return;
239+
}
240+
chunks.push(chunk);
241+
});
211242
res.on('end', () => {
212243
const body = Buffer.concat(chunks);
213244
const responseHeaders = new Headers();

cdk/src/handlers/shared/types.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,47 @@ export type ScreeningResult =
418418
| { readonly status: 'blocked'; readonly screened_at: string; readonly categories: [string, ...string[]] };
419419

420420
// ---------------------------------------------------------------------------
421-
// Attachment record (persisted metadata in TaskRecord)
421+
// Attachment record (persisted metadata in TaskRecord) — discriminated union
422+
// keyed on screening.status ensures that passed records always have storage fields.
422423
// ---------------------------------------------------------------------------
423424

424-
export interface AttachmentRecord {
425+
interface BaseAttachmentRecord {
426+
readonly attachment_id: string;
427+
readonly type: AttachmentType;
428+
readonly content_type: string;
429+
readonly filename: string;
430+
readonly source_url?: string;
431+
readonly token_estimate?: number;
432+
}
433+
434+
export interface PendingAttachmentRecord extends BaseAttachmentRecord {
435+
readonly screening: { readonly status: 'pending' };
436+
readonly s3_key?: string;
437+
readonly s3_version_id?: string;
438+
readonly size_bytes?: number;
439+
readonly checksum_sha256?: string;
440+
}
441+
442+
export interface PassedAttachmentRecord extends BaseAttachmentRecord {
443+
readonly screening: { readonly status: 'passed'; readonly screened_at: string };
444+
readonly s3_key: string;
445+
readonly s3_version_id: string;
446+
readonly size_bytes: number;
447+
readonly checksum_sha256: string;
448+
}
449+
450+
export interface BlockedAttachmentRecord extends BaseAttachmentRecord {
451+
readonly screening: { readonly status: 'blocked'; readonly screened_at: string; readonly categories: [string, ...string[]] };
452+
readonly s3_key?: string;
453+
readonly s3_version_id?: string;
454+
readonly size_bytes?: number;
455+
readonly checksum_sha256?: string;
456+
}
457+
458+
export type AttachmentRecord = PendingAttachmentRecord | PassedAttachmentRecord | BlockedAttachmentRecord;
459+
460+
/** Parameters for creating an AttachmentRecord — accepts the union of all fields. */
461+
export type CreateAttachmentRecordParams = {
425462
readonly attachment_id: string;
426463
readonly type: AttachmentType;
427464
readonly content_type: string;
@@ -433,22 +470,23 @@ export interface AttachmentRecord {
433470
readonly source_url?: string;
434471
readonly checksum_sha256?: string;
435472
readonly token_estimate?: number;
436-
}
437-
438-
/** Parameters for creating an AttachmentRecord with cross-field invariant validation. */
439-
export type CreateAttachmentRecordParams = AttachmentRecord;
473+
};
440474

441475
/**
442476
* Factory function enforcing cross-field invariants on AttachmentRecord construction.
443-
* Validates that required fields are present based on screening status and type.
477+
* Returns the appropriate discriminated union variant based on screening status.
444478
*/
445479
export function createAttachmentRecord(params: CreateAttachmentRecordParams): AttachmentRecord {
446480
if (params.screening.status === 'passed') {
447481
if (!params.s3_key || !params.s3_version_id || !params.checksum_sha256 || !params.size_bytes) {
448482
throw new Error('Passed screening requires s3_key, s3_version_id, checksum_sha256, and size_bytes');
449483
}
484+
return params as PassedAttachmentRecord;
485+
}
486+
if (params.screening.status === 'blocked') {
487+
return params as BlockedAttachmentRecord;
450488
}
451-
return params;
489+
return params as PendingAttachmentRecord;
452490
}
453491

454492
// ---------------------------------------------------------------------------

cdk/src/handlers/slack-command-processor.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,8 @@ const SLACK_FILE_MAX_SIZE_BYTES = 10 * 1024 * 1024;
375375
/** Max number of file attachments per Slack message. */
376376
const SLACK_FILE_MAX_COUNT = 10;
377377

378-
/** MIME types supported for attachments (must match validation.ts). */
379-
const SUPPORTED_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
378+
/** MIME types supported for attachments (must match validation.ts — PNG/JPEG only). */
379+
const SUPPORTED_IMAGE_MIMES = new Set(['image/png', 'image/jpeg']);
380380
const SUPPORTED_FILE_MIMES = new Set([
381381
'text/plain', 'text/csv', 'text/markdown', 'application/json',
382382
'application/pdf', 'text/x-log',
@@ -428,6 +428,13 @@ async function extractSlackFileAttachments(
428428
continue;
429429
}
430430

431+
// Validate the download URL points to a legitimate Slack domain before
432+
// sending the bot token — prevents SSRF and token exfiltration via crafted events.
433+
if (!isSlackFileUrl(file.url_private_download)) {
434+
errors.push(`\`${file.name}\` (invalid download URL — not a Slack domain)`);
435+
continue;
436+
}
437+
431438
// Download the file from Slack CDN using the bot token
432439
try {
433440
const response = await fetch(file.url_private_download, {
@@ -476,6 +483,17 @@ async function extractSlackFileAttachments(
476483
return attachments;
477484
}
478485

486+
/** Validate that a URL points to a legitimate Slack file domain. */
487+
function isSlackFileUrl(url: string): boolean {
488+
try {
489+
const parsed = new URL(url);
490+
return parsed.protocol === 'https:'
491+
&& (parsed.hostname === 'files.slack.com' || parsed.hostname.endsWith('.slack.com'));
492+
} catch {
493+
return false;
494+
}
495+
}
496+
479497
// ─── Helpers ──────────────────────────────────────────────────────────────────
480498

481499
async function lookupPlatformUser(teamId: string, userId: string): Promise<string | null> {

0 commit comments

Comments
 (0)