diff --git a/convex/crons.ts b/convex/crons.ts index 87bc9e08..4985dd27 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -79,6 +79,13 @@ crons.interval( { batchSize: 100 }, ); +crons.interval( + "skill-scan-request-prune", + { hours: 6 }, + internal.securityScan.pruneExpiredSkillScanRequestsInternal, + { batchSize: 250 }, +); + crons.interval( "download-dedupe-prune", { hours: 24 }, diff --git a/convex/http.ts b/convex/http.ts index ae550f20..20ab5fd2 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -35,6 +35,10 @@ import { publishSoulV1Http, resolveSkillVersionV1Http, searchSkillsV1Http, + skillScanBatchStatusV1Http, + skillScanBatchSubmitV1Http, + skillScanGetRouterV1Http, + skillScanSubmitV1Http, skillSecurityVerdictsV1Http, skillsDeleteRouterV1Http, skillsGetRouterV1Http, @@ -87,6 +91,12 @@ http.route({ handler: listSkillsV1Http, }); +http.route({ + pathPrefix: `${ApiRoutes.skillScans}/`, + method: "GET", + handler: skillScanGetRouterV1Http, +}); + http.route({ path: ApiRoutes.packages, method: "GET", @@ -141,6 +151,24 @@ http.route({ handler: publishSkillV1Http, }); +http.route({ + path: ApiRoutes.skillScans, + method: "POST", + handler: skillScanSubmitV1Http, +}); + +http.route({ + path: `${ApiRoutes.skillScans}/batch`, + method: "POST", + handler: skillScanBatchSubmitV1Http, +}); + +http.route({ + path: `${ApiRoutes.skillScans}/batch/status`, + method: "POST", + handler: skillScanBatchStatusV1Http, +}); + http.route({ path: ApiRoutes.packages, method: "POST", diff --git a/convex/httpApiV1.shared.test.ts b/convex/httpApiV1.shared.test.ts index 9cdfeca5..8e9c1e51 100644 --- a/convex/httpApiV1.shared.test.ts +++ b/convex/httpApiV1.shared.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from "vitest"; import type { Id } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; -import { formatUserFacingErrorMessage, resolveVersionTagsBatch } from "./httpApiV1/shared"; +import { + formatUserFacingErrorMessage, + parseMultipartSkillScan, + resolveVersionTagsBatch, +} from "./httpApiV1/shared"; function makeCtx() { return { @@ -82,4 +86,28 @@ describe("http API v1 shared helpers", () => { expect(result).toEqual([{ stable: "1.5.0" }]); }); + + it("validates skill scan multipart payloads before storing uploaded files", async () => { + const form = new FormData(); + form.set("payload", JSON.stringify({ source: { kind: "upload" }, update: true })); + form.append("files", new Blob(["# Demo"], { type: "text/markdown" }), "SKILL.md"); + const request = new Request("https://clawhub.ai/api/v1/skills/-/scan", { + method: "POST", + body: form, + }); + const store = vi.fn(); + const ctx = { + storage: { + store, + delete: vi.fn(), + }, + } as unknown as ActionCtx; + + await expect( + parseMultipartSkillScan(ctx, request, () => { + throw new Error("update is not valid for uploaded scans"); + }), + ).rejects.toThrow("update is not valid for uploaded scans"); + expect(store).not.toHaveBeenCalled(); + }); }); diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 54b60737..6fc2de0c 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -20,6 +20,10 @@ import { publishSkillV1Handler, resolveSkillVersionV1Handler, searchSkillsV1Handler, + skillScanBatchStatusV1Handler, + skillScanBatchSubmitV1Handler, + skillScanGetRouterV1Handler, + skillScanSubmitV1Handler, skillSecurityVerdictsV1Handler, skillsDeleteRouterV1Handler, skillsGetRouterV1Handler, @@ -61,6 +65,10 @@ export const listSkillsV1Http = httpAction(listSkillsV1Handler); export const skillsGetRouterV1Http = httpAction(skillsGetRouterV1Handler); export const publishSkillV1Http = httpAction(publishSkillV1Handler); export const skillSecurityVerdictsV1Http = httpAction(skillSecurityVerdictsV1Handler); +export const skillScanSubmitV1Http = httpAction(skillScanSubmitV1Handler); +export const skillScanGetRouterV1Http = httpAction(skillScanGetRouterV1Handler); +export const skillScanBatchSubmitV1Http = httpAction(skillScanBatchSubmitV1Handler); +export const skillScanBatchStatusV1Http = httpAction(skillScanBatchStatusV1Handler); export const skillsPostRouterV1Http = httpAction(skillsPostRouterV1Handler); export const skillsDeleteRouterV1Http = httpAction(skillsDeleteRouterV1Handler); export const exportSkillsV1Http = httpAction(exportSkillsV1Handler); diff --git a/convex/httpApiV1/shared.ts b/convex/httpApiV1/shared.ts index 2c6b7b71..74c1ce0e 100644 --- a/convex/httpApiV1/shared.ts +++ b/convex/httpApiV1/shared.ts @@ -422,6 +422,71 @@ export async function parseMultipartPublish( return parsePublishBody(body); } +export async function parseMultipartSkillScan( + ctx: ActionCtx, + request: Request, + validatePayload?: (payload: Record) => Record, +): Promise<{ + payload: Record; + files: Array<{ + path: string; + size: number; + storageId: Id<"_storage">; + sha256: string; + contentType?: string; + }>; +}> { + const form = await request.formData(); + const payloadRaw = form.get("payload"); + if (!payloadRaw || typeof payloadRaw !== "string") { + throw new Error("Missing payload"); + } + let payload: Record; + try { + payload = JSON.parse(payloadRaw) as Record; + } catch { + throw new Error("Invalid JSON payload"); + } + const validatedPayload = validatePayload ? validatePayload(payload) : payload; + + const fileEntries = form + .getAll("files") + .map((entry) => toFileLike(entry)) + .filter((file): file is FileLikeEntry => Boolean(file)) + .filter((file) => !isMacJunkPath(file.name)); + if (fileEntries.length === 0) throw new Error("files required"); + if (!fileEntries.some((file) => file.name.trim().toLowerCase() === "skill.md")) { + throw new Error("SKILL.md required"); + } + const oversized = fileEntries.find((file) => file.size > MAX_PUBLISH_FILE_BYTES); + if (oversized) throw new Error(getPublishFileSizeError(oversized.name)); + + const files: Array<{ + path: string; + size: number; + storageId: Id<"_storage">; + sha256: string; + contentType?: string; + }> = []; + + try { + for (const file of fileEntries) { + const path = file.name; + const size = file.size; + const contentType = file.type || undefined; + const buffer = new Uint8Array(await file.arrayBuffer()); + const sha256 = await sha256Hex(buffer); + const storageId = await ctx.storage.store(file as Blob); + files.push({ path, size, storageId, sha256, contentType }); + } + } catch (error) { + await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId))); + throw error; + } + + return { payload: validatedPayload, files }; +} + export function parsePublishBody(body: unknown) { const parsed = parseArk(CliPublishRequestSchema, body, "Publish payload"); if (parsed.files.length === 0) throw new Error("files required"); diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index eac51153..cc88e7ea 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -1,7 +1,11 @@ import { + ApiRoutes, ApiV1SkillBulkRescanBatchRequestSchema, ApiV1SkillBulkRescanStatusRequestSchema, ApiV1SkillRepairVtPendingRequestSchema, + ApiV1SkillScanBatchRequestSchema, + ApiV1SkillScanBatchStatusRequestSchema, + ApiV1SkillScanSubmitRequestSchema, SkillAppealRequestSchema, SkillAppealResolveRequestSchema, SkillReportTriageRequestSchema, @@ -25,6 +29,7 @@ import type { import { selectGeneratedSkillCardFile, sourceSkillVersionFiles } from "../lib/skillCards"; import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "../lib/skillFileAccess"; import { + buildDeterministicZip, buildMergedExportZip, type MergedExportManifestEntry, validateSlug, @@ -37,6 +42,7 @@ import { getPathSegments, json, parseJsonPayload, + parseMultipartSkillScan, parseMultipartPublish, parsePublishBody, publicApiOrigin, @@ -279,7 +285,10 @@ type SkillSecuritySnapshot = { const internalRefs = internal as unknown as { securityScan: { + createUploadedSkillScanRequestInternal: unknown; + createPublishedSkillScanRequestInternal: unknown; enqueueBulkSkillRescanBatchForAdminInternal: unknown; + getSkillScanRequestForUserInternal: unknown; getBulkSkillRescanBatchStatusForAdminInternal: unknown; requestSkillRescanForUserInternal: unknown; }; @@ -309,6 +318,138 @@ async function runActionRef(ctx: ActionCtx, ref: unknown, args: unknown): Pro return (await ctx.runAction(ref as never, args as never)) as T; } +function isMultipartRequest(request: Request) { + return ( + request.headers.get("content-type")?.toLowerCase().includes("multipart/form-data") === true + ); +} + +async function deleteStoredScanFiles(ctx: ActionCtx, files: Array<{ storageId: Id<"_storage"> }>) { + await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId))); +} + +function encodeJsonEntry(value: unknown) { + return new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`); +} + +function encodeTextEntry(value: string) { + return new TextEncoder().encode(value); +} + +function scanReportPart(status: Record, key: string) { + const report = status.report; + if (!report || typeof report !== "object" || Array.isArray(report)) return null; + return (report as Record)[key] ?? null; +} + +function buildSkillScanReportZip(status: Record) { + const manifest = { + scanId: status.scanId, + sourceKind: status.sourceKind, + update: status.update, + status: status.status, + artifact: status.artifact ?? null, + createdAt: status.createdAt, + updatedAt: status.updatedAt, + completedAt: status.completedAt ?? null, + writtenBack: status.writtenBack === true, + }; + const scanIdText = typeof status.scanId === "string" ? status.scanId : ""; + const statusText = typeof status.status === "string" ? status.status : ""; + const readme = [ + "# ClawHub Scan Report", + "", + `Scan ID: ${scanIdText}`, + `Status: ${statusText}`, + "", + "This archive uses the ClawHub security-audit export shape:", + "", + "- manifest.json", + "- clawscan.json", + "- skillspector.json", + "- static-analysis.json", + "- virustotal.json", + "- README.md", + "", + ].join("\n"); + + return buildDeterministicZip([ + { path: "manifest.json", bytes: encodeJsonEntry(manifest) }, + { path: "clawscan.json", bytes: encodeJsonEntry(scanReportPart(status, "clawscan")) }, + { path: "skillspector.json", bytes: encodeJsonEntry(scanReportPart(status, "skillspector")) }, + { + path: "static-analysis.json", + bytes: encodeJsonEntry(scanReportPart(status, "staticAnalysis")), + }, + { path: "virustotal.json", bytes: encodeJsonEntry(scanReportPart(status, "virustotal")) }, + { path: "README.md", bytes: encodeTextEntry(readme) }, + ]); +} + +async function handleSkillScanBatchSubmit(ctx: ActionCtx, request: Request, headers: HeadersInit) { + const auth = await requireApiTokenUserOrResponse(ctx, request, headers); + if (!auth.ok) return auth.response; + const admin = requireAdminOrResponse(auth.user, headers); + if (!admin.ok) return admin.response; + try { + const body = parseArk( + ApiV1SkillScanBatchRequestSchema, + await request.json(), + "Skill scan batch payload", + ) as { + mode?: "all-active-latest"; + cursor?: string | null; + batchSize?: number; + dryRun?: boolean; + }; + const result = await runMutationRef( + ctx, + internalRefs.securityScan.enqueueBulkSkillRescanBatchForAdminInternal, + { + actorUserId: auth.userId, + ...(body.mode ? { mode: body.mode } : {}), + cursor: body.cursor ?? null, + ...(body.batchSize !== undefined ? { batchSize: body.batchSize } : {}), + ...(body.dryRun !== undefined ? { dryRun: body.dryRun } : {}), + }, + ); + return json(result, 200, headers); + } catch (error) { + if (error instanceof SyntaxError) return text("Invalid JSON", 400, headers); + return text(error instanceof Error ? error.message : "Skill scan batch failed", 400, headers); + } +} + +async function handleSkillScanBatchStatus(ctx: ActionCtx, request: Request, headers: HeadersInit) { + const auth = await requireApiTokenUserOrResponse(ctx, request, headers); + if (!auth.ok) return auth.response; + const admin = requireAdminOrResponse(auth.user, headers); + if (!admin.ok) return admin.response; + try { + const body = parseArk( + ApiV1SkillScanBatchStatusRequestSchema, + await request.json(), + "Skill scan batch status payload", + ) as { jobIds: string[] }; + const result = await runQueryRef( + ctx, + internalRefs.securityScan.getBulkSkillRescanBatchStatusForAdminInternal, + { + actorUserId: auth.userId, + jobIds: body.jobIds, + }, + ); + return json(result, 200, headers); + } catch (error) { + if (error instanceof SyntaxError) return text("Invalid JSON", 400, headers); + return text( + error instanceof Error ? error.message : "Skill scan batch status failed", + 400, + headers, + ); + } +} + function isDefinitiveSecurityStatus( status: NormalizedSecurityStatus | null | undefined, ): status is "clean" | "suspicious" | "malicious" { @@ -964,6 +1105,127 @@ export async function skillSecurityVerdictsV1Handler(ctx: ActionCtx, request: Re return json({ schema: "clawhub.skill.security-verdicts.v1", items }, 200, rate.headers); } +export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + + try { + if (isMultipartRequest(request)) { + const multipart = await parseMultipartSkillScan(ctx, request, (payload) => { + const parsed = parseArk( + ApiV1SkillScanSubmitRequestSchema, + payload, + "Skill scan payload", + ) as { + source: { kind: "upload" } | { kind: "published"; slug: string; version?: string }; + update?: boolean; + }; + if (parsed.source.kind !== "upload") { + throw new Error("multipart scan payload must use source.kind=upload"); + } + if (parsed.update === true) { + throw new Error("update is not valid for uploaded scans"); + } + return parsed; + }); + const result = await runMutationRef( + ctx, + internalRefs.securityScan.createUploadedSkillScanRequestInternal, + { + actorUserId: auth.userId, + files: multipart.files, + }, + ).catch(async (error) => { + await deleteStoredScanFiles(ctx, multipart.files); + throw error; + }); + return json(result, 202, rate.headers); + } + + const body = parseArk( + ApiV1SkillScanSubmitRequestSchema, + await request.json(), + "Skill scan payload", + ) as { + source: { kind: "upload" } | { kind: "published"; slug: string; version?: string }; + update?: boolean; + }; + if (body.source.kind === "upload") { + return text("uploaded scans must use multipart/form-data", 400, rate.headers); + } + const result = await runMutationRef( + ctx, + internalRefs.securityScan.createPublishedSkillScanRequestInternal, + { + actorUserId: auth.userId, + slug: body.source.slug, + ...(body.source.version ? { version: body.source.version } : {}), + update: body.update === true, + }, + ); + return json(result, 202, rate.headers); + } catch (error) { + if (error instanceof SyntaxError) return text("Invalid JSON", 400, rate.headers); + return text( + error instanceof Error ? error.message : "Skill scan submit failed", + 400, + rate.headers, + ); + } +} + +export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + + const segments = getPathSegments(request, `${ApiRoutes.skillScans}/`); + const scanId = segments[0]; + if (!scanId) return text("scanId required", 400, rate.headers); + + try { + const status = (await runQueryRef( + ctx, + internalRefs.securityScan.getSkillScanRequestForUserInternal, + { + actorUserId: auth.userId, + scanId: scanId as Id<"skillScanRequests">, + }, + )) as Record; + + if (segments.length === 1) return json(status, 200, rate.headers); + + if (segments.length === 2 && segments[1] === "download") { + if (status.status !== "succeeded") return text("Scan is not complete", 409, rate.headers); + const zip = buildSkillScanReportZip(status); + const headers = mergeHeaders(rate.headers, { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="clawhub-scan-${scanId}.zip"`, + }); + return new Response(zip, { status: 200, headers }); + } + + return text("Not found", 404, rate.headers); + } catch (error) { + return text(error instanceof Error ? error.message : "Skill scan failed", 400, rate.headers); + } +} + +export async function skillScanBatchSubmitV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + return handleSkillScanBatchSubmit(ctx, request, rate.headers); +} + +export async function skillScanBatchStatusV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + return handleSkillScanBatchStatus(ctx, request, rate.headers); +} + export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { const rate = await applyRateLimit(ctx, request, "read"); if (!rate.ok) return rate.response; diff --git a/convex/schema.ts b/convex/schema.ts index a4ab2aa6..e0016d46 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -111,6 +111,53 @@ const llmRiskSummaryBucketValidator = v.object({ highestSeverity: v.optional(v.string()), }); +const llmAnalysisValidator = v.object({ + status: v.string(), + verdict: v.optional(v.string()), + confidence: v.optional(v.string()), + summary: v.optional(v.string()), + dimensions: v.optional( + v.array( + v.object({ + name: v.string(), + label: v.string(), + rating: v.string(), + detail: v.string(), + }), + ), + ), + guidance: v.optional(v.string()), + findings: v.optional(v.string()), + agenticRiskFindings: v.optional(v.array(llmAgenticRiskFindingValidator)), + riskSummary: v.optional( + v.object({ + abnormal_behavior_control: llmRiskSummaryBucketValidator, + permission_boundary: llmRiskSummaryBucketValidator, + sensitive_data_protection: llmRiskSummaryBucketValidator, + }), + ), + model: v.optional(v.string()), + checkedAt: v.number(), +}); + +const staticScanValidator = 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 users = defineTable({ name: v.optional(v.string()), image: v.optional(v.string()), @@ -413,6 +460,7 @@ const packageReleaseModerationOverrideValidator = v.object({ const securityScanTargetKindValidator = v.union( v.literal("skillVersion"), v.literal("packageRelease"), + v.literal("skillScanRequest"), ); const securityScanJobStatusValidator = v.union( v.literal("queued"), @@ -450,6 +498,8 @@ const packageFilesValidator = v.array( }), ); +const skillScanRequestSourceKindValidator = v.union(v.literal("upload"), v.literal("published")); + const skills = defineTable({ slug: v.string(), displayName: v.string(), @@ -1125,6 +1175,7 @@ const securityScanJobs = defineTable({ targetKind: securityScanTargetKindValidator, skillVersionId: v.optional(v.id("skillVersions")), packageReleaseId: v.optional(v.id("packageReleases")), + skillScanRequestId: v.optional(v.id("skillScanRequests")), status: securityScanJobStatusValidator, source: securityScanJobSourceValidator, priority: v.number(), @@ -1148,7 +1199,48 @@ const securityScanJobs = defineTable({ .index("by_status_and_lease_expires_at", ["status", "leaseExpiresAt"]) .index("by_status_malicious_signal_next_run_at", ["status", "hasMaliciousSignal", "nextRunAt"]) .index("by_skill_version", ["skillVersionId"]) - .index("by_package_release", ["packageReleaseId"]); + .index("by_package_release", ["packageReleaseId"]) + .index("by_skill_scan_request", ["skillScanRequestId"]); + +const skillScanRequests = defineTable({ + actorUserId: v.id("users"), + sourceKind: skillScanRequestSourceKindValidator, + update: v.boolean(), + writtenBack: v.boolean(), + status: securityScanJobStatusValidator, + securityScanJobId: v.optional(v.id("securityScanJobs")), + 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")), + files: packageFilesValidator, + parsed: v.optional( + v.object({ + frontmatter: v.record(v.string(), v.any()), + metadata: v.optional(v.any()), + clawdis: v.optional(v.any()), + moltbot: v.optional(v.any()), + license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)), + }), + ), + sha256hash: v.optional(v.string()), + vtAnalysis: v.optional(vtAnalysisValidator), + skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator), + llmAnalysis: v.optional(llmAnalysisValidator), + capabilityTags: v.optional(v.array(v.string())), + staticScan: v.optional(staticScanValidator), + lastError: v.optional(v.string()), + runId: v.optional(v.string()), + completedAt: v.optional(v.number()), + expiresAt: v.number(), + createdAt: v.number(), + updatedAt: v.number(), +}) + .index("by_actor_user_id_and_created_at", ["actorUserId", "createdAt"]) + .index("by_security_scan_job_id", ["securityScanJobId"]) + .index("by_skill_version_id_and_created_at", ["skillVersionId", "createdAt"]) + .index("by_expires_at", ["expiresAt"]); const skillCardGenerationJobs = defineTable({ skillId: v.id("skills"), @@ -2151,6 +2243,7 @@ export default defineSchema({ packages, packageReleases, securityScanJobs, + skillScanRequests, skillCardGenerationJobs, packageStatEvents, packageTrustedPublishers, diff --git a/convex/securityScan.test.ts b/convex/securityScan.test.ts index 52aad187..99a47c47 100644 --- a/convex/securityScan.test.ts +++ b/convex/securityScan.test.ts @@ -9,6 +9,7 @@ import { enqueueBulkSkillRescanBatchForAdminInternal, failCodexScanJob, getBulkSkillRescanBatchStatusForAdminInternal, + pruneExpiredSkillScanRequestsInternal, requestPackageRescanForUserInternal, requestPackageRescan, requestSkillRescanForUserInternal, @@ -123,6 +124,12 @@ const clearQueuedBackfillJobsForLocalDevHandler = ( { dryRun: boolean; matched: number; deleted: number; sampleDeletedJobIds: string[] } > )._handler; +const pruneExpiredSkillScanRequestsInternalHandler = ( + pruneExpiredSkillScanRequestsInternal as unknown as WrappedHandler< + { batchSize?: number }, + { ok: true; deletedRequests: number; deletedJobs: number; deletedFiles: number; done: boolean } + > +)._handler; const requestSkillRescanHandler = ( requestSkillRescan as unknown as WrappedHandler< @@ -1206,6 +1213,81 @@ describe("securityScan", () => { expect(deleted).toEqual(["securityScanJobs:backfill-1", "securityScanJobs:backfill-2"]); }); + it("prunes expired uploaded scan request blobs without deleting published version files", async () => { + const requests = [ + { + _id: "skillScanRequests:upload", + sourceKind: "upload", + securityScanJobId: "securityScanJobs:upload", + files: [{ storageId: "storage:upload-1" }, { storageId: "storage:upload-2" }], + }, + { + _id: "skillScanRequests:published", + sourceKind: "published", + securityScanJobId: "securityScanJobs:published", + files: [{ storageId: "storage:published-version-file" }], + }, + ]; + const deletedDocs: string[] = []; + const deletedStorage: string[] = []; + const take = vi.fn(async () => requests); + const indexBuilder = { + lt: vi.fn(() => indexBuilder), + }; + const withIndex = vi.fn( + (indexName: string, buildRange: (q: typeof indexBuilder) => unknown) => { + expect(indexName).toBe("by_expires_at"); + buildRange(indexBuilder); + expect(indexBuilder.lt).toHaveBeenCalledWith("expiresAt", expect.any(Number)); + return { take }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + expect(tableName).toBe("skillScanRequests"); + return { withIndex }; + }), + insert: vi.fn(async () => "noop"), + patch: vi.fn(async () => undefined), + replace: vi.fn(async () => undefined), + get: vi.fn(async (id: string) => ({ + _id: id, + targetKind: "skillScanRequest", + })), + delete: vi.fn(async (id: string) => { + deletedDocs.push(id); + }), + normalizeId: vi.fn(() => null), + system: {}, + }, + storage: { + delete: vi.fn(async (id: string) => { + deletedStorage.push(id); + }), + }, + }; + + const result = await pruneExpiredSkillScanRequestsInternalHandler(ctx as never, { + batchSize: 10, + }); + + expect(result).toEqual({ + ok: true, + deletedRequests: 2, + deletedJobs: 2, + deletedFiles: 2, + done: true, + }); + expect(deletedStorage).toEqual(["storage:upload-1", "storage:upload-2"]); + expect(deletedDocs).toEqual([ + "securityScanJobs:upload", + "skillScanRequests:upload", + "securityScanJobs:published", + "skillScanRequests:published", + ]); + }); + it("fails claimed package jobs when the ClawPack URL is unavailable", async () => { vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret"); diff --git a/convex/securityScan.ts b/convex/securityScan.ts index 60902c17..bc7f016b 100644 --- a/convex/securityScan.ts +++ b/convex/securityScan.ts @@ -18,6 +18,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_BULK_RESCAN_BATCH_SIZE = 50; const MAX_BULK_RESCAN_BATCH_SIZE = 100; const MAX_BULK_RESCAN_STATUS_JOB_IDS = 200; @@ -25,6 +27,7 @@ const BULK_RESCAN_SAMPLE_LIMIT = 10; const MAX_STORED_SKILLSPECTOR_ISSUES = 25; const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000; const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512; +const DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; const finalLlmAnalysisStatuses = new Set(["clean", "suspicious", "malicious"]); const artifactBackedLlmAnalysisStatuses = new Set(["clean", "benign", "suspicious", "malicious"]); @@ -42,8 +45,10 @@ type CancelSkipReason = type JobTarget = { job: Doc<"securityScanJobs">; + skill?: Doc<"skills"> | null; version?: Doc<"skillVersions">; release?: Doc<"packageReleases">; + scanRequest?: Doc<"skillScanRequests">; missing?: true; }; @@ -206,6 +211,14 @@ const skillSpectorAnalysisValidator = v.object({ checkedAt: v.number(), }); +const scanRequestFileValidator = v.object({ + path: v.string(), + size: v.number(), + storageId: v.id("_storage"), + sha256: v.string(), + contentType: v.optional(v.string()), +}); + const internalRefs = internal as unknown as { packages: { getPackageByIdInternal: unknown; @@ -215,10 +228,15 @@ const internalRefs = internal as unknown as { }; securityScan: { claimQueuedJobsInternal: unknown; + createUploadedSkillScanRequestInternal: unknown; + createPublishedSkillScanRequestInternal: unknown; enqueuePackageReleaseScanInternal: unknown; enqueueSkillVersionScanInternal: unknown; failJobInternal: unknown; + getSkillScanRequestForUserInternal: unknown; getJobTargetInternal: unknown; + recordSkillScanRequestFailedInternal: unknown; + recordSkillScanRequestSucceededInternal: unknown; succeedJobInternal: unknown; }; skills: { @@ -754,6 +772,362 @@ export const requestSkillRescan = mutation({ }, }); +function skillScanRequestExpiresAt(now: number) { + return now + DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS; +} + +function skillScanReportFromRequest(request: Doc<"skillScanRequests">) { + return { + clawscan: request.llmAnalysis ?? null, + skillspector: request.skillSpectorAnalysis ?? null, + staticAnalysis: request.staticScan ?? null, + virustotal: request.vtAnalysis + ? { + ...request.vtAnalysis, + ...request.vtAnalysis.engineStats, + } + : null, + }; +} + +function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) { + return { + ...(request.slug ? { slug: request.slug } : {}), + ...(request.displayName ? { displayName: request.displayName } : {}), + ...(request.version ? { version: request.version } : {}), + ...(request.sha256hash ? { sha256hash: request.sha256hash } : {}), + fileCount: request.files.length, + }; +} + +function skillScanStatusResponse( + request: Doc<"skillScanRequests">, + job: Doc<"securityScanJobs"> | null, +) { + const status = + request.status === "succeeded" || request.status === "failed" + ? request.status + : (job?.status ?? request.status); + return { + ok: true as const, + scanId: request._id, + jobId: request.securityScanJobId, + status, + sourceKind: request.sourceKind, + update: request.update, + writtenBack: request.writtenBack, + artifact: skillScanArtifactFromRequest(request), + report: skillScanReportFromRequest(request), + lastError: request.lastError ?? job?.lastError, + createdAt: request.createdAt, + updatedAt: Math.max(request.updatedAt, job?.updatedAt ?? request.updatedAt), + completedAt: request.completedAt ?? job?.completedAt, + }; +} + +async function enqueueSkillScanRequestJob(ctx: MutationCtx, requestId: Id<"skillScanRequests">) { + const request = await ctx.db.get(requestId); + if (!request) throw new ConvexError("Scan request not found"); + const now = Date.now(); + const jobId = await ctx.db.insert("securityScanJobs", { + targetKind: "skillScanRequest", + skillScanRequestId: request._id, + status: "queued", + source: "manual", + priority: 100, + hasMaliciousSignal: false, + waitForVtUntil: now, + nextRunAt: now, + attempts: 0, + createdAt: now, + updatedAt: now, + }); + await ctx.db.patch(request._id, { + securityScanJobId: jobId, + updatedAt: now, + }); + return jobId; +} + +export const createUploadedSkillScanRequestInternal = internalMutation({ + args: { + actorUserId: v.id("users"), + files: v.array(scanRequestFileValidator), + displayName: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + if (!actor) throw new ConvexError("Unauthorized"); + if (args.files.length === 0) throw new ConvexError("files required"); + if ( + !args.files.some((file) => { + const lower = file.path.trim().toLowerCase(); + return lower === "skill.md"; + }) + ) { + throw new ConvexError("SKILL.md required"); + } + + const now = Date.now(); + const scanId = await ctx.db.insert("skillScanRequests", { + actorUserId: actor._id, + sourceKind: "upload", + update: false, + writtenBack: false, + status: "queued", + displayName: args.displayName, + version: "local", + files: args.files, + expiresAt: skillScanRequestExpiresAt(now), + createdAt: now, + updatedAt: now, + }); + const jobId = await enqueueSkillScanRequestJob(ctx, scanId); + + await ctx.db.insert("auditLogs", { + actorUserId: actor._id, + action: "skill.clawscan.scan_upload", + targetType: "skillScanRequest", + targetId: scanId, + metadata: { + jobId, + fileCount: args.files.length, + }, + createdAt: now, + }); + + return { + ok: true as const, + scanId, + jobId, + status: "queued" as const, + sourceKind: "upload" as const, + update: false, + alreadyQueued: false, + }; + }, +}); + +export const createPublishedSkillScanRequestInternal = internalMutation({ + args: { + actorUserId: v.id("users"), + slug: v.string(), + version: v.optional(v.string()), + update: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + if (!actor) throw new ConvexError("Unauthorized"); + + const slug = args.slug.trim().toLowerCase(); + if (!slug) throw new ConvexError("Slug required"); + const skill = await ctx.db + .query("skills") + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .unique(); + if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found"); + + await assertCanManageOwnedResource(ctx, { + actor, + ownerUserId: skill.ownerUserId, + ownerPublisherId: skill.ownerPublisherId, + allowPlatformModerator: true, + }); + + const requestedVersion = args.version?.trim(); + const version = requestedVersion + ? await ctx.db + .query("skillVersions") + .withIndex("by_skill_version", (q) => + q.eq("skillId", skill._id).eq("version", requestedVersion), + ) + .unique() + : skill.latestVersionId + ? await ctx.db.get(skill.latestVersionId) + : null; + if (!version || version.softDeletedAt) throw new ConvexError("Skill version not found"); + + const fingerprintEntries = await ctx.db + .query("skillVersionFingerprints") + .withIndex("by_version", (q) => q.eq("versionId", version._id)) + .collect(); + const files = sourceSkillVersionFiles(version.files, { + generatedBundleFingerprints: fingerprintEntries + .filter((entry) => entry.kind === "generated-bundle") + .map((entry) => entry.fingerprint), + }); + + const now = Date.now(); + const update = args.update === true; + const scanId = await ctx.db.insert("skillScanRequests", { + actorUserId: actor._id, + sourceKind: "published", + update, + writtenBack: false, + status: "queued", + slug: skill.slug, + displayName: skill.displayName, + version: version.version, + skillId: skill._id, + skillVersionId: version._id, + files, + parsed: version.parsed, + sha256hash: version.sha256hash, + vtAnalysis: version.vtAnalysis, + capabilityTags: version.capabilityTags, + staticScan: version.staticScan, + expiresAt: skillScanRequestExpiresAt(now), + createdAt: now, + updatedAt: now, + }); + const jobId = await enqueueSkillScanRequestJob(ctx, scanId); + + await ctx.db.insert("auditLogs", { + actorUserId: actor._id, + action: update ? "skill.clawscan.scan_published_update" : "skill.clawscan.scan_published", + targetType: "skillVersion", + targetId: version._id, + metadata: { + skillId: skill._id, + slug: skill.slug, + version: version.version, + scanId, + jobId, + update, + }, + createdAt: now, + }); + + return { + ok: true as const, + scanId, + jobId, + status: "queued" as const, + sourceKind: "published" as const, + update, + alreadyQueued: false, + }; + }, +}); + +export const getSkillScanRequestForUserInternal = internalQuery({ + args: { + actorUserId: v.id("users"), + scanId: v.id("skillScanRequests"), + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + if (!actor) throw new ConvexError("Unauthorized"); + const request = await ctx.db.get(args.scanId); + if (!request) throw new ConvexError("Scan not found"); + if (request.actorUserId !== actor._id && actor.role !== "admin" && actor.role !== "moderator") { + throw new ConvexError("Forbidden"); + } + const job = request.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null; + return skillScanStatusResponse(request, job); + }, +}); + +export const recordSkillScanRequestSucceededInternal = internalMutation({ + args: { + scanId: v.id("skillScanRequests"), + jobId: v.id("securityScanJobs"), + runId: v.optional(v.string()), + llmAnalysis: llmAnalysisValidator, + skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator), + writtenBack: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.scanId); + if (!request) throw new ConvexError("Scan request not found"); + const now = Date.now(); + await ctx.db.patch(request._id, { + status: "succeeded", + llmAnalysis: args.llmAnalysis, + ...(args.skillSpectorAnalysis + ? { skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis) } + : {}), + writtenBack: args.writtenBack === true || request.writtenBack, + runId: args.runId, + completedAt: now, + updatedAt: now, + }); + return { ok: true as const }; + }, +}); + +export const recordSkillScanRequestFailedInternal = internalMutation({ + args: { + scanId: v.id("skillScanRequests"), + error: v.string(), + llmAnalysis: v.optional(llmAnalysisValidator), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.scanId); + if (!request) throw new ConvexError("Scan request not found"); + const now = Date.now(); + await ctx.db.patch(request._id, { + status: "failed", + lastError: args.error.slice(0, 2000), + ...(args.llmAnalysis ? { llmAnalysis: args.llmAnalysis } : {}), + completedAt: now, + updatedAt: now, + }); + return { ok: true as const }; + }, +}); + +export const pruneExpiredSkillScanRequestsInternal = internalMutation({ + args: { + batchSize: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const batchSize = Math.max( + 1, + Math.min( + args.batchSize ?? DEFAULT_PRUNE_SKILL_SCAN_REQUEST_LIMIT, + MAX_PRUNE_SKILL_SCAN_REQUEST_LIMIT, + ), + ); + const now = Date.now(); + const requests = await ctx.db + .query("skillScanRequests") + .withIndex("by_expires_at", (q) => q.lt("expiresAt", now)) + .take(batchSize); + + let deletedJobs = 0; + let deletedFiles = 0; + for (const request of requests) { + if (request.securityScanJobId) { + const job = await ctx.db.get(request.securityScanJobId); + if (job?.targetKind === "skillScanRequest") { + await ctx.db.delete(job._id); + deletedJobs += 1; + } + } + if (request.sourceKind === "upload") { + for (const file of request.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(request._id); + } + + return { + ok: true as const, + deletedRequests: requests.length, + deletedJobs, + deletedFiles, + done: requests.length < batchSize, + }; + }, +}); + async function requestPackageRescanForActor( ctx: MutationCtx, args: { @@ -1168,6 +1542,13 @@ export const claimQueuedJobsInternal = internalMutation({ lastError: undefined, updatedAt: now, }); + if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { + await ctx.db.patch(job.skillScanRequestId, { + status: "running", + lastError: undefined, + updatedAt: now, + }); + } claimed.push({ ...job, status: "running" as const, @@ -1206,6 +1587,15 @@ export const getJobTargetInternal = internalQuery({ trustedOpenClawPlugin: isOpenClawPluginPackage(pkg, ownerPublisher), }; } + if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { + const scanRequest = await ctx.db.get(job.skillScanRequestId); + if (!scanRequest) return { job, missing: true as const }; + const version = scanRequest.skillVersionId + ? 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 }; + } return { job, missing: true as const }; }, }); @@ -1252,6 +1642,14 @@ export const failJobInternal = internalMutation({ workerId: undefined, updatedAt: now, }); + if (job.targetKind === "skillScanRequest" && job.skillScanRequestId) { + await ctx.db.patch(job.skillScanRequestId, { + status: retry ? "queued" : "failed", + lastError: args.error.slice(0, 2000), + ...(retry ? {} : { completedAt: now }), + updatedAt: now, + }); + } return { ok: true as const, retry }; }, }); @@ -1291,6 +1689,7 @@ export const claimCodexScanJobs = action({ continue; } + const scanRequest = target.scanRequest as Doc<"skillScanRequests"> | undefined; const version = target.version as Doc<"skillVersions"> | undefined; const release = target.release as Doc<"packageReleases"> | undefined; let files: Array<{ @@ -1300,7 +1699,9 @@ export const claimCodexScanJobs = action({ storageId: Id<"_storage">; contentType?: string; }> = []; - if (version) { + if (scanRequest) { + files = scanRequest.files; + } else if (version) { const fingerprintEntries = await runQueryRef< Array<{ fingerprint: string; kind?: "source" | "generated-bundle" }> >(ctx, internalRefs.skills.listVersionFingerprintsInternal, { @@ -1406,6 +1807,33 @@ export const completeCodexScanJob = action({ releaseId: target.release._id, llmAnalysis: args.llmAnalysis, }); + } else if (target.job.targetKind === "skillScanRequest" && target.scanRequest) { + let writtenBack = false; + if ( + target.scanRequest.sourceKind === "published" && + target.scanRequest.update && + target.version + ) { + if (args.skillSpectorAnalysis) { + await runMutationRef(ctx, internalRefs.skills.updateVersionSkillSpectorAnalysisInternal, { + versionId: target.version._id, + skillSpectorAnalysis: capSkillSpectorAnalysisForStorage(args.skillSpectorAnalysis), + }); + } + await runMutationRef(ctx, internalRefs.skills.updateVersionLlmAnalysisInternal, { + versionId: target.version._id, + llmAnalysis: args.llmAnalysis, + }); + writtenBack = true; + } + await runMutationRef(ctx, internalRefs.securityScan.recordSkillScanRequestSucceededInternal, { + scanId: target.scanRequest._id, + jobId: args.jobId, + runId: args.runId, + llmAnalysis: args.llmAnalysis, + skillSpectorAnalysis: args.skillSpectorAnalysis, + writtenBack, + }); } else { throw new ConvexError("Unsupported security scan target"); } @@ -1462,6 +1890,16 @@ export const failCodexScanJob = action({ llmAnalysis, }); } + } else if (target.job.targetKind === "skillScanRequest" && target.scanRequest) { + await runMutationRef( + ctx, + internalRefs.securityScan.recordSkillScanRequestFailedInternal, + { + scanId: target.scanRequest._id, + error: args.error, + llmAnalysis, + }, + ); } } } diff --git a/docs/cli.md b/docs/cli.md index 57656c9d..12e64e7a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -189,6 +189,24 @@ Stores your API token + cached registry URL. clawhub skill publish ./my-skill --version 1.0.0 ``` +### `scan [path]` + +- Requires `clawhub login`. +- Runs ClawHub ClawScan through `POST /api/v1/skills/-/scan`, then polls until the scan is terminal. +- Local path scans are always ephemeral. They upload the local skill bundle for scanning, print the security report, and never create or update a published skill/version. +- Published scans require ownership or publisher management access. Moderators/admins can use the same backend through `clawhub-mod`. +- `--update` is valid only with `--slug`; it writes successful published scan results back to the selected version. +- `--output ` downloads the full report archive with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`. +- `--json` prints the full poll response for automation. + +```bash +clawhub scan ./my-skill +clawhub scan ./my-skill --output report.zip +clawhub scan --slug gifgrep +clawhub scan --slug gifgrep --version 1.2.3 +clawhub scan --slug gifgrep --update --output report.zip +``` + #### GitHub Actions ClawHub ships an official reusable workflow at diff --git a/docs/http-api.md b/docs/http-api.md index dabd8b3b..4618f387 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -369,6 +369,56 @@ Notes: - `moderation` is a current skill-level moderation snapshot derived from the latest version. - When querying a historical version, check `moderation.matchesRequestedVersion` and `moderation.sourceVersion` before treating `moderation` and `security` as the same version context. +### `POST /api/v1/skills/-/scan` + +Authenticated submit endpoint for new ClawScan jobs. + +Local upload scans use `multipart/form-data`: + +- `payload`: JSON string, usually `{ "source": { "kind": "upload" }, "update": false }` +- `files`: repeated local skill files + +Published scans use JSON: + +```json +{ + "source": { "kind": "published", "slug": "gifgrep", "version": "1.2.3" }, + "update": false +} +``` + +Notes: + +- Local upload scans require auth but are ephemeral. They never mutate public skill, version, moderation, or trust state. +- Scan request payloads and downloadable reports expire from the scan-request store after the retention window. +- Local upload scans reject `update: true`. +- Published scans require owner/publisher management access, or platform moderator/admin authority. +- Published scans write back only when `update: true` and the scan completes successfully. +- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "upload|published", "update": false }`. + +### `GET /api/v1/skills/-/scan/{scanId}` + +Authenticated poll endpoint for a submitted scan. + +- Returns queued/running/succeeded/failed status. +- When available, `report` contains `clawscan`, `skillspector`, `staticAnalysis`, and `virustotal` sections. +- Failed scan jobs return `status: "failed"` with `lastError`. + +### `GET /api/v1/skills/-/scan/{scanId}/download` + +Authenticated report archive endpoint. + +- Requires a succeeded scan; non-terminal scans return `409`. +- Returns a ZIP with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`. + +### `POST /api/v1/skills/-/scan/batch` + +Admin-only canonical batch rescan route. It accepts the same payload shape as legacy `POST /api/v1/skills/-/rescan-batch`. + +### `POST /api/v1/skills/-/scan/batch/status` + +Admin-only canonical batch status route. It accepts `{ "jobIds": ["..."] }` and returns the same aggregate counters as legacy `POST /api/v1/skills/-/rescan-batch/status`. + ### `GET /api/v1/skills/{slug}/verify` Returns the Skill Card verification envelope used by `clawhub skill verify`. diff --git a/packages/clawhub-mod/src/commands/moderation.test.ts b/packages/clawhub-mod/src/commands/moderation.test.ts index 4a4f5a86..a0d4afb1 100644 --- a/packages/clawhub-mod/src/commands/moderation.test.ts +++ b/packages/clawhub-mod/src/commands/moderation.test.ts @@ -170,12 +170,11 @@ describe("cmdRescanSkill", () => { it("posts a moderator skill rescan request", async () => { httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, - slug: "markdown2doc", - version: "1.0.4", - skillId: "skills:1", - skillVersionId: "skillVersions:1", + scanId: "skillScanRequests:1", jobId: "securityScanJobs:1", - alreadyQueued: false, + status: "queued", + sourceKind: "published", + update: true, }); const result = await cmdRescanSkill( @@ -185,14 +184,17 @@ describe("cmdRescanSkill", () => { false, ); - expect(result).toMatchObject({ ok: true, slug: "markdown2doc", version: "1.0.4" }); + expect(result).toMatchObject({ ok: true, scanId: "skillScanRequests:1", update: true }); expect(httpMocks.apiRequest).toHaveBeenCalledWith( "https://clawhub.ai", expect.objectContaining({ method: "POST", - path: "/api/v1/skills/markdown2doc/rescan", + path: "/api/v1/skills/-/scan", token: "tkn", - body: { version: "1.0.4" }, + body: { + source: { kind: "published", slug: "markdown2doc", version: "1.0.4" }, + update: true, + }, }), expect.anything(), ); @@ -242,7 +244,7 @@ describe("cmdRescanAllSkills", () => { "https://clawhub.ai", expect.objectContaining({ method: "POST", - path: "/api/v1/skills/-/rescan-batch", + path: "/api/v1/skills/-/scan/batch", body: { mode: "all-active-latest", cursor: null, @@ -257,7 +259,7 @@ describe("cmdRescanAllSkills", () => { "https://clawhub.ai", expect.objectContaining({ method: "POST", - path: "/api/v1/skills/-/rescan-batch", + path: "/api/v1/skills/-/scan/batch", body: { mode: "all-active-latest", cursor: "cursor-2", @@ -307,7 +309,7 @@ describe("cmdRescanAllSkills", () => { "https://clawhub.ai", expect.objectContaining({ method: "POST", - path: "/api/v1/skills/-/rescan-batch", + path: "/api/v1/skills/-/scan/batch", token: "tkn", body: { mode: "all-active-latest", @@ -323,7 +325,7 @@ describe("cmdRescanAllSkills", () => { "https://clawhub.ai", expect.objectContaining({ method: "POST", - path: "/api/v1/skills/-/rescan-batch/status", + path: "/api/v1/skills/-/scan/batch/status", token: "tkn", body: { jobIds: ["securityScanJobs:1"] }, }), diff --git a/packages/clawhub-mod/src/commands/moderation.ts b/packages/clawhub-mod/src/commands/moderation.ts index 56d52c31..10625155 100644 --- a/packages/clawhub-mod/src/commands/moderation.ts +++ b/packages/clawhub-mod/src/commands/moderation.ts @@ -16,10 +16,10 @@ import { ApiV1ReclassifyBanResponseSchema, ApiV1RemediateAutobansResponseSchema, ApiV1SetRoleResponseSchema, - ApiV1SkillBulkRescanBatchResponseSchema, - ApiV1SkillBulkRescanStatusResponseSchema, + ApiV1SkillScanBatchResponseSchema, + ApiV1SkillScanBatchStatusResponseSchema, + ApiV1SkillScanSubmitResponseSchema, ApiV1SkillRepairVtPendingResponseSchema, - ApiV1SkillRescanResponseSchema, ApiV1UnbanUserResponseSchema, ApiV1UserSearchResponseSchema, parseArk, @@ -217,15 +217,22 @@ export async function cmdRescanSkill( registry, { method: "POST", - path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/rescan`, + path: ApiRoutes.skillScans, token, - body: version ? { version } : {}, + body: { + source: { + kind: "published", + slug, + ...(version ? { version } : {}), + }, + update: true, + }, }, - ApiV1SkillRescanResponseSchema, + ApiV1SkillScanSubmitResponseSchema, ); - const parsed = parseArk(ApiV1SkillRescanResponseSchema, result, "Skill rescan response"); + const parsed = parseArk(ApiV1SkillScanSubmitResponseSchema, result, "Skill rescan response"); spinner?.succeed( - `OK. Queued ClawScan for ${parsed.slug}@${parsed.version} (${parsed.alreadyQueued ? "existing job" : "new job"}).`, + `OK. Queued ClawScan for ${slug}${version ? `@${version}` : ""} (${parsed.alreadyQueued ? "existing job" : "new job"}).`, ); if (options.json) { process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`); @@ -283,7 +290,7 @@ export async function cmdRescanAllSkills( registry, { method: "POST", - path: `${ApiRoutes.skills}/-/rescan-batch`, + path: `${ApiRoutes.skillScans}/batch`, token, body: { mode: "all-active-latest", @@ -292,10 +299,10 @@ export async function cmdRescanAllSkills( dryRun: options.dryRun === true, }, }, - ApiV1SkillBulkRescanBatchResponseSchema, + ApiV1SkillScanBatchResponseSchema, ); const batch = parseArk( - ApiV1SkillBulkRescanBatchResponseSchema, + ApiV1SkillScanBatchResponseSchema, result, "Bulk skill rescan batch response", ); @@ -473,14 +480,14 @@ async function pollBulkRescanStatus( registry, { method: "POST", - path: `${ApiRoutes.skills}/-/rescan-batch/status`, + path: `${ApiRoutes.skillScans}/batch/status`, token, body: { jobIds }, }, - ApiV1SkillBulkRescanStatusResponseSchema, + ApiV1SkillScanBatchStatusResponseSchema, ); const status = parseArk( - ApiV1SkillBulkRescanStatusResponseSchema, + ApiV1SkillScanBatchStatusResponseSchema, result, "Bulk skill rescan status response", ); diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index cddb80b7..512377e1 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -31,6 +31,7 @@ import { } from "./cli/commands/packages.js"; import { cmdPublish } from "./cli/commands/publish.js"; import { cmdCreatePublisher } from "./cli/commands/publishers.js"; +import { cmdScan } from "./cli/commands/scan.js"; import { cmdExplore, cmdInstall, @@ -371,6 +372,19 @@ registerCommand(program, ["publish"]) await cmdPublish(opts, folder, options); }); +registerCommand(program, ["scan"]) + .description("Run ClawScan on a local skill bundle or one of your published skills") + .argument("[path]", "Local skill folder path") + .option("--slug ", "Published skill slug to scan") + .option("--version ", "Published skill version to scan") + .option("--update", "Write published scan results back to the selected version") + .option("-o, --output ", "Write the full report ZIP to a file") + .option("--json", "Output scan report JSON") + .action(async (folder, options) => { + const opts = await resolveGlobalOpts(); + await cmdScan(opts, folder, options); + }); + registerCommand(program, ["delete"]) .description("Soft-delete one of your skills") .argument("", "Skill slug") diff --git a/packages/clawhub/src/cli/commands/scan.test.ts b/packages/clawhub/src/cli/commands/scan.test.ts new file mode 100644 index 00000000..02255ccb --- /dev/null +++ b/packages/clawhub/src/cli/commands/scan.test.ts @@ -0,0 +1,214 @@ +/* @vitest-environment node */ + +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createAuthTokenModuleMocks, + createHttpModuleMocks, + createRegistryModuleMocks, + createUiModuleMocks, + makeGlobalOpts, +} from "../../../test/cliCommandTestKit.js"; +import { ApiRoutes } from "../../schema/index.js"; + +const authTokenMocks = createAuthTokenModuleMocks(); +const registryMocks = createRegistryModuleMocks(); +const httpMocks = createHttpModuleMocks(); +const uiMocks = createUiModuleMocks(); + +vi.mock("../authToken.js", () => authTokenMocks.moduleFactory()); +vi.mock("../registry.js", () => registryMocks.moduleFactory()); +vi.mock("../../http.js", () => httpMocks.moduleFactory()); +vi.mock("../ui.js", () => uiMocks.moduleFactory()); + +const { cmdScan } = await import("./scan"); + +const mockLog = vi.spyOn(console, "log").mockImplementation(() => {}); +const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + +async function makeTmpWorkdir() { + return await mkdtemp(join(tmpdir(), "clawhub-scan-")); +} + +function completedScan(overrides: Record = {}) { + return { + ok: true, + scanId: "scan_123", + jobId: "job_123", + status: "succeeded", + sourceKind: "published", + update: false, + writtenBack: false, + artifact: { + slug: "demo", + displayName: "Demo", + version: "1.2.3", + }, + report: { + clawscan: { + status: "clean", + verdict: "clean", + confidence: "high", + summary: "No suspicious behavior found.", + guidance: "OK to publish.", + findings: "No findings.", + checkedAt: 1_700_000_000_000, + }, + skillspector: { + status: "clean", + score: 100, + severity: "none", + issueCount: 0, + issues: [], + checkedAt: 1_700_000_000_000, + }, + staticAnalysis: { + status: "clean", + reasonCodes: [], + findings: [], + summary: "Static checks passed.", + checkedAt: 1_700_000_000_000, + }, + virustotal: null, + }, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_100_000, + completedAt: 1_700_000_100_000, + ...overrides, + }; +} + +afterEach(() => { + vi.clearAllMocks(); + mockLog.mockClear(); + mockWrite.mockClear(); + process.exitCode = undefined; +}); + +describe("cmdScan", () => { + it("uploads a local skill bundle and polls until complete", async () => { + const workdir = await makeTmpWorkdir(); + try { + const folder = join(workdir, "local-skill"); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, "SKILL.md"), "# Local Skill\n", "utf8"); + await writeFile(join(folder, "notes.md"), "notes\n", "utf8"); + + httpMocks.apiRequestForm.mockResolvedValueOnce({ + ok: true, + scanId: "scan_123", + jobId: "job_123", + status: "queued", + sourceKind: "upload", + update: false, + }); + httpMocks.apiRequest.mockResolvedValueOnce(completedScan({ sourceKind: "upload" })); + + await cmdScan(makeGlobalOpts(workdir), "local-skill", {}); + + const submitCall = httpMocks.apiRequestForm.mock.calls[0]; + expect(submitCall?.[1]).toMatchObject({ + method: "POST", + path: ApiRoutes.skillScans, + token: "tkn", + }); + if (!submitCall) throw new Error("missing scan submit call"); + const form = (submitCall[1] as { form: FormData }).form; + const payloadRaw = form.get("payload"); + expect(typeof payloadRaw).toBe("string"); + expect(JSON.parse(payloadRaw as string)).toEqual({ + source: { kind: "upload" }, + update: false, + }); + const files = form?.getAll("files") as Array; + expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]); + expect(httpMocks.apiRequest).toHaveBeenCalledWith( + "https://clawhub.ai", + expect.objectContaining({ + method: "GET", + path: `${ApiRoutes.skillScans}/scan_123`, + token: "tkn", + }), + expect.anything(), + ); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("ClawScan")); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining("No suspicious behavior found."), + ); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("submits a published scan with update mode", async () => { + httpMocks.apiRequest + .mockResolvedValueOnce({ + ok: true, + scanId: "scan_123", + jobId: "job_123", + status: "queued", + sourceKind: "published", + update: true, + }) + .mockResolvedValueOnce(completedScan({ update: true, writtenBack: true })); + + await cmdScan(makeGlobalOpts(), undefined, { + slug: "demo", + version: "1.2.3", + update: true, + }); + + expect(httpMocks.apiRequest.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + path: ApiRoutes.skillScans, + token: "tkn", + body: { + source: { kind: "published", slug: "demo", version: "1.2.3" }, + update: true, + }, + }); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Written back: yes")); + }); + + it("downloads the canonical report zip when --output is set", async () => { + const workdir = await makeTmpWorkdir(); + try { + const output = join(workdir, "report.zip"); + httpMocks.apiRequest + .mockResolvedValueOnce({ + ok: true, + scanId: "scan_123", + jobId: "job_123", + status: "queued", + sourceKind: "published", + update: false, + }) + .mockResolvedValueOnce(completedScan()); + httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4])); + + await cmdScan(makeGlobalOpts(workdir), undefined, { slug: "demo", output }); + + expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", { + path: `${ApiRoutes.skillScans}/scan_123/download`, + token: "tkn", + }); + expect(await readFile(output)).toEqual(Buffer.from([80, 75, 3, 4])); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("rejects ambiguous or invalid source options", async () => { + await expect(cmdScan(makeGlobalOpts(), "local-skill", { slug: "demo" })).rejects.toThrow( + "Choose either a local path or --slug, not both", + ); + await expect(cmdScan(makeGlobalOpts(), "local-skill", { update: true })).rejects.toThrow( + "--update is only valid with --slug", + ); + await expect(cmdScan(makeGlobalOpts(), undefined, {})).rejects.toThrow( + "Provide a local path or --slug", + ); + }); +}); diff --git a/packages/clawhub/src/cli/commands/scan.ts b/packages/clawhub/src/cli/commands/scan.ts new file mode 100644 index 00000000..40af67e5 --- /dev/null +++ b/packages/clawhub/src/cli/commands/scan.ts @@ -0,0 +1,295 @@ +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { apiRequest, apiRequestForm, fetchBinary } from "../../http.js"; +import { + ApiRoutes, + ApiV1SkillScanStatusResponseSchema, + ApiV1SkillScanSubmitResponseSchema, + type ApiV1SkillScanStatusResponse, +} from "../../schema/index.js"; +import { listTextFiles } from "../../skills.js"; +import { requireAuthToken } from "../authToken.js"; +import { getRegistry } from "../registry.js"; +import type { GlobalOpts } from "../types.js"; +import { createSpinner, fail, formatError } from "../ui.js"; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; +const MAX_POLL_ATTEMPTS = 900; + +type ScanOptions = { + slug?: string; + version?: string; + update?: boolean; + output?: string; + json?: boolean; +}; + +type ReportRecord = Record; + +export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, options: ScanOptions) { + validateScanOptions(pathArg, options); + + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const spinner = createSpinner("Submitting scan"); + + try { + const submitted = pathArg + ? await submitLocalScan(opts, registry, token, pathArg) + : await submitPublishedScan(registry, token, options); + + spinner.text = `Scan queued (${submitted.scanId})`; + const status = await pollScan(registry, token, submitted.scanId, spinner); + + if (status.status === "failed") { + spinner.fail(`Scan failed (${status.scanId})`); + if (options.json) printJson(status); + else printScanReport(status); + throw new Error(status.lastError ?? "Scan failed"); + } + + spinner.succeed(`Scan complete (${status.scanId})`); + + if (options.json) printJson(status); + else printScanReport(status); + + if (options.output) { + const bytes = await fetchBinary(registry, { + path: `${ApiRoutes.skillScans}/${encodeURIComponent(status.scanId)}/download`, + token, + }); + await mkdir(dirname(resolve(opts.workdir, options.output)), { recursive: true }); + await writeFile(resolve(opts.workdir, options.output), bytes); + if (!options.json) console.log(`Report ZIP: ${resolve(opts.workdir, options.output)}`); + } + } catch (error) { + spinner.fail(formatError(error)); + throw error; + } +} + +function validateScanOptions(pathArg: string | undefined, options: ScanOptions) { + const hasPath = Boolean(pathArg?.trim()); + const hasSlug = Boolean(options.slug?.trim()); + if (hasPath && hasSlug) fail("Choose either a local path or --slug, not both"); + if (!hasPath && !hasSlug) fail("Provide a local path or --slug"); + if (hasPath && options.update) fail("--update is only valid with --slug"); +} + +async function submitLocalScan(opts: GlobalOpts, registry: string, token: string, pathArg: string) { + const folder = resolve(opts.workdir, pathArg); + const folderStat = await stat(folder).catch(() => null); + if (!folderStat?.isDirectory()) fail("Path must be a folder"); + + const files = await listTextFiles(folder); + if ( + !files.some((file) => { + const lower = file.relPath.toLowerCase(); + return lower === "skill.md"; + }) + ) { + fail("SKILL.md required"); + } + if (files.length === 0) fail("No files found"); + + const form = new FormData(); + form.set("payload", JSON.stringify({ source: { kind: "upload" }, update: false })); + for (const file of files) { + const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? "text/plain" }); + form.append("files", blob, file.relPath); + } + + return await apiRequestForm( + registry, + { method: "POST", path: ApiRoutes.skillScans, token, form }, + ApiV1SkillScanSubmitResponseSchema, + ); +} + +async function submitPublishedScan(registry: string, token: string, options: ScanOptions) { + const slug = options.slug?.trim(); + if (!slug) fail("--slug required"); + const version = options.version?.trim(); + return await apiRequest( + registry, + { + method: "POST", + path: ApiRoutes.skillScans, + token, + body: { + source: { + kind: "published", + slug, + ...(version ? { version } : {}), + }, + update: options.update === true, + }, + }, + ApiV1SkillScanSubmitResponseSchema, + ); +} + +async function pollScan( + registry: string, + token: string, + scanId: string, + spinner: ReturnType, +) { + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + const status = await apiRequest( + registry, + { + method: "GET", + path: `${ApiRoutes.skillScans}/${encodeURIComponent(scanId)}`, + token, + }, + ApiV1SkillScanStatusResponseSchema, + ); + spinner.text = `Scan ${status.status} (${scanId})`; + if (status.status === "succeeded" || status.status === "failed") return status; + await sleep(DEFAULT_POLL_INTERVAL_MS); + } + throw new Error(`Timed out waiting for scan ${scanId}`); +} + +function sleep(ms: number) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function printJson(status: ApiV1SkillScanStatusResponse) { + console.log(JSON.stringify(status, null, 2)); +} + +function printScanReport(status: ApiV1SkillScanStatusResponse) { + const artifact = asRecord(status.artifact) ?? {}; + const report = asRecord(status.report) ?? {}; + const clawscan = asRecord(report.clawscan) ?? {}; + const skillspector = asRecord(report.skillspector) ?? {}; + const staticAnalysis = asRecord(report.staticAnalysis) ?? {}; + const virustotal = asRecord(report.virustotal); + + console.log(""); + console.log("ClawHub Scan Report"); + console.log(`Scan ID: ${status.scanId}`); + console.log(`Status: ${status.status.toUpperCase()}`); + console.log(`Source: ${status.sourceKind}`); + console.log(`Update requested: ${status.update ? "yes" : "no"}`); + console.log(`Written back: ${status.writtenBack ? "yes" : "no"}`); + printOptional("Slug", stringValue(artifact.slug)); + printOptional("Name", stringValue(artifact.displayName)); + printOptional("Version", stringValue(artifact.version)); + printOptional("Created", dateValue(status.createdAt)); + printOptional("Completed", dateValue(status.completedAt)); + + console.log(""); + console.log("ClawScan"); + printOptional("Verdict", upperValue(clawscan.verdict ?? clawscan.status)); + printOptional("Confidence", stringValue(clawscan.confidence)); + printOptional("Summary", stringValue(clawscan.summary)); + printOptional("Guidance", stringValue(clawscan.guidance)); + printFindings(clawscan.findings); + printAgenticRisks(clawscan.agenticRiskFindings); + + console.log(""); + console.log("SkillSpector"); + printOptional("Status", upperValue(skillspector.status)); + printOptional("Score", numberValue(skillspector.score)); + printOptional("Severity", stringValue(skillspector.severity)); + printOptional("Issue count", numberValue(skillspector.issueCount)); + printIssueList(skillspector.issues); + + console.log(""); + console.log("Static Analysis"); + printOptional("Status", upperValue(staticAnalysis.status)); + printOptional("Reason codes", arrayValue(staticAnalysis.reasonCodes)); + printOptional("Summary", stringValue(staticAnalysis.summary)); + printIssueList(staticAnalysis.findings); + + console.log(""); + console.log("VirusTotal"); + if (!virustotal) { + console.log("Status: not available"); + } else { + printOptional("Status", stringValue(virustotal.status)); + printOptional("Malicious", numberValue(virustotal.malicious)); + printOptional("Suspicious", numberValue(virustotal.suspicious)); + printOptional("Harmless", numberValue(virustotal.harmless)); + printOptional("Undetected", numberValue(virustotal.undetected)); + } +} + +function printOptional(label: string, value: string | undefined) { + if (!value) return; + console.log(`${label}: ${value}`); +} + +function printFindings(value: unknown) { + if (typeof value === "string" && value.trim()) { + console.log(`Findings: ${value.trim()}`); + } +} + +function printAgenticRisks(value: unknown) { + if (!Array.isArray(value) || value.length === 0) return; + console.log("Agentic risk findings:"); + for (const item of value.slice(0, 20)) { + const finding = asRecord(item); + if (!finding) continue; + const label = stringValue(finding.categoryLabel) ?? stringValue(finding.categoryId) ?? "risk"; + const status = stringValue(finding.status) ?? "unknown"; + const severity = stringValue(finding.severity) ?? "unknown"; + console.log(`- ${label}: ${status} (${severity})`); + printIndented("Impact", stringValue(finding.userImpact)); + printIndented("Recommendation", stringValue(finding.recommendation)); + } +} + +function printIssueList(value: unknown) { + if (!Array.isArray(value) || value.length === 0) return; + for (const item of value.slice(0, 25)) { + const issue = asRecord(item); + if (!issue) continue; + const code = stringValue(issue.code ?? issue.issueId ?? issue.pattern) ?? "issue"; + const severity = stringValue(issue.severity) ?? "unknown"; + const file = stringValue(issue.file); + const message = stringValue(issue.message ?? issue.explanation ?? issue.finding); + console.log(`- ${code}: ${severity}${file ? ` in ${file}` : ""}`); + printIndented("Detail", message); + printIndented("Remediation", stringValue(issue.remediation)); + } +} + +function printIndented(label: string, value: string | undefined) { + if (!value) return; + console.log(` ${label}: ${value}`); +} + +function asRecord(value: unknown): ReportRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as ReportRecord) + : null; +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function upperValue(value: unknown) { + return stringValue(value)?.toUpperCase(); +} + +function numberValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? String(value) : undefined; +} + +function arrayValue(value: unknown) { + return Array.isArray(value) && value.length > 0 + ? value.map((item) => String(item)).join(", ") + : undefined; +} + +function dateValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) + ? new Date(value).toISOString() + : undefined; +} diff --git a/packages/clawhub/src/schema/routes.ts b/packages/clawhub/src/schema/routes.ts index fcc40f36..3b58d219 100644 --- a/packages/clawhub/src/schema/routes.ts +++ b/packages/clawhub/src/schema/routes.ts @@ -17,6 +17,7 @@ export const ApiRoutes = { download: "/api/v1/download", publishTokenMint: "/api/v1/publish/token/mint", skills: "/api/v1/skills", + skillScans: "/api/v1/skills/-/scan", packages: "/api/v1/packages", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", diff --git a/packages/clawhub/src/schema/schemas.ts b/packages/clawhub/src/schema/schemas.ts index 7b6ca42c..e402c0a1 100644 --- a/packages/clawhub/src/schema/schemas.ts +++ b/packages/clawhub/src/schema/schemas.ts @@ -427,6 +427,66 @@ export const ApiV1SkillRescanResponseSchema = type({ }); export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred]; +export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"'); +export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred]; + +export const ApiV1SkillScanSourceSchema = type({ + kind: '"upload"', +}).or({ + kind: '"published"', + slug: "string", + version: "string?", +}); +export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred]; + +export const ApiV1SkillScanSubmitRequestSchema = type({ + source: ApiV1SkillScanSourceSchema, + update: "boolean?", +}); +export type ApiV1SkillScanSubmitRequest = (typeof ApiV1SkillScanSubmitRequestSchema)[inferred]; + +export const ApiV1SkillScanSubmitResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + alreadyQueued: "boolean?", +}); +export type ApiV1SkillScanSubmitResponse = (typeof ApiV1SkillScanSubmitResponseSchema)[inferred]; + +export const ApiV1SkillScanStatusResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + writtenBack: "boolean?", + artifact: "unknown?", + report: "unknown?", + lastError: "string?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", +}); +export type ApiV1SkillScanStatusResponse = (typeof ApiV1SkillScanStatusResponseSchema)[inferred]; + +export const ApiV1SkillScanDownloadManifestSchema = type({ + scanId: "string", + sourceKind: '"upload"|"published"', + update: "boolean", + status: ApiV1SkillScanStatusSchema, + artifact: "unknown?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", + writtenBack: "boolean?", +}); +export type ApiV1SkillScanDownloadManifest = + (typeof ApiV1SkillScanDownloadManifestSchema)[inferred]; + export const ApiV1SkillBulkRescanBatchRequestSchema = type({ mode: '"all-active-latest"?', cursor: "string|null?", @@ -471,6 +531,48 @@ export const ApiV1SkillBulkRescanStatusResponseSchema = type({ export type ApiV1SkillBulkRescanStatusResponse = (typeof ApiV1SkillBulkRescanStatusResponseSchema)[inferred]; +export const ApiV1SkillScanBatchRequestSchema = type({ + mode: '"all-active-latest"?', + cursor: "string|null?", + batchSize: "number?", + dryRun: "boolean?", +}); +export type ApiV1SkillScanBatchRequest = (typeof ApiV1SkillScanBatchRequestSchema)[inferred]; + +export const ApiV1SkillScanBatchResponseSchema = type({ + ok: "true", + mode: '"all-active-latest"', + queued: "number", + alreadyQueued: "number", + skipped: "number", + jobIds: "string[]", + nextCursor: "string|null", + done: "boolean", + sampleSlugs: "string[]", +}); +export type ApiV1SkillScanBatchResponse = (typeof ApiV1SkillScanBatchResponseSchema)[inferred]; + +export const ApiV1SkillScanBatchStatusRequestSchema = type({ + jobIds: "string[]", +}); +export type ApiV1SkillScanBatchStatusRequest = + (typeof ApiV1SkillScanBatchStatusRequestSchema)[inferred]; + +export const ApiV1SkillScanBatchStatusResponseSchema = type({ + ok: "true", + total: "number", + queued: "number", + running: "number", + succeeded: "number", + failed: "number", + missing: "number", + terminal: "number", + done: "boolean", + failedJobIds: "string[]", +}); +export type ApiV1SkillScanBatchStatusResponse = + (typeof ApiV1SkillScanBatchStatusResponseSchema)[inferred]; + export const ApiV1SkillRepairVtPendingRequestSchema = type({ cursor: "string|null?", batchSize: "number?", diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 0281e62c..82886b92 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -16,6 +16,7 @@ export declare const ApiRoutes: { readonly download: "/api/v1/download"; readonly publishTokenMint: "/api/v1/publish/token/mint"; readonly skills: "/api/v1/skills"; + readonly skillScans: "/api/v1/skills/-/scan"; readonly plugins: "/api/v1/plugins"; readonly packages: "/api/v1/packages"; readonly codePlugins: "/api/v1/code-plugins"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 03fe9d9f..a01cbae6 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -16,6 +16,7 @@ export const ApiRoutes = { download: "/api/v1/download", publishTokenMint: "/api/v1/publish/token/mint", skills: "/api/v1/skills", + skillScans: "/api/v1/skills/-/scan", plugins: "/api/v1/plugins", packages: "/api/v1/packages", codePlugins: "/api/v1/code-plugins", diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map index 04a49622..5e2383a2 100644 --- a/packages/schema/dist/routes.js.map +++ b/packages/schema/dist/routes.js.map @@ -1 +1 @@ -{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/schemas.d.ts b/packages/schema/dist/schemas.d.ts index 8ea0a59f..6eb5b999 100644 --- a/packages/schema/dist/schemas.d.ts +++ b/packages/schema/dist/schemas.d.ts @@ -371,6 +371,65 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va alreadyQueued: boolean; }, {}>; export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred]; +export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"queued" | "running" | "succeeded" | "failed", {}>; +export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred]; +export declare const ApiV1SkillScanSourceSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + kind: "upload"; +} | { + kind: "published"; + slug: string; + version?: string | undefined; +}, {}>; +export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred]; +export declare const ApiV1SkillScanSubmitRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + source: { + kind: "upload"; + } | { + kind: "published"; + slug: string; + version?: string | undefined; + }; + update?: boolean | undefined; +}, {}>; +export type ApiV1SkillScanSubmitRequest = (typeof ApiV1SkillScanSubmitRequestSchema)[inferred]; +export declare const ApiV1SkillScanSubmitResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; + scanId: string; + status: "queued" | "running" | "succeeded" | "failed"; + sourceKind: "upload" | "published"; + update: boolean; + jobId?: string | undefined; + alreadyQueued?: boolean | undefined; +}, {}>; +export type ApiV1SkillScanSubmitResponse = (typeof ApiV1SkillScanSubmitResponseSchema)[inferred]; +export declare const ApiV1SkillScanStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; + scanId: string; + status: "queued" | "running" | "succeeded" | "failed"; + sourceKind: "upload" | "published"; + update: boolean; + createdAt: number; + updatedAt: number; + jobId?: string | undefined; + writtenBack?: boolean | undefined; + artifact?: unknown; + report?: unknown; + lastError?: string | undefined; + completedAt?: number | undefined; +}, {}>; +export type ApiV1SkillScanStatusResponse = (typeof ApiV1SkillScanStatusResponseSchema)[inferred]; +export declare const ApiV1SkillScanDownloadManifestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + scanId: string; + sourceKind: "upload" | "published"; + update: boolean; + status: "queued" | "running" | "succeeded" | "failed"; + createdAt: number; + updatedAt: number; + artifact?: unknown; + completedAt?: number | undefined; + writtenBack?: boolean | undefined; +}, {}>; +export type ApiV1SkillScanDownloadManifest = (typeof ApiV1SkillScanDownloadManifestSchema)[inferred]; export declare const ApiV1SkillBulkRescanBatchRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ mode?: "all-active-latest" | undefined; cursor?: string | null | undefined; @@ -407,6 +466,42 @@ export declare const ApiV1SkillBulkRescanStatusResponseSchema: import("arktype/i failedJobIds: string[]; }, {}>; export type ApiV1SkillBulkRescanStatusResponse = (typeof ApiV1SkillBulkRescanStatusResponseSchema)[inferred]; +export declare const ApiV1SkillScanBatchRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + mode?: "all-active-latest" | undefined; + cursor?: string | null | undefined; + batchSize?: number | undefined; + dryRun?: boolean | undefined; +}, {}>; +export type ApiV1SkillScanBatchRequest = (typeof ApiV1SkillScanBatchRequestSchema)[inferred]; +export declare const ApiV1SkillScanBatchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; + mode: "all-active-latest"; + queued: number; + alreadyQueued: number; + skipped: number; + jobIds: string[]; + nextCursor: string | null; + done: boolean; + sampleSlugs: string[]; +}, {}>; +export type ApiV1SkillScanBatchResponse = (typeof ApiV1SkillScanBatchResponseSchema)[inferred]; +export declare const ApiV1SkillScanBatchStatusRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + jobIds: string[]; +}, {}>; +export type ApiV1SkillScanBatchStatusRequest = (typeof ApiV1SkillScanBatchStatusRequestSchema)[inferred]; +export declare const ApiV1SkillScanBatchStatusResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; + total: number; + queued: number; + running: number; + succeeded: number; + failed: number; + missing: number; + terminal: number; + done: boolean; + failedJobIds: string[]; +}, {}>; +export type ApiV1SkillScanBatchStatusResponse = (typeof ApiV1SkillScanBatchStatusResponseSchema)[inferred]; export declare const ApiV1SkillRepairVtPendingRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ cursor?: string | null | undefined; batchSize?: number | undefined; @@ -480,7 +575,7 @@ export declare const ApiV1SkillResolveResponseSchema: import("arktype/internal/v export declare const ApiV1SkillVerifyResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ schema: "clawhub.skill.verify.v1"; ok: boolean; - decision: "pass" | "fail"; + decision: "fail" | "pass"; reasons: string[]; slug: string; displayName: string; diff --git a/packages/schema/dist/schemas.js b/packages/schema/dist/schemas.js index 87c08662..6210b8d1 100644 --- a/packages/schema/dist/schemas.js +++ b/packages/schema/dist/schemas.js @@ -334,6 +334,53 @@ export const ApiV1SkillRescanResponseSchema = type({ jobId: "string", alreadyQueued: "boolean", }); +export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"'); +export const ApiV1SkillScanSourceSchema = type({ + kind: '"upload"', +}).or({ + kind: '"published"', + slug: "string", + version: "string?", +}); +export const ApiV1SkillScanSubmitRequestSchema = type({ + source: ApiV1SkillScanSourceSchema, + update: "boolean?", +}); +export const ApiV1SkillScanSubmitResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + alreadyQueued: "boolean?", +}); +export const ApiV1SkillScanStatusResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + writtenBack: "boolean?", + artifact: "unknown?", + report: "unknown?", + lastError: "string?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", +}); +export const ApiV1SkillScanDownloadManifestSchema = type({ + scanId: "string", + sourceKind: '"upload"|"published"', + update: "boolean", + status: ApiV1SkillScanStatusSchema, + artifact: "unknown?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", + writtenBack: "boolean?", +}); export const ApiV1SkillBulkRescanBatchRequestSchema = type({ mode: '"all-active-latest"?', cursor: "string|null?", @@ -366,6 +413,38 @@ export const ApiV1SkillBulkRescanStatusResponseSchema = type({ done: "boolean", failedJobIds: "string[]", }); +export const ApiV1SkillScanBatchRequestSchema = type({ + mode: '"all-active-latest"?', + cursor: "string|null?", + batchSize: "number?", + dryRun: "boolean?", +}); +export const ApiV1SkillScanBatchResponseSchema = type({ + ok: "true", + mode: '"all-active-latest"', + queued: "number", + alreadyQueued: "number", + skipped: "number", + jobIds: "string[]", + nextCursor: "string|null", + done: "boolean", + sampleSlugs: "string[]", +}); +export const ApiV1SkillScanBatchStatusRequestSchema = type({ + jobIds: "string[]", +}); +export const ApiV1SkillScanBatchStatusResponseSchema = type({ + ok: "true", + total: "number", + queued: "number", + running: "number", + succeeded: "number", + failed: "number", + missing: "number", + terminal: "number", + done: "boolean", + failedJobIds: "string[]", +}); export const ApiV1SkillRepairVtPendingRequestSchema = type({ cursor: "string|null?", batchSize: "number?", diff --git a/packages/schema/dist/schemas.js.map b/packages/schema/dist/schemas.js.map index b717ed0a..92fa35ac 100644 --- a/packages/schema/dist/schemas.js.map +++ b/packages/schema/dist/schemas.js.map @@ -1 +1 @@ -{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index a263a662..504cb233 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -17,6 +17,7 @@ export const ApiRoutes = { download: "/api/v1/download", publishTokenMint: "/api/v1/publish/token/mint", skills: "/api/v1/skills", + skillScans: "/api/v1/skills/-/scan", plugins: "/api/v1/plugins", packages: "/api/v1/packages", codePlugins: "/api/v1/code-plugins", diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index 9af71c13..69f21b1e 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -399,6 +399,66 @@ export const ApiV1SkillRescanResponseSchema = type({ }); export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred]; +export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"'); +export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred]; + +export const ApiV1SkillScanSourceSchema = type({ + kind: '"upload"', +}).or({ + kind: '"published"', + slug: "string", + version: "string?", +}); +export type ApiV1SkillScanSource = (typeof ApiV1SkillScanSourceSchema)[inferred]; + +export const ApiV1SkillScanSubmitRequestSchema = type({ + source: ApiV1SkillScanSourceSchema, + update: "boolean?", +}); +export type ApiV1SkillScanSubmitRequest = (typeof ApiV1SkillScanSubmitRequestSchema)[inferred]; + +export const ApiV1SkillScanSubmitResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + alreadyQueued: "boolean?", +}); +export type ApiV1SkillScanSubmitResponse = (typeof ApiV1SkillScanSubmitResponseSchema)[inferred]; + +export const ApiV1SkillScanStatusResponseSchema = type({ + ok: "true", + scanId: "string", + jobId: "string?", + status: ApiV1SkillScanStatusSchema, + sourceKind: '"upload"|"published"', + update: "boolean", + writtenBack: "boolean?", + artifact: "unknown?", + report: "unknown?", + lastError: "string?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", +}); +export type ApiV1SkillScanStatusResponse = (typeof ApiV1SkillScanStatusResponseSchema)[inferred]; + +export const ApiV1SkillScanDownloadManifestSchema = type({ + scanId: "string", + sourceKind: '"upload"|"published"', + update: "boolean", + status: ApiV1SkillScanStatusSchema, + artifact: "unknown?", + createdAt: "number", + updatedAt: "number", + completedAt: "number?", + writtenBack: "boolean?", +}); +export type ApiV1SkillScanDownloadManifest = + (typeof ApiV1SkillScanDownloadManifestSchema)[inferred]; + export const ApiV1SkillBulkRescanBatchRequestSchema = type({ mode: '"all-active-latest"?', cursor: "string|null?", @@ -443,6 +503,48 @@ export const ApiV1SkillBulkRescanStatusResponseSchema = type({ export type ApiV1SkillBulkRescanStatusResponse = (typeof ApiV1SkillBulkRescanStatusResponseSchema)[inferred]; +export const ApiV1SkillScanBatchRequestSchema = type({ + mode: '"all-active-latest"?', + cursor: "string|null?", + batchSize: "number?", + dryRun: "boolean?", +}); +export type ApiV1SkillScanBatchRequest = (typeof ApiV1SkillScanBatchRequestSchema)[inferred]; + +export const ApiV1SkillScanBatchResponseSchema = type({ + ok: "true", + mode: '"all-active-latest"', + queued: "number", + alreadyQueued: "number", + skipped: "number", + jobIds: "string[]", + nextCursor: "string|null", + done: "boolean", + sampleSlugs: "string[]", +}); +export type ApiV1SkillScanBatchResponse = (typeof ApiV1SkillScanBatchResponseSchema)[inferred]; + +export const ApiV1SkillScanBatchStatusRequestSchema = type({ + jobIds: "string[]", +}); +export type ApiV1SkillScanBatchStatusRequest = + (typeof ApiV1SkillScanBatchStatusRequestSchema)[inferred]; + +export const ApiV1SkillScanBatchStatusResponseSchema = type({ + ok: "true", + total: "number", + queued: "number", + running: "number", + succeeded: "number", + failed: "number", + missing: "number", + terminal: "number", + done: "boolean", + failedJobIds: "string[]", +}); +export type ApiV1SkillScanBatchStatusResponse = + (typeof ApiV1SkillScanBatchStatusResponseSchema)[inferred]; + export const ApiV1SkillRepairVtPendingRequestSchema = type({ cursor: "string|null?", batchSize: "number?", diff --git a/scripts/security/run-codex-scan-worker.ts b/scripts/security/run-codex-scan-worker.ts index 991cbc5a..177ea2f0 100644 --- a/scripts/security/run-codex-scan-worker.ts +++ b/scripts/security/run-codex-scan-worker.ts @@ -19,7 +19,7 @@ type ClaimedJob = { job: { _id: string; leaseToken: string; - targetKind: "skillVersion" | "packageRelease"; + targetKind: "skillVersion" | "packageRelease" | "skillScanRequest"; source: string; hasMaliciousSignal: boolean; waitForVtUntil: number; diff --git a/specs/security-moderation.md b/specs/security-moderation.md index bc7942dc..719ba3bf 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -94,6 +94,14 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic signals, and staff moderation state, not publisher-supplied explanatory text. Legacy persisted note fields may exist on old rows for schema compatibility, but publish, rescan, API, UI, and prompt paths must ignore them. +- User-submitted `POST /api/v1/skills/-/scan` upload scans are authenticated but + ephemeral. They store uploaded files only on `skillScanRequests`, feed the + normal ClawScan worker, and must never create or patch public `skills`, + `skillVersions`, moderation, or trust state. Expired `skillScanRequests` rows + must be pruned by cron so uploaded file payloads do not become durable skill + 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. - `auditLogs` remains the global compliance/security ledger. Product-facing moderation timelines live in `skillModerationEventLogs` and `packageModerationEventLogs`. diff --git a/specs/superpowers/2026-06-03-clawhub-scan-command-design.md b/specs/superpowers/2026-06-03-clawhub-scan-command-design.md new file mode 100644 index 00000000..6e383346 --- /dev/null +++ b/specs/superpowers/2026-06-03-clawhub-scan-command-design.md @@ -0,0 +1,238 @@ +# ClawHub Scan Command Design + +- Date: 2026-06-03 +- Status: Approved for implementation +- Scope: authenticated ClawScan job API, public CLI scan command, moderator CLI rescan routing, and scan report export shape + +## Problem + +ClawHub runs ClawScan for published artifacts, but users do not have a first-class way to ask ClawHub to scan a local skill bundle before publishing. The existing `skill verify` command is read-only: it checks stored verification state for a published skill version and does not create fresh scan results. + +Publishers and power users need a command like: + +```sh +clawhub scan +``` + +The command should upload a local skill bundle, run ClawHub's scan pipeline, wait until results are ready, print the results in the terminal, and optionally save the full scan report to a file. It should also support fresh scans of already-published skills, with strict ownership checks and an explicit update mode. + +## Goals + +1. Add `clawhub scan` as a power-user and publisher command. +2. Support ephemeral local skill scans for any authenticated user. +3. Support owner-only published-skill scans. +4. Keep local uploads ephemeral. They must not create or update public registry state. +5. Let published-skill scans run read-only by default and update stored ClawScan state only with an explicit `--update`. +6. Use submit-and-poll behavior so the CLI feels synchronous without holding one long HTTP request open. +7. Reuse the security-audit download shape: `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`. +8. Update `clawhub-mod` to use the canonical scan API for staff rescans while preserving moderator/admin semantics. +9. Update schema and docs so the scan API routes are canonical and the older rescan routes are either compatibility aliases or clearly deprecated. + +## Non-Goals + +1. No anonymous scans. +2. No scanning of unpublished plugin packages in this slice; start with skill bundles. +3. No automatic publish after a clean local scan. +4. No local writes for ephemeral scans other than optional CLI output files. +5. No change to `clawhub skill verify`; it remains a read-only stored-state verification command. + +## API Direction + +Use a canonical scan job group: + +```txt +POST /api/v1/skills/-/scan +GET /api/v1/skills/-/scan/{scanId} +GET /api/v1/skills/-/scan/{scanId}/download +``` + +`POST /api/v1/skills/-/scan` creates a scan job and returns a `scanId`. The caller then polls the status endpoint until the scan reaches a terminal state. + +### Submit Modes + +Local ephemeral scan: + +```json +{ + "source": { "kind": "upload" }, + "update": false +} +``` + +This request is multipart. The payload carries the JSON metadata, and file parts carry the local skill bundle files. + +Published skill scan: + +```json +{ + "source": { "kind": "published", "slug": "demo", "version": "1.2.3" }, + "update": false +} +``` + +Published skill update scan: + +```json +{ + "source": { "kind": "published", "slug": "demo", "version": "1.2.3" }, + "update": true +} +``` + +If `version` is omitted for a published scan, the backend scans the latest version. + +### Authorization + +All scan endpoints require a valid API token. + +Local upload scans: + +- allowed for any authenticated account in good standing +- always ephemeral +- cannot set `update: true` +- do not write skill, version, moderation, or public security state + +Published skill scans: + +- allowed only for the skill owner or a publisher member with management rights +- `update: false` runs a fresh scan and returns the result without writing it back +- `update: true` writes the final ClawScan result back to the selected published version +- moderators/admins may use the same backend through `clawhub-mod`, preserving the existing operator capability + +### Existing Route Cleanup + +The current `POST /api/v1/skills/{slug}/rescan` route should move behind the canonical scan API. For compatibility, it can remain as an alias that submits a published scan with `update: true`. + +The current admin batch routes: + +```txt +POST /api/v1/skills/-/rescan-batch +POST /api/v1/skills/-/rescan-batch/status +``` + +should move to scan group routes: + +```txt +POST /api/v1/skills/-/scan/batch +POST /api/v1/skills/-/scan/batch/status +``` + +The existing `GET /api/v1/skills/{slug}/scan` route currently reads stored scan details. Because this name conflicts with new scan-job creation, keep it as a legacy detail route for now and document the distinction. A later API cleanup can rename stored details to a security-audit route. + +## CLI Direction + +Add a top-level public CLI command: + +```sh +clawhub scan +clawhub scan --output report.zip +clawhub scan --slug demo +clawhub scan --slug demo --version 1.2.3 +clawhub scan --slug demo --update +clawhub scan --slug demo --output report.zip +``` + +Rules: + +- Exactly one scan source is required: either `` or `--slug`. +- `` must resolve to a local skill folder containing `SKILL.md` or `skill.md`. +- `--update` is valid only with `--slug`. +- `--output ` writes the report ZIP to that exact file path. +- The default terminal output should be a full report, similar to the security-audit UI, not a terse pass/fail summary. +- `--json` should print the terminal report data as JSON for automation. It does not replace `--output`, which always writes the ZIP report. + +## Terminal Report + +The terminal report should mirror the security audit UI enough that a publisher can make the same judgment from the CLI: + +1. artifact identity and scan metadata +2. ClawScan verdict, confidence, summary, guidance, and findings +3. agentic risk buckets and concrete evidence when available +4. SkillSpector status, score/severity, issue count, and issues +5. static analysis status, reason codes, summary, and findings +6. VirusTotal telemetry, including engine counts when present +7. update/writeback status for published scans + +The CLI should exit non-zero for failed scan jobs. A clean, suspicious, or malicious completed scan is still a successful command execution; policy interpretation belongs in the printed result and JSON. + +## Report ZIP + +Reuse the security-audit Download button archive shape: + +```txt +manifest.json +clawscan.json +skillspector.json +static-analysis.json +virustotal.json +README.md +``` + +The ZIP should be available through `GET /api/v1/skills/-/scan/{scanId}/download` and should match the bytes written by `clawhub scan --output `. + +`manifest.json` should include: + +- scan id +- source kind +- update mode +- artifact identity +- user-facing timestamps +- terminal status +- whether the scan result was written back + +## Backend Shape + +The existing ClawScan worker is currently built around stored `securityScanJobs` that target published skill versions or package releases. This feature needs a scan-job abstraction that can also represent ephemeral uploaded skills. + +Recommended implementation: + +1. Add a persisted scan request/job record for user-submitted scans. +2. Store uploaded local files in Convex storage with ownership and expiry metadata. +3. Materialize ephemeral jobs into the same worker workspace shape used by published scans. +4. Store final scan result payloads on the scan job record. +5. For published `update: true`, also patch the selected version through the existing ClawScan result update path. +6. Expire or clean up ephemeral uploaded files and completed ephemeral scan records after a bounded retention window. + +This keeps worker behavior shared while preventing local uploads from leaking into public artifact state. + +## Moderator CLI + +Update `clawhub-mod skills rescan ` to call the canonical scan API in published update mode, using moderator/admin authorization. Keep its existing prompt, `--version`, `--yes`, and `--json` behavior. + +Update `clawhub-mod skills rescan-all` to call the new canonical batch route. If compatibility aliases remain for older callers, tests should still prove the moderator CLI uses the canonical route. + +## Tests + +Backend tests should cover: + +1. local upload scan requires auth +2. local upload scan rejects `update: true` +3. published scan rejects non-owners +4. published scan allows owners +5. moderator/admin path allows operator rescans +6. polling returns queued/running/complete/failed states +7. download endpoint returns the expected ZIP entries +8. `update: true` writes back only for published scans +9. `update: false` does not mutate published version scan fields + +CLI tests should cover: + +1. `clawhub scan ` uploads multipart and polls +2. `clawhub scan --slug demo` submits read-only published scans +3. `clawhub scan --slug demo --update` sends update mode +4. `--output report.zip` writes the downloaded ZIP bytes +5. invalid combinations fail clearly +6. `clawhub-mod skills rescan` uses the canonical route + +## Rollout Notes + +This is a behavior and API change, so update: + +- `packages/schema` +- `packages/clawhub` +- `packages/clawhub-mod` +- `docs/cli.md` +- `docs/http-api.md` +- `specs/security-moderation.md` + +Keep the first implementation narrowly skill-focused. Plugin/package scan support can reuse the same scan-job API later once the skill path is stable. diff --git a/specs/superpowers/plans/2026-06-03-clawhub-scan-command-plan.md b/specs/superpowers/plans/2026-06-03-clawhub-scan-command-plan.md new file mode 100644 index 00000000..1ba31e16 --- /dev/null +++ b/specs/superpowers/plans/2026-06-03-clawhub-scan-command-plan.md @@ -0,0 +1,266 @@ +# ClawHub Scan Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build authenticated `clawhub scan` submit-and-poll support for ephemeral local skill scans and owner/operator published skill rescans. + +**Architecture:** Add a persisted scan-request record that can target either uploaded ephemeral files or a published skill version, then enqueue the existing `securityScanJobs` worker against that record. The public CLI submits a scan, polls the status route until terminal, renders the full security report, and optionally downloads the canonical report ZIP. + +**Tech Stack:** Convex HTTP actions/mutations, existing ClawScan worker queue, `@clawhub/schema` route/schemas, Bun/Commander CLI packages, Vitest. + +--- + +## File Map + +- `convex/schema.ts`: add `skillScanRequests` and extend `securityScanJobs` with `skillScanRequestId`. +- `convex/securityScan.ts`: create/poll/download scan requests and hydrate/complete/fail worker jobs for scan-request targets. +- `convex/httpApiV1/skillsV1.ts`: add canonical `/api/v1/skills/-/scan` submit/poll/download/batch handlers and keep legacy rescan aliases. +- `convex/httpApiV1/shared.ts`: add multipart parsing helper for scan upload files if the publish parser is too specific. +- `convex/http.ts`: register scan routes before generic `/api/v1/skills/*` routes. +- `packages/schema/src/routes.ts` and `packages/schema/src/schemas.ts`: add route constants and request/response schemas. +- `packages/clawhub/src/schema/routes.ts` and `packages/clawhub/src/schema/schemas.ts`: update vendored schema copies used by the published CLI package. +- `packages/clawhub/src/cli.ts`: register the new top-level `scan` command. +- `packages/clawhub/src/cli/commands/scan.ts`: implement scan source validation, submit, poll, terminal report, JSON output, and ZIP download. +- `packages/clawhub/src/cli/commands/scan.test.ts`: cover CLI source validation, request shape, polling, and `--output`. +- `packages/clawhub-mod/src/commands/moderation.ts`: route single and batch rescans through canonical scan endpoints. +- `packages/clawhub-mod/src/commands/moderation.test.ts`: prove moderator commands use canonical scan routes. +- `docs/cli.md`, `docs/http-api.md`, and `specs/security-moderation.md`: document the public command, API shape, and security invariants. + +## Task 1: Shared Schema Contract + +**Files:** + +- Modify: `packages/schema/src/routes.ts` +- Modify: `packages/schema/src/schemas.ts` +- Modify: `packages/clawhub/src/schema/routes.ts` +- Modify: `packages/clawhub/src/schema/schemas.ts` +- Test: `packages/schema/src/schemas.test.ts` if present, otherwise `packages/clawhub/src/cli/commands/scan.test.ts` validates parsed shapes through CLI usage. + +- [ ] **Step 1: Write the failing schema/CLI contract test** + +Add a test that expects the CLI to submit: + +```ts +{ + source: { kind: 'published', slug: 'demo', version: '1.2.3' }, + update: true, +} +``` + +to `POST /api/v1/skills/-/scan` and accept: + +```ts +{ + scanId: 'scan_123', + jobId: 'job_123', + status: 'queued', + sourceKind: 'published', + update: true, +} +``` + +- [ ] **Step 2: Run the test to verify RED** + +Run: `bun test packages/clawhub/src/cli/commands/scan.test.ts` + +Expected: FAIL because the `scan` command and scan schemas do not exist yet. + +- [ ] **Step 3: Add route constants** + +Add `skillScans: '/api/v1/skills/-/scan'` to both route files. + +- [ ] **Step 4: Add Zod schemas** + +Add schemas for: + +```ts +ApiV1SkillScanSourceSchema; +ApiV1SkillScanSubmitRequestSchema; +ApiV1SkillScanSubmitResponseSchema; +ApiV1SkillScanStatusResponseSchema; +ApiV1SkillScanDownloadManifestSchema; +ApiV1SkillScanBatchRequestSchema; +ApiV1SkillScanBatchResponseSchema; +ApiV1SkillScanBatchStatusRequestSchema; +ApiV1SkillScanBatchStatusResponseSchema; +``` + +Keep the batch schemas compatible with existing bulk rescan request/status shapes while renaming them to the scan route vocabulary. + +- [ ] **Step 5: Run the targeted test** + +Run: `bun test packages/clawhub/src/cli/commands/scan.test.ts` + +Expected: still FAIL until the CLI command exists, but schema import failures should be gone. + +## Task 2: Convex Scan Request Storage + +**Files:** + +- Modify: `convex/schema.ts` +- Modify: `convex/securityScan.ts` + +- [ ] **Step 1: Add a focused failing backend test if an existing Convex test harness covers security scans** + +Search with: `rg "securityScan|requestSkillRescan|bulk rescan" convex packages -g '*.test.ts'` + +If a harness exists, add tests for local scans rejecting `update: true` and published scans requiring owner/operator permissions. If no harness exists, cover the behavior through HTTP/CLI tests and document the gap in the final handoff. + +- [ ] **Step 2: Extend schema** + +Add `skillScanRequests` with actor, source kind, optional slug/version/version ids, stored files, status, result fields, writeback flag, timestamps, and indexes by actor, job, and expiry. + +Extend `securityScanTargetKindValidator` with `skillScanRequest` and add optional `skillScanRequestId` plus `by_skill_scan_request`. + +- [ ] **Step 3: Add internal helpers** + +Implement helpers in `convex/securityScan.ts` for creating uploaded scan requests, creating published scan requests, polling scan request status, and recording completed/failed results. + +- [ ] **Step 4: Wire worker hydration** + +Update `getJobTargetInternal` and `claimCodexScanJobs` so scan-request jobs hydrate the same `files` URL shape as skill-version jobs. + +- [ ] **Step 5: Wire worker completion** + +Update `completeCodexScanJob` and `failCodexScanJob` so scan-request jobs store results on the request. If `sourceKind === 'published' && update === true`, also write successful ClawScan results back through the existing skill-version update path. + +## Task 3: HTTP Scan API + +**Files:** + +- Modify: `convex/httpApiV1/skillsV1.ts` +- Modify: `convex/httpApiV1/shared.ts` +- Modify: `convex/http.ts` + +- [ ] **Step 1: Add failing HTTP route tests if an HTTP handler harness exists** + +Search with: `rg "httpRouter|httpAction|api/v1/skills" convex packages -g '*.test.ts'` + +Add tests for auth-required local submit, owner-only published submit, poll, and download ZIP entries when a harness exists. + +- [ ] **Step 2: Implement multipart upload parsing** + +Parse a `payload` JSON part plus `files[]` file parts. Store uploaded file blobs in Convex storage and pass path/size/hash/storage metadata into the internal create helper. + +- [ ] **Step 3: Implement `POST /api/v1/skills/-/scan`** + +Require token auth. For `source.kind === 'upload'`, reject `update: true`. For `source.kind === 'published'`, resolve slug/version and enforce owner/member/operator access before enqueueing. + +- [ ] **Step 4: Implement `GET /api/v1/skills/-/scan/{scanId}`** + +Require token auth. Return queued/running/succeeded/failed status, artifact identity, writeback status, and the full report payload when available. + +- [ ] **Step 5: Implement `GET /api/v1/skills/-/scan/{scanId}/download`** + +Require token auth. Return a ZIP with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`. + +- [ ] **Step 6: Move batch routes under the scan group** + +Register `POST /api/v1/skills/-/scan/batch` and `POST /api/v1/skills/-/scan/batch/status`, then keep the old `/-/rescan-batch` routes as compatibility aliases. + +## Task 4: Public CLI Command + +**Files:** + +- Create: `packages/clawhub/src/cli/commands/scan.ts` +- Create: `packages/clawhub/src/cli/commands/scan.test.ts` +- Modify: `packages/clawhub/src/cli.ts` + +- [ ] **Step 1: Write failing CLI tests** + +Test these behaviors: + +```sh +clawhub scan fixtures/skill +clawhub scan --slug demo --version 1.2.3 --update +clawhub scan --slug demo --output report.zip +clawhub scan fixtures/skill --update +clawhub scan fixtures/skill --slug demo +``` + +Expected: the first three submit/poll correctly; the last two fail with clear validation errors. + +- [ ] **Step 2: Implement local source validation** + +Resolve ``, require `SKILL.md` or `skill.md`, collect files with `listTextFiles`, and submit multipart using `apiRequestForm`. + +- [ ] **Step 3: Implement published source submission** + +Require `--slug`, optional `--version`, optional `--update`, and submit JSON to `ApiRoutes.skillScans`. + +- [ ] **Step 4: Implement polling** + +Poll `GET /api/v1/skills/-/scan/{scanId}` until `succeeded` or `failed`. Print progress unless `--json` is set. + +- [ ] **Step 5: Implement terminal and JSON reports** + +Render artifact metadata, ClawScan summary/findings/guidance, SkillSpector issues, static scan findings, VirusTotal counts, and update/writeback status. With `--json`, print the parsed status response. + +- [ ] **Step 6: Implement `--output`** + +Call `GET /api/v1/skills/-/scan/{scanId}/download` after terminal success and write the returned ZIP bytes to the requested file path. + +## Task 5: Moderator CLI Migration + +**Files:** + +- Modify: `packages/clawhub-mod/src/commands/moderation.ts` +- Modify: `packages/clawhub-mod/src/commands/moderation.test.ts` + +- [ ] **Step 1: Update failing tests** + +Expect `clawhub-mod skills rescan ` to call `POST /api/v1/skills/-/scan` with published `update: true`. + +Expect `clawhub-mod skills rescan-all` to call `POST /api/v1/skills/-/scan/batch` and poll `/api/v1/skills/-/scan/batch/status`. + +- [ ] **Step 2: Update implementation** + +Keep prompts and JSON output behavior. Change only the route contract and response parsing. + +## Task 6: Docs, Specs, Verification + +**Files:** + +- Modify: `docs/cli.md` +- Modify: `docs/http-api.md` +- Modify: `specs/security-moderation.md` + +- [ ] **Step 1: Document user-facing CLI** + +Add examples for local ephemeral scans, published scans, `--update`, `--output`, and `--json`. + +- [ ] **Step 2: Document HTTP API** + +Add submit, poll, download, and batch scan routes. Mark legacy rescan routes as compatibility aliases where they remain. + +- [ ] **Step 3: Document security invariant** + +State that local uploaded scans are authenticated but ephemeral, never public-state mutations, and published update scans require owner/member/operator authority. + +- [ ] **Step 4: Run verification** + +Run targeted tests first: + +```sh +bun test packages/clawhub/src/cli/commands/scan.test.ts +bun test packages/clawhub-mod/src/commands/moderation.test.ts +``` + +Then run broader package/type gates when targeted tests pass: + +```sh +bun run ci:packages +bun run ci:static +``` + +If Convex schema or generated API changes require codegen, run: + +```sh +bunx convex codegen +``` + +## Self-Review + +- Spec coverage: covered local ephemeral scans, published read-only scans, explicit update scans, submit/poll behavior, report ZIP, moderator migration, docs, and legacy route compatibility. +- Placeholder scan: no `TBD`, `TODO`, `fill in`, or undefined future task references remain. +- Type consistency: this plan consistently uses `skillScanRequests`, `skillScanRequestId`, `source.kind`, `scanId`, `jobId`, `status`, and `update`.