From f35278a87146a476aef5d97b041aea9623315058 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 11:15:36 -0700 Subject: [PATCH 1/8] feat(file): add Compress operation to bundle files into a .zip archive --- apps/sim/app/api/tools/file/manage/route.ts | 148 ++++++++++++++++++++ apps/sim/blocks/blocks/file.ts | 88 +++++++++++- apps/sim/lib/api/contracts/tools/file.ts | 13 ++ apps/sim/tools/file/compress.test.ts | 78 +++++++++++ apps/sim/tools/file/compress.ts | 72 ++++++++++ apps/sim/tools/file/index.ts | 1 + apps/sim/tools/registry.ts | 2 + 7 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 apps/sim/tools/file/compress.test.ts create mode 100644 apps/sim/tools/file/compress.ts diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 22cf2cbe20..6ff999692c 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -2,6 +2,7 @@ import { Buffer, isUtf8 } from 'buffer' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' +import JSZip from 'jszip' import { type NextRequest, NextResponse } from 'next/server' import { fileManageContract } from '@/lib/api/contracts/tools/file' import { parseRequest } from '@/lib/api/server' @@ -133,6 +134,44 @@ const MAX_GET_CONTENT_FILE_BYTES = 64 * 1024 * 1024 /** Combined extracted-text cap so the content array stays within the large-value-ref ceiling. */ const MAX_GET_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024 +/** Per-file download cap for the compress operation. */ +const MAX_COMPRESS_FILE_BYTES = 100 * 1024 * 1024 +/** Combined input cap for the compress operation to bound in-memory archiving. */ +const MAX_COMPRESS_TOTAL_BYTES = 100 * 1024 * 1024 + +/** Ensure an archive name ends with a single `.zip` extension. */ +const ensureZipExtension = (name: string): string => + name.toLowerCase().endsWith('.zip') ? name : `${name}.zip` + +/** Strip the trailing extension from a file name (e.g., "report.pdf" -> "report"). */ +const stripExtension = (name: string): string => { + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(0, dot) : name +} + +/** + * Return a zip entry name unique within `usedNames`, appending a numeric suffix + * before the extension on collision (e.g., "data.csv" -> "data (1).csv"). + */ +const uniqueZipEntryName = (name: string, usedNames: Set): string => { + if (!usedNames.has(name)) { + usedNames.add(name) + return name + } + + const dot = name.lastIndexOf('.') + const base = dot > 0 ? name.slice(0, dot) : name + const ext = dot > 0 ? name.slice(dot) : '' + let counter = 1 + let candidate = `${base} (${counter})${ext}` + while (usedNames.has(candidate)) { + counter += 1 + candidate = `${base} (${counter})${ext}` + } + usedNames.add(candidate) + return candidate +} + const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0) /** @@ -462,6 +501,115 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await releaseLock(lockKey, lockValue) } } + + case 'compress': { + const { fileId, fileInput, archiveName } = body + const requestId = generateRequestId() + + const selectedFileIds = Array.isArray(fileId) + ? fileId.map((id) => id.trim()).filter(Boolean) + : fileId + ? normalizeFileIdList(fileId) + : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + const workspaceFiles = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const userFiles: UserFile[] = workspaceFiles + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles) + + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const zip = new JSZip() + const usedNames = new Set() + let totalBytes = 0 + for (const userFile of userFiles) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied + + const buffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_COMPRESS_FILE_BYTES, + }) + totalBytes += buffer.length + if (totalBytes > MAX_COMPRESS_TOTAL_BYTES) { + return NextResponse.json( + { + success: false, + error: `Combined input is too large to compress. Maximum is ${ + MAX_COMPRESS_TOTAL_BYTES / (1024 * 1024) + } MB.`, + }, + { status: 413 } + ) + } + zip.file(uniqueZipEntryName(userFile.name, usedNames), buffer) + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }) + + const requestedName = typeof archiveName === 'string' ? archiveName.trim() : '' + const targetName = ensureZipExtension( + requestedName || (userFiles.length === 1 ? stripExtension(userFiles[0].name) : 'archive') + ) + const { folderSegments, leafName } = splitWorkspaceFilePath(targetName) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + const result = await uploadWorkspaceFile( + workspaceId, + userId, + zipBuffer, + leafName, + 'application/zip', + { folderId } + ) + + const compressedFile: UserFile = { + ...result, + url: ensureAbsoluteUrl(result.url), + size: zipBuffer.length, + } + + logger.info('Files compressed', { + fileId: result.id, + name: result.name, + fileCount: userFiles.length, + size: zipBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: compressedFile.id, + name: compressedFile.name, + size: compressedFile.size, + url: compressedFile.url, + file: compressedFile, + files: [compressedFile], + }, + }) + } } } catch (error) { if (isWorkspaceAccessDeniedError(error)) { diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index b315411c61..f4cdbbfcf2 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -822,9 +822,9 @@ export const FileV5Block: BlockConfig = { ...FileV4Block, type: 'file_v5', name: 'File', - description: 'Read, get content, fetch, write, and append files', + description: 'Read, get content, fetch, write, append, and compress files', longDescription: - 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.', + 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, or compress files into a .zip archive.', hideFromToolbar: false, bestPractices: ` - Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments). @@ -833,6 +833,7 @@ export const FileV5Block: BlockConfig = { - Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. - Use Write to create a new workspace file and Append to add content to an existing one. + - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "file"/"files" outputs, which is handy for getting large attachments under provider upload limits. `, subBlocks: [ { @@ -845,6 +846,7 @@ export const FileV5Block: BlockConfig = { { label: 'Fetch', id: 'file_fetch' }, { label: 'Write', id: 'file_write' }, { label: 'Append', id: 'file_append' }, + { label: 'Compress', id: 'file_compress' }, ], value: () => 'file_read', }, @@ -962,9 +964,45 @@ export const FileV5Block: BlockConfig = { condition: { field: 'operation', value: 'file_append' }, required: { field: 'operation', value: 'file_append' }, }, + { + id: 'compressFile', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'compressInput', + acceptedTypes: '*', + placeholder: 'Select workspace files', + multiple: true, + mode: 'basic', + condition: { field: 'operation', value: 'file_compress' }, + required: { field: 'operation', value: 'file_compress' }, + }, + { + id: 'compressFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'compressInput', + placeholder: 'Workspace file ID or JSON array of IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'file_compress' }, + required: { field: 'operation', value: 'file_compress' }, + }, + { + id: 'archiveName', + title: 'Archive Name', + type: 'short-input' as SubBlockType, + placeholder: 'archive.zip (auto-named from source if omitted)', + condition: { field: 'operation', value: 'file_compress' }, + }, ], tools: { - access: ['file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append'], + access: [ + 'file_read', + 'file_get_content', + 'file_fetch', + 'file_write', + 'file_append', + 'file_compress', + ], config: { tool: (params) => params.operation || 'file_read', params: (params) => { @@ -1005,6 +1043,38 @@ export const FileV5Block: BlockConfig = { } } + if (operation === 'file_compress') { + const compressInput = params.compressInput + if (!compressInput) { + throw new Error('File is required for compress') + } + + const archiveName = + typeof params.archiveName === 'string' && params.archiveName.trim() + ? params.archiveName.trim() + : undefined + + const fileIds = parseReadFileIds(compressInput) + if (fileIds) { + return { + fileId: fileIds, + archiveName, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(compressInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for compress') + } + + return { + fileInput: normalized, + archiveName, + workspaceId: params._context?.workspaceId, + } + } + if (operation === 'file_fetch') { const fileUrl = resolveHttpFileUrl(params.fileUrl) @@ -1089,11 +1159,21 @@ export const FileV5Block: BlockConfig = { contentType: { type: 'string', description: 'MIME content type for write' }, appendFileInput: { type: 'json', description: 'File to append to' }, appendContent: { type: 'string', description: 'Content to append to file' }, + compressInput: { + type: 'json', + description: 'Selected workspace files or canonical file IDs to compress', + }, + archiveName: { type: 'string', description: 'Name for the compressed .zip archive' }, }, outputs: { files: { type: 'file[]', - description: 'Workspace file objects (read) or fetched file objects (fetch)', + description: + 'Workspace file objects (read), fetched file objects (fetch), or the compressed archive (compress)', + }, + file: { + type: 'file', + description: 'Compressed archive file object (compress)', }, contents: { type: 'array', diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 1a151342e3..1d1e1b09ba 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -64,6 +64,18 @@ export const fileManageContentBodySchema = z message: 'Either fileId or fileInput is required for content operation', }) +export const fileManageCompressBodySchema = z + .object({ + operation: z.literal('compress'), + workspaceId: z.string().min(1).optional(), + fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), + fileInput: z.unknown().optional(), + archiveName: z.string().min(1).max(255).optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for compress operation', + }) + export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, @@ -71,6 +83,7 @@ export const fileManageBodySchema = z.union([ fileManageMoveBodySchema, fileManageReadBodySchema, fileManageContentBodySchema, + fileManageCompressBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/tools/file/compress.test.ts b/apps/sim/tools/file/compress.test.ts new file mode 100644 index 0000000000..bce57acb20 --- /dev/null +++ b/apps/sim/tools/file/compress.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { fileCompressTool } from '@/tools/file/compress' + +describe('fileCompressTool', () => { + it('builds a compress request body from file IDs and archive name', () => { + const body = fileCompressTool.request.body?.({ + fileId: ['wf_a', 'wf_b'], + archiveName: 'documents.zip', + _context: { workspaceId: 'ws_1' }, + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'compress', + fileId: ['wf_a', 'wf_b'], + archiveName: 'documents.zip', + workspaceId: 'ws_1', + }) + }) + + it('forwards a selected file object when no IDs are provided', () => { + const fileInput = { id: 'wf_c', name: 'report.pdf' } + const body = fileCompressTool.request.body?.({ + fileInput, + workspaceId: 'ws_2', + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'compress', + fileInput, + workspaceId: 'ws_2', + }) + }) + + it('returns the compressed archive on success', async () => { + const archive = { + id: 'wf_zip', + name: 'archive.zip', + size: 1024, + url: 'https://example.com/archive.zip', + type: 'application/zip', + key: 'workspace/ws_1/archive.zip', + } + + const result = await fileCompressTool.transformResponse?.( + Response.json({ + success: true, + data: { + id: archive.id, + name: archive.name, + size: archive.size, + url: archive.url, + file: archive, + files: [archive], + }, + }) + ) + + expect(result).toMatchObject({ + success: true, + output: { id: 'wf_zip', name: 'archive.zip', size: 1024, files: [archive] }, + }) + }) + + it('propagates route failures as tool failures', async () => { + const result = await fileCompressTool.transformResponse?.( + Response.json({ success: false, error: 'Combined input is too large to compress.' }) + ) + + expect(result).toMatchObject({ + success: false, + error: 'Combined input is too large to compress.', + output: {}, + }) + }) +}) diff --git a/apps/sim/tools/file/compress.ts b/apps/sim/tools/file/compress.ts new file mode 100644 index 0000000000..5b74b94efb --- /dev/null +++ b/apps/sim/tools/file/compress.ts @@ -0,0 +1,72 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileCompressParams { + fileId?: string | string[] + fileInput?: unknown + archiveName?: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileCompressTool: ToolConfig = { + id: 'file_compress', + name: 'File Compress', + description: + 'Compress one or more workspace files into a single .zip archive stored in the workspace. Useful for getting large attachments under provider upload limits.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID, or an array of canonical workspace file IDs.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object, or an array of file objects.', + }, + archiveName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Name for the .zip archive (e.g., "documents.zip"). Defaults to the source file name when compressing a single file, otherwise "archive.zip".', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'compress', + fileId: params.fileId, + fileInput: params.fileInput, + archiveName: params.archiveName, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to compress files' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'Compressed archive file ID' }, + name: { type: 'string', description: 'Compressed archive file name' }, + size: { type: 'number', description: 'Compressed archive size in bytes' }, + url: { type: 'string', description: 'URL to access the compressed archive', optional: true }, + file: { type: 'file', description: 'Compressed archive file object' }, + files: { + type: 'file[]', + description: 'Compressed archive file object, as a single-item array', + }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index c05a3a1995..e38d7be37e 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -6,6 +6,7 @@ import { } from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' +export { fileCompressTool } from '@/tools/file/compress' export { fileGetContentTool, fileGetTool, fileReadTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e7c8f70198..eb9d77ee81 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -850,6 +850,7 @@ import { } from '@/tools/fathom' import { fileAppendTool, + fileCompressTool, fileFetchTool, fileGetContentTool, fileGetTool, @@ -3958,6 +3959,7 @@ export const tools: Record = { file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, file_append: fileAppendTool, + file_compress: fileCompressTool, file_fetch: fileFetchTool, file_get: fileGetTool, file_get_content: fileGetContentTool, From 3dfc9cc1cc81952f91f37829d13e67a9e8eae2a5 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 11:27:49 -0700 Subject: [PATCH 2/8] feat(file): add Decompress operation to extract .zip archives Adds the inbound half of the archive pair: extracts a .zip back into the workspace with zip-slip path sanitization, symlink skipping, and entry/ size caps to bound zip-bomb expansion. Extracted files are returned in the files output, ready to chain downstream. --- apps/sim/app/api/tools/file/manage/route.ts | 178 ++++++++++++++++++++ apps/sim/blocks/blocks/file.ts | 59 ++++++- apps/sim/lib/api/contracts/tools/file.ts | 12 ++ apps/sim/tools/file/compress.test.ts | 45 ++++- apps/sim/tools/file/compress.ts | 55 ++++++ apps/sim/tools/file/index.ts | 2 +- apps/sim/tools/registry.ts | 2 + 7 files changed, 348 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 6ff999692c..af4e04925e 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -172,6 +172,47 @@ const uniqueZipEntryName = (name: string, usedNames: Set): string => { return candidate } +/** Input archive download cap for the decompress operation. */ +const MAX_DECOMPRESS_ARCHIVE_BYTES = 100 * 1024 * 1024 +/** Maximum number of entries extracted from a single archive. */ +const MAX_DECOMPRESS_ENTRIES = 1000 +/** Maximum uncompressed size for any single archive entry. */ +const MAX_DECOMPRESS_ENTRY_BYTES = 100 * 1024 * 1024 +/** Maximum total uncompressed size across all entries, to bound zip-bomb expansion. */ +const MAX_DECOMPRESS_TOTAL_BYTES = 200 * 1024 * 1024 + +const S_IFMT = 0o170000 +const S_IFLNK = 0o120000 + +/** Read a zip entry's declared uncompressed size without materializing it (zip-bomb pre-check). */ +const readEntryUncompressedSize = (entry: JSZip.JSZipObject): number | undefined => { + const data = (entry as JSZip.JSZipObject & { _data?: { uncompressedSize?: number } })._data + const size = data?.uncompressedSize + return typeof size === 'number' && Number.isFinite(size) ? size : undefined +} + +/** True when a zip entry's unix mode marks it as a symlink (never extracted). */ +const isSymlinkEntry = (entry: JSZip.JSZipObject): boolean => { + const mode = (entry as JSZip.JSZipObject & { unixPermissions?: number | null }).unixPermissions + return typeof mode === 'number' && (mode & S_IFMT) === S_IFLNK +} + +/** + * Normalize a zip entry path into safe workspace folder segments, guarding against + * zip-slip. Returns null for traversal (`..`), so the entry is skipped rather than + * written outside its intended location. + */ +const sanitizeArchiveEntryPath = (rawPath: string): string[] | null => { + const segments = rawPath + .replace(/\\/g, '/') + .split('/') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== '.') + + if (segments.length === 0 || segments.includes('..')) return null + return segments +} + const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0) /** @@ -610,6 +651,143 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } + + case 'decompress': { + const { fileId, fileInput } = body + const requestId = generateRequestId() + + const selectedFileIds = fileId ? [fileId] : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + const workspaceFiles = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const archive = workspaceFiles + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles)[0] + + if (!archive) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const denied = await assertToolFileAccess(archive.key, userId, requestId, logger) + if (denied) return denied + + const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, { + maxBytes: MAX_DECOMPRESS_ARCHIVE_BYTES, + }) + + let zip: JSZip + try { + zip = await JSZip.loadAsync(archiveBuffer) + } catch { + return NextResponse.json( + { success: false, error: `"${archive.name}" is not a valid .zip archive` }, + { status: 400 } + ) + } + + const entries = Object.values(zip.files).filter( + (entry) => !entry.dir && !isSymlinkEntry(entry) + ) + if (entries.length > MAX_DECOMPRESS_ENTRIES) { + return NextResponse.json( + { + success: false, + error: `Archive has too many entries to extract. Maximum is ${MAX_DECOMPRESS_ENTRIES}.`, + }, + { status: 413 } + ) + } + + const folderIdCache = new Map() + const extractedFiles: UserFile[] = [] + let totalBytes = 0 + + for (const entry of entries) { + const declaredSize = readEntryUncompressedSize(entry) + if (declaredSize !== undefined && declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) { + return NextResponse.json( + { + success: false, + error: `Archive entry "${entry.name}" is too large to extract. Maximum is ${ + MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024) + } MB per file.`, + }, + { status: 413 } + ) + } + + const segments = sanitizeArchiveEntryPath(entry.name) + if (!segments) { + logger.warn('Skipping unsafe archive entry', { name: entry.name }) + continue + } + + const buffer = await entry.async('nodebuffer') + totalBytes += buffer.length + if (totalBytes > MAX_DECOMPRESS_TOTAL_BYTES) { + return NextResponse.json( + { + success: false, + error: `Archive expands to more than the ${ + MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024) + } MB extraction limit.`, + }, + { status: 413 } + ) + } + + const leafName = segments[segments.length - 1] + const folderSegments = segments.slice(0, -1) + const folderKey = folderSegments.join('/') + let folderId = folderIdCache.get(folderKey) + if (folderId === undefined) { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + folderIdCache.set(folderKey, folderId) + } + + const mimeType = getMimeTypeFromExtension(getFileExtension(leafName)) + const uploaded = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + leafName, + mimeType, + { folderId } + ) + extractedFiles.push({ ...uploaded, url: ensureAbsoluteUrl(uploaded.url) }) + } + + logger.info('Archive decompressed', { + fileId: archive.id, + name: archive.name, + extractedCount: extractedFiles.length, + }) + + return NextResponse.json({ + success: true, + data: { + file: extractedFiles[0], + files: extractedFiles, + }, + }) + } } } catch (error) { if (isWorkspaceAccessDeniedError(error)) { diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index f4cdbbfcf2..71a50f56ae 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -822,9 +822,9 @@ export const FileV5Block: BlockConfig = { ...FileV4Block, type: 'file_v5', name: 'File', - description: 'Read, get content, fetch, write, append, and compress files', + description: 'Read, get content, fetch, write, append, compress, and decompress files', longDescription: - 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, or compress files into a .zip archive.', + 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, or extract a .zip archive into the workspace.', hideFromToolbar: false, bestPractices: ` - Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments). @@ -833,7 +833,8 @@ export const FileV5Block: BlockConfig = { - Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. - Use Write to create a new workspace file and Append to add content to an existing one. - - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "file"/"files" outputs, which is handy for getting large attachments under provider upload limits. + - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "file"/"files" outputs. + - Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks. `, subBlocks: [ { @@ -847,6 +848,7 @@ export const FileV5Block: BlockConfig = { { label: 'Write', id: 'file_write' }, { label: 'Append', id: 'file_append' }, { label: 'Compress', id: 'file_compress' }, + { label: 'Decompress', id: 'file_decompress' }, ], value: () => 'file_read', }, @@ -993,6 +995,27 @@ export const FileV5Block: BlockConfig = { placeholder: 'archive.zip (auto-named from source if omitted)', condition: { field: 'operation', value: 'file_compress' }, }, + { + id: 'decompressFile', + title: 'Archive', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'decompressInput', + acceptedTypes: '.zip', + placeholder: 'Select a .zip archive', + mode: 'basic', + condition: { field: 'operation', value: 'file_decompress' }, + required: { field: 'operation', value: 'file_decompress' }, + }, + { + id: 'decompressFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'decompressInput', + placeholder: 'Workspace file ID of the .zip archive', + mode: 'advanced', + condition: { field: 'operation', value: 'file_decompress' }, + required: { field: 'operation', value: 'file_decompress' }, + }, ], tools: { access: [ @@ -1002,6 +1025,7 @@ export const FileV5Block: BlockConfig = { 'file_write', 'file_append', 'file_compress', + 'file_decompress', ], config: { tool: (params) => params.operation || 'file_read', @@ -1075,6 +1099,31 @@ export const FileV5Block: BlockConfig = { } } + if (operation === 'file_decompress') { + const decompressInput = params.decompressInput + if (!decompressInput) { + throw new Error('File is required for decompress') + } + + const fileIds = parseReadFileIds(decompressInput) + if (fileIds) { + return { + fileId: Array.isArray(fileIds) ? fileIds[0] : fileIds, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(decompressInput, { single: true }) + if (!normalized) { + throw new Error('File is required for decompress') + } + + return { + fileInput: normalized, + workspaceId: params._context?.workspaceId, + } + } + if (operation === 'file_fetch') { const fileUrl = resolveHttpFileUrl(params.fileUrl) @@ -1164,6 +1213,10 @@ export const FileV5Block: BlockConfig = { description: 'Selected workspace files or canonical file IDs to compress', }, archiveName: { type: 'string', description: 'Name for the compressed .zip archive' }, + decompressInput: { + type: 'json', + description: 'Selected .zip archive or canonical file ID to extract', + }, }, outputs: { files: { diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 1d1e1b09ba..0b7a439615 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -76,6 +76,17 @@ export const fileManageCompressBodySchema = z message: 'Either fileId or fileInput is required for compress operation', }) +export const fileManageDecompressBodySchema = z + .object({ + operation: z.literal('decompress'), + workspaceId: z.string().min(1).optional(), + fileId: z.string().min(1).optional(), + fileInput: z.unknown().optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for decompress operation', + }) + export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, @@ -84,6 +95,7 @@ export const fileManageBodySchema = z.union([ fileManageReadBodySchema, fileManageContentBodySchema, fileManageCompressBodySchema, + fileManageDecompressBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/tools/file/compress.test.ts b/apps/sim/tools/file/compress.test.ts index bce57acb20..d6718de265 100644 --- a/apps/sim/tools/file/compress.test.ts +++ b/apps/sim/tools/file/compress.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { fileCompressTool } from '@/tools/file/compress' +import { fileCompressTool, fileDecompressTool } from '@/tools/file/compress' describe('fileCompressTool', () => { it('builds a compress request body from file IDs and archive name', () => { @@ -76,3 +76,46 @@ describe('fileCompressTool', () => { }) }) }) + +describe('fileDecompressTool', () => { + it('builds a decompress request body from a file ID', () => { + const body = fileDecompressTool.request.body?.({ + fileId: 'wf_zip', + _context: { workspaceId: 'ws_1' }, + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'decompress', + fileId: 'wf_zip', + workspaceId: 'ws_1', + }) + }) + + it('returns the extracted files on success', async () => { + const extracted = [ + { id: 'wf_a', name: 'a.txt', url: 'https://example.com/a.txt', key: 'k/a.txt' }, + { id: 'wf_b', name: 'b.txt', url: 'https://example.com/b.txt', key: 'k/b.txt' }, + ] + + const result = await fileDecompressTool.transformResponse?.( + Response.json({ success: true, data: { file: extracted[0], files: extracted } }) + ) + + expect(result).toMatchObject({ + success: true, + output: { file: extracted[0], files: extracted }, + }) + }) + + it('propagates route failures as tool failures', async () => { + const result = await fileDecompressTool.transformResponse?.( + Response.json({ success: false, error: '"data.txt" is not a valid .zip archive' }) + ) + + expect(result).toMatchObject({ + success: false, + error: '"data.txt" is not a valid .zip archive', + output: {}, + }) + }) +}) diff --git a/apps/sim/tools/file/compress.ts b/apps/sim/tools/file/compress.ts index 5b74b94efb..af453ed9c0 100644 --- a/apps/sim/tools/file/compress.ts +++ b/apps/sim/tools/file/compress.ts @@ -70,3 +70,58 @@ export const fileCompressTool: ToolConfig = { }, }, } + +interface FileDecompressParams { + fileId?: string + fileInput?: unknown + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileDecompressTool: ToolConfig = { + id: 'file_decompress', + name: 'File Decompress', + description: + 'Extract the contents of a .zip archive into the workspace, preserving the archive folder structure.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID of the .zip archive to extract.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected .zip archive file object.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'decompress', + fileId: params.fileId, + fileInput: params.fileInput, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to decompress archive' } + } + return { success: true, output: data.data } + }, + + outputs: { + files: { type: 'file[]', description: 'Extracted workspace file objects' }, + file: { type: 'file', description: 'First extracted workspace file object' }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index e38d7be37e..853b0c8669 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -6,7 +6,7 @@ import { } from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' -export { fileCompressTool } from '@/tools/file/compress' +export { fileCompressTool, fileDecompressTool } from '@/tools/file/compress' export { fileGetContentTool, fileGetTool, fileReadTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index eb9d77ee81..bb80d09d21 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -851,6 +851,7 @@ import { import { fileAppendTool, fileCompressTool, + fileDecompressTool, fileFetchTool, fileGetContentTool, fileGetTool, @@ -3960,6 +3961,7 @@ export const tools: Record = { file_parser_v3: fileParserV3Tool, file_append: fileAppendTool, file_compress: fileCompressTool, + file_decompress: fileDecompressTool, file_fetch: fileFetchTool, file_get: fileGetTool, file_get_content: fileGetContentTool, From 3a9ec67514b81eddbe03ab66baefdaf11d6dcfb7 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 11:36:45 -0700 Subject: [PATCH 3/8] fix(file): align archive ops with v5 output surface and zip mime - Drop the single 'file' output reintroduced for compress/decompress; v5 intentionally exposes only 'files' (plus id/name/size/url scalars), so compress/decompress reuse the existing surface with no new block output - Add zip/gz to EXTENSION_TO_MIME (previously only in the reverse map), so archive extensions resolve to a real mime instead of octet-stream - Update File v5 block test for the two new operations --- apps/sim/app/api/tools/file/manage/route.ts | 2 -- apps/sim/blocks/blocks.test.ts | 4 ++++ apps/sim/blocks/blocks/file.ts | 6 +----- apps/sim/lib/uploads/utils/file-utils.ts | 4 ++++ apps/sim/tools/file/compress.test.ts | 4 ++-- apps/sim/tools/file/compress.ts | 2 -- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index af4e04925e..c058dcb0b1 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -646,7 +646,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name: compressedFile.name, size: compressedFile.size, url: compressedFile.url, - file: compressedFile, files: [compressedFile], }, }) @@ -783,7 +782,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - file: extractedFiles[0], files: extractedFiles, }, }) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 79dce03022..9799ef72eb 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -172,7 +172,11 @@ describe.concurrent('Blocks Module', () => { 'file_fetch', 'file_write', 'file_append', + 'file_compress', + 'file_decompress', ]) + expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress') + expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress') expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content') diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 71a50f56ae..3697b6de51 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1222,11 +1222,7 @@ export const FileV5Block: BlockConfig = { files: { type: 'file[]', description: - 'Workspace file objects (read), fetched file objects (fetch), or the compressed archive (compress)', - }, - file: { - type: 'file', - description: 'Compressed archive file object (compress)', + 'Workspace file objects (read), fetched file objects (fetch), the compressed archive (compress), or extracted files (decompress)', }, contents: { type: 'array', diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index b275845928..0fd254f2e2 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -241,6 +241,10 @@ const EXTENSION_TO_MIME: Record = { yml: 'application/x-yaml', rtf: 'application/rtf', + // Archives + zip: 'application/zip', + gz: 'application/gzip', + // Code / plain-text source py: 'text/x-python', js: 'text/javascript', diff --git a/apps/sim/tools/file/compress.test.ts b/apps/sim/tools/file/compress.test.ts index d6718de265..c7f887cfa0 100644 --- a/apps/sim/tools/file/compress.test.ts +++ b/apps/sim/tools/file/compress.test.ts @@ -98,12 +98,12 @@ describe('fileDecompressTool', () => { ] const result = await fileDecompressTool.transformResponse?.( - Response.json({ success: true, data: { file: extracted[0], files: extracted } }) + Response.json({ success: true, data: { files: extracted } }) ) expect(result).toMatchObject({ success: true, - output: { file: extracted[0], files: extracted }, + output: { files: extracted }, }) }) diff --git a/apps/sim/tools/file/compress.ts b/apps/sim/tools/file/compress.ts index af453ed9c0..a9c901a4fa 100644 --- a/apps/sim/tools/file/compress.ts +++ b/apps/sim/tools/file/compress.ts @@ -63,7 +63,6 @@ export const fileCompressTool: ToolConfig = { name: { type: 'string', description: 'Compressed archive file name' }, size: { type: 'number', description: 'Compressed archive size in bytes' }, url: { type: 'string', description: 'URL to access the compressed archive', optional: true }, - file: { type: 'file', description: 'Compressed archive file object' }, files: { type: 'file[]', description: 'Compressed archive file object, as a single-item array', @@ -122,6 +121,5 @@ export const fileDecompressTool: ToolConfig outputs: { files: { type: 'file[]', description: 'Extracted workspace file objects' }, - file: { type: 'file', description: 'First extracted workspace file object' }, }, } From 6a7d1c29fd42ac7c01759a2df11173f9d178e09f Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 11:44:42 -0700 Subject: [PATCH 4/8] fix(file): harden compress naming per review - Flatten zip entry names to a safe basename so untrusted fileInput names with .. or / cannot produce zip-slip entry paths (cursor) - Treat archiveName as a flat name landing at the workspace root instead of passing it through splitWorkspaceFilePath, which silently created folders for names with separators (greptile) - Add the upfront empty-input guard before any DB calls, matching the read and content operations (greptile) --- apps/sim/app/api/tools/file/manage/route.ts | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index c058dcb0b1..a3aa8670be 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -149,6 +149,19 @@ const stripExtension = (name: string): string => { return dot > 0 ? name.slice(0, dot) : name } +/** + * Reduce an arbitrary name to a safe, flat file name: takes the final path + * segment, drops directory and traversal components, and falls back when the + * result would be empty or a dot segment. Used for zip entry names and the + * compress archive name so untrusted input cannot introduce nested or + * zip-slip-style paths. + */ +const toFlatFileName = (name: string, fallback: string): string => { + const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim() + if (!leaf || leaf === '.' || leaf === '..') return fallback + return leaf +} + /** * Return a zip entry name unique within `usedNames`, appending a numeric suffix * before the extension on collision (e.g., "data.csv" -> "data (1).csv"). @@ -554,6 +567,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : extractFileIdsFromInput(fileInput) const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + const workspaceFiles = await Promise.all( selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) ) @@ -572,10 +589,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) .concat(selectedInputFiles) - if (userFiles.length === 0) { - return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) - } - const zip = new JSZip() const usedNames = new Set() let totalBytes = 0 @@ -598,7 +611,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } - zip.file(uniqueZipEntryName(userFile.name, usedNames), buffer) + zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer) } const zipBuffer = await zip.generateAsync({ @@ -608,14 +621,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const requestedName = typeof archiveName === 'string' ? archiveName.trim() : '' - const targetName = ensureZipExtension( - requestedName || (userFiles.length === 1 ? stripExtension(userFiles[0].name) : 'archive') - ) - const { folderSegments, leafName } = splitWorkspaceFilePath(targetName) + const baseName = requestedName + ? toFlatFileName(requestedName, 'archive') + : userFiles.length === 1 + ? stripExtension(toFlatFileName(userFiles[0].name, 'archive')) + : 'archive' + const leafName = ensureZipExtension(baseName) const folderId = await ensureWorkspaceFileFolderPath({ workspaceId, userId, - pathSegments: folderSegments, + pathSegments: [], }) const result = await uploadWorkspaceFile( workspaceId, From aae3d46f074d67d62f4a58beddeb798327395fc9 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 11:52:50 -0700 Subject: [PATCH 5/8] fix(file): make decompress extraction atomic and bound per-entry size - Read and validate every entry before writing any file, so hitting a size cap no longer leaves partially-extracted files in the workspace (cursor) - Enforce the per-entry cap on the materialized buffer in addition to the declared size, covering entries that omit an uncompressed size (cursor) - Pre-check declared sizes up front to reject standard zip bombs before materializing, and return 422 when no files could be extracted (cursor) --- apps/sim/app/api/tools/file/manage/route.ts | 81 ++++++++++++++------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index a3aa8670be..5933772a59 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -725,44 +725,74 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const folderIdCache = new Map() - const extractedFiles: UserFile[] = [] - let totalBytes = 0 + const entryTooLargeResponse = (name: string) => + NextResponse.json( + { + success: false, + error: `Archive entry "${name}" is too large to extract. Maximum is ${ + MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024) + } MB per file.`, + }, + { status: 413 } + ) + const totalTooLargeResponse = () => + NextResponse.json( + { + success: false, + error: `Archive expands to more than the ${ + MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024) + } MB extraction limit.`, + }, + { status: 413 } + ) + // Reject standard zip bombs up front using the declared uncompressed sizes, + // before materializing any entry into memory. + let declaredTotal = 0 for (const entry of entries) { const declaredSize = readEntryUncompressedSize(entry) - if (declaredSize !== undefined && declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) { - return NextResponse.json( - { - success: false, - error: `Archive entry "${entry.name}" is too large to extract. Maximum is ${ - MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024) - } MB per file.`, - }, - { status: 413 } - ) - } + if (declaredSize === undefined) continue + if (declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) + declaredTotal += declaredSize + if (declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() + } + // Read and validate every safe entry before writing anything, so a cap + // breach never leaves partially-extracted files behind in the workspace. + const pending: Array<{ segments: string[]; buffer: Buffer }> = [] + let skippedCount = 0 + let totalBytes = 0 + for (const entry of entries) { const segments = sanitizeArchiveEntryPath(entry.name) if (!segments) { + skippedCount += 1 logger.warn('Skipping unsafe archive entry', { name: entry.name }) continue } const buffer = await entry.async('nodebuffer') + // Enforce the per-entry cap on the materialized size too, covering + // entries that omit a declared uncompressed size. + if (buffer.length > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) totalBytes += buffer.length - if (totalBytes > MAX_DECOMPRESS_TOTAL_BYTES) { - return NextResponse.json( - { - success: false, - error: `Archive expands to more than the ${ - MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024) - } MB extraction limit.`, - }, - { status: 413 } - ) - } + if (totalBytes > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() + + pending.push({ segments, buffer }) + } + + if (pending.length === 0) { + return NextResponse.json( + { + success: false, + error: `No files could be extracted from "${archive.name}".`, + }, + { status: 422 } + ) + } + const folderIdCache = new Map() + const extractedFiles: UserFile[] = [] + for (const { segments, buffer } of pending) { const leafName = segments[segments.length - 1] const folderSegments = segments.slice(0, -1) const folderKey = folderSegments.join('/') @@ -792,6 +822,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileId: archive.id, name: archive.name, extractedCount: extractedFiles.length, + skippedCount, }) return NextResponse.json({ From 986c294fe6377e50de43baaad5e19b162df256a6 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 12:00:13 -0700 Subject: [PATCH 6/8] fix(file): exclude skipped entries from caps and reject multi-archive decompress - Resolve safe (sanitized) zip entries up front so unsafe/skipped entries no longer count toward the per-entry and total uncompressed-size caps (cursor) - Reject decompress input that resolves to more than one archive with a clear error instead of silently extracting only the first (cursor) --- apps/sim/app/api/tools/file/manage/route.ts | 26 +++++++++++++-------- apps/sim/blocks/blocks/file.ts | 15 ++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 5933772a59..7888a21314 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -746,10 +746,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) + // Resolve which entries are safe to extract first, so unsafe entries + // (skipped below) never count toward the size caps. + const safeEntries: Array<{ entry: JSZip.JSZipObject; segments: string[] }> = [] + let skippedCount = 0 + for (const entry of entries) { + const segments = sanitizeArchiveEntryPath(entry.name) + if (!segments) { + skippedCount += 1 + logger.warn('Skipping unsafe archive entry', { name: entry.name }) + continue + } + safeEntries.push({ entry, segments }) + } + // Reject standard zip bombs up front using the declared uncompressed sizes, // before materializing any entry into memory. let declaredTotal = 0 - for (const entry of entries) { + for (const { entry } of safeEntries) { const declaredSize = readEntryUncompressedSize(entry) if (declaredSize === undefined) continue if (declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) @@ -760,16 +774,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Read and validate every safe entry before writing anything, so a cap // breach never leaves partially-extracted files behind in the workspace. const pending: Array<{ segments: string[]; buffer: Buffer }> = [] - let skippedCount = 0 let totalBytes = 0 - for (const entry of entries) { - const segments = sanitizeArchiveEntryPath(entry.name) - if (!segments) { - skippedCount += 1 - logger.warn('Skipping unsafe archive entry', { name: entry.name }) - continue - } - + for (const { entry, segments } of safeEntries) { const buffer = await entry.async('nodebuffer') // Enforce the per-entry cap on the materialized size too, covering // entries that omit a declared uncompressed size. diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 3697b6de51..10ccb477f7 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1107,19 +1107,26 @@ export const FileV5Block: BlockConfig = { const fileIds = parseReadFileIds(decompressInput) if (fileIds) { + const ids = Array.isArray(fileIds) ? fileIds : [fileIds] + if (ids.length > 1) { + throw new Error('Decompress accepts a single .zip archive at a time') + } return { - fileId: Array.isArray(fileIds) ? fileIds[0] : fileIds, + fileId: ids[0], workspaceId: params._context?.workspaceId, } } - const normalized = normalizeFileInput(decompressInput, { single: true }) - if (!normalized) { + const normalized = normalizeFileInput(decompressInput) + if (!normalized || normalized.length === 0) { throw new Error('File is required for decompress') } + if (normalized.length > 1) { + throw new Error('Decompress accepts a single .zip archive at a time') + } return { - fileInput: normalized, + fileInput: normalized[0], workspaceId: params._context?.workspaceId, } } From 128a80af88fab9df2a44bf9703e6f43b51fffcfe Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 12:06:12 -0700 Subject: [PATCH 7/8] fix(file): enforce single-archive decompress at the API boundary The block already rejects multiple archives, but the manage route is the real boundary (callable directly and by the LLM tool) and still took the first of multiple resolved inputs. Add the empty-input and >1-archive guards in the route so extra archives are rejected with a clear error rather than silently ignored (cursor). --- apps/sim/app/api/tools/file/manage/route.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 7888a21314..1f19ed29f9 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -673,6 +673,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const selectedFileIds = fileId ? [fileId] : extractFileIdsFromInput(fileInput) const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + if (selectedFileIds.length + selectedInputFiles.length > 1) { + return NextResponse.json( + { success: false, error: 'Decompress accepts a single .zip archive at a time' }, + { status: 400 } + ) + } + const workspaceFiles = await Promise.all( selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) ) From cdf521b9f29b421de4b9b449cca838cbc3e3e39d Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 16 Jun 2026 12:13:27 -0700 Subject: [PATCH 8/8] docs(file): correct compress description and stale file-output references - Drop the misleading 'under provider upload limits' claim from the compress tool description (models cannot read zip archives) - Fix bestPractices to reference the 'files' output, not a non-existent 'file' - Remove the stale 'file' property from the compress test fixture so it matches the real API response (greptile) --- apps/sim/blocks/blocks/file.ts | 2 +- apps/sim/tools/file/compress.test.ts | 1 - apps/sim/tools/file/compress.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 10ccb477f7..541192bfe2 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -833,7 +833,7 @@ export const FileV5Block: BlockConfig = { - Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. - Use Write to create a new workspace file and Append to add content to an existing one. - - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "file"/"files" outputs. + - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output. - Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks. `, subBlocks: [ diff --git a/apps/sim/tools/file/compress.test.ts b/apps/sim/tools/file/compress.test.ts index c7f887cfa0..318b85bccf 100644 --- a/apps/sim/tools/file/compress.test.ts +++ b/apps/sim/tools/file/compress.test.ts @@ -52,7 +52,6 @@ describe('fileCompressTool', () => { name: archive.name, size: archive.size, url: archive.url, - file: archive, files: [archive], }, }) diff --git a/apps/sim/tools/file/compress.ts b/apps/sim/tools/file/compress.ts index a9c901a4fa..3dc28ef58d 100644 --- a/apps/sim/tools/file/compress.ts +++ b/apps/sim/tools/file/compress.ts @@ -12,7 +12,7 @@ export const fileCompressTool: ToolConfig = { id: 'file_compress', name: 'File Compress', description: - 'Compress one or more workspace files into a single .zip archive stored in the workspace. Useful for getting large attachments under provider upload limits.', + 'Compress one or more workspace files into a single .zip archive stored in the workspace, for bundling files to download, transfer, or store.', version: '1.0.0', params: {