Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions convex/lib/skillZip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { unzipSync } from "fflate";
import { describe, expect, it } from "vitest";
import {
buildDeterministicPackageZip,
buildLegacyPackageScanZip,
buildDeterministicZip,
buildSkillMeta,
type SkillZipMeta,
Expand Down Expand Up @@ -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");
},
);
});
});
33 changes: 33 additions & 0 deletions convex/lib/skillZip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down
38 changes: 38 additions & 0 deletions convex/packageInspectorHttp.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions convex/packageInspectorHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Loading