From b360acd96871974b68f2b1447e6a3776616c0cd3 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Sat, 24 Oct 2026 17:45:11 +0300 Subject: [PATCH] Cap what a repository archive unpacks to before it reaches tmpfs The scan capped the compressed download at 100 MB to bound what it materializes on the function's /tmp, but the tar it wrote and the tree it extracted were unbounded. gzip turns a few hundred KB of zeros into hundreds of MB, so a repository with large low-entropy files passed the download cap and filled the shared 500 MB tmpfs, and the ENOSPC counted as retryable, so the queue redelivered the same scan up to five times. The download is now decompressed in the pipeline under a second budget of 100 MB, and the file written to disk is the plain tar, so extraction cannot grow past it. Exceeding either budget is an UnscannableRepoError, which already maps to a suspicious verdict for manual review instead of a retry. tar 7.5.15, the version in bun.lock, has no decompression guard of its own; the ratio guard in later 7.5.x releases is relative and lets a 2 MB download unpack to 300 MB at 144:1, so an absolute cap is still needed. --- apps/cursor/src/lib/plugins/scan.ts | 54 ++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/cursor/src/lib/plugins/scan.ts b/apps/cursor/src/lib/plugins/scan.ts index 7e9835d6..64d8837c 100644 --- a/apps/cursor/src/lib/plugins/scan.ts +++ b/apps/cursor/src/lib/plugins/scan.ts @@ -19,6 +19,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { createGunzip } from "node:zlib"; import { Agent, CursorAgentError, type RunResult } from "@cursor/sdk"; import { x as extractTar } from "tar"; import { z } from "zod"; @@ -296,7 +297,13 @@ async function applyBlockedShortCircuit(pluginId: string) { // Cap how much of the repo archive we materialize on the function's tmpfs. // Plugins are typically rules/.md/.json — kilobytes — but we share /tmp with // other step executions and have a hard ~500MB limit on Vercel. +// +// Both the compressed download and what it unpacks to are capped. gzip turns +// a few hundred KB of zeros into hundreds of MB, so bounding the download +// alone leaves the tar on disk, and the extracted tree, unbounded. The tar is +// the larger of the two, so the peak is at most the sum of these caps. const REPO_ARCHIVE_MAX_BYTES = 100 * 1024 * 1024; +const REPO_UNPACKED_MAX_BYTES = 100 * 1024 * 1024; // The Vercel function sandbox only guarantees a writable /tmp; os.homedir() // points at a path that may not exist or be read-only (`/home/sbx_user...`). @@ -331,23 +338,29 @@ async function redirectSdkStateToTmp(): Promise { return stateRoot; } -function archiveSizeGuard(tag: string) { - let downloaded = 0; +/** + * Pass-through that fails the pipeline with `UnscannableRepoError` once more + * than `limit` bytes have gone through it, so the download or the + * decompression stops there instead of filling tmpfs. + */ +function byteBudget( + tag: string, + label: string, + limit: number, + exceeded: (limit: number) => string, +) { + let seen = 0; return new Transform({ transform(chunk: Buffer, _encoding, callback) { - downloaded += chunk.length; - if (downloaded > REPO_ARCHIVE_MAX_BYTES) { - callback( - new UnscannableRepoError( - `Repository archive exceeded the ${REPO_ARCHIVE_MAX_BYTES}-byte scan limit.`, - ), - ); + seen += chunk.length; + if (seen > limit) { + callback(new UnscannableRepoError(exceeded(limit))); return; } callback(null, chunk); }, final(callback) { - logInfo(tag, "GitHub archive download complete", { downloaded }); + logInfo(tag, `GitHub archive ${label} complete`, { bytes: seen }); callback(); }, }); @@ -360,7 +373,9 @@ async function cloneRepo( ): Promise<{ cwd: string; cleanup: () => Promise }> { const root = await mkdtemp(path.join(tmpdir(), "plugin-scan-")); const cwd = path.join(root, "repo"); - const archivePath = path.join(root, "repo.tar.gz"); + // Stored already decompressed, so the file on disk is bounded by + // REPO_UNPACKED_MAX_BYTES and extraction cannot grow beyond it. + const archivePath = path.join(root, "repo.tar"); const cleanup = () => rm(root, { recursive: true, force: true }).catch(() => {}); const startedAt = Date.now(); @@ -403,7 +418,22 @@ async function cloneRepo( await pipeline( Readable.fromWeb(response.body as never), - archiveSizeGuard(tag), + byteBudget( + tag, + "download", + REPO_ARCHIVE_MAX_BYTES, + (limit) => `Repository archive exceeded the ${limit}-byte scan limit.`, + ), + // GitHub tarballs are gzip. Decompressing here, under a second budget, + // stops a small archive that unpacks to gigabytes before it reaches disk. + createGunzip(), + byteBudget( + tag, + "decompression", + REPO_UNPACKED_MAX_BYTES, + (limit) => + `Repository archive unpacks to more than the ${limit}-byte scan limit.`, + ), createWriteStream(archivePath), );