From de7994dd92b9ebaaa06d0e99e41676e23397f273 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 30 Jul 2026 21:49:06 -0700 Subject: [PATCH] fix: scan historical legacy package paths --- convex/lib/skillZip.test.ts | 27 ++++++++++++++++++++ convex/lib/skillZip.ts | 33 +++++++++++++++++++++++++ convex/packageInspectorHttp.test.ts | 38 +++++++++++++++++++++++++++++ convex/packageInspectorHttp.ts | 4 +-- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/convex/lib/skillZip.test.ts b/convex/lib/skillZip.test.ts index 5c1d19b0fc..398e27bf83 100644 --- a/convex/lib/skillZip.test.ts +++ b/convex/lib/skillZip.test.ts @@ -4,6 +4,7 @@ import { unzipSync } from "fflate"; import { describe, expect, it } from "vitest"; import { buildDeterministicPackageZip, + buildLegacyPackageScanZip, buildDeterministicZip, buildSkillMeta, type SkillZipMeta, @@ -183,4 +184,30 @@ describe("skillZip", () => { ).toThrow("unsafe package path"); }); }); + + describe("buildLegacyPackageScanZip", () => { + it("keeps historical Linux-safe names that modern package publication rejects", () => { + const zip = buildLegacyPackageScanZip([ + { path: "s2-os-core:requirements.txt", bytes: new TextEncoder().encode("legacy") }, + { + path: "docs/Standard\u00e2\u0080\u0094_Unit.md", + bytes: new TextEncoder().encode("legacy"), + }, + ]); + + expect(Object.keys(unzipSync(zip)).sort()).toEqual([ + "package/docs/Standard\u00e2\u0080\u0094_Unit.md", + "package/s2-os-core:requirements.txt", + ]); + }); + + it.each(["../escape", "dir/../escape", "/absolute", "dir\\escape", "dir//escape"])( + "still rejects archive traversal path %s", + (path) => { + expect(() => + buildLegacyPackageScanZip([{ path, bytes: new TextEncoder().encode("unsafe") }]), + ).toThrow("unsafe legacy scan path"); + }, + ); + }); }); diff --git a/convex/lib/skillZip.ts b/convex/lib/skillZip.ts index 386f80b5f8..c4b06bcd69 100644 --- a/convex/lib/skillZip.ts +++ b/convex/lib/skillZip.ts @@ -81,6 +81,39 @@ export function buildDeterministicPackageZip(entries: ZipEntry[]) { `Package contains file/ancestor path collision: ${hierarchyCollision.ancestor} and ${hierarchyCollision.descendant}`, ); } + return buildPackageZip(entries); +} + +/** + * Reconstruct a historical package only for the protected Linux scan worker. + * Legacy rows can predate portable filename validation, so this keeps their + * names while retaining the archive traversal and hierarchy protections. + */ +export function buildLegacyPackageScanZip(entries: ZipEntry[]) { + const unsafeEntry = entries.find((entry) => !isSafeLegacyScanPath(entry.path)); + if (unsafeEntry) { + throw new Error(`Package contains unsafe legacy scan path: ${unsafeEntry.path}`); + } + const hierarchyCollision = findClawPackagePathHierarchyCollision( + entries.map((entry) => entry.path), + ); + if (hierarchyCollision) { + throw new Error( + `Package contains file/ancestor path collision: ${hierarchyCollision.ancestor} and ${hierarchyCollision.descendant}`, + ); + } + return buildPackageZip(entries); +} + +function isSafeLegacyScanPath(value: string) { + if (!value || value.length > 500 || value !== value.trim() || value.startsWith("/")) { + return false; + } + if (value.includes("\\") || value.includes("\0")) return false; + return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +function buildPackageZip(entries: ZipEntry[]) { const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path)); const zipData: ZipInput = {}; diff --git a/convex/packageInspectorHttp.test.ts b/convex/packageInspectorHttp.test.ts index 841875cc6b..f9705ebeac 100644 --- a/convex/packageInspectorHttp.test.ts +++ b/convex/packageInspectorHttp.test.ts @@ -1,9 +1,11 @@ /* @vitest-environment node */ +import { unzipSync } from "fflate"; import { afterEach, describe, expect, it, vi } from "vitest"; import { absolutePackageArtifactUrl, packageInspectorAcknowledgeHttp, + packageInspectorArtifactHttp, packageInspectorClaimHttp, packageInspectorResultsHttp, } from "./packageInspectorHttp"; @@ -19,6 +21,8 @@ const packageInspectorClaimHttpHandler = (packageInspectorClaimHttp as unknown a const packageInspectorAcknowledgeHttpHandler = ( packageInspectorAcknowledgeHttp as unknown as HttpHandler )._handler; +const packageInspectorArtifactHttpHandler = (packageInspectorArtifactHttp as unknown as HttpHandler) + ._handler; afterEach(() => { vi.unstubAllEnvs(); @@ -33,6 +37,40 @@ describe("package inspector HTTP helpers", () => { ); }); + it("projects historical legacy filenames for the protected Linux scan worker", async () => { + vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token"); + const response = await packageInspectorArtifactHttpHandler( + { + runQuery: vi.fn().mockResolvedValue({ + packageName: "s2-space-agent-os", + version: "2.0.0", + artifactKind: "legacy-zip", + files: [ + { path: "s2-os-core:requirements.txt", storageId: "storage:requirements" }, + { + path: "docs/blueprints/Standard\u00e2\u0080\u0094_Unit_Habitat_Swarm.md", + storageId: "storage:blueprint", + }, + ], + }), + storage: { + get: vi.fn(async (storageId: string) => new Blob([storageId])), + }, + }, + new Request( + "https://example.com/api/v1/package-inspector/artifact?releaseId=packageReleases:s2", + { headers: { Authorization: "Bearer worker-token" } }, + ), + ); + + expect(response.status).toBe(200); + const entries = unzipSync(new Uint8Array(await response.arrayBuffer())); + expect(Object.keys(entries).sort()).toEqual([ + "package/docs/blueprints/Standard\u00e2\u0080\u0094_Unit_Habitat_Swarm.md", + "package/s2-os-core:requirements.txt", + ]); + }); + it("keeps owner notifications off when nightly results omit the opt-in", async () => { vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token"); const runAction = vi.fn(); diff --git a/convex/packageInspectorHttp.ts b/convex/packageInspectorHttp.ts index 94b5f85cb2..d9e1e3761b 100644 --- a/convex/packageInspectorHttp.ts +++ b/convex/packageInspectorHttp.ts @@ -3,7 +3,7 @@ import type { Id } from "./_generated/dataModel"; import { httpAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; import { json, parseJsonPayload, text } from "./httpApiV1/shared"; -import { buildDeterministicPackageZip } from "./lib/skillZip"; +import { buildLegacyPackageScanZip } from "./lib/skillZip"; const internalRefs = internal as unknown as { packages: { @@ -177,7 +177,7 @@ export const packageInspectorArtifactHttp = httpAction(async (ctx, request) => { bytes: new Uint8Array(await blob.arrayBuffer()), }); } - const zip = buildDeterministicPackageZip(entries); + const zip = buildLegacyPackageScanZip(entries); return new Response(new Blob([zip], { type: "application/zip" }), { status: 200, headers: {