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:
Patrick Erichsen
2026-06-03 16:05:11 -07:00
committed by GitHub
parent 162528abe4
commit e8cfbddf17
30 changed files with 2538 additions and 33 deletions
+7
View File
@@ -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 },
+28
View File
@@ -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",
+29 -1
View File
@@ -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();
});
});
+8
View File
@@ -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);
+65
View File
@@ -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");
+262
View File
@@ -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
View File
@@ -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,
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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"] },
}),
+21 -14
View File
@@ -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",
);
+14
View File
@@ -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",
);
});
});
+295
View File
@@ -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;
}
+1
View File
@@ -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",
+102
View File
@@ -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?",
+1
View File
@@ -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";
+1
View File
@@ -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",
+1 -1
View File
@@ -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"}
+96 -1
View File
@@ -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;
+79
View File
@@ -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?",
File diff suppressed because one or more lines are too long
+1
View File
@@ -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",
+102
View File
@@ -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?",
+1 -1
View File
@@ -19,7 +19,7 @@ type ClaimedJob = {
job: {
_id: string;
leaseToken: string;
targetKind: "skillVersion" | "packageRelease";
targetKind: "skillVersion" | "packageRelease" | "skillScanRequest";
source: string;
hasMaliciousSignal: boolean;
waitForVtUntil: number;
+8
View File
@@ -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`.