Skip to content
107 changes: 10 additions & 97 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Buffer, isUtf8 } from 'buffer'
import type { Readable } from 'stream'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
Expand All @@ -21,6 +20,16 @@ import {
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import {
inflateEntryWithinCaps,
isSymlinkEntry,
MAX_ARCHIVE_BYTES as MAX_DECOMPRESS_ARCHIVE_BYTES,
MAX_ARCHIVE_ENTRIES as MAX_DECOMPRESS_ENTRIES,
MAX_ARCHIVE_ENTRY_BYTES as MAX_DECOMPRESS_ENTRY_BYTES,
MAX_ARCHIVE_TOTAL_BYTES as MAX_DECOMPRESS_TOTAL_BYTES,
readEntryUncompressedSize,
sanitizeArchiveEntryPath,
} from '@/lib/uploads/archive'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
Expand Down Expand Up @@ -199,102 +208,6 @@ const uniqueZipEntryName = (name: string, usedNames: Set<string>): 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. This
* value comes straight from the (attacker-controlled) ZIP metadata, so it is only
* usable as a cheap fast-reject for honestly-declared archives — never as the
* authoritative cap. {@link inflateEntryWithinCaps} enforces the real limit on the
* inflated byte stream.
*/
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
}

type InflateResult = { ok: true; buffer: Buffer } | { ok: false; reason: 'entry' | 'total' }

/**
* Inflate a single zip entry through a streaming counting sink, tearing the
* stream down the moment cumulative output would exceed the per-entry cap or the
* remaining total budget. The declared uncompressed size in the ZIP header is
* attacker-controlled and is NOT trusted here: a forged-small or absent size
* cannot cause the full (potentially gigabyte-scale) entry to be materialized in
* memory, because enforcement happens on the actual inflated bytes as they
* arrive. Peak memory is bounded by the cap plus one DEFLATE chunk.
*/
const inflateEntryWithinCaps = (
entry: JSZip.JSZipObject,
remainingTotalBudget: number
): Promise<InflateResult> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
let settled = false
const stream = entry.nodeStream() as Readable

const settle = (result: InflateResult) => {
if (settled) return
settled = true
stream.destroy()
resolve(result)
}

stream.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > MAX_DECOMPRESS_ENTRY_BYTES) {
settle({ ok: false, reason: 'entry' })
return
}
if (size > remainingTotalBudget) {
settle({ ok: false, reason: 'total' })
return
}
chunks.push(chunk)
})
stream.on('end', () => settle({ ok: true, buffer: Buffer.concat(chunks, size) }))
stream.on('error', (error) => {
if (settled) return
settled = true
stream.destroy()
reject(error)
})
})

/** 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)

/**
Expand Down
66 changes: 57 additions & 9 deletions apps/sim/lib/copilot/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools'
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import {
type ChatUploadArchiveEntry,
listChatUploadArchiveEntries,
} from '@/lib/copilot/tools/handlers/upload-file-reader'
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
import { buildUserSkillTool } from '@/lib/mothership/skills'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isArchiveFileName } from '@/lib/uploads/utils/file-utils'
import { stripVersionSuffix } from '@/tools/utils'

const logger = createLogger('CopilotChatPayload')
/** Max archive entries listed inline in the upload context before truncating. */
const MAX_UPLOAD_TREE_ENTRIES = 50
const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000
const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES = 500

Expand Down Expand Up @@ -297,15 +304,56 @@ export async function buildCopilotRequestPayload(
} catch {
encodedUploadName = displayName
}
const lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
let lines: string[]
if (isArchiveFileName(displayName)) {
// An archive is presented as a virtual folder. Show a capped file tree
// up front so the agent sees the contents without a glob round-trip;
// degrade to a glob hint if the tree can't be built (never block send).
let entries: ChatUploadArchiveEntry[] | null = null
try {
entries = await listChatUploadArchiveEntries(displayName, chatId)
} catch (treeErr) {
logger.warn('Failed to build archive upload tree', {
filename,
chatId,
error: toError(treeErr).message,
})
}
if (entries && entries.length > 0) {
const shown = entries.slice(0, MAX_UPLOAD_TREE_ENTRIES)
const treeLines = shown.map((entry) => ` ${entry.path}`)
if (entries.length > MAX_UPLOAD_TREE_ENTRIES) {
treeLines.push(` … and ${entries.length - MAX_UPLOAD_TREE_ENTRIES} more`)
}
lines = [
`Archive "${displayName}" (${mediaType}, ${f.size} bytes) uploaded — ${
entries.length
} file${entries.length === 1 ? '' : 's'}:`,
...treeLines,
'',
`List entries with: glob("uploads/${encodedUploadName}/**")`,
`Read an entry with: read("uploads/${encodedUploadName}/<path>")`,
`To save the archive permanently: materialize_file(fileName: "${displayName}")`,
]
} else {
lines = [
`Archive "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`List entries with: glob("uploads/${encodedUploadName}/**")`,
`Read an entry with: read("uploads/${encodedUploadName}/<path>")`,
`To save the archive permanently: materialize_file(fileName: "${displayName}")`,
]
}
} else {
lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
}
}
uploadContexts.push({
type: 'uploaded_file',
Expand Down
Loading
Loading