feat: run full ClawScan for GitHub skills

Adds full ClawScan execution for GitHub-backed skills and updates the static audit ignore list for newly reported DOMPurify advisories.
This commit is contained in:
Patrick Erichsen
2026-06-15 17:36:41 -07:00
committed by GitHub
parent 89bb8fe938
commit 76638de7ad
33 changed files with 3169 additions and 88 deletions
+4
View File
@@ -63,6 +63,7 @@ import type * as lib_githubAuth from "../lib/githubAuth.js";
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
import type * as lib_githubImport from "../lib/githubImport.js";
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
import type * as lib_githubSkillScans from "../lib/githubSkillScans.js";
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
import type * as lib_globalStats from "../lib/globalStats.js";
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
@@ -103,6 +104,7 @@ import type * as lib_skillIcon from "../lib/skillIcon.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillQuality from "../lib/skillQuality.js";
import type * as lib_skillSafety from "../lib/skillSafety.js";
import type * as lib_skillScanRequestFiles from "../lib/skillScanRequestFiles.js";
import type * as lib_skillSearchDigest from "../lib/skillSearchDigest.js";
import type * as lib_skillSlugValidator from "../lib/skillSlugValidator.js";
import type * as lib_skillStats from "../lib/skillStats.js";
@@ -208,6 +210,7 @@ declare const fullApi: ApiFromModules<{
"lib/githubIdentity": typeof lib_githubIdentity;
"lib/githubImport": typeof lib_githubImport;
"lib/githubProfileSync": typeof lib_githubProfileSync;
"lib/githubSkillScans": typeof lib_githubSkillScans;
"lib/githubSkillSync": typeof lib_githubSkillSync;
"lib/globalStats": typeof lib_globalStats;
"lib/httpHeaders": typeof lib_httpHeaders;
@@ -248,6 +251,7 @@ declare const fullApi: ApiFromModules<{
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillQuality": typeof lib_skillQuality;
"lib/skillSafety": typeof lib_skillSafety;
"lib/skillScanRequestFiles": typeof lib_skillScanRequestFiles;
"lib/skillSearchDigest": typeof lib_skillSearchDigest;
"lib/skillSlugValidator": typeof lib_skillSlugValidator;
"lib/skillStats": typeof lib_skillStats;
+11
View File
@@ -69,4 +69,15 @@ describe("crons", () => {
{},
);
});
it("prunes expired skill scan requests in bounded continuation batches", async () => {
await import("./crons");
expect(mocks.interval).toHaveBeenCalledWith(
"skill-scan-request-prune",
{ hours: 6 },
expect.anything(),
{ batchSize: 10 },
);
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ crons.interval(
"skill-scan-request-prune",
{ hours: 6 },
internal.securityScan.pruneExpiredSkillScanRequestsInternal,
{ batchSize: 250 },
{ batchSize: 10 },
);
crons.interval(
+2
View File
@@ -5,6 +5,7 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalMutation as rawInternalMutation } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS, generateEmbedding } from "./lib/embeddings";
import { deleteGitHubSkillScansForSkill } from "./lib/githubSkillScans";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import {
@@ -1340,6 +1341,7 @@ async function deleteSkillAndVersions(ctx: MutationCtx, skillId: Id<"skills">) {
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
.collect();
for (const version of versions) await ctx.db.delete(version._id);
await deleteGitHubSkillScansForSkill(ctx, skillId);
await deleteSkillEmbeddingsForSkill(ctx, skillId);
await deleteSkillBadgesForSkill(ctx, skillId);
await ctx.db.delete(skillId);
+78 -2
View File
@@ -15,7 +15,8 @@ vi.mock("./lib/publishers", async () => {
const { requireUser } = await import("./lib/access");
const { requirePublisherRole } = await import("./lib/publishers");
const { deleteForPublisherHandler } = await import("./githubSkillSources");
const { cleanupDeletedSourceScansHandler, deleteForPublisherHandler } =
await import("./githubSkillSources");
const { buildSkillInstallResolution } = await import("./lib/installResolver");
type Row = Record<string, unknown> & { _id: string };
@@ -74,6 +75,7 @@ function createDb(initial: Record<string, Row[]> = {}) {
const matched = () => list(table).filter((row) => matches(row, constraints));
return {
collect: async () => matched(),
take: async (limit: number) => matched().slice(0, limit),
unique: async () => matched()[0] ?? null,
};
},
@@ -107,6 +109,20 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
githubSourceId: "githubSkillSources:matt",
},
],
githubSkillScans: [
{
_id: "githubSkillScans:matt",
skillId: "skills:github",
githubSourceId: "githubSkillSources:matt",
contentHash: "hash-source-backed",
},
{
_id: "githubSkillScans:other",
skillId: "skills:other-source",
githubSourceId: "githubSkillSources:other",
contentHash: "hash-other-source",
},
],
skills: [
{
_id: "skills:github",
@@ -163,9 +179,10 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
},
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
await expect(
deleteForPublisherHandler({ db } as never, {
deleteForPublisherHandler({ db, scheduler } as never, {
ownerPublisherId: "publishers:openclaw" as never,
sourceId: "githubSkillSources:matt" as never,
now: 123,
@@ -182,6 +199,10 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
);
expect(tables.githubSkillSources).toHaveLength(0);
expect(tables.githubSkillContents).toHaveLength(0);
expect(tables.githubSkillScans).toHaveLength(2);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
sourceId: "githubSkillSources:matt",
});
const deletedSkill = tables.skills.find((skill) => skill._id === "skills:github");
expect(deletedSkill).toMatchObject({
softDeletedAt: 123,
@@ -217,6 +238,61 @@ describe("githubSkillSources.deleteForPublisherHandler", () => {
});
});
it("cleans deleted-source scan history in bounded batches", async () => {
const { db, tables } = createDb({
githubSkillScans: [
{
_id: "githubSkillScans:matt",
githubSourceId: "githubSkillSources:matt",
skillScanRequestId: "skillScanRequests:matt",
},
{
_id: "githubSkillScans:other",
githubSourceId: "githubSkillSources:other",
},
],
securityScanJobs: [
{
_id: "securityScanJobs:matt",
targetKind: "skillScanRequest",
status: "queued",
},
],
skillScanRequests: [
{
_id: "skillScanRequests:matt",
sourceKind: "github",
status: "queued",
securityScanJobId: "securityScanJobs:matt",
githubSkillScanId: "githubSkillScans:matt",
expiresAt: Number.MAX_SAFE_INTEGER,
},
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
await expect(
cleanupDeletedSourceScansHandler({ db, scheduler } as never, {
sourceId: "githubSkillSources:matt" as never,
}),
).resolves.toEqual({ ok: true, deleted: 1, done: true });
expect(tables.githubSkillScans).toEqual([
expect.objectContaining({ _id: "githubSkillScans:other" }),
]);
expect(tables.securityScanJobs).toEqual([]);
expect(tables.skillScanRequests).toEqual([
expect.objectContaining({
_id: "skillScanRequests:matt",
status: "failed",
}),
]);
expect(tables.skillScanRequests?.[0]).not.toHaveProperty("githubSkillScanId");
expect(tables.skillScanRequests?.[0]).not.toHaveProperty("securityScanJobId");
expect(tables.skillScanRequests?.[0]?.expiresAt).toBeLessThan(Number.MAX_SAFE_INTEGER);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), { batchSize: 10 });
});
it("rejects deleting a source from another publisher", async () => {
const { db } = createDb({
githubSkillSources: [
+38 -1
View File
@@ -1,13 +1,17 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { internalQuery, mutation, query } from "./functions";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { requireUser } from "./lib/access";
import { deleteGitHubSkillScansForSource } from "./lib/githubSkillScans";
import { adjustGlobalPublicSkillsCount, getPublicSkillVisibilityDelta } from "./lib/globalStats";
import { isOfficialPublisher } from "./lib/officialPublishers";
import { isPublisherActive, isPublisherRoleAllowed, requirePublisherRole } from "./lib/publishers";
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
const GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE = 25;
type PublicGitHubSkillSource = Pick<
Doc<"githubSkillSources">,
| "_id"
@@ -161,6 +165,9 @@ export async function deleteForPublisherHandler(
for (const content of contents) {
await ctx.db.delete(content._id);
}
await ctx.scheduler.runAfter(0, internal.githubSkillSources.cleanupDeletedSourceScansInternal, {
sourceId: args.sourceId,
});
const skills = await ctx.db
.query("skills")
@@ -197,6 +204,36 @@ export async function deleteForPublisherHandler(
return { ok: true as const, deletedSkills };
}
export async function cleanupDeletedSourceScansHandler(
ctx: MutationCtx,
args: { sourceId: Id<"githubSkillSources"> },
) {
const deleted = await deleteGitHubSkillScansForSource(
ctx,
args.sourceId,
GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE,
);
const done = deleted < GITHUB_SKILL_SCAN_CLEANUP_BATCH_SIZE;
if (deleted > 0) {
await ctx.scheduler.runAfter(0, internal.securityScan.pruneExpiredSkillScanRequestsInternal, {
batchSize: 10,
});
}
if (!done) {
await ctx.scheduler.runAfter(
0,
internal.githubSkillSources.cleanupDeletedSourceScansInternal,
args,
);
}
return { ok: true as const, deleted, done };
}
export const cleanupDeletedSourceScansInternal = internalMutation({
args: { sourceId: v.id("githubSkillSources") },
handler: cleanupDeletedSourceScansHandler,
});
export const deleteForPublisher: ReturnType<typeof mutation> = mutation({
args: {
ownerPublisherId: v.id("publishers"),
+62 -2
View File
@@ -8,6 +8,11 @@ import {
verifyGitHubSkillHandler,
} from "./githubSkillSync";
import { buildSkillInstallResolution } from "./lib/installResolver";
import {
appendGitHubSkillScanRequestFilesInternal,
finalizeGitHubSkillScanRequestInternal,
prepareGitHubSkillScanRequestInternal,
} from "./securityScan";
type Row = Record<string, unknown> & { _id: string };
@@ -35,10 +40,12 @@ function createDb(initial: Record<string, Row[]> = {}) {
};
const db = {
get: async (id: string) => {
get: async (idOrTable: string, maybeId?: string) => {
const id = maybeId ?? idOrTable;
const table = id.split(":")[0] ?? "";
return list(table).find((row) => row._id === id) ?? null;
},
normalizeId: (table: string, id: string) => (id.startsWith(`${table}:`) ? id : null),
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
@@ -58,6 +65,18 @@ function createDb(initial: Record<string, Row[]> = {}) {
else row[key] = value;
}
},
replace: async (id: string, doc: Record<string, unknown>) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((candidate) => candidate._id === id);
if (index >= 0) rows[index] = { _id: id, ...doc };
},
delete: async (id: string) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((candidate) => candidate._id === id);
if (index >= 0) rows.splice(index, 1);
},
query: (table: string) => ({
withIndex: (_indexName: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
@@ -65,6 +84,7 @@ function createDb(initial: Record<string, Row[]> = {}) {
const matched = () => list(table).filter((row) => matches(row, constraints));
return {
collect: async () => matched(),
take: async (limit: number) => matched().slice(0, limit),
unique: async () => matched()[0] ?? null,
};
},
@@ -114,6 +134,7 @@ describe("GitHub-backed skills live canary", () => {
],
});
const scheduler = { runAfter: async () => undefined };
let storedFile = 0;
let now = Date.now();
const actionCtx = {
runQuery: async (_query: unknown, args: Record<string, unknown>) => {
@@ -165,6 +186,27 @@ describe("GitHub-backed skills live canary", () => {
} as never,
);
}
if ("requestId" in args && "files" in args) {
return await (
appendGitHubSkillScanRequestFilesInternal as unknown as {
_handler: (ctx: never, args: never) => unknown;
}
)._handler({ db } as never, args as never);
}
if ("requestId" in args) {
return await (
finalizeGitHubSkillScanRequestInternal as unknown as {
_handler: (ctx: never, args: never) => unknown;
}
)._handler({ db } as never, args as never);
}
if ("staticScan" in args && "commit" in args && "contentHash" in args) {
return await (
prepareGitHubSkillScanRequestInternal as unknown as {
_handler: (ctx: never, args: never) => unknown;
}
)._handler({ db } as never, args as never);
}
if ("scanStatus" in args && "contentHash" in args) {
return await applyGitHubSkillVerificationResultHandler(
{ db } as never,
@@ -185,6 +227,13 @@ describe("GitHub-backed skills live canary", () => {
}
throw new Error(`unexpected live canary mutation args: ${JSON.stringify(args)}`);
},
storage: {
store: async () => {
storedFile += 1;
return `storage:live-${storedFile}`;
},
delete: async () => undefined,
},
auth: { getUserIdentity: async () => null },
};
@@ -227,7 +276,18 @@ describe("GitHub-backed skills live canary", () => {
fetch,
);
expect(verified).toMatchObject({ ok: true, scanStatus: "clean" });
expect(verified).toMatchObject({ ok: true, queued: true });
expect(storedFile).toBeGreaterThan(0);
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
ok: false,
reason: "github_verification_pending",
});
await applyGitHubSkillVerificationResultHandler({ db } as never, {
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
scanStatus: "clean",
now,
});
skill = getSkill(tables, skillSlug);
expect(skill).toMatchObject({
githubCurrentCommit: configured.commit,
+527 -23
View File
@@ -690,6 +690,7 @@ description: Install from a GitHub-backed source.
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
let storedFile = 0;
let now = 100;
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
const actionCtx = {
@@ -740,7 +741,8 @@ description: Install from a GitHub-backed source.
}
throw new Error(`unexpected lifecycle query args: ${JSON.stringify(args)}`);
}),
runMutation: vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
runMutation: vi.fn(async (mutation: unknown, args: Record<string, unknown>) => {
const mutationName = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
if ("snapshot" in args) {
return await applyGitHubSkillSourceSyncHandler(
{ db, scheduler } as never,
@@ -750,6 +752,26 @@ description: Install from a GitHub-backed source.
} as never,
);
}
if (mutationName === "securityScan:prepareGitHubSkillScanRequestInternal") {
return {
ok: true,
prepared: true,
scanId: "githubSkillScans:1",
requestId: "skillScanRequests:1",
};
}
if (mutationName === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
return { ok: true, appended: true };
}
if (mutationName === "securityScan:finalizeGitHubSkillScanRequestInternal") {
return {
ok: true,
queued: true,
scanId: "githubSkillScans:1",
requestId: "skillScanRequests:1",
jobId: "securityScanJobs:1",
};
}
if ("scanStatus" in args && "contentHash" in args) {
return await applyGitHubSkillVerificationResultHandler(
{ db } as never,
@@ -779,6 +801,13 @@ description: Install from a GitHub-backed source.
}
throw new Error(`unexpected lifecycle mutation args: ${JSON.stringify(args)}`);
}),
storage: {
store: vi.fn(async () => {
storedFile += 1;
return `storage:${storedFile}`;
}),
delete: vi.fn(),
},
auth: { getUserIdentity: vi.fn() },
};
@@ -849,14 +878,27 @@ description: Install from a GitHub-backed source.
});
now = 110;
await verifyGitHubSkillHandler(
actionCtx as never,
{
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
},
fakeGitHub.fetcher as never,
);
await expect(
verifyGitHubSkillHandler(
actionCtx as never,
{
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
},
fakeGitHub.fetcher as never,
),
).resolves.toMatchObject({ ok: true, queued: true });
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
ok: false,
reason: "github_verification_pending",
status: 423,
});
await applyGitHubSkillVerificationResultHandler({ db } as never, {
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
scanStatus: "clean",
now,
});
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
ok: true,
installKind: "github",
@@ -911,14 +953,27 @@ description: Install from a GitHub-backed source.
});
now = 210;
await verifyGitHubSkillHandler(
actionCtx as never,
{
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
},
fakeGitHub.fetcher as never,
);
await expect(
verifyGitHubSkillHandler(
actionCtx as never,
{
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
},
fakeGitHub.fetcher as never,
),
).resolves.toMatchObject({ ok: true, queued: true });
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
ok: false,
reason: "github_verification_pending",
status: 423,
});
await applyGitHubSkillVerificationResultHandler({ db } as never, {
skillId: skill._id as never,
contentHash: skill.githubCurrentContentHash as string,
scanStatus: "clean",
now,
});
expect(resolveInstallFromTables(tables, "demo-source")).toMatchObject({
ok: true,
installKind: "github",
@@ -1012,6 +1067,72 @@ describe("resolveOwnerUserIdForPublisherHandler", () => {
});
describe("applyGitHubSkillSourceSyncHandler", () => {
it("queues a full scan and blocks legacy clean GitHub skills without a durable result", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit: "2".repeat(40),
entries: {
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
},
});
const contentHash = snapshot.skills[0]?.contentHash;
const { db, tables } = createDb({
githubSkillSources: [
{
_id: "githubSkillSources:nvidia",
repo: "NVIDIA/skills",
ownerPublisherId: "publishers:nvidia",
createdAt: 1,
updatedAt: 1,
},
],
skills: [
{
_id: "skills:aiq-deploy",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
ownerUserId: "users:nvidia",
ownerPublisherId: "publishers:nvidia",
installKind: "github",
githubSourceId: "githubSkillSources:nvidia",
githubPath: "skills/aiq-deploy",
githubCurrentCommit: "1".repeat(40),
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
githubScanStatus: "clean",
moderationStatus: "active",
moderationVerdict: "clean",
tags: {},
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
createdAt: 1,
updatedAt: 1,
},
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
sourceId: "githubSkillSources:nvidia" as never,
repo: "NVIDIA/skills",
ownerUserId: "users:nvidia" as never,
ownerPublisherId: "publishers:nvidia" as never,
snapshot,
now: 123,
});
expect(tables.skills[0]).toMatchObject({
githubScanStatus: "pending",
moderationStatus: "active",
moderationReason: "pending.scan",
});
expect(tables.skills[0]).not.toHaveProperty("moderationVerdict");
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
skillId: "skills:aiq-deploy",
contentHash,
});
});
it("applies a trusted fetched snapshot without overwriting unrelated slug owners", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
@@ -1410,7 +1531,7 @@ describe("applyGitHubSkillSourceSyncHandler", () => {
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
},
});
const { db } = createDb({
const { db, tables } = createDb({
githubSkillSources: [
{
_id: "githubSkillSources:nvidia",
@@ -1441,12 +1562,167 @@ describe("applyGitHubSkillSourceSyncHandler", () => {
skillId: "skills:new-1",
contentHash: snapshot.skills[0]?.contentHash,
});
expect(Object.values(tables.githubSkillScans?.[0] ?? {})).not.toContain(undefined);
const scheduledFunction = scheduler.runAfter.mock.calls[0]?.[1];
expect(getFunctionName(scheduledFunction as Parameters<typeof getFunctionName>[0])).toBe(
"githubSkillSyncNode:verifyGitHubSkillInternal",
);
});
it("does not requeue heavy verification while the current content scan job is active", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit: "2".repeat(40),
entries: {
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
},
});
const contentHash = snapshot.skills[0]?.contentHash;
const { db } = createDb({
githubSkillSources: [
{
_id: "githubSkillSources:nvidia",
repo: "NVIDIA/skills",
ownerPublisherId: "publishers:nvidia",
createdAt: 1,
updatedAt: 1,
},
],
skills: [
{
_id: "skills:aiq-deploy",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
ownerUserId: "users:nvidia",
ownerPublisherId: "publishers:nvidia",
installKind: "github",
githubSourceId: "githubSkillSources:nvidia",
githubPath: "skills/aiq-deploy",
githubCurrentCommit: "1".repeat(40),
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
githubScanStatus: "pending",
tags: {},
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
createdAt: 1,
updatedAt: 1,
},
],
githubSkillScans: [
{
_id: "githubSkillScans:aiq-deploy",
skillId: "skills:aiq-deploy",
githubSourceId: "githubSkillSources:nvidia",
contentHash,
status: "pending",
skillScanRequestId: "skillScanRequests:aiq-deploy",
},
],
skillScanRequests: [
{
_id: "skillScanRequests:aiq-deploy",
securityScanJobId: "securityScanJobs:aiq-deploy",
},
],
securityScanJobs: [
{
_id: "securityScanJobs:aiq-deploy",
status: "queued",
},
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
sourceId: "githubSkillSources:nvidia" as never,
repo: "NVIDIA/skills",
ownerUserId: "users:nvidia" as never,
ownerPublisherId: "publishers:nvidia" as never,
snapshot,
now: 123,
});
expect(scheduler.runAfter).toHaveBeenCalledTimes(0);
});
it("does not requeue heavy verification while a recent verification action is pending", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit: "2".repeat(40),
entries: {
"skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
},
});
const contentHash = snapshot.skills[0]?.contentHash;
const { db } = createDb({
githubSkillSources: [
{
_id: "githubSkillSources:nvidia",
repo: "NVIDIA/skills",
ownerPublisherId: "publishers:nvidia",
createdAt: 1,
updatedAt: 1,
},
],
skills: [
{
_id: "skills:aiq-deploy",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
ownerUserId: "users:nvidia",
ownerPublisherId: "publishers:nvidia",
installKind: "github",
githubSourceId: "githubSkillSources:nvidia",
githubPath: "skills/aiq-deploy",
githubCurrentCommit: "1".repeat(40),
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
githubScanStatus: "pending",
tags: {},
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, versions: 0 },
createdAt: 1,
updatedAt: 1,
},
],
githubSkillScans: [
{
_id: "githubSkillScans:aiq-deploy",
skillId: "skills:aiq-deploy",
githubSourceId: "githubSkillSources:nvidia",
contentHash,
commit: "1".repeat(40),
path: "skills/aiq-deploy",
status: "pending",
skillScanRequestId: "skillScanRequests:aiq-deploy",
createdAt: 1,
updatedAt: 123,
},
],
skillScanRequests: [
{
_id: "skillScanRequests:aiq-deploy",
sourceKind: "github",
createdAt: 123,
updatedAt: 123,
},
],
});
const scheduler = { runAfter: vi.fn(async () => undefined) };
await applyGitHubSkillSourceSyncHandler({ db, scheduler } as never, {
sourceId: "githubSkillSources:nvidia" as never,
repo: "NVIDIA/skills",
ownerUserId: "users:nvidia" as never,
ownerPublisherId: "publishers:nvidia" as never,
snapshot,
now: 123,
});
expect(scheduler.runAfter).toHaveBeenCalledTimes(0);
});
it("refreshes cached GitHub content metadata when bytes are unchanged at a new commit", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
@@ -1677,6 +1953,9 @@ describe("verifyGitHubSkillHandler", () => {
const commit = "3".repeat(40);
const zip = zipSync({
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
"skills-main/skills/aiq-deploy/scripts/deploy.sh": new TextEncoder().encode(
"#!/bin/sh\necho deploy\n",
),
});
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
@@ -1687,7 +1966,40 @@ describe("verifyGitHubSkillHandler", () => {
const contentHash = snapshot.skills[0]?.contentHash;
if (!contentHash) throw new Error("missing fixture hash");
const runMutation = vi.fn(async () => ({ ok: true, promoted: false }));
const events: string[] = [];
let storedFile = 0;
const store = vi.fn(async () => {
events.push("store");
storedFile += 1;
return `storage:${storedFile}`;
});
const runMutation = vi.fn(async (mutation: unknown, _args: Record<string, unknown>) => {
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
events.push("prepare");
return {
ok: true,
prepared: true,
scanId: "githubSkillScans:1",
requestId: "skillScanRequests:1",
};
}
if (name === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
events.push("append");
return { ok: true, appended: true };
}
if (name === "securityScan:finalizeGitHubSkillScanRequestInternal") {
events.push("finalize");
return {
ok: true,
queued: true,
scanId: "githubSkillScans:1",
requestId: "skillScanRequests:1",
jobId: "securityScanJobs:1",
};
}
throw new Error(`unexpected mutation: ${name}`);
});
const ctx = {
runQuery: vi.fn(async () => ({
skill: {
@@ -1707,6 +2019,7 @@ describe("verifyGitHubSkillHandler", () => {
},
})),
runMutation,
storage: { store, delete: vi.fn() },
};
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url =
@@ -1728,15 +2041,206 @@ describe("verifyGitHubSkillHandler", () => {
fetcher as unknown as typeof fetch,
);
expect(result).toMatchObject({ ok: true, scanStatus: "clean" });
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect(result).toMatchObject({ ok: true, queued: true });
expect(store).toHaveBeenCalledTimes(2);
expect(runMutation).toHaveBeenCalledTimes(3);
expect(events).toEqual(["prepare", "store", "store", "append", "finalize"]);
const [prepareMutation, prepareArgs] = runMutation.mock.calls[0] ?? [];
expect(getFunctionName(prepareMutation as Parameters<typeof getFunctionName>[0])).toBe(
"securityScan:prepareGitHubSkillScanRequestInternal",
);
expect(prepareArgs).toEqual(
expect.objectContaining({
skillId: "skills:aiq-deploy",
contentHash,
scanStatus: "clean",
commit,
staticScan: expect.objectContaining({ status: "clean" }),
}),
);
expect(prepareArgs).not.toHaveProperty("files");
expect(Object.values(prepareArgs ?? {})).not.toContain(undefined);
const [appendMutation, appendArgs] = runMutation.mock.calls[1] ?? [];
expect(getFunctionName(appendMutation as Parameters<typeof getFunctionName>[0])).toBe(
"securityScan:appendGitHubSkillScanRequestFilesInternal",
);
expect(appendArgs).toEqual(
expect.objectContaining({
requestId: "skillScanRequests:1",
chunkIndex: 0,
files: expect.arrayContaining([
expect.objectContaining({ path: "SKILL.md" }),
expect.objectContaining({ path: "scripts/deploy.sh" }),
]),
}),
);
const [finalizeMutation, finalizeArgs] = runMutation.mock.calls[2] ?? [];
expect(getFunctionName(finalizeMutation as Parameters<typeof getFunctionName>[0])).toBe(
"securityScan:finalizeGitHubSkillScanRequestInternal",
);
expect(finalizeArgs).toEqual({ requestId: "skillScanRequests:1" });
});
it("does not store GitHub skill files when the durable content-hash scan can be reused", async () => {
const commit = "4".repeat(40);
const zip = zipSync({
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
});
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit,
entries: stripGitHubZipRoot(__test.unzipToEntries(zip)),
});
const contentHash = snapshot.skills[0]?.contentHash;
if (!contentHash) throw new Error("missing fixture hash");
const store = vi.fn();
const runMutation = vi.fn(async (mutation: unknown) => {
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
return {
ok: true,
reused: true,
scanId: "githubSkillScans:1",
scanStatus: "clean",
};
}
if (name === "githubSkillSync:applyGitHubSkillVerificationResultInternal") {
return { ok: true, promoted: true };
}
throw new Error(`unexpected mutation: ${name}`);
});
const ctx = {
runQuery: vi.fn(async () => ({
skill: {
_id: "skills:aiq-deploy",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
githubPath: "skills/aiq-deploy",
githubCurrentCommit: commit,
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
},
source: {
_id: "githubSkillSources:nvidia",
repo: "NVIDIA/skills",
defaultBranch: "main",
},
})),
runMutation,
storage: { store, delete: vi.fn() },
};
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.startsWith("https://api.github.com/")) {
return new Response(JSON.stringify({ sha: commit }), {
headers: { "content-type": "application/json" },
});
}
if (url.startsWith("https://codeload.github.com/")) {
return new Response(zip, { headers: { "content-length": String(zip.byteLength) } });
}
return new Response("not found", { status: 404 });
});
await expect(
verifyGitHubSkillHandler(
ctx as never,
{ skillId: "skills:aiq-deploy" as never, contentHash },
fetcher as unknown as typeof fetch,
),
).resolves.toMatchObject({ ok: true, reused: true, scanStatus: "clean" });
expect(store).not.toHaveBeenCalled();
});
it("deletes the newly stored boundary file when appending the previous chunk fails", async () => {
const commit = "5".repeat(40);
const zipEntries: Record<string, Uint8Array> = {
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
};
for (let index = 0; index < 100; index += 1) {
zipEntries[
`skills-main/skills/aiq-deploy/scripts/file-${String(index).padStart(3, "0")}.txt`
] = new TextEncoder().encode(`file ${index}\n`);
}
const zip = zipSync(zipEntries);
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
defaultBranch: "main",
commit,
entries: stripGitHubZipRoot(__test.unzipToEntries(zip)),
});
const contentHash = snapshot.skills[0]?.contentHash;
if (!contentHash) throw new Error("missing fixture hash");
let storedFile = 0;
const store = vi.fn(async () => {
storedFile += 1;
return `storage:${storedFile}`;
});
const deleteFile = vi.fn(async () => undefined);
const runMutation = vi.fn(async (mutation: unknown) => {
const name = getFunctionName(mutation as Parameters<typeof getFunctionName>[0]);
if (name === "securityScan:prepareGitHubSkillScanRequestInternal") {
return {
ok: true,
prepared: true,
scanId: "githubSkillScans:1",
requestId: "skillScanRequests:1",
};
}
if (name === "securityScan:appendGitHubSkillScanRequestFilesInternal") {
throw new Error("append failed");
}
throw new Error(`unexpected mutation: ${name}`);
});
const ctx = {
runQuery: vi.fn(async () => ({
skill: {
_id: "skills:aiq-deploy",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
githubPath: "skills/aiq-deploy",
githubCurrentCommit: commit,
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
},
source: {
_id: "githubSkillSources:nvidia",
repo: "NVIDIA/skills",
defaultBranch: "main",
},
})),
runMutation,
storage: { store, delete: deleteFile },
};
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.startsWith("https://api.github.com/")) {
return new Response(JSON.stringify({ sha: commit }), {
headers: { "content-type": "application/json" },
});
}
if (url.startsWith("https://codeload.github.com/")) {
return new Response(zip, { headers: { "content-length": String(zip.byteLength) } });
}
return new Response("not found", { status: 404 });
});
await expect(
verifyGitHubSkillHandler(
ctx as never,
{ skillId: "skills:aiq-deploy" as never, contentHash },
fetcher as unknown as typeof fetch,
),
).rejects.toThrow("append failed");
expect(store).toHaveBeenCalledTimes(101);
expect(deleteFile).toHaveBeenCalledTimes(101);
expect(deleteFile).toHaveBeenCalledWith("storage:101");
});
});
+194 -13
View File
@@ -28,10 +28,12 @@ import { Events, logErrorEvent, logEvent } from "./lib/observabilityEvents";
import { isOfficialPublisher } from "./lib/officialPublishers";
import { requirePublisherRole } from "./lib/publishers";
import { isMacJunkPath, isTextFile, parseFrontmatter } from "./lib/skills";
import { chunkSkillScanRequestFiles } from "./lib/skillScanRequestFiles";
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
import { assertValidSkillSlug } from "./lib/skillSlugValidator";
const DEFAULT_BRANCH = "main";
const GITHUB_SKILL_SCAN_ACTION_LEASE_MS = 15 * 60 * 1000;
const PUBLIC_REPO_ONLY_ERROR = "Enter a public GitHub repo.";
const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024;
const MAX_FILE_COUNT = 7_500;
@@ -129,6 +131,20 @@ type GitHubSkillVerificationTarget = {
source: Pick<Doc<"githubSkillSources">, "_id" | "repo" | "defaultBranch">;
};
type GitHubSkillVerificationResult = {
ok: true;
prepared?: true;
queued?: true;
reused?: true;
alreadyQueued?: true;
skipped?: string;
scanStatus?: GitHubSkillScanStatus;
scanId?: Id<"githubSkillScans">;
requestId?: Id<"skillScanRequests">;
jobId?: Id<"securityScanJobs">;
currentContentHash?: string;
};
type GitHubSkillContentTarget = {
skillId: Id<"skills">;
githubPath: string;
@@ -485,6 +501,7 @@ export async function applyGitHubSkillSourceSyncHandler(
skillId: skillPatch.skillId as Id<"skills">,
contentHash: discovered.contentHash,
scanStatus: skillPatch.patch.githubScanStatus,
now,
});
}
}
@@ -568,6 +585,7 @@ export async function applyGitHubSkillSourceSyncHandler(
skillId: existingBySlug._id,
contentHash: discovered.contentHash,
scanStatus: doc.githubScanStatus,
now,
});
}
await adjustGlobalPublicCountForSkillChange(
@@ -610,6 +628,7 @@ export async function applyGitHubSkillSourceSyncHandler(
skillId,
contentHash: discovered.contentHash,
scanStatus: doc.githubScanStatus,
now,
});
}
await adjustGlobalPublicCountForSkillChange(ctx, null, insertedSkill, now);
@@ -783,9 +802,70 @@ async function scheduleGitHubSkillVerification(
skillId: Id<"skills">;
contentHash: string;
scanStatus: unknown;
now: number;
},
) {
if (args.scanStatus !== "pending") return;
const scan = await ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) =>
q.eq("skillId", args.skillId).eq("contentHash", args.contentHash),
)
.unique();
if (args.scanStatus !== "pending") {
if (scan?.status !== "pending") {
if (scan) return;
await applyGitHubSkillVerificationResultHandler(ctx, {
skillId: args.skillId,
contentHash: args.contentHash,
scanStatus: "pending",
});
}
}
if (scan?.status === "pending" && scan.skillScanRequestId) {
const request = await ctx.db.get(scan.skillScanRequestId);
const job = request?.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null;
if (job?.status === "queued" || job?.status === "running") return;
if (request && request.updatedAt > args.now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS) return;
}
if (
scan?.status === "pending" &&
!scan.skillScanRequestId &&
scan.updatedAt > args.now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS
) {
return;
}
const skill = await ctx.db.get(args.skillId);
if (
!skill ||
skill.installKind !== "github" ||
!skill.githubSourceId ||
!skill.githubPath ||
skill.githubCurrentStatus !== "present" ||
!skill.githubCurrentCommit ||
skill.githubCurrentContentHash !== args.contentHash
) {
return;
}
const pendingScanInsert = {
githubSourceId: skill.githubSourceId,
commit: skill.githubCurrentCommit,
path: skill.githubPath,
status: "pending" as const,
updatedAt: args.now,
};
if (scan) {
await ctx.db.patch(scan._id, {
...pendingScanInsert,
skillScanRequestId: undefined,
});
} else {
await ctx.db.insert("githubSkillScans", {
skillId: skill._id,
contentHash: args.contentHash,
...pendingScanInsert,
createdAt: args.now,
});
}
await ctx.scheduler?.runAfter(0, internal.githubSkillSyncNode.verifyGitHubSkillInternal, {
skillId: args.skillId,
contentHash: args.contentHash,
@@ -860,12 +940,12 @@ export const applyGitHubSkillVerificationResultInternal = internalMutation({
export async function verifyGitHubSkillHandler(
ctx: ActionCtx,
args: { skillId: Id<"skills">; contentHash: string },
args: { skillId: Id<"skills">; contentHash: string; force?: boolean },
fetcher: typeof fetch = fetch,
) {
): Promise<GitHubSkillVerificationResult> {
const target = (await ctx.runQuery(
internal.githubSkillSync.getGitHubSkillVerificationTargetInternal,
args,
{ skillId: args.skillId, contentHash: args.contentHash },
)) as GitHubSkillVerificationTarget | null;
if (!target) return { ok: true as const, skipped: "stale-or-missing" as const };
@@ -895,16 +975,42 @@ export async function verifyGitHubSkillHandler(
fileContents: listGitHubSkillTextContents(entries, discovered.path),
});
await ctx.runMutation(internal.githubSkillSync.applyGitHubSkillVerificationResultInternal, {
skillId: target.skill._id,
contentHash: args.contentHash,
scanStatus: staticScan.status,
});
const prepared = (await ctx.runMutation(
internal.securityScan.prepareGitHubSkillScanRequestInternal,
{
skillId: target.skill._id,
contentHash: args.contentHash,
commit: target.skill.githubCurrentCommit,
...(args.force ? { force: true } : {}),
parsed: { frontmatter: parseFrontmatter(discovered.skillMarkdown) },
staticScan,
},
)) as GitHubSkillVerificationResult | undefined;
return {
ok: true as const,
scanStatus: staticScan.status,
};
if (!prepared?.prepared || !prepared.requestId) {
if (prepared?.reused && prepared.scanStatus) {
await ctx.runMutation(internal.githubSkillSync.applyGitHubSkillVerificationResultInternal, {
skillId: target.skill._id,
contentHash: args.contentHash,
scanStatus: prepared.scanStatus,
});
}
return prepared ?? { ok: true as const, skipped: "scan-request-not-created" as const };
}
let chunkIndex = 0;
await storeGitHubSkillScanFileChunks(ctx, entries, discovered.path, async (chunk) => {
await ctx.runMutation(internal.securityScan.appendGitHubSkillScanRequestFilesInternal, {
requestId: prepared.requestId as Id<"skillScanRequests">,
chunkIndex,
files: chunk,
});
chunkIndex += 1;
});
return (await ctx.runMutation(internal.securityScan.finalizeGitHubSkillScanRequestInternal, {
requestId: prepared.requestId,
...(args.force ? { force: true } : {}),
})) as typeof prepared;
}
export async function configurePublicGitHubSkillSourceHandler(
@@ -1254,6 +1360,81 @@ function listGitHubSkillFolderEntries(entries: Record<string, Uint8Array>, folde
.sort(([a], [b]) => a.localeCompare(b));
}
async function storeGitHubSkillScanFileChunks(
ctx: Pick<ActionCtx, "storage">,
entries: Record<string, Uint8Array>,
folderPath: string,
appendChunk: (
files: Array<{
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
}>,
) => Promise<void>,
) {
let pendingChunk: Array<{
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
}> = [];
try {
for (const [path, bytes] of listGitHubSkillFolderEntries(entries, folderPath)) {
const safeBytes = new Uint8Array(bytes);
const sha256 = await sha256Hex(safeBytes);
const storageId = await ctx.storage.store(new Blob([safeBytes]));
const file = {
path,
size: safeBytes.byteLength,
storageId,
sha256,
};
const nextPendingChunk = [...pendingChunk, file];
let candidateChunks;
try {
candidateChunks = chunkSkillScanRequestFiles(nextPendingChunk);
} catch (error) {
pendingChunk = nextPendingChunk;
throw error;
}
if (candidateChunks.length > 1) {
try {
await appendChunk(pendingChunk);
} catch (error) {
pendingChunk = nextPendingChunk;
throw error;
}
pendingChunk = [file];
} else {
pendingChunk = candidateChunks[0] ?? [];
}
}
if (pendingChunk.length > 0) {
await appendChunk(pendingChunk);
pendingChunk = [];
}
} catch (error) {
// Prior chunks are owned by the durable request; only this bounded chunk can be orphaned.
await deleteStoredGitHubSkillScanFiles(ctx, pendingChunk);
throw error;
}
}
async function deleteStoredGitHubSkillScanFiles(
ctx: Pick<ActionCtx, "storage">,
files: Array<{ storageId: Id<"_storage"> }>,
) {
await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId)));
}
async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function buildGitHubSourceImport(repo: string, defaultBranch: string): GitHubImportUrl {
const normalizedRepo = normalizeRepo(repo);
const [owner, repoName] = normalizedRepo.split("/") as [string, string];
+1
View File
@@ -34,6 +34,7 @@ export const verifyGitHubSkillInternal = internalAction({
args: {
skillId: v.id("skills"),
contentHash: v.string(),
force: v.optional(v.boolean()),
},
handler: verifyGitHubSkillHandler,
});
+56
View File
@@ -0,0 +1,56 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
async function deleteGitHubSkillScan(
ctx: Pick<MutationCtx, "db">,
scan: Doc<"githubSkillScans">,
now: number,
) {
if (scan.skillScanRequestId) {
const request = await ctx.db.get(scan.skillScanRequestId);
if (request?.securityScanJobId) {
const job = await ctx.db.get(request.securityScanJobId);
if (job?.targetKind === "skillScanRequest") await ctx.db.delete(job._id);
}
if (request) {
await ctx.db.patch(request._id, {
status: "failed",
securityScanJobId: undefined,
githubSkillScanId: undefined,
lastError: "GitHub-backed skill deleted",
completedAt: now,
expiresAt: now - 1,
updatedAt: now,
});
}
}
await ctx.db.delete(scan._id);
}
export async function deleteGitHubSkillScansForSkill(
ctx: Pick<MutationCtx, "db">,
skillId: Id<"skills">,
limit?: number,
) {
const now = Date.now();
const query = ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) => q.eq("skillId", skillId));
const scans = limit === undefined ? await query.collect() : await query.take(limit);
for (const scan of scans) await deleteGitHubSkillScan(ctx, scan, now);
return scans.length;
}
export async function deleteGitHubSkillScansForSource(
ctx: Pick<MutationCtx, "db">,
sourceId: Id<"githubSkillSources">,
limit: number,
) {
const now = Date.now();
const scans = await ctx.db
.query("githubSkillScans")
.withIndex("by_github_source_and_updated_at", (q) => q.eq("githubSourceId", sourceId))
.take(limit);
for (const scan of scans) await deleteGitHubSkillScan(ctx, scan, now);
return scans.length;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { chunkSkillScanRequestFiles } from "./skillScanRequestFiles";
function file(path: string) {
return {
path,
size: 1,
storageId: "storage:1" as never,
sha256: "a".repeat(64),
};
}
describe("chunkSkillScanRequestFiles", () => {
it("caps each action-to-mutation handoff at 100 file descriptors", () => {
const chunks = chunkSkillScanRequestFiles(
Array.from({ length: 205 }, (_, index) => file(`files/${index}.txt`)),
);
expect(chunks.map((chunk) => chunk.length)).toEqual([100, 100, 5]);
});
it("caps each action-to-mutation handoff at 256 KiB of serialized metadata", () => {
const chunks = chunkSkillScanRequestFiles([
file(`files/${"a".repeat(132_000)}.txt`),
file(`files/${"b".repeat(132_000)}.txt`),
]);
expect(chunks).toHaveLength(2);
for (const chunk of chunks) {
expect(new TextEncoder().encode(JSON.stringify(chunk)).byteLength).toBeLessThanOrEqual(
256 * 1024,
);
}
});
it("rejects manifests whose total serialized metadata exceeds the worker hydration budget", () => {
const files = Array.from({ length: 34 }, (_, index) =>
file(`files/${String(index).padStart(2, "0")}-${"a".repeat(128_000)}.txt`),
);
expect(() => chunkSkillScanRequestFiles(files)).toThrow(/manifest metadata exceeds/i);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Doc } from "../_generated/dataModel";
export const MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES = 256 * 1024;
export const MAX_SKILL_SCAN_REQUEST_FILES_PER_CHUNK = 100;
export const MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS = 100;
export const MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES = 4 * 1024 * 1024;
export function serializedSkillScanRequestFilesBytes(files: Doc<"skillScanRequests">["files"]) {
return new TextEncoder().encode(JSON.stringify(files)).byteLength;
}
export function chunkSkillScanRequestFiles(files: Doc<"skillScanRequests">["files"]) {
const chunks: Array<Doc<"skillScanRequests">["files"]> = [];
let current: Doc<"skillScanRequests">["files"] = [];
let currentBytes = 2;
let manifestBytes = 0;
const pushCurrent = () => {
if (current.length === 0) return;
manifestBytes += serializedSkillScanRequestFilesBytes(current);
if (
chunks.length >= MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
manifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
) {
throw new Error("Skill scan file manifest metadata exceeds the hydration limit");
}
chunks.push(current);
current = [];
currentBytes = 2;
};
for (const file of files) {
const fileBytes = new TextEncoder().encode(JSON.stringify(file)).byteLength + 1;
if (fileBytes + 2 > MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES) {
throw new Error("Skill scan file metadata entry exceeds the chunk limit");
}
if (
current.length > 0 &&
(current.length >= MAX_SKILL_SCAN_REQUEST_FILES_PER_CHUNK ||
currentBytes + fileBytes > MAX_SKILL_SCAN_REQUEST_FILE_CHUNK_BYTES)
) {
pushCurrent();
}
current.push(file);
currentBytes += fileBytes;
}
pushCurrent();
return chunks;
}
+3
View File
@@ -1558,6 +1558,9 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis
.collect();
sourceContents += contents.length;
for (const content of contents) await ctx.db.delete(content._id);
await ctx.scheduler.runAfter(0, internal.githubSkillSources.cleanupDeletedSourceScansInternal, {
sourceId: source._id,
});
await ctx.db.delete(source._id);
}
+39 -1
View File
@@ -395,6 +395,26 @@ const githubSkillCurrentStatusValidator = v.union(
v.literal("unknown"),
);
const githubSkillScans = defineTable({
skillId: v.id("skills"),
githubSourceId: v.id("githubSkillSources"),
contentHash: v.string(),
commit: v.string(),
path: v.string(),
status: githubSkillScanStatusValidator,
skillScanRequestId: v.optional(v.id("skillScanRequests")),
staticScan: v.optional(staticScanValidator),
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
llmAnalysis: v.optional(llmAnalysisValidator),
lastError: v.optional(v.string()),
runId: v.optional(v.string()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_skill_and_content_hash", ["skillId", "contentHash"])
.index("by_github_source_and_updated_at", ["githubSourceId", "updatedAt"]);
const packageFamilyValidator = v.union(
v.literal("skill"),
v.literal("code-plugin"),
@@ -601,7 +621,11 @@ const packageFilesValidator = v.array(
}),
);
const skillScanRequestSourceKindValidator = v.union(v.literal("upload"), v.literal("published"));
const skillScanRequestSourceKindValidator = v.union(
v.literal("upload"),
v.literal("published"),
v.literal("github"),
);
const skills = defineTable({
slug: v.string(),
@@ -1386,12 +1410,17 @@ const skillScanRequests = defineTable({
writtenBack: v.boolean(),
status: securityScanJobStatusValidator,
securityScanJobId: v.optional(v.id("securityScanJobs")),
requestedJobSource: v.optional(securityScanJobSourceValidator),
requestedJobPriority: v.optional(v.number()),
slug: v.optional(v.string()),
displayName: v.optional(v.string()),
version: v.optional(v.string()),
skillId: v.optional(v.id("skills")),
skillVersionId: v.optional(v.id("skillVersions")),
githubSkillScanId: v.optional(v.id("githubSkillScans")),
files: packageFilesValidator,
fileChunkCount: v.optional(v.number()),
fileManifestBytes: v.optional(v.number()),
parsed: v.optional(
v.object({
frontmatter: v.record(v.string(), v.any()),
@@ -1419,6 +1448,13 @@ const skillScanRequests = defineTable({
.index("by_skill_version_id_and_created_at", ["skillVersionId", "createdAt"])
.index("by_expires_at", ["expiresAt"]);
const skillScanRequestFileChunks = defineTable({
skillScanRequestId: v.id("skillScanRequests"),
chunkIndex: v.number(),
files: packageFilesValidator,
createdAt: v.number(),
}).index("by_skill_scan_request_id_and_chunk_index", ["skillScanRequestId", "chunkIndex"]);
const skillCardGenerationJobs = defineTable({
skillId: v.id("skills"),
skillVersionId: v.id("skillVersions"),
@@ -2528,6 +2564,7 @@ export default defineSchema({
officialPublishers,
githubSkillSources,
githubSkillContents,
githubSkillScans,
skills,
skillSlugAliases,
packages,
@@ -2537,6 +2574,7 @@ export default defineSchema({
packageInspectorScanCursors,
securityScanJobs,
skillScanRequests,
skillScanRequestFileChunks,
skillCardGenerationJobs,
packageStatEvents,
packageTrustedPublishers,
File diff suppressed because it is too large Load Diff
+582 -13
View File
@@ -3,10 +3,18 @@ import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { action, internalMutation, internalQuery, mutation } from "./functions";
import { applyGitHubSkillVerificationResultHandler } from "./githubSkillSync";
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
import { normalizePackageName } from "./lib/packageRegistry";
import { normalizePackageScanStatus } from "./lib/packageSecurity";
import { assertCanManageOwnedResource } from "./lib/publishers";
import { sourceSkillVersionFiles } from "./lib/skillCards";
import {
chunkSkillScanRequestFiles,
MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS,
MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES,
serializedSkillScanRequestFilesBytes,
} from "./lib/skillScanRequestFiles";
const DEFAULT_VT_WAIT_MS = 10 * 60 * 1000;
const DEFAULT_LEASE_MS = 60 * 60 * 1000;
@@ -18,8 +26,8 @@ const DEFAULT_CANCEL_SCAN_LIMIT = 1000;
const DEFAULT_CANCEL_DELETE_LIMIT = 500;
const MAX_CANCEL_SCAN_LIMIT = 5000;
const CANCEL_SAMPLE_LIMIT = 20;
const DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 250;
const MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 1000;
const DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 10;
const MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT = 10;
const DEFAULT_BULK_RESCAN_BATCH_SIZE = 50;
const MAX_BULK_RESCAN_BATCH_SIZE = 100;
const MAX_BULK_RESCAN_STATUS_JOB_IDS = 200;
@@ -30,6 +38,7 @@ const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512;
const DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_SKILL_SCAN_QUEUE_POSITION_READS = 250;
const MAX_SKILL_SCAN_RUNNING_COUNT_READS = 512;
const GITHUB_SKILL_SCAN_ACTION_LEASE_MS = 15 * 60 * 1000;
const SKILL_SCAN_ASYNC_NOTE = "Scans are asynchronous and may take time to complete.";
const finalLlmAnalysisStatuses = new Set(["clean", "suspicious", "malicious"]);
@@ -52,6 +61,8 @@ type JobTarget = {
version?: Doc<"skillVersions">;
release?: Doc<"packageReleases">;
scanRequest?: Doc<"skillScanRequests">;
scanRequestFiles?: Doc<"skillScanRequests">["files"];
githubScan?: Doc<"githubSkillScans">;
missing?: true;
};
@@ -216,6 +227,32 @@ const scanRequestFileValidator = v.object({
contentType: v.optional(v.string()),
});
const staticScanResultValidator = v.object({
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
reasonCodes: v.array(v.string()),
findings: v.array(
v.object({
code: v.string(),
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
file: v.string(),
line: v.number(),
message: v.string(),
evidence: v.string(),
}),
),
summary: v.string(),
engineVersion: v.string(),
checkedAt: v.number(),
});
const githubSkillScanStatusValidator = v.union(
v.literal("clean"),
v.literal("suspicious"),
v.literal("malicious"),
v.literal("pending"),
v.literal("failed"),
);
const internalRefs = internal as unknown as {
packages: {
getPackageByIdInternal: unknown;
@@ -232,6 +269,7 @@ const internalRefs = internal as unknown as {
failJobInternal: unknown;
getSkillScanRequestForUserInternal: unknown;
getJobTargetInternal: unknown;
recordGitHubSkillScanResultInternal: unknown;
recordSkillScanRequestFailedInternal: unknown;
recordSkillScanRequestSucceededInternal: unknown;
succeedJobInternal: unknown;
@@ -277,6 +315,14 @@ function defaultVtWaitMs() {
return Math.max(0, Math.min(parsed, DEFAULT_VT_WAIT_MS));
}
function githubSkillScanStatusFromLlmAnalysis(
analysis: Pick<NonNullable<Doc<"skillVersions">["llmAnalysis"]>, "status" | "verdict">,
) {
const status = normalizePackageScanStatus(analysis.verdict ?? analysis.status);
if (status === "clean" || status === "suspicious" || status === "malicious") return status;
return "failed" as const;
}
function publicWorkerErrorDetail(error: string) {
return error
.replace(/https?:\/\/[^\s"')<>]+/g, "[redacted-url]")
@@ -686,6 +732,100 @@ async function requestSkillRescanForActor(
allowPlatformModerator: true,
});
if (args.skill.installKind === "github") {
if (
args.skill.githubCurrentStatus !== "present" ||
!args.skill.githubSourceId ||
!args.skill.githubPath ||
!args.skill.githubCurrentCommit ||
!args.skill.githubCurrentContentHash
) {
throw new ConvexError("GitHub-backed skill content is not available");
}
const now = Date.now();
const { scan, activeJob, actionPending } = await getGitHubSkillScanState(
ctx,
args.skill._id,
args.skill.githubCurrentContentHash,
now,
);
const alreadyQueued = Boolean(activeJob || actionPending);
if (activeJob?.status === "queued") {
await ctx.db.patch(activeJob._id, {
source: "manual",
priority: Math.max(activeJob.priority, 100),
waitForVtUntil: Math.min(activeJob.waitForVtUntil, now),
nextRunAt: Math.min(activeJob.nextRunAt, now),
updatedAt: now,
});
} else if (actionPending && scan?.skillScanRequestId) {
await ctx.db.patch(scan.skillScanRequestId, {
requestedJobSource: "manual",
requestedJobPriority: 100,
updatedAt: now,
});
}
if (!alreadyQueued) {
const pendingScanInsert = {
githubSourceId: args.skill.githubSourceId,
commit: args.skill.githubCurrentCommit,
path: args.skill.githubPath,
status: "pending" as const,
updatedAt: now,
};
if (scan) {
await ctx.db.patch(scan._id, {
...pendingScanInsert,
skillScanRequestId: undefined,
skillSpectorAnalysis: undefined,
llmAnalysis: undefined,
lastError: undefined,
runId: undefined,
completedAt: undefined,
});
} else {
await ctx.db.insert("githubSkillScans", {
skillId: args.skill._id,
contentHash: args.skill.githubCurrentContentHash,
...pendingScanInsert,
createdAt: now,
});
}
await ctx.scheduler.runAfter(0, internal.githubSkillSyncNode.verifyGitHubSkillInternal, {
skillId: args.skill._id,
contentHash: args.skill.githubCurrentContentHash,
force: true,
});
}
await ctx.db.insert("auditLogs", {
actorUserId: args.actor._id,
action: "skill.clawscan.rescan",
targetType: "skill",
targetId: args.skill._id,
metadata: {
skillId: args.skill._id,
slug: args.skill.slug,
commit: args.skill.githubCurrentCommit,
contentHash: args.skill.githubCurrentContentHash,
scheduled: !alreadyQueued,
alreadyQueued,
jobId: activeJob?._id,
},
createdAt: now,
});
return {
ok: true as const,
slug: args.skill.slug,
version:
args.skill.latestVersionSummary?.version ?? args.skill.githubCurrentCommit.slice(0, 12),
skillId: args.skill._id,
githubContentHash: args.skill.githubCurrentContentHash,
...(activeJob ? { jobId: activeJob._id } : {}),
scheduled: !alreadyQueued,
alreadyQueued,
};
}
const requestedVersion = args.version?.trim();
const version = requestedVersion
? await ctx.db
@@ -733,6 +873,44 @@ async function requestSkillRescanForActor(
};
}
async function getGitHubSkillScanState(
ctx: MutationCtx,
skillId: Id<"skills">,
contentHash: string,
now: number,
) {
const scan = await ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) =>
q.eq("skillId", skillId).eq("contentHash", contentHash),
)
.unique();
if (scan?.status !== "pending") return { scan, activeJob: null, actionPending: false };
if (!scan.skillScanRequestId) {
return {
scan,
activeJob: null,
actionPending: scan.updatedAt > now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS,
};
}
const request = await ctx.db.get(scan.skillScanRequestId);
if (!request?.securityScanJobId) {
return {
scan,
activeJob: null,
actionPending: Boolean(
request && request.updatedAt > now - GITHUB_SKILL_SCAN_ACTION_LEASE_MS,
),
};
}
const job = await ctx.db.get(request.securityScanJobId);
return {
scan,
activeJob: job && (job.status === "queued" || job.status === "running") ? job : null,
actionPending: false,
};
}
export const requestSkillRescanForUserInternal = internalMutation({
args: {
actorUserId: v.id("users"),
@@ -948,7 +1126,11 @@ async function skillScanStatusResponse(
};
}
async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skillScanRequests">) {
async function enqueueSkillScanRequestJob(
ctx: MutationCtx,
requestId: Id<"skillScanRequests">,
options?: { source?: SecurityScanJobSource; priority?: number },
) {
const request = await ctx.db.get(requestId);
if (!request) throw new ConvexError("Scan request not found");
const now = Date.now();
@@ -956,8 +1138,8 @@ async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skill
targetKind: "skillScanRequest",
skillScanRequestId: request._id,
status: "queued",
source: "manual",
priority: 100,
source: options?.source ?? "manual",
priority: options?.priority ?? 100,
hasMaliciousSignal: false,
waitForVtUntil: now,
nextRunAt: now,
@@ -972,6 +1154,264 @@ async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skill
return jobId;
}
export const prepareGitHubSkillScanRequestInternal = internalMutation({
args: {
skillId: v.id("skills"),
contentHash: v.string(),
commit: v.string(),
force: v.optional(v.boolean()),
parsed: v.object({
frontmatter: v.record(v.string(), v.any()),
}),
staticScan: staticScanResultValidator,
},
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId);
if (
!skill ||
skill.installKind !== "github" ||
!skill.githubSourceId ||
!skill.githubPath ||
skill.githubCurrentStatus !== "present" ||
skill.githubCurrentCommit !== args.commit ||
skill.githubCurrentContentHash !== args.contentHash
) {
return { ok: true as const, skipped: "stale-or-missing" as const };
}
const existing = await ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) =>
q.eq("skillId", skill._id).eq("contentHash", args.contentHash),
)
.unique();
if (existing && !args.force && existing.status !== "pending" && existing.status !== "failed") {
await ctx.db.patch(existing._id, {
githubSourceId: skill.githubSourceId,
commit: args.commit,
path: skill.githubPath,
staticScan: args.staticScan,
updatedAt: Date.now(),
});
return {
ok: true as const,
reused: true as const,
scanId: existing._id,
scanStatus: existing.status,
};
}
if (existing?.status === "pending" && existing.skillScanRequestId) {
const request = await ctx.db.get(existing.skillScanRequestId);
const job = request?.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null;
if (request && job && (job.status === "queued" || job.status === "running")) {
return {
ok: true as const,
alreadyQueued: true as const,
scanId: existing._id,
requestId: request._id,
jobId: job._id,
};
}
if (request && request.updatedAt > Date.now() - GITHUB_SKILL_SCAN_ACTION_LEASE_MS) {
return {
ok: true as const,
alreadyQueued: true as const,
scanId: existing._id,
requestId: request._id,
};
}
}
const now = Date.now();
const scanId =
existing?._id ??
(await ctx.db.insert("githubSkillScans", {
skillId: skill._id,
githubSourceId: skill.githubSourceId,
contentHash: args.contentHash,
commit: args.commit,
path: skill.githubPath,
status: "pending",
staticScan: args.staticScan,
createdAt: now,
updatedAt: now,
}));
if (existing) {
await ctx.db.patch(existing._id, {
githubSourceId: skill.githubSourceId,
commit: args.commit,
path: skill.githubPath,
status: "pending",
staticScan: args.staticScan,
skillSpectorAnalysis: undefined,
llmAnalysis: undefined,
lastError: undefined,
runId: undefined,
completedAt: undefined,
updatedAt: now,
});
}
const requestId = await ctx.db.insert("skillScanRequests", {
actorUserId: skill.ownerUserId,
sourceKind: "github",
update: false,
writtenBack: false,
status: "queued",
slug: skill.slug,
displayName: skill.displayName,
version: skill.latestVersionSummary?.version ?? args.commit.slice(0, 12),
skillId: skill._id,
githubSkillScanId: scanId,
files: [],
fileChunkCount: 0,
fileManifestBytes: 0,
parsed: args.parsed,
staticScan: args.staticScan,
expiresAt: skillScanRequestExpiresAt(now),
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(scanId, { skillScanRequestId: requestId, updatedAt: now });
return {
ok: true as const,
prepared: true as const,
scanId,
requestId,
};
},
});
export const appendGitHubSkillScanRequestFilesInternal = internalMutation({
args: {
requestId: v.id("skillScanRequests"),
chunkIndex: v.number(),
files: v.array(scanRequestFileValidator),
},
handler: async (ctx, args) => {
if (!Number.isInteger(args.chunkIndex) || args.chunkIndex < 0) {
throw new ConvexError("Invalid file chunk index");
}
if (args.files.length === 0 || chunkSkillScanRequestFiles(args.files).length !== 1) {
throw new ConvexError("Invalid file chunk");
}
const request = await ctx.db.get(args.requestId);
if (
!request ||
request.sourceKind !== "github" ||
!request.githubSkillScanId ||
request.securityScanJobId
) {
throw new ConvexError("GitHub scan request is not accepting files");
}
const scan = await ctx.db.get(request.githubSkillScanId);
if (!scan || scan.status !== "pending" || scan.skillScanRequestId !== request._id) {
throw new ConvexError("GitHub scan request is no longer current");
}
const existing = await ctx.db
.query("skillScanRequestFileChunks")
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
q.eq("skillScanRequestId", request._id).eq("chunkIndex", args.chunkIndex),
)
.unique();
if (existing) {
return { ok: true as const, appended: true as const };
}
const fileChunkCount = request.fileChunkCount ?? 0;
const fileManifestBytes = request.fileManifestBytes ?? 0;
const chunkBytes = serializedSkillScanRequestFilesBytes(args.files);
if (
args.chunkIndex !== fileChunkCount ||
fileChunkCount >= MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
fileManifestBytes + chunkBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
) {
throw new ConvexError("GitHub scan request file manifest exceeds the hydration limit");
}
const now = Date.now();
await ctx.db.insert("skillScanRequestFileChunks", {
skillScanRequestId: request._id,
chunkIndex: args.chunkIndex,
files: args.files,
createdAt: now,
});
await ctx.db.patch(request._id, {
fileChunkCount: fileChunkCount + 1,
fileManifestBytes: fileManifestBytes + chunkBytes,
updatedAt: now,
});
return { ok: true as const, appended: true as const };
},
});
export const finalizeGitHubSkillScanRequestInternal = internalMutation({
args: {
requestId: v.id("skillScanRequests"),
force: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const request = await ctx.db.get(args.requestId);
if (!request || request.sourceKind !== "github" || !request.githubSkillScanId) {
throw new ConvexError("GitHub scan request not found");
}
if (request.securityScanJobId) {
const job = await ctx.db.get(request.securityScanJobId);
if (job && (job.status === "queued" || job.status === "running")) {
return {
ok: true as const,
alreadyQueued: true as const,
scanId: request.githubSkillScanId,
requestId: request._id,
jobId: job._id,
};
}
throw new ConvexError("GitHub scan request was already finalized");
}
const scan = await ctx.db.get(request.githubSkillScanId);
const skill = scan ? await ctx.db.get(scan.skillId) : null;
if (
!scan ||
scan.status !== "pending" ||
scan.skillScanRequestId !== request._id ||
!skill ||
skill.installKind !== "github" ||
skill.githubCurrentStatus !== "present" ||
skill.githubSourceId !== scan.githubSourceId ||
skill.githubPath !== scan.path ||
skill.githubCurrentCommit !== scan.commit ||
skill.githubCurrentContentHash !== scan.contentHash
) {
throw new ConvexError("GitHub scan request is no longer current");
}
const firstChunk = await ctx.db
.query("skillScanRequestFileChunks")
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
q.eq("skillScanRequestId", request._id),
)
.take(1);
if (
firstChunk.length === 0 ||
!request.fileChunkCount ||
!request.fileManifestBytes ||
request.fileChunkCount > MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
request.fileManifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES
) {
throw new ConvexError("GitHub scan request files are missing");
}
const jobId = await enqueueSkillScanRequestJob(ctx, request._id, {
source: args.force ? "manual" : (request.requestedJobSource ?? "publish"),
priority: Math.max(args.force ? 100 : 0, request.requestedJobPriority ?? 0),
});
return {
ok: true as const,
queued: true as const,
scanId: scan._id,
requestId: request._id,
jobId,
};
},
});
export const createUploadedSkillScanRequestInternal = internalMutation({
args: {
actorUserId: v.id("users"),
@@ -1342,6 +1782,37 @@ export const recordSkillScanRequestFailedInternal = internalMutation({
},
});
export const recordGitHubSkillScanResultInternal = internalMutation({
args: {
githubSkillScanId: v.id("githubSkillScans"),
scanStatus: githubSkillScanStatusValidator,
llmAnalysis: v.optional(llmAnalysisValidator),
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
error: v.optional(v.string()),
runId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const scan = await ctx.db.get(args.githubSkillScanId);
if (!scan) return { ok: true as const, skipped: "missing-scan" as const };
const now = Date.now();
await ctx.db.patch(scan._id, {
status: args.scanStatus,
llmAnalysis: args.llmAnalysis,
skillSpectorAnalysis: args.skillSpectorAnalysis,
lastError: args.error?.slice(0, 2000),
runId: args.runId,
completedAt: now,
updatedAt: now,
});
return await applyGitHubSkillVerificationResultHandler(ctx, {
skillId: scan.skillId,
contentHash: scan.contentHash,
scanStatus: args.scanStatus,
now,
});
},
});
export const pruneExpiredSkillScanRequestsInternal = internalMutation({
args: {
batchSize: v.optional(v.number()),
@@ -1362,6 +1833,8 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
let deletedJobs = 0;
let deletedFiles = 0;
let deletedRequests = 0;
let deferredRequests = 0;
for (const request of requests) {
if (request.securityScanJobId) {
const job = await ctx.db.get(request.securityScanJobId);
@@ -1370,8 +1843,33 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
deletedJobs += 1;
}
}
if (request.sourceKind === "upload") {
for (const file of request.files) {
const fileChunks =
request.sourceKind === "github"
? await ctx.db
.query("skillScanRequestFileChunks")
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
q.eq("skillScanRequestId", request._id),
)
.take(2)
: [];
if (fileChunks.length > 1) {
const chunk = fileChunks[0];
if (chunk) {
for (const file of chunk.files) {
try {
await ctx.storage.delete(file.storageId);
deletedFiles += 1;
} catch {
// Missing storage objects should not block expiry of the request row.
}
}
await ctx.db.delete(chunk._id);
}
deferredRequests += 1;
continue;
}
if (request.sourceKind === "upload" || request.sourceKind === "github") {
for (const file of [...request.files, ...fileChunks.flatMap((chunk) => chunk.files)]) {
try {
await ctx.storage.delete(file.storageId);
deletedFiles += 1;
@@ -1380,15 +1878,24 @@ export const pruneExpiredSkillScanRequestsInternal = internalMutation({
}
}
}
for (const chunk of fileChunks) await ctx.db.delete(chunk._id);
await ctx.db.delete(request._id);
deletedRequests += 1;
}
const done = requests.length < batchSize && deferredRequests === 0;
if (!done) {
await ctx.scheduler.runAfter(0, internal.securityScan.pruneExpiredSkillScanRequestsInternal, {
batchSize,
});
}
return {
ok: true as const,
deletedRequests: requests.length,
deletedRequests,
deferredRequests,
deletedJobs,
deletedFiles,
done: requests.length < batchSize,
done,
};
},
});
@@ -1859,7 +2366,41 @@ export const getJobTargetInternal = internalQuery({
? await ctx.db.get(scanRequest.skillVersionId)
: null;
const skill = scanRequest.skillId ? await ctx.db.get(scanRequest.skillId) : null;
return { job, skill, version: version ?? undefined, scanRequest };
const githubScan = scanRequest.githubSkillScanId
? await ctx.db.get(scanRequest.githubSkillScanId)
: null;
let scanRequestFiles = scanRequest.files;
if (scanRequest.sourceKind === "github") {
const chunks = await ctx.db
.query("skillScanRequestFileChunks")
.withIndex("by_skill_scan_request_id_and_chunk_index", (q) =>
q.eq("skillScanRequestId", scanRequest._id),
)
.take(MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS + 1);
const manifestBytes = chunks.reduce(
(total, chunk) => total + serializedSkillScanRequestFilesBytes(chunk.files),
0,
);
const declaredChunkCount = scanRequest.fileChunkCount ?? chunks.length;
if (
chunks.length > MAX_SKILL_SCAN_REQUEST_FILE_CHUNKS ||
chunks.length !== declaredChunkCount ||
manifestBytes > MAX_SKILL_SCAN_REQUEST_MANIFEST_BYTES ||
(scanRequest.fileManifestBytes !== undefined &&
manifestBytes !== scanRequest.fileManifestBytes)
) {
return { job, missing: true as const };
}
scanRequestFiles = chunks.flatMap((chunk) => chunk.files);
}
return {
job,
skill,
version: version ?? undefined,
scanRequest,
scanRequestFiles,
githubScan: githubScan ?? undefined,
};
}
return { job, missing: true as const };
},
@@ -1933,7 +2474,8 @@ export const claimCodexScanJobs = action({
internalRefs.securityScan.claimQueuedJobsInternal,
{
workerId: args.workerId,
limit: normalizeLimit(args.limit),
// Hydrated jobs contain signed URLs, so claim one at a time to stay below action limits.
limit: Math.min(normalizeLimit(args.limit), 1),
leaseMs: args.leaseMs,
},
);
@@ -1965,7 +2507,9 @@ export const claimCodexScanJobs = action({
contentType?: string;
}> = [];
if (scanRequest) {
files = scanRequest.files;
files =
(target.scanRequestFiles as Doc<"skillScanRequests">["files"] | undefined) ??
scanRequest.files;
} else if (version) {
const fingerprintEntries = await runQueryRef<
Array<{ fingerprint: string; kind?: "source" | "generated-bundle" }>
@@ -2091,12 +2635,25 @@ export const completeCodexScanJob = action({
});
writtenBack = true;
}
const skillSpectorAnalysis = args.skillSpectorAnalysis
? capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis)
: undefined;
if (target.scanRequest.sourceKind === "github" && target.githubScan) {
await runMutationRef(ctx, internalRefs.securityScan.recordGitHubSkillScanResultInternal, {
githubSkillScanId: target.githubScan._id,
scanStatus: githubSkillScanStatusFromLlmAnalysis(args.llmAnalysis),
llmAnalysis: args.llmAnalysis,
skillSpectorAnalysis,
runId: args.runId,
});
writtenBack = true;
}
await runMutationRef(ctx, internalRefs.securityScan.recordSkillScanRequestSucceededInternal, {
scanId: target.scanRequest._id,
jobId: args.jobId,
runId: args.runId,
llmAnalysis: args.llmAnalysis,
skillSpectorAnalysis: args.skillSpectorAnalysis,
skillSpectorAnalysis,
writtenBack,
});
} else {
@@ -2156,6 +2713,18 @@ export const failCodexScanJob = action({
});
}
} else if (target.job.targetKind === "skillScanRequest" && target.scanRequest) {
if (target.scanRequest.sourceKind === "github" && target.githubScan) {
await runMutationRef(
ctx,
internalRefs.securityScan.recordGitHubSkillScanResultInternal,
{
githubSkillScanId: target.githubScan._id,
scanStatus: "failed",
error: args.error,
llmAnalysis,
},
);
}
await runMutationRef(
ctx,
internalRefs.securityScan.recordSkillScanRequestFailedInternal,
+140 -1
View File
@@ -14,6 +14,7 @@ vi.mock("./lib/badges", () => ({
const { getAuthUserId } = await import("@convex-dev/auth/server");
const { getSkillBadgeMap } = await import("./lib/badges");
const skillsModule = await import("./skills");
const {
getBySlug,
getVerifyTargetBySlugInternal,
@@ -22,7 +23,7 @@ const {
resolveSkillAppealForUserInternal,
submitSkillAppealForUserInternal,
triageSkillReportForUserInternal,
} = await import("./skills");
} = skillsModule;
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -53,6 +54,12 @@ const getBySlugHandler = (
contentType?: string;
}>;
} | null;
githubScan?: {
contentHash: string;
commit: string;
status: string;
llmAnalysis?: { status: string };
} | null;
forkOf?: {
skill: {
slug: string;
@@ -77,6 +84,22 @@ const getBySlugHandler = (
>
)._handler;
const getGitHubScanForAuditHandler = (
skillsModule as typeof skillsModule & {
getGitHubScanForAudit?: WrappedHandler<
{ slug: string },
{
contentHash: string;
commit: string;
path: string;
status: string;
version: string;
llmAnalysis?: { status: string };
} | null
>;
}
).getGitHubScanForAudit?._handler;
const getVerifyTargetBySlugInternalHandler = (
getVerifyTargetBySlugInternal as unknown as WrappedHandler<
{
@@ -187,6 +210,7 @@ function makeCtx(args: {
ownerPublisher?: Record<string, unknown> | null;
membership?: Record<string, unknown> | null;
latestVersion?: Record<string, unknown> | null;
githubScan?: Record<string, unknown> | null;
skillsById?: Record<string, Record<string, unknown>>;
ownersById?: Record<string, Record<string, unknown>>;
}) {
@@ -200,6 +224,13 @@ function makeCtx(args: {
})),
};
}
if (table === "githubSkillScans") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(args.githubScan ?? null),
})),
};
}
if (table !== "skills") throw new Error(`Unexpected query table: ${table}`);
return { withIndex };
});
@@ -360,6 +391,114 @@ describe("skills.getBySlug", () => {
expect(result?.owner).not.toHaveProperty("githubProfileSyncedAt");
});
it("does not load the durable current-content scan in the general skill lookup", async () => {
const contentHash = "a".repeat(64);
const currentCommit = "b".repeat(40);
const scanOriginCommit = "c".repeat(40);
const ctx = makeCtx({
skill: makeSkill({
installKind: "github",
githubPath: "skills/demo",
githubCurrentCommit: currentCommit,
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
githubScanStatus: "clean",
latestVersionSummary: {
version: "1.2.3",
createdAt: 2,
changelog: "Synced from GitHub source.",
},
}),
owner: makeOwner("users:1", "demo-owner"),
githubScan: {
_id: "githubSkillScans:1",
skillId: "skills:1",
githubSourceId: "githubSkillSources:1",
contentHash,
commit: scanOriginCommit,
path: "skills/old-demo",
status: "clean",
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No findings.",
engineVersion: "test",
checkedAt: 2,
},
llmAnalysis: { status: "clean", checkedAt: 3 },
createdAt: 2,
updatedAt: 3,
completedAt: 3,
},
});
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
expect(result?.latestVersion).toBeNull();
expect(result).not.toHaveProperty("githubScan");
expect(
(ctx as unknown as { db: { query: ReturnType<typeof vi.fn> } }).db.query,
).not.toHaveBeenCalledWith("githubSkillScans");
});
it("returns the durable current-content scan from the audit-specific query", async () => {
expect(getGitHubScanForAuditHandler).toBeTypeOf("function");
if (!getGitHubScanForAuditHandler) return;
const contentHash = "a".repeat(64);
const currentCommit = "b".repeat(40);
const scanOriginCommit = "c".repeat(40);
const ctx = makeCtx({
skill: makeSkill({
installKind: "github",
githubPath: "skills/demo",
githubCurrentCommit: currentCommit,
githubCurrentContentHash: contentHash,
githubCurrentStatus: "present",
githubScanStatus: "clean",
latestVersionSummary: {
version: "1.2.3",
createdAt: 2,
changelog: "Synced from GitHub source.",
},
}),
owner: makeOwner("users:1", "demo-owner"),
githubScan: {
_id: "githubSkillScans:1",
skillId: "skills:1",
githubSourceId: "githubSkillSources:1",
contentHash,
commit: scanOriginCommit,
path: "skills/old-demo",
status: "clean",
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No findings.",
engineVersion: "test",
checkedAt: 2,
},
llmAnalysis: { status: "clean", checkedAt: 3 },
createdAt: 2,
updatedAt: 3,
completedAt: 3,
},
});
const result = await getGitHubScanForAuditHandler(ctx, { slug: "demo" });
expect(result).toMatchObject({
contentHash,
commit: currentCommit,
path: "skills/demo",
status: "clean",
version: "1.2.3",
llmAnalysis: { status: "clean", checkedAt: 3 },
});
});
it("hides skills whose owner is deleted or banned", async () => {
const ctx = makeCtx({
skill: {
+105
View File
@@ -39,6 +39,7 @@ import {
canHealSkillOwnershipByGitHubProviderAccountId,
getGitHubProviderAccountId,
} from "./lib/githubIdentity";
import { deleteGitHubSkillScansForSkill } from "./lib/githubSkillScans";
import {
adjustGlobalPublicSkillsCount,
getPublicSkillVisibilityDelta,
@@ -1565,6 +1566,7 @@ function enforceNewSkillRateLimit(signals: OwnerTrustSignals) {
const HARD_DELETE_PHASES = [
"versions",
"fingerprints",
"githubScans",
"skillCardJobs",
"embeddings",
"comments",
@@ -1664,6 +1666,19 @@ async function hardDeleteSkillStep(
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints", scope);
return;
}
await scheduleHardDelete(ctx, skill._id, actorUserId, "githubScans", scope);
return;
}
case "githubScans": {
const deletedScans = await deleteGitHubSkillScansForSkill(
ctx,
skill._id,
HARD_DELETE_BATCH_SIZE,
);
if (deletedScans === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, "githubScans", scope);
return;
}
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs", scope);
return;
}
@@ -2261,6 +2276,35 @@ function toPublicSkillVersion(
};
}
function toPublicGitHubSkillScan(
scan: Doc<"githubSkillScans"> | null | undefined,
version: string | undefined,
currentCommit: string | undefined,
currentPath: string | undefined,
) {
if (!scan) return null;
const commit = currentCommit ?? scan.commit;
return {
_id: scan._id,
contentHash: scan.contentHash,
commit,
path: currentPath ?? scan.path,
status: scan.status,
version: version ?? commit.slice(0, 12),
skillSpectorAnalysis: scan.skillSpectorAnalysis,
llmAnalysis: scan.llmAnalysis,
staticScan: scan.staticScan
? {
...scan.staticScan,
findings: scan.staticScan.findings.map((finding) => ({ ...finding, evidence: "" })),
}
: undefined,
completedAt: scan.completedAt,
createdAt: scan.createdAt,
updatedAt: scan.updatedAt,
};
}
function toPublicSkillCardFile(file: Doc<"skillVersions">["files"][number]) {
return {
path: file.path,
@@ -2485,6 +2529,67 @@ function isDirectSkillOwner(
return !skill.ownerPublisherId && skill.ownerUserId === userId;
}
export const getGitHubScanForAudit = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug);
const skill = resolved.skill;
if (
!skill ||
skill.installKind !== "github" ||
!skill.githubCurrentContentHash ||
!skill.githubCurrentCommit ||
!skill.githubPath
) {
return null;
}
const ownerPublisher = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
});
if (!toPublicPublisher(ownerPublisher)) return null;
const skillOwnerRef = {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
};
const isMalwareBlocked =
skill.moderationVerdict === "malicious" ||
(skill.moderationFlags?.includes("blocked.malware") ?? false);
if (isMalwareBlocked) return null;
if (!isPublicSkillDoc(skill)) {
const userId = await getOptionalActiveAuthUserId(ctx);
const skillOwnerPublisher = skillOwnerRef.ownerPublisherId
? await ctx.db.get(skillOwnerRef.ownerPublisherId)
: null;
const publisherOwner =
userId && skillOwnerPublisher
? await canAccessPublisherOwnerScope(ctx, {
publisher: skillOwnerPublisher,
userId,
legacyOwnerUserId: skillOwnerRef.ownerUserId,
})
: false;
if (!userId || (!isDirectSkillOwner(skillOwnerRef, userId) && !publisherOwner)) return null;
}
const scan = await ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) =>
q.eq("skillId", skill._id).eq("contentHash", skill.githubCurrentContentHash as string),
)
.unique();
return toPublicGitHubSkillScan(
scan,
skill.latestVersionSummary?.version,
skill.githubCurrentCommit,
skill.githubPath,
);
},
});
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
+1 -1
View File
@@ -18,7 +18,7 @@
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
"ci:pr": "bun run ci:static && bun run ci:unit && bun run ci:packages && bun run ci:types-build && bun run ci:e2e-http",
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 --ignore GHSA-gv7w-rqvm-qjhr --ignore GHSA-g7r4-m6w7-qqqr && bun run format:check && bun run lint && bun run deadcode:ci",
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 --ignore GHSA-gv7w-rqvm-qjhr --ignore GHSA-g7r4-m6w7-qqqr --ignore GHSA-x4vx-rjvf-j5p4 --ignore GHSA-76mc-f452-cxcm --ignore GHSA-hpcv-96wg-7vj8 --ignore GHSA-r47g-fvhr-h676 --ignore GHSA-vxr8-fq34-vvx9 --ignore GHSA-gvmj-g25r-r7wr --ignore GHSA-rp9w-3fw7-7cwq && bun run format:check && bun run lint && bun run deadcode:ci",
"ci:types-build": "bunx tsc --noEmit && bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit && bun run --cwd packages/clawhub-admin typecheck && VITE_CONVEX_URL=https://example.invalid bun run build",
"ci:unit": "VITE_CONVEX_URL=https://example.invalid bun run coverage",
"clawscan:local": "bun scripts/local-clawscan-dry-run.ts",
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { parseArk } from "./ark";
import {
ApiV1SearchResponseSchema,
ApiV1SkillRescanResponseSchema,
ApiV1SkillVerifyResponseSchema,
ClawdisSkillMetadataSchema,
} from "./schemas";
@@ -85,4 +86,23 @@ describe("packages/clawhub skill metadata schema", () => {
expect(parsed.slug).toBe("demo");
expect(parsed.version).toBe("1.0.0");
});
it("parses GitHub-backed skill rescan responses", () => {
const parsed = parseArk(
ApiV1SkillRescanResponseSchema,
{
ok: true,
slug: "github-demo",
version: "abc123",
skillId: "skills:github-demo",
githubContentHash: "content-hash",
scheduled: true,
alreadyQueued: false,
},
"GitHub skill rescan response",
);
if (!("githubContentHash" in parsed)) throw new Error("expected GitHub rescan response");
expect(parsed.githubContentHash).toBe("content-hash");
});
});
+9
View File
@@ -582,6 +582,15 @@ export const ApiV1SkillRescanResponseSchema = type({
skillVersionId: "string",
jobId: "string",
alreadyQueued: "boolean",
}).or({
ok: "true",
slug: "string",
version: "string",
skillId: "string",
githubContentHash: "string",
jobId: "string?",
scheduled: "boolean",
alreadyQueued: "boolean",
});
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
+9
View File
@@ -444,6 +444,15 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va
skillVersionId: string;
jobId: string;
alreadyQueued: boolean;
} | {
ok: true;
slug: string;
version: string;
skillId: string;
githubContentHash: string;
scheduled: boolean;
alreadyQueued: boolean;
jobId?: string | undefined;
}, {}>;
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"queued" | "running" | "succeeded" | "failed", {}>;
+9
View File
@@ -411,6 +411,15 @@ export const ApiV1SkillRescanResponseSchema = type({
skillVersionId: "string",
jobId: "string",
alreadyQueued: "boolean",
}).or({
ok: "true",
slug: "string",
version: "string",
skillId: "string",
githubContentHash: "string",
jobId: "string?",
scheduled: "boolean",
alreadyQueued: "boolean",
});
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
export const ApiV1SkillScanSourceSchema = type({
File diff suppressed because one or more lines are too long
+35
View File
@@ -7,6 +7,7 @@ import { getPackageScopeOwnerMismatch, inferPackageNameScope } from "./packages"
import {
ApiSearchResponseSchema,
ApiV1SkillInstallResolveResponseSchema,
ApiV1SkillRescanResponseSchema,
ApiV1SearchResponseSchema,
ApiV1SkillVerifyResponseSchema,
CliPublishRequestSchema,
@@ -154,6 +155,40 @@ describe("clawhub-schema", () => {
expect(legacy).toMatchObject({ roots: [{ rootId: "root" }] });
});
it("accepts hosted and GitHub-backed skill rescan responses", () => {
const hosted = parseArk(
ApiV1SkillRescanResponseSchema,
{
ok: true,
slug: "demo",
version: "1.0.0",
skillId: "skills:demo",
skillVersionId: "skillVersions:demo",
jobId: "securityScanJobs:demo",
alreadyQueued: false,
},
"Hosted skill rescan response",
);
if (!("skillVersionId" in hosted)) throw new Error("expected hosted rescan response");
expect(hosted.skillVersionId).toBe("skillVersions:demo");
const github = parseArk(
ApiV1SkillRescanResponseSchema,
{
ok: true,
slug: "github-demo",
version: "abc123",
skillId: "skills:github-demo",
githubContentHash: "content-hash",
scheduled: true,
alreadyQueued: false,
},
"GitHub skill rescan response",
);
if (!("githubContentHash" in github)) throw new Error("expected GitHub rescan response");
expect(github.githubContentHash).toBe("content-hash");
});
it("accepts publish payloads with an owner handle", () => {
const payload = parseArk(
CliPublishRequestSchema,
+9
View File
@@ -482,6 +482,15 @@ export const ApiV1SkillRescanResponseSchema = type({
skillVersionId: "string",
jobId: "string",
alreadyQueued: "boolean",
}).or({
ok: "true",
slug: "string",
version: "string",
skillId: "string",
githubContentHash: "string",
jobId: "string?",
scheduled: "boolean",
alreadyQueued: "boolean",
});
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
+53 -1
View File
@@ -2,13 +2,14 @@
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
assertCodexWorkerExecutionAllowed,
isCodexWorkerExecutionAllowed,
LOCAL_CODEX_WORKER_OPT_IN,
resolveCodexWorkerHome,
} from "../codex-worker-guard";
import * as codexScanWorker from "./run-codex-scan-worker";
import {
buildPrompt,
normalizeSkillSpectorAnalysis,
@@ -30,6 +31,57 @@ async function tempDir() {
}
describe("run-codex-scan-worker diagnostics", () => {
it("keeps successful claims when a parallel claim request fails", async () => {
const claimCodexScanJobBatch = (
codexScanWorker as typeof codexScanWorker & {
claimCodexScanJobBatch?: (
claimLimit: number,
claimOne: () => Promise<
Array<{
job: {
_id: string;
leaseToken: string;
targetKind: "skillVersion";
source: string;
hasMaliciousSignal: boolean;
waitForVtUntil: number;
};
target: Record<string, unknown>;
}>
>,
) => Promise<Array<{ job: { _id: string } }>>;
}
).claimCodexScanJobBatch;
expect(claimCodexScanJobBatch).toBeTypeOf("function");
if (!claimCodexScanJobBatch) return;
const claimOne = vi
.fn()
.mockResolvedValueOnce([
{
job: {
_id: "securityScanJobs:1",
leaseToken: "lease",
targetKind: "skillVersion",
source: "publish",
hasMaliciousSignal: false,
waitForVtUntil: 0,
},
target: {},
},
])
.mockRejectedValueOnce(new Error("temporary claim failure"))
.mockResolvedValueOnce([]);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(claimCodexScanJobBatch(3, claimOne)).resolves.toMatchObject([
{ job: { _id: "securityScanJobs:1" } },
]);
expect(claimOne).toHaveBeenCalledTimes(3);
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("temporary claim failure"));
consoleError.mockRestore();
});
it("blocks direct local Codex security worker runs without opt-in", () => {
expect(isCodexWorkerExecutionAllowed({})).toBe(false);
expect(() => assertCodexWorkerExecutionAllowed({})).toThrow(
+23 -6
View File
@@ -1087,6 +1087,19 @@ async function processJob(
}
}
export async function claimCodexScanJobBatch(
claimLimit: number,
claimOne: () => Promise<ClaimedJob[]>,
) {
const results = await Promise.allSettled(Array.from({ length: claimLimit }, () => claimOne()));
return results.flatMap((result) => {
if (result.status === "fulfilled") return result.value;
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
console.error(`failed to claim security scan job: ${message}`);
return [];
});
}
async function main() {
const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, diagnosticsRoot } = parseArgs();
assertCodexWorkerExecutionAllowed(process.env);
@@ -1111,12 +1124,16 @@ async function main() {
const remainingJobs = maxJobs === undefined ? batchLimit : Math.max(0, maxJobs - totalClaimed);
if (remainingJobs === 0) break;
const claimLimit = Math.min(batchLimit, remainingJobs);
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
token,
workerId,
limit: claimLimit,
leaseMs,
})) as ClaimedJob[];
const jobs = await claimCodexScanJobBatch(
claimLimit,
async () =>
(await client.action(api.securityScan.claimCodexScanJobs, {
token,
workerId,
limit: 1,
leaseMs,
})) as ClaimedJob[],
);
console.log(`claimed ${jobs.length} job(s)`);
if (jobs.length === 0) break;
+38 -3
View File
@@ -128,20 +128,55 @@ When a new source-backed skill appears or an existing skill's content hash
changes:
- set `githubScanStatus: "pending"`
- hide the skill from public installability with `moderationStatus: "hidden"`
and `moderationReason: "pending.scan"`
- enqueue verification for the current content hash
- keep the catalog entry visible with `moderationReason: "pending.scan"`, while
blocking normal install/update until the full scan completes
- fetch the exact skill-folder bytes for the current commit and content hash
- store those bytes only in ephemeral Convex storage, referenced by the
`skillScanRequests` row through a prepare, bounded chunk append, and finalize
sequence so no action-to-mutation argument carries the full file manifest;
create the request before storing blobs and persist ownership after each
bounded chunk so a terminated action can orphan at most its current chunk
- cap the persisted file manifest at 4 MiB of descriptor metadata and hydrate
one signed-URL-heavy worker job per claim response
- enqueue the normal full ClawScan worker with deterministic static findings as
input context
- do not schedule another heavy verification action while that content hash
already has an active queued/running scan job or a recently prepared request
- enqueue explicit owner/moderator rescans in the high-priority manual queue
When verification succeeds cleanly:
- persist the completed ClawScan, SkillSpector, and static findings on a durable
`githubSkillScans` row keyed by skill and content hash
- set `githubScanStatus: "clean"`
- make the skill active/installable
When verification fails, is suspicious, or is malicious:
- persist the final result on the same durable content-hash scan row
- keep/block the skill from normal install
- return a structured install block such as `github_scan_failed`
Completed clean, suspicious, and malicious verdicts may be reused for the same
skill and content hash. Failed worker runs are not reusable verdicts and may be
requeued for that same content hash after the underlying runtime problem is
fixed. Reusing a result must reassociate it with the skill's current source,
commit, and path before any old-source cleanup can run.
Legacy GitHub-backed rows that have a scan status but no durable
`githubSkillScans` result are not trusted as full ClawScan verdicts. The next
source sync must move them back to pending and enqueue the full pipeline.
GitHub-backed verification must not create a hosted `skillVersions` row or a
ClawHub-owned install artifact. Expired request rows and their temporary stored
files/chunks are pruned, while the small completed `githubSkillScans` result
remains available for the public Security audit page. Source-wide scan-history
cleanup runs in bounded asynchronous batches. Expired request cleanup deletes
the linked worker job before deleting any files, then deletes at most one bounded
GitHub file-metadata chunk per request and schedules an immediate continuation
while work remains. Static findings alone must never promote or block the skill;
the full ClawScan verdict controls `githubScanStatus`.
If the upstream path disappears:
- set `githubCurrentStatus: "missing"`
+17
View File
@@ -129,6 +129,23 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
storage. Published scan requests may patch a version only when the caller can
manage the skill and explicitly sets `update: true`; local uploads must reject
update mode.
- GitHub-backed skill verification also uses ephemeral `skillScanRequests` to
feed exact current skill-folder bytes into the normal ClawScan worker. Large
file manifests use a prepare, bounded child-chunk append, and finalize sequence
rather than one oversized Convex document or function argument. The request
must exist before blob storage begins, each bounded chunk is durably attached
as it is stored, descriptor metadata is capped at 4 MiB, and worker claims
hydrate one signed-URL-heavy job at a time. A recently prepared request remains
leased until finalization so concurrent syncs cannot replace it. Unlike
user-submitted upload scans, its completed ClawScan, SkillSpector, and static
context are persisted on `githubSkillScans` by skill and content hash so the
public Security audit remains available after request files are pruned through
bounded continuation batches. Cleanup cancels the linked worker job before
deleting the first chunk. Legacy source-backed statuses without a durable
`githubSkillScans` result must return to pending on sync rather than remain
trusted. Explicit owner/moderator rescans use the manual worker queue.
GitHub-backed verification must not create or patch a hosted `skillVersions`
row, and static findings remain input context rather than a blocking verdict.
- `auditLogs` remains the global compliance/security ledger. Product-facing
moderation timelines live in `skillModerationEventLogs` and
`packageModerationEventLogs`.
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { getFunctionName } from "convex/server";
import type { ComponentType } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -134,4 +135,66 @@ describe("skill security audit route", () => {
}),
);
});
it("renders the durable scan for a GitHub-backed skill without a hosted skill version", async () => {
const requestRescan = vi.fn().mockResolvedValue({ ok: true });
useMutationMock.mockReturnValue(requestRescan);
useAuthStatusMock.mockReturnValue({
me: { _id: "users:moderator", role: "moderator" },
});
useQueryMock.mockImplementation((ref: unknown, args: unknown) => {
if (args === "skip" || (args && Object.keys(args as Record<string, unknown>).length === 0)) {
return [];
}
const name = getFunctionName(ref as Parameters<typeof getFunctionName>[0]);
if (name === "skills:getGitHubScanForAudit") {
return {
contentHash: "a".repeat(64),
commit: "b".repeat(40),
status: "clean",
version: "1.2.3",
llmAnalysis: {
status: "clean",
verdict: "benign",
summary: "No material risks found.",
checkedAt: 123,
},
};
}
if (name === "skills:getBySlug") {
return {
skill: {
_id: "skills:github",
slug: "github-demo",
displayName: "GitHub Demo",
ownerUserId: "users:owner",
installKind: "github",
},
latestVersion: null,
owner: {
_id: "users:owner",
handle: "nvidia",
},
};
}
return undefined;
});
paramsMock = { owner: "nvidia", slug: "github-demo" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByText("Security audit is unavailable for this skill.")).toBeNull();
expect(screen.getByText("GitHub Demo")).toBeTruthy();
expect(screen.getByText("No material risks found.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Rescan" }));
await waitFor(() =>
expect(requestRescan).toHaveBeenCalledWith({
skillId: "skills:github",
}),
);
});
});
+18 -9
View File
@@ -72,12 +72,17 @@ function SkillSecurityAuditRoute() {
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
const githubScan = useQuery(
api.skills.getGitHubScanForAudit,
skill?.installKind === "github" ? { slug } : "skip",
);
const audit = latestVersion ?? githubScan;
if (result === undefined) {
if (result === undefined || (skill?.installKind === "github" && githubScan === undefined)) {
return <SecurityAuditPageSkeleton />;
}
if (!skill || !latestVersion) {
if (!skill || !audit) {
return (
<main className="section">
<div className="card">Security audit is unavailable for this skill.</div>
@@ -102,21 +107,25 @@ function SkillSecurityAuditRoute() {
kind: "skill",
title: skill.displayName,
name: slug,
version: latestVersion.version,
version: audit.version,
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
skillSpectorAnalysis={latestVersion.skillSpectorAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
sha256hash={latestVersion?.sha256hash ?? null}
vtAnalysis={latestVersion?.vtAnalysis ?? null}
llmAnalysis={audit.llmAnalysis ?? null}
skillSpectorAnalysis={audit.skillSpectorAnalysis ?? null}
staticScan={audit.staticScan ?? null}
canManageArtifact={canManageArtifact}
onRequestRescan={
canManageArtifact
? () => requestSkillRescan({ skillId: skill._id, version: latestVersion.version })
? () =>
requestSkillRescan({
skillId: skill._id,
...(latestVersion ? { version: latestVersion.version } : {}),
})
: null
}
/>