From 2f653a73c2b6bb0051047567514245ee238239fa Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:15:43 -0700 Subject: [PATCH] fix(skills): reject incomplete tar archive listings (#125406) --- src/skills/lifecycle/install-download.test.ts | 49 ++++++++++++++++++- src/skills/lifecycle/install-extract.ts | 16 +++--- .../lifecycle/install-tar-verbose.test.ts | 3 ++ src/skills/lifecycle/install-tar-verbose.ts | 4 +- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/skills/lifecycle/install-download.test.ts b/src/skills/lifecycle/install-download.test.ts index af9f5517b5f4..95318c8dc6f4 100644 --- a/src/skills/lifecycle/install-download.test.ts +++ b/src/skills/lifecycle/install-download.test.ts @@ -109,7 +109,9 @@ function createCancelableBody() { return { stream, wasCanceled: () => canceled }; } -function runCommandResult(params?: Partial>) { +function runCommandResult( + params?: Partial>, +) { return { code: 0, stdout: "", @@ -287,6 +289,51 @@ describe("installDownloadSpec extraction safety", () => { }); describe("installDownloadSpec extraction safety (tar.bz2)", () => { + it.each(["plain", "verbose"] as const)( + "rejects truncated %s tar listings before extraction", + async (truncatedListing) => { + const name = `tbz2-truncated-${truncatedListing}`; + const entry = buildEntry(name); + const targetDir = path.join(resolveSkillToolsRootDir(entry), "target"); + + mockArchiveResponse(new Uint8Array([1, 2, 3])); + runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => { + const cmd = (argv[0] ?? []) as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return runCommandResult({ + stdout: "package/hello.txt\n", + ...(truncatedListing === "plain" ? { stdoutTruncatedBytes: 1 } : {}), + }); + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return runCommandResult({ + stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n", + ...(truncatedListing === "verbose" ? { stdoutTruncatedBytes: 1 } : {}), + }); + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + throw new Error("should not extract"); + } + return runCommandResult(); + }); + + const result = await installDownloadSkill({ + name, + url: `https://example.invalid/${name}.tbz2`, + archive: "tar.bz2", + targetDir, + }); + + expect(result.ok).toBe(false); + expect(result.stderr).toContain("tar listing output was truncated"); + expect( + runCommandWithTimeoutMock.mock.calls.some( + (call) => (call[0] as string[])[0] === "tar" && (call[0] as string[])[1] === "xf", + ), + ).toBe(false); + }, + ); + it("handles tar.bz2 extraction safety edge-cases", async () => { for (const testCase of [ { diff --git a/src/skills/lifecycle/install-extract.ts b/src/skills/lifecycle/install-extract.ts index 38bc398fdc3a..c21e95001600 100644 --- a/src/skills/lifecycle/install-extract.ts +++ b/src/skills/lifecycle/install-extract.ts @@ -14,19 +14,21 @@ import { hasBinary } from "../loading/config.js"; import { parseTarVerboseMetadata } from "./install-tar-verbose.js"; type ArchiveExtractResult = { stdout: string; stderr: string; code: number | null }; +type TarListingResult = ArchiveExtractResult & { stdoutTruncatedBytes?: number }; type TarPreflightResult = { entries: string[]; metadata: ReturnType; }; function commandFailureResult( - result: { stdout: string; stderr: string; code: number | null }, + result: TarListingResult, fallbackStderr: string, ): ArchiveExtractResult { + const truncated = (result.stdoutTruncatedBytes ?? 0) > 0; return { stdout: result.stdout, - stderr: result.stderr || fallbackStderr, - code: result.code, + stderr: truncated ? "tar listing output was truncated; refusing to extract" : fallbackStderr, + code: truncated ? 1 : result.code, }; } @@ -49,16 +51,16 @@ async function readTarPreflight(params: { const listResult = await runCommandWithTimeout(["tar", "tf", params.archivePath], { timeoutMs: params.timeoutMs, }); - if (listResult.code !== 0) { - return commandFailureResult(listResult, "tar list failed"); + if (listResult.code !== 0 || listResult.stdoutTruncatedBytes) { + return commandFailureResult(listResult, listResult.stderr || "tar list failed"); } const entries = normalizeStringEntries(listResult.stdout.split("\n")); const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], { timeoutMs: params.timeoutMs, }); - if (verboseResult.code !== 0) { - return commandFailureResult(verboseResult, "tar verbose list failed"); + if (verboseResult.code !== 0 || verboseResult.stdoutTruncatedBytes) { + return commandFailureResult(verboseResult, verboseResult.stderr || "tar verbose list failed"); } const metadata = parseTarVerboseMetadata(verboseResult.stdout); if (metadata.length !== entries.length) { diff --git a/src/skills/lifecycle/install-tar-verbose.test.ts b/src/skills/lifecycle/install-tar-verbose.test.ts index dd94fe11652b..1c9f80a76008 100644 --- a/src/skills/lifecycle/install-tar-verbose.test.ts +++ b/src/skills/lifecycle/install-tar-verbose.test.ts @@ -19,5 +19,8 @@ describe("parseTarVerboseMetadata", () => { expect(() => parseTarVerboseMetadata("-rw-r--r-- user/group 9007199254740993 2026-05-28 00:00 SKILL.md"), ).toThrow(/unable to parse tar entry size/u); + expect(() => + parseTarVerboseMetadata("?rw-r--r-- user/group 1 2026-05-28 00:00 SKILL.md"), + ).toThrow(/unable to parse tar entry type/u); }); }); diff --git a/src/skills/lifecycle/install-tar-verbose.ts b/src/skills/lifecycle/install-tar-verbose.ts index 94171843d840..0522c1c86257 100644 --- a/src/skills/lifecycle/install-tar-verbose.ts +++ b/src/skills/lifecycle/install-tar-verbose.ts @@ -34,8 +34,10 @@ function mapTarVerboseTypeChar(typeChar: string): string { return "Socket"; case "d": return "Directory"; - default: + case "-": return "File"; + default: + throw new Error(`unable to parse tar entry type: ${typeChar}`); } }