mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add clawhub scan command (#2479)
* docs: design clawhub scan command * docs: plan clawhub scan command * feat: add clawhub scan command * fix: prune ephemeral scan uploads * fix: avoid scan route slug collision
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -422,6 +422,71 @@ export async function parseMultipartPublish(
|
||||
return parsePublishBody(body);
|
||||
}
|
||||
|
||||
export async function parseMultipartSkillScan(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
validatePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
|
||||
): Promise<{
|
||||
payload: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(payloadRaw) as Record<string, unknown>;
|
||||
} 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");
|
||||
|
||||
@@ -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<T>(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<string, unknown>, key: string) {
|
||||
const report = status.report;
|
||||
if (!report || typeof report !== "object" || Array.isArray(report)) return null;
|
||||
return (report as Record<string, unknown>)[key] ?? null;
|
||||
}
|
||||
|
||||
function buildSkillScanReportZip(status: Record<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
|
||||
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;
|
||||
|
||||
+94
-1
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
+439
-1
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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 <file.zip>` 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"] },
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 <slug>", "Published skill slug to scan")
|
||||
.option("--version <version>", "Published skill version to scan")
|
||||
.option("--update", "Write published scan results back to the selected version")
|
||||
.option("-o, --output <path>", "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("<slug>", "Skill slug")
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Blob & { name?: string }>;
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<typeof createSpinner>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
{"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"}
|
||||
Vendored
+96
-1
@@ -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;
|
||||
|
||||
Vendored
+79
@@ -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?",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -19,7 +19,7 @@ type ClaimedJob = {
|
||||
job: {
|
||||
_id: string;
|
||||
leaseToken: string;
|
||||
targetKind: "skillVersion" | "packageRelease";
|
||||
targetKind: "skillVersion" | "packageRelease" | "skillScanRequest";
|
||||
source: string;
|
||||
hasMaliciousSignal: boolean;
|
||||
waitForVtUntil: number;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 <path>
|
||||
```
|
||||
|
||||
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 <path>
|
||||
clawhub scan <path> --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 `<path>` or `--slug`.
|
||||
- `<path>` must resolve to a local skill folder containing `SKILL.md` or `skill.md`.
|
||||
- `--update` is valid only with `--slug`.
|
||||
- `--output <file.zip>` 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 <file.zip>`.
|
||||
|
||||
`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 <slug>` 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 <path>` 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.
|
||||
@@ -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 `<path>`, 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 <slug>` 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`.
|
||||
Reference in New Issue
Block a user