Compare commits

...
39 changed files with 7256 additions and 32 deletions
@@ -0,0 +1,95 @@
---
name: security-scan-overview
description: Use when ClawHub staff or agents need production security scan health, ClawScan verdict/category rollups, failed or queued/running scan triage, or per-skill/per-plugin scanner drilldown through clawhub-mod security-scans commands and Convex/API staff surfaces.
---
# Security Scan Overview
Use the repo-local `clawhub-mod` CLI from a checked-out ClawHub repo. Treat
ClawScan/Codex verdict and category fields as the source of truth. Treat
SkillSpector, static scan, VirusTotal, and worker details as supporting
evidence for drilldown and diagnosis.
## Quick Checks
Validate the token and target registry first:
```sh
bun run mod -- whoami
```
For production, the default registry is `https://clawhub.ai`. For local or
staging proof, pass the exact API base:
```sh
bun run mod -- --registry <convex-http-url> whoami
```
## Commands
Overview for skills and plugins:
```sh
bun run mod -- security-scans overview --window-hours 24
bun run mod -- security-scans overview --window-hours 24 --json
```
Current breakdowns by artifact kind or ClawScan verdict/category:
```sh
bun run mod -- security-scans overview --artifact-kind skill
bun run mod -- security-scans list --artifact-kind plugin --verdict malicious --json
bun run mod -- security-scans list --artifact-kind skill --category <clawscan-category-key>
```
Pipeline health:
```sh
bun run mod -- security-scans failed --artifact-kind all --limit 25 --json
bun run mod -- security-scans queued --artifact-kind all --limit 25
bun run mod -- security-scans running --artifact-kind all --limit 25
```
Artifact drilldown:
```sh
bun run mod -- security-scans inspect --skill <slug> --json
bun run mod -- security-scans inspect --plugin <package-name> --json
```
Use `--artifact-kind skill` or `--artifact-kind plugin` with `--cursor` when
paginating. The combined `all` view is a first-page operator summary and should
not be used as a cursor stream.
## Reporting
Report ClawScan first:
- Current verdict totals as `X/Y (Z%)` for pass, suspicious, malicious, pending,
failed, and unknown.
- ClawScan category rows as category label/key, artifact kind, verdict, and
count/percentage.
- Last-window health: scan events, queued, running, succeeded, and failed.
- Failed samples with artifact kind, slug/package, version, ClawScan verdict,
job status, error, and updated time.
For one artifact, summarize:
1. ClawScan verdict/status/category/summary.
2. Worker status, attempts, queue/start/finish/failure times, and last error.
3. SkillSpector score/severity/category as evidence only.
4. Static scan and VirusTotal results as supporting signals.
## Scale And Safety
- Prefer `overview` before `list`; it reads digest rollups instead of paging
artifact rows.
- Keep `list` limits bounded. Start with 25, increase only when needed, and use
cursor pagination for skill/plugin-specific streams.
- Do not scrape the management UI for data; use `clawhub-mod` or the
corresponding `/api/v1/security-scans/*` staff endpoints.
- These commands are read-only. Do not queue rescans or mutate moderation state
unless the user explicitly asks for that separate action.
- If a result seems stale, say which registry was queried and check whether
digest backfills or scan workers are currently queued/running before drawing a
production conclusion.
@@ -0,0 +1,4 @@
interface:
display_name: "Security Scan Overview"
short_description: "Summarize ClawHub scan health."
default_prompt: "Use $security-scan-overview to summarize ClawHub security scan health with ClawScan as the verdict source of truth."
+2
View File
@@ -51,6 +51,8 @@ skills-lock.json
!.agents/skills/clawhub-moderation/**
!.agents/skills/autoreview/
!.agents/skills/autoreview/**
!.agents/skills/security-scan-overview/
!.agents/skills/security-scan-overview/**
skills/*
.codex/*
!.codex/environments/
+6
View File
@@ -33,6 +33,7 @@ import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
import type * as httpApiV1_securityScansV1 from "../httpApiV1/securityScansV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
@@ -89,6 +90,7 @@ import type * as lib_reservedHandles from "../lib/reservedHandles.js";
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_securityScanDigest from "../lib/securityScanDigest.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
import type * as lib_skillCards from "../lib/skillCards.js";
@@ -119,6 +121,7 @@ import type * as search from "../search.js";
import type * as securityDataset from "../securityDataset.js";
import type * as securityDatasetNode from "../securityDatasetNode.js";
import type * as securityScan from "../securityScan.js";
import type * as securityScanDigests from "../securityScanDigests.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillCards from "../skillCards.js";
@@ -170,6 +173,7 @@ declare const fullApi: ApiFromModules<{
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
"httpApiV1/securityScansV1": typeof httpApiV1_securityScansV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
@@ -226,6 +230,7 @@ declare const fullApi: ApiFromModules<{
"lib/reservedSlugs": typeof lib_reservedSlugs;
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/securityScanDigest": typeof lib_securityScanDigest;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
"lib/skillCards": typeof lib_skillCards;
@@ -256,6 +261,7 @@ declare const fullApi: ApiFromModules<{
securityDataset: typeof securityDataset;
securityDatasetNode: typeof securityDatasetNode;
securityScan: typeof securityScan;
securityScanDigests: typeof securityScanDigests;
seed: typeof seed;
seedSouls: typeof seedSouls;
skillCards: typeof skillCards;
+7
View File
@@ -35,6 +35,7 @@ import {
publishSoulV1Http,
resolveSkillVersionV1Http,
searchSkillsV1Http,
securityScansGetRouterV1Http,
skillsDeleteRouterV1Http,
skillsGetRouterV1Http,
skillsPostRouterV1Http,
@@ -199,6 +200,12 @@ http.route({
handler: createPublisherV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.securityScans}/`,
method: "GET",
handler: securityScansGetRouterV1Http,
});
http.route({
path: ApiRoutes.whoami,
method: "GET",
+114
View File
@@ -262,6 +262,120 @@ describe("httpApiV1 handlers", () => {
});
});
it("security-scans forbids non-moderator api tokens", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:viewer",
user: { _id: "users:viewer", role: "user" },
} as never);
const runQuery = vi.fn();
const ctx = makeCtx({ runQuery });
const response = await __handlers.securityScansGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/security-scans/overview"),
);
expect(response.status).toBe(403);
expect(runQuery).not.toHaveBeenCalled();
});
it("security-scans overview passes moderator token actor through to the digest query", async () => {
const overview = {
generatedAt: 1,
window: { hours: 24, totalsByKind: {}, rows: [], truncated: false },
current: {},
failed: { items: [], limit: 2 },
};
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:mod",
user: { _id: "users:mod", role: "moderator" },
} as never);
const runQuery = vi.fn().mockResolvedValue(overview);
const ctx = makeCtx({
runQuery,
});
const response = await __handlers.securityScansGetRouterV1Handler(
ctx,
new Request(
"https://example.com/api/v1/security-scans/overview?artifactKind=skill&windowHours=48&failedLimit=2",
),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(overview);
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:mod",
artifactKind: "skill",
windowHours: 48,
failedLimit: 2,
});
});
it("security-scans artifacts parses filters and pagination", async () => {
const page = { items: [], nextCursor: "next", done: false, limit: 5 };
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runQuery = vi.fn().mockResolvedValue(page);
const ctx = makeCtx({
runQuery,
});
const response = await __handlers.securityScansGetRouterV1Handler(
ctx,
new Request(
"https://example.com/api/v1/security-scans/artifacts?artifactKind=plugin&scanJobStatus=queued&limit=5&cursor=c1",
),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(page);
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
artifactKind: "plugin",
cursor: "c1",
limit: 5,
clawScanVerdict: undefined,
scanJobStatus: "queued",
failureStatus: undefined,
clawScanPrimaryCategoryKey: undefined,
});
});
it("security-scans artifact inspect accepts a skill slug", async () => {
const detail = {
found: true,
artifactKind: "skill",
state: null,
artifact: {},
scanJob: null,
evidence: {},
};
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:mod",
user: { _id: "users:mod", role: "moderator" },
} as never);
const runQuery = vi.fn().mockResolvedValue(detail);
const ctx = makeCtx({
runQuery,
});
const response = await __handlers.securityScansGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/security-scans/artifact?skillSlug=demo"),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(detail);
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:mod",
skillSlug: "demo",
packageName: undefined,
});
});
it("skills export allows authenticated non-admin users at the key rate limit", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:actor",
+3
View File
@@ -14,6 +14,7 @@ import {
publishPackageV1Handler,
} from "./httpApiV1/packagesV1";
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
import { securityScansGetRouterV1Handler } from "./httpApiV1/securityScansV1";
import {
exportSkillsV1Handler,
listSkillsV1Handler,
@@ -49,6 +50,7 @@ export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
export const createPublisherV1Http = httpAction(createPublisherV1Handler);
export const securityScansGetRouterV1Http = httpAction(securityScansGetRouterV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
@@ -87,6 +89,7 @@ export const __handlers = {
listBundlePluginsV1Handler,
verifyDocsSessionV1Handler,
createPublisherV1Handler,
securityScansGetRouterV1Handler,
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
+167
View File
@@ -0,0 +1,167 @@
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { assertModerator } from "../lib/access";
import { applyRateLimit } from "../lib/httpRateLimit";
import {
CLAW_SCAN_DIGEST_VERDICTS,
SECURITY_SCAN_FAILURE_STATUSES,
SECURITY_SCAN_PIPELINE_STATUSES,
} from "../lib/securityScanDigest";
import {
getPathSegments,
json,
requireApiTokenUserOrResponse,
text,
toOptionalNumber,
} from "./shared";
const securityScanInternalRefs = internal as unknown as {
securityScanDigests: {
getStaffSecurityScanOverviewInternal: unknown;
listStaffSecurityScanArtifactsInternal: unknown;
getStaffSecurityScanArtifactInternal: unknown;
};
};
async function runSecurityScanQueryRef<T>(
ctx: Pick<ActionCtx, "runQuery">,
ref: unknown,
args: unknown,
): Promise<T> {
return (await ctx.runQuery(ref as never, args as never)) as T;
}
function requireModeratorOrResponse(user: Doc<"users">, headers: HeadersInit) {
try {
assertModerator(user);
return { ok: true as const };
} catch {
return { ok: false as const, response: text("Moderator role required.", 403, headers) };
}
}
function toOptionalArtifactKind(value: string | null) {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "skill" || normalized === "plugin") return normalized;
return null;
}
function toOptionalClawScanVerdict(value: string | null) {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
return CLAW_SCAN_DIGEST_VERDICTS.includes(normalized as never) ? normalized : null;
}
function toOptionalScanJobStatus(value: string | null) {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
return SECURITY_SCAN_PIPELINE_STATUSES.includes(normalized as never) ? normalized : null;
}
function toOptionalFailureStatus(value: string | null) {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
return SECURITY_SCAN_FAILURE_STATUSES.includes(normalized as never) ? normalized : null;
}
function normalizeOptionalString(value: string | null) {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function mapSecurityScanReadError(error: unknown, headers: HeadersInit) {
const message = error instanceof Error ? error.message : "Security scan read failed";
const lower = message.toLowerCase();
if (lower.includes("forbidden") || lower.includes("moderator")) {
return text("Forbidden", 403, headers);
}
if (lower.includes("not found")) {
return text(message, 404, headers);
}
return text(message, 400, headers);
}
export async function securityScansGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, "/api/v1/security-scans/");
if (segments.length !== 1) return text("Not found", 404, rate.headers);
const action = segments[0];
if (action !== "overview" && action !== "artifacts" && action !== "artifact") {
return text("Not found", 404, rate.headers);
}
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!authResult.ok) return authResult.response;
const moderator = requireModeratorOrResponse(authResult.user, rate.headers);
if (!moderator.ok) return moderator.response;
const params = new URL(request.url).searchParams;
const actorUserId = authResult.userId as Id<"users">;
try {
if (action === "overview") {
const artifactKind = toOptionalArtifactKind(params.get("artifactKind"));
if (artifactKind === null) return text("Invalid artifactKind", 400, rate.headers);
const result = await runSecurityScanQueryRef(
ctx,
securityScanInternalRefs.securityScanDigests.getStaffSecurityScanOverviewInternal,
{
actorUserId,
artifactKind,
windowHours: toOptionalNumber(params.get("windowHours")),
failedLimit: toOptionalNumber(params.get("failedLimit")),
},
);
return json(result, 200, rate.headers);
}
if (action === "artifacts") {
const artifactKind = toOptionalArtifactKind(params.get("artifactKind"));
if (artifactKind === undefined) return text("Missing artifactKind", 400, rate.headers);
if (artifactKind === null) return text("Invalid artifactKind", 400, rate.headers);
const clawScanVerdict = toOptionalClawScanVerdict(params.get("clawScanVerdict"));
if (clawScanVerdict === null) return text("Invalid clawScanVerdict", 400, rate.headers);
const scanJobStatus = toOptionalScanJobStatus(params.get("scanJobStatus"));
if (scanJobStatus === null) return text("Invalid scanJobStatus", 400, rate.headers);
const failureStatus = toOptionalFailureStatus(params.get("failureStatus"));
if (failureStatus === null) return text("Invalid failureStatus", 400, rate.headers);
const result = await runSecurityScanQueryRef(
ctx,
securityScanInternalRefs.securityScanDigests.listStaffSecurityScanArtifactsInternal,
{
actorUserId,
artifactKind,
cursor: normalizeOptionalString(params.get("cursor")) ?? null,
limit: toOptionalNumber(params.get("limit")),
clawScanVerdict,
scanJobStatus,
failureStatus,
clawScanPrimaryCategoryKey: normalizeOptionalString(
params.get("clawScanPrimaryCategoryKey"),
),
},
);
return json(result, 200, rate.headers);
}
const result = await runSecurityScanQueryRef(
ctx,
securityScanInternalRefs.securityScanDigests.getStaffSecurityScanArtifactInternal,
{
actorUserId,
skillSlug: normalizeOptionalString(params.get("skillSlug")),
packageName: normalizeOptionalString(params.get("packageName")),
},
);
return json(result, 200, rate.headers);
} catch (error) {
return mapSecurityScanReadError(error, rate.headers);
}
}
+305
View File
@@ -0,0 +1,305 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
buildPluginSecurityScanArtifactState,
buildSkillSecurityScanArtifactState,
clampSecurityScanDigestBackfillBatchSize,
clawScanVerdictForState,
clawScanVerdictFromLlmAnalysis,
getCurrentRollupDeltas,
toSecurityScanHourBucket,
} from "./securityScanDigest";
const checkedAt = Date.UTC(2026, 0, 1, 12, 15, 0);
function makeClawScanAnalysis(overrides: Record<string, unknown> = {}) {
return {
status: "clean",
verdict: "benign",
summary: "Looks purpose aligned.",
checkedAt,
...overrides,
} as never;
}
function makeSkill(overrides: Record<string, unknown> = {}) {
return {
_id: "skills:abc" as never,
slug: "demo-skill",
displayName: "Demo Skill",
ownerUserId: "users:owner" as never,
ownerPublisherId: "publishers:owner" as never,
...overrides,
};
}
function makeSkillVersion(overrides: Record<string, unknown> = {}) {
return {
_id: "skillVersions:v1" as never,
version: "1.0.0",
createdAt: checkedAt - 1_000,
vtAnalysis: undefined,
skillSpectorAnalysis: undefined,
llmAnalysis: makeClawScanAnalysis(),
staticScan: undefined,
...overrides,
};
}
function makePackage(overrides: Record<string, unknown> = {}) {
return {
_id: "packages:plugin" as never,
name: "@demo/plugin",
displayName: "Demo Plugin",
ownerUserId: "users:owner" as never,
ownerPublisherId: "publishers:owner" as never,
...overrides,
};
}
function makePackageRelease(overrides: Record<string, unknown> = {}) {
return {
_id: "packageReleases:r1" as never,
version: "2.0.0",
createdAt: checkedAt - 1_000,
vtAnalysis: undefined,
skillSpectorAnalysis: undefined,
llmAnalysis: undefined,
staticScan: undefined,
...overrides,
};
}
function makeJob(overrides: Record<string, unknown> = {}) {
return {
_id: "securityScanJobs:j1" as never,
status: "queued",
source: "publish",
createdAt: checkedAt - 500,
updatedAt: checkedAt - 250,
completedAt: undefined,
lastError: undefined,
...overrides,
};
}
describe("clawScanVerdictFromLlmAnalysis", () => {
it("maps ClawScan/Codex verdicts into dashboard buckets", () => {
expect(clawScanVerdictFromLlmAnalysis(makeClawScanAnalysis())).toBe("pass");
expect(
clawScanVerdictFromLlmAnalysis(
makeClawScanAnalysis({ status: "suspicious", verdict: "suspicious" }),
),
).toBe("suspicious");
expect(
clawScanVerdictFromLlmAnalysis(
makeClawScanAnalysis({ status: "completed", verdict: "malicious" }),
),
).toBe("malicious");
expect(clawScanVerdictFromLlmAnalysis(undefined)).toBe("pending");
});
it("promotes clean ClawScan results with visible medium-or-higher findings to review", () => {
expect(
clawScanVerdictFromLlmAnalysis(
makeClawScanAnalysis({
agenticRiskFindings: [
{
categoryId: "ASI03",
categoryLabel: "Identity and Privilege Abuse",
riskBucket: "permission_boundary",
status: "note",
severity: "medium",
confidence: "medium",
evidence: {
path: "metadata",
snippet: "requires.env: TODOIST_API_TOKEN",
explanation: "Broad account access is material.",
},
userImpact: "Account-level access.",
recommendation: "Review the permission scope.",
},
],
}),
),
).toBe("suspicious");
});
});
describe("clawScanVerdictForState", () => {
it("uses queued/running/failed job state when no ClawScan analysis exists", () => {
expect(clawScanVerdictForState({ scanJobStatus: "queued" })).toBe("pending");
expect(clawScanVerdictForState({ scanJobStatus: "running" })).toBe("pending");
expect(clawScanVerdictForState({ scanJobStatus: "failed" })).toBe("failed");
expect(clawScanVerdictForState({ scanJobStatus: "none" })).toBe("unknown");
});
});
describe("security scan digest builders", () => {
it("builds a skill artifact state with ClawScan as the verdict source and scanner evidence", () => {
const state = buildSkillSecurityScanArtifactState({
skill: makeSkill() as never,
version: makeSkillVersion({
llmAnalysis: makeClawScanAnalysis({ status: "clean", verdict: "benign" }),
skillSpectorAnalysis: {
status: "suspicious",
score: 85,
severity: "HIGH",
recommendation: "REVIEW",
issueCount: 1,
issues: [
{
issueId: "secret-egress",
category: "Prompt Injection",
severity: "HIGH",
explanation: "Possible injection.",
},
],
checkedAt,
},
staticScan: {
status: "malicious",
reasonCodes: ["malicious.external_transfer"],
findings: [],
summary: "External transfer.",
engineVersion: "v1",
checkedAt,
},
}) as never,
scanJob: makeJob({ status: "succeeded", completedAt: checkedAt }) as never,
now: checkedAt,
});
expect(state.artifactKind).toBe("skill");
expect(state.artifactKey).toBe("skill:skills:abc");
expect(state.targetKey).toBe("skillVersion:skillVersions:v1");
expect(state.clawScanVerdict).toBe("pass");
expect(state.scanJobStatus).toBe("succeeded");
expect(state.skillSpectorScore).toBe(85);
expect(state.skillSpectorTopCategory).toBe("Prompt Injection");
expect(state.staticStatus).toBe("malicious");
});
it("builds a plugin artifact state for failed scans without source analysis", () => {
const state = buildPluginSecurityScanArtifactState({
pkg: makePackage() as never,
release: makePackageRelease() as never,
scanJob: makeJob({ status: "failed", lastError: "Worker timed out" }) as never,
now: checkedAt,
});
expect(state.artifactKind).toBe("plugin");
expect(state.name).toBe("@demo/plugin");
expect(state.clawScanVerdict).toBe("failed");
expect(state.scanJobStatus).toBe("failed");
expect(state.failureStatus).toBe("failed");
expect(state.lastError).toBe("Worker timed out");
});
});
describe("security scan digest rollups", () => {
it("computes current rollup deltas when an artifact changes verdict and category", () => {
const previous = buildSkillSecurityScanArtifactState({
skill: makeSkill() as never,
version: makeSkillVersion() as never,
now: checkedAt,
});
const next = buildSkillSecurityScanArtifactState({
skill: makeSkill() as never,
version: makeSkillVersion({
llmAnalysis: makeClawScanAnalysis({
status: "malicious",
verdict: "malicious",
agenticRiskFindings: [
{
categoryId: "ASI07",
categoryLabel: "Insecure Inter-Agent Communication",
riskBucket: "sensitive_data_protection",
status: "concern",
severity: "critical",
confidence: "high",
evidence: {
path: "SKILL.md",
snippet: "send secrets",
explanation: "Secrets leave the workspace.",
},
userImpact: "Secret exfiltration.",
recommendation: "Remove the transfer.",
},
],
}),
}) as never,
now: checkedAt + 1,
});
const deltas = getCurrentRollupDeltas(previous, next);
expect(deltas).toEqual(
expect.arrayContaining([
expect.objectContaining({
delta: -1,
dimensions: expect.objectContaining({
rollupKind: "all",
clawScanVerdict: "pass",
}),
}),
expect.objectContaining({
delta: 1,
dimensions: expect.objectContaining({
rollupKind: "all",
clawScanVerdict: "malicious",
}),
}),
expect.objectContaining({
delta: 1,
dimensions: expect.objectContaining({
rollupKind: "clawscanRiskBucket",
categoryKey: "sensitive_data_protection",
}),
}),
expect.objectContaining({
delta: 1,
dimensions: expect.objectContaining({
rollupKind: "clawscanCategory",
categoryKey: "ASI07",
}),
}),
]),
);
});
it("computes decrement deltas when an artifact state is pruned", () => {
const previous = buildSkillSecurityScanArtifactState({
skill: makeSkill() as never,
version: makeSkillVersion() as never,
now: checkedAt,
});
expect(getCurrentRollupDeltas(previous, null)).toEqual([
expect.objectContaining({
delta: -1,
dimensions: expect.objectContaining({
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "pass",
}),
}),
]);
});
});
describe("security scan digest backfill helpers", () => {
it("rounds timestamps down to hourly buckets", () => {
expect(toSecurityScanHourBucket(Date.UTC(2026, 0, 1, 12, 59, 59))).toBe(
Date.UTC(2026, 0, 1, 12, 0, 0),
);
});
it("clamps page size for cursor-safe backfills", () => {
expect(clampSecurityScanDigestBackfillBatchSize(undefined)).toBe(50);
expect(clampSecurityScanDigestBackfillBatchSize(0)).toBe(1);
expect(clampSecurityScanDigestBackfillBatchSize(500)).toBe(250);
expect(clampSecurityScanDigestBackfillBatchSize(12.8)).toBe(12);
});
});
+533
View File
@@ -0,0 +1,533 @@
import type { Doc } from "../_generated/dataModel";
export const SECURITY_SCAN_ARTIFACT_KINDS = ["skill", "plugin"] as const;
export type SecurityScanArtifactKind = (typeof SECURITY_SCAN_ARTIFACT_KINDS)[number];
export const CLAW_SCAN_DIGEST_VERDICTS = [
"pass",
"suspicious",
"malicious",
"pending",
"failed",
"unknown",
] as const;
export type ClawScanDigestVerdict = (typeof CLAW_SCAN_DIGEST_VERDICTS)[number];
export const SECURITY_SCAN_PIPELINE_STATUSES = [
"none",
"queued",
"running",
"succeeded",
"failed",
] as const;
export type SecurityScanPipelineStatus = (typeof SECURITY_SCAN_PIPELINE_STATUSES)[number];
export const SECURITY_SCAN_FAILURE_STATUSES = ["none", "failed"] as const;
export type SecurityScanFailureStatus = (typeof SECURITY_SCAN_FAILURE_STATUSES)[number];
export const SECURITY_SCAN_ROLLUP_KINDS = [
"all",
"clawscanRiskBucket",
"clawscanCategory",
] as const;
export type SecurityScanRollupKind = (typeof SECURITY_SCAN_ROLLUP_KINDS)[number];
export type SecurityScanArtifactStateFields = Omit<
Doc<"securityScanArtifactStates">,
"_creationTime" | "_id"
>;
export type SecurityScanCurrentRollupDimensions = Pick<
Doc<"securityScanCurrentRollups">,
| "artifactKind"
| "rollupKind"
| "categoryKey"
| "clawScanVerdict"
| "scanJobStatus"
| "failureStatus"
> & {
categoryLabel?: string;
};
export type SecurityScanHourlyRollupDimensions = Pick<
Doc<"securityScanHourlyRollups">,
"artifactKind" | "clawScanVerdict" | "scanJobStatus" | "failureStatus"
>;
export type SecurityScanRollupDelta = {
dimensions: SecurityScanCurrentRollupDimensions;
delta: 1 | -1;
};
type LlmAnalysisLike = NonNullable<Doc<"skillVersions">["llmAnalysis"]>;
type SkillSpectorAnalysisLike = NonNullable<Doc<"skillVersions">["skillSpectorAnalysis"]>;
type StaticScanLike = NonNullable<Doc<"skillVersions">["staticScan"]>;
type VirusTotalAnalysisLike = NonNullable<Doc<"skillVersions">["vtAnalysis"]>;
type SecurityScanJobLike = Pick<
Doc<"securityScanJobs">,
| "_id"
| "status"
| "source"
| "workerId"
| "attempts"
| "createdAt"
| "updatedAt"
| "completedAt"
| "lastError"
>;
type SkillDigestInput = Pick<
Doc<"skills">,
"_id" | "slug" | "displayName" | "ownerUserId" | "ownerPublisherId"
>;
type SkillVersionDigestInput = Pick<
Doc<"skillVersions">,
| "_id"
| "version"
| "vtAnalysis"
| "skillSpectorAnalysis"
| "llmAnalysis"
| "staticScan"
| "createdAt"
>;
type PackageDigestInput = Pick<
Doc<"packages">,
"_id" | "name" | "displayName" | "ownerUserId" | "ownerPublisherId"
>;
type PackageReleaseDigestInput = Pick<
Doc<"packageReleases">,
| "_id"
| "version"
| "vtAnalysis"
| "skillSpectorAnalysis"
| "llmAnalysis"
| "staticScan"
| "createdAt"
>;
const HOUR_MS = 60 * 60 * 1000;
const DEFAULT_BACKFILL_BATCH_SIZE = 50;
const MAX_BACKFILL_BATCH_SIZE = 250;
const MAX_DIGEST_TEXT_LENGTH = 2_000;
const RISK_BUCKET_LABELS: Record<string, string> = {
abnormal_behavior_control: "Abnormal behavior control",
permission_boundary: "Permission boundary",
sensitive_data_protection: "Sensitive data protection",
};
function normalizeToken(value: string | null | undefined) {
return value?.trim().toLowerCase();
}
function limitDigestText(value: string | null | undefined, maxLength = MAX_DIGEST_TEXT_LENGTH) {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
}
function severityRank(severity: string | null | undefined) {
switch (normalizeToken(severity)) {
case "critical":
return 5;
case "high":
return 4;
case "medium":
return 3;
case "low":
return 2;
case "info":
return 1;
default:
return 0;
}
}
function confidenceRank(confidence: string | null | undefined) {
switch (normalizeToken(confidence)) {
case "high":
return 3;
case "medium":
return 2;
case "low":
return 1;
default:
return 0;
}
}
function isLowConfidence(value: unknown) {
return typeof value === "string" && value.trim().toLowerCase() === "low";
}
function getVisibleAgenticRiskFindings(analysis: LlmAnalysisLike | null | undefined) {
return (analysis?.agenticRiskFindings ?? []).filter(
(finding) =>
(finding.status === "note" || finding.status === "concern") &&
Boolean(finding.evidence) &&
!isLowConfidence(finding.confidence),
);
}
function getHighestVisibleSeverity(analysis: LlmAnalysisLike | null | undefined) {
let best: string | undefined;
let bestRank = 0;
for (const finding of getVisibleAgenticRiskFindings(analysis)) {
const rank = severityRank(finding.severity);
if (rank > bestRank) {
best = finding.severity;
bestRank = rank;
}
}
return best;
}
function getPrimaryClawScanFinding(analysis: LlmAnalysisLike | null | undefined) {
let best: NonNullable<LlmAnalysisLike["agenticRiskFindings"]>[number] | undefined;
for (const finding of getVisibleAgenticRiskFindings(analysis)) {
if (!best) {
best = finding;
continue;
}
const severityDiff = severityRank(finding.severity) - severityRank(best.severity);
if (severityDiff > 0) {
best = finding;
continue;
}
if (severityDiff < 0) continue;
const statusDiff = (finding.status === "concern" ? 1 : 0) - (best.status === "concern" ? 1 : 0);
if (statusDiff > 0) {
best = finding;
continue;
}
if (statusDiff < 0) continue;
const confidenceDiff = confidenceRank(finding.confidence) - confidenceRank(best.confidence);
if (confidenceDiff > 0) best = finding;
}
return best;
}
function normalizeClawScanVerdictToken(
value: string | null | undefined,
): ClawScanDigestVerdict | null {
switch (normalizeToken(value)) {
case "malicious":
return "malicious";
case "review":
case "suspicious":
case "warn":
case "warning":
return "suspicious";
case "benign":
case "clean":
case "cleared":
case "pass":
case "undetected-only-fallback":
return "pass";
case "pending":
case "loading":
case "not_found":
case "queued":
case "running":
return "pending";
case "error":
case "failed":
return "failed";
case "unknown":
return "unknown";
default:
return null;
}
}
export function clawScanVerdictFromLlmAnalysis(
analysis: LlmAnalysisLike | null | undefined,
): ClawScanDigestVerdict {
const verdict = normalizeClawScanVerdictToken(analysis?.verdict);
const status = normalizeClawScanVerdictToken(analysis?.status);
const normalized = verdict ?? status;
if (!normalized) return analysis ? "unknown" : "pending";
if (
normalized === "pass" &&
severityRank(getHighestVisibleSeverity(analysis)) >= severityRank("medium")
) {
return "suspicious";
}
return normalized;
}
export function pipelineStatusFromJob(
job: Pick<SecurityScanJobLike, "status"> | null | undefined,
): SecurityScanPipelineStatus {
if (!job) return "none";
return job.status;
}
export function failureStatusFromJob(
job: Pick<SecurityScanJobLike, "status"> | null | undefined,
): SecurityScanFailureStatus {
return job?.status === "failed" ? "failed" : "none";
}
export function clawScanVerdictForState(params: {
llmAnalysis?: LlmAnalysisLike | null;
scanJobStatus?: SecurityScanPipelineStatus;
}): ClawScanDigestVerdict {
const fromAnalysis = clawScanVerdictFromLlmAnalysis(params.llmAnalysis);
if (fromAnalysis !== "pending" || params.llmAnalysis) return fromAnalysis;
if (params.scanJobStatus === "queued" || params.scanJobStatus === "running") return "pending";
if (params.scanJobStatus === "failed") return "failed";
return "unknown";
}
export function toSecurityScanHourBucket(timestamp: number) {
return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
}
export function clampSecurityScanDigestBackfillBatchSize(batchSize: number | null | undefined) {
if (!Number.isFinite(batchSize ?? NaN)) return DEFAULT_BACKFILL_BATCH_SIZE;
return Math.max(1, Math.min(MAX_BACKFILL_BATCH_SIZE, Math.floor(batchSize!)));
}
function getSkillSpectorTopCategory(analysis: SkillSpectorAnalysisLike | null | undefined) {
let best: NonNullable<SkillSpectorAnalysisLike["issues"]>[number] | undefined;
for (const issue of analysis?.issues ?? []) {
if (!issue.category) continue;
if (!best || severityRank(issue.severity) > severityRank(best.severity)) best = issue;
}
return best?.category;
}
function getVtEngineStats(analysis: VirusTotalAnalysisLike | null | undefined) {
return analysis?.engineStats;
}
function getEvidenceUpdatedAt(params: {
llmAnalysis?: LlmAnalysisLike | null;
skillSpectorAnalysis?: SkillSpectorAnalysisLike | null;
staticScan?: StaticScanLike | null;
vtAnalysis?: VirusTotalAnalysisLike | null;
}) {
const timestamps = [
params.llmAnalysis?.checkedAt,
params.skillSpectorAnalysis?.checkedAt,
params.staticScan?.checkedAt,
params.vtAnalysis?.checkedAt,
].filter((value): value is number => typeof value === "number");
return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
}
function getJobTiming(job: SecurityScanJobLike | null | undefined) {
return {
lastScanJobId: job?._id,
lastScanJobSource: job?.source,
lastScanWorkerId: job?.workerId,
lastScanAttempts: job?.attempts,
lastScanQueuedAt: job?.createdAt,
lastScanStartedAt: job?.status === "running" ? job.updatedAt : undefined,
lastScanCompletedAt:
job?.status === "succeeded" ? (job.completedAt ?? job.updatedAt) : undefined,
lastScanFailedAt: job?.status === "failed" ? job.updatedAt : undefined,
lastScanUpdatedAt: job?.updatedAt,
lastError: limitDigestText(job?.lastError),
};
}
function buildSharedScanFields(params: {
llmAnalysis?: LlmAnalysisLike | null;
skillSpectorAnalysis?: SkillSpectorAnalysisLike | null;
staticScan?: StaticScanLike | null;
vtAnalysis?: VirusTotalAnalysisLike | null;
scanJob?: SecurityScanJobLike | null;
}) {
const scanJobStatus = pipelineStatusFromJob(params.scanJob);
const failureStatus = failureStatusFromJob(params.scanJob);
const primaryFinding = getPrimaryClawScanFinding(params.llmAnalysis);
const vtStats = getVtEngineStats(params.vtAnalysis);
const clawScanVerdict = clawScanVerdictForState({
llmAnalysis: params.llmAnalysis,
scanJobStatus,
});
return {
clawScanVerdict,
clawScanStatus: params.llmAnalysis?.status,
clawScanCheckedAt: params.llmAnalysis?.checkedAt,
clawScanSummary: limitDigestText(params.llmAnalysis?.summary),
clawScanModel: params.llmAnalysis?.model,
clawScanPrimaryRiskBucket: primaryFinding?.riskBucket,
clawScanPrimaryCategoryKey: primaryFinding?.categoryId,
clawScanPrimaryCategoryLabel: primaryFinding?.categoryLabel,
clawScanVisibleFindingCount: getVisibleAgenticRiskFindings(params.llmAnalysis).length,
clawScanHighestSeverity: getHighestVisibleSeverity(params.llmAnalysis),
scanJobStatus,
failureStatus,
...getJobTiming(params.scanJob),
skillSpectorStatus: params.skillSpectorAnalysis?.status,
skillSpectorScore: params.skillSpectorAnalysis?.score,
skillSpectorSeverity: params.skillSpectorAnalysis?.severity,
skillSpectorRecommendation: params.skillSpectorAnalysis?.recommendation,
skillSpectorIssueCount: params.skillSpectorAnalysis?.issueCount,
skillSpectorTopCategory: getSkillSpectorTopCategory(params.skillSpectorAnalysis),
skillSpectorCheckedAt: params.skillSpectorAnalysis?.checkedAt,
staticStatus: params.staticScan?.status,
staticReasonCount: params.staticScan?.reasonCodes.length,
staticCheckedAt: params.staticScan?.checkedAt,
vtStatus: params.vtAnalysis?.status,
vtVerdict: params.vtAnalysis?.verdict,
vtMalicious: vtStats?.malicious,
vtSuspicious: vtStats?.suspicious,
vtCheckedAt: params.vtAnalysis?.checkedAt,
evidenceUpdatedAt: getEvidenceUpdatedAt(params),
};
}
export function buildSkillSecurityScanArtifactState(params: {
skill: SkillDigestInput;
version: SkillVersionDigestInput;
scanJob?: SecurityScanJobLike | null;
now: number;
}): SecurityScanArtifactStateFields {
return {
artifactKind: "skill",
targetKind: "skillVersion",
artifactKey: `skill:${params.skill._id}`,
targetKey: `skillVersion:${params.version._id}`,
skillId: params.skill._id,
skillVersionId: params.version._id,
ownerUserId: params.skill.ownerUserId,
ownerPublisherId: params.skill.ownerPublisherId,
slug: params.skill.slug,
displayName: params.skill.displayName,
version: params.version.version,
...buildSharedScanFields({
llmAnalysis: params.version.llmAnalysis,
skillSpectorAnalysis: params.version.skillSpectorAnalysis,
staticScan: params.version.staticScan,
vtAnalysis: params.version.vtAnalysis,
scanJob: params.scanJob,
}),
createdAt: params.now,
updatedAt: params.now,
};
}
export function buildPluginSecurityScanArtifactState(params: {
pkg: PackageDigestInput;
release: PackageReleaseDigestInput;
scanJob?: SecurityScanJobLike | null;
now: number;
}): SecurityScanArtifactStateFields {
return {
artifactKind: "plugin",
targetKind: "packageRelease",
artifactKey: `plugin:${params.pkg._id}`,
targetKey: `packageRelease:${params.release._id}`,
packageId: params.pkg._id,
packageReleaseId: params.release._id,
ownerUserId: params.pkg.ownerUserId,
ownerPublisherId: params.pkg.ownerPublisherId,
name: params.pkg.name,
displayName: params.pkg.displayName,
version: params.release.version,
...buildSharedScanFields({
llmAnalysis: params.release.llmAnalysis,
skillSpectorAnalysis: params.release.skillSpectorAnalysis,
staticScan: params.release.staticScan,
vtAnalysis: params.release.vtAnalysis,
scanJob: params.scanJob,
}),
createdAt: params.now,
updatedAt: params.now,
};
}
export function getCurrentRollupEntriesForState(
state: Pick<
SecurityScanArtifactStateFields,
| "artifactKind"
| "clawScanVerdict"
| "scanJobStatus"
| "failureStatus"
| "clawScanPrimaryRiskBucket"
| "clawScanPrimaryCategoryKey"
| "clawScanPrimaryCategoryLabel"
>,
): SecurityScanCurrentRollupDimensions[] {
const base = {
artifactKind: state.artifactKind,
clawScanVerdict: state.clawScanVerdict,
scanJobStatus: state.scanJobStatus,
failureStatus: state.failureStatus,
};
const entries: SecurityScanCurrentRollupDimensions[] = [
{
...base,
rollupKind: "all",
categoryKey: "all",
categoryLabel: "All artifacts",
},
];
if (state.clawScanPrimaryRiskBucket) {
entries.push({
...base,
rollupKind: "clawscanRiskBucket",
categoryKey: state.clawScanPrimaryRiskBucket,
categoryLabel:
RISK_BUCKET_LABELS[state.clawScanPrimaryRiskBucket] ?? state.clawScanPrimaryRiskBucket,
});
}
if (state.clawScanPrimaryCategoryKey) {
entries.push({
...base,
rollupKind: "clawscanCategory",
categoryKey: state.clawScanPrimaryCategoryKey,
categoryLabel: state.clawScanPrimaryCategoryLabel ?? state.clawScanPrimaryCategoryKey,
});
}
return entries;
}
function rollupEntryKey(entry: SecurityScanCurrentRollupDimensions) {
return [
entry.artifactKind,
entry.rollupKind,
entry.categoryKey,
entry.clawScanVerdict,
entry.scanJobStatus,
entry.failureStatus,
].join("|");
}
export function getCurrentRollupDeltas(
previous: SecurityScanArtifactStateFields | null | undefined,
next: SecurityScanArtifactStateFields | null | undefined,
): SecurityScanRollupDelta[] {
const deltas = new Map<string, SecurityScanRollupDelta>();
const addDelta = (entry: SecurityScanCurrentRollupDimensions, delta: 1 | -1) => {
const key = rollupEntryKey(entry);
const existing = deltas.get(key);
if (!existing) {
deltas.set(key, { dimensions: entry, delta });
return;
}
const combined = existing.delta + delta;
if (combined === 0) {
deltas.delete(key);
return;
}
deltas.set(key, { dimensions: entry, delta: combined > 0 ? 1 : -1 });
};
if (previous) {
for (const entry of getCurrentRollupEntriesForState(previous)) addDelta(entry, -1);
}
if (next) {
for (const entry of getCurrentRollupEntriesForState(next)) addDelta(entry, 1);
}
return [...deltas.values()];
}
+238
View File
@@ -0,0 +1,238 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
clearSecurityScanCurrentRollupsPage,
deleteSecurityScanArtifactState,
recordSecurityScanHourlyRollupEvent,
rebuildSecurityScanCurrentRollupsFromStatesPage,
upsertSecurityScanArtifactState,
} from "../securityScanDigests";
import { buildSkillSecurityScanArtifactState } from "./securityScanDigest";
type FakeRow = Record<string, unknown> & {
_id: string;
_creationTime: number;
};
type FakeTable =
| "securityScanArtifactStates"
| "securityScanCurrentRollups"
| "securityScanHourlyRollups"
| "securityScanHourlyRollupEvents";
class FakeDb {
private nextId = 1;
readonly tables: Record<FakeTable, FakeRow[]> = {
securityScanArtifactStates: [],
securityScanCurrentRollups: [],
securityScanHourlyRollups: [],
securityScanHourlyRollupEvents: [],
};
query(table: FakeTable) {
const tables = this.tables;
const filters: Array<[string, unknown]> = [];
const range = {
eq(field: string, value: unknown) {
filters.push([field, value]);
return range;
},
};
const query = {
withIndex(_indexName: string, buildRange: (q: typeof range) => unknown) {
buildRange(range);
return query;
},
async unique() {
const matches = tables[table].filter((row) =>
filters.every(([field, value]) => row[field] === value),
);
if (matches.length > 1) throw new Error(`Expected unique ${table} row`);
return matches[0] ?? null;
},
async take(limit: number) {
return tables[table]
.filter((row) => filters.every(([field, value]) => row[field] === value))
.slice(0, limit);
},
async paginate(opts: { cursor: string | null; numItems: number }) {
const offset = opts.cursor ? Number(opts.cursor) : 0;
const matches = tables[table].filter((row) =>
filters.every(([field, value]) => row[field] === value),
);
const page = matches.slice(offset, offset + opts.numItems);
const nextOffset = offset + page.length;
const isDone = nextOffset >= matches.length;
return {
page,
isDone,
continueCursor: isDone ? null : String(nextOffset),
};
},
};
return query;
}
async insert(table: FakeTable, fields: Record<string, unknown>) {
const row = {
...fields,
_id: `${table}:${this.nextId}`,
_creationTime: this.nextId,
};
this.nextId++;
this.tables[table].push(row);
return row._id;
}
async patch(id: string, fields: Record<string, unknown>) {
const row = this.findById(id);
Object.assign(row, fields);
}
async delete(id: string) {
for (const rows of Object.values(this.tables)) {
const index = rows.findIndex((row) => row._id === id);
if (index >= 0) {
rows.splice(index, 1);
return;
}
}
throw new Error(`Missing row ${id}`);
}
private findById(id: string) {
for (const row of Object.values(this.tables).flat()) {
if (row._id === id) return row;
}
throw new Error(`Missing row ${id}`);
}
}
const checkedAt = Date.UTC(2026, 0, 1, 12, 15, 0);
function makeCtx() {
const db = new FakeDb();
return { db, ctx: { db } as never };
}
function makeSkillState(verdict: "pass" | "malicious" = "pass", now = checkedAt) {
return buildSkillSecurityScanArtifactState({
skill: {
_id: "skills:abc" as never,
slug: "demo-skill",
displayName: "Demo Skill",
ownerUserId: "users:owner" as never,
ownerPublisherId: "publishers:owner" as never,
},
version: {
_id: "skillVersions:v1" as never,
version: "1.0.0",
createdAt: checkedAt - 1_000,
vtAnalysis: undefined,
skillSpectorAnalysis: undefined,
staticScan: undefined,
llmAnalysis:
verdict === "malicious"
? {
status: "malicious",
verdict: "malicious",
checkedAt,
}
: {
status: "clean",
verdict: "benign",
checkedAt,
},
} as never,
now,
});
}
describe("security scan digest mutation helpers", () => {
it("keeps current rollups idempotent across upserts and verdict changes", async () => {
const { db, ctx } = makeCtx();
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass", checkedAt + 1));
expect(db.tables.securityScanArtifactStates).toHaveLength(1);
expect(db.tables.securityScanCurrentRollups).toMatchObject([
{ clawScanVerdict: "pass", count: 1 },
]);
await upsertSecurityScanArtifactState(ctx, makeSkillState("malicious", checkedAt + 2));
expect(db.tables.securityScanArtifactStates).toHaveLength(1);
expect(db.tables.securityScanCurrentRollups).toMatchObject([
{ clawScanVerdict: "malicious", count: 1 },
]);
});
it("decrements current rollups when an artifact state is pruned", async () => {
const { db, ctx } = makeCtx();
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
await deleteSecurityScanArtifactState(
ctx,
db.tables.securityScanArtifactStates[0] as never,
checkedAt + 1,
);
expect(db.tables.securityScanArtifactStates).toHaveLength(0);
expect(db.tables.securityScanCurrentRollups).toHaveLength(0);
});
it("repairs drifted current rollups by clearing and rebuilding from artifact states", async () => {
const { db, ctx } = makeCtx();
await upsertSecurityScanArtifactState(ctx, makeSkillState("pass"));
db.tables.securityScanCurrentRollups[0].count = 42;
expect(await clearSecurityScanCurrentRollupsPage(ctx, { artifactKind: "skill" })).toMatchObject(
{
deletedCount: 1,
isDone: true,
},
);
expect(
await rebuildSecurityScanCurrentRollupsFromStatesPage(ctx, {
artifactKind: "skill",
batchSize: 10,
}),
).toMatchObject({
scannedCount: 1,
rollupDeltaCount: 1,
isDone: true,
});
expect(db.tables.securityScanCurrentRollups).toMatchObject([
{ artifactKind: "skill", clawScanVerdict: "pass", count: 1 },
]);
});
it("records hourly rollup events idempotently by event key", async () => {
const { db, ctx } = makeCtx();
const dimensions = {
artifactKind: "skill" as const,
clawScanVerdict: "pass" as const,
scanJobStatus: "succeeded" as const,
failureStatus: "none" as const,
};
await recordSecurityScanHourlyRollupEvent(ctx, {
eventKey: "securityScanJobs:job1:succeeded",
occurredAt: checkedAt,
dimensions,
});
const duplicate = await recordSecurityScanHourlyRollupEvent(ctx, {
eventKey: "securityScanJobs:job1:succeeded",
occurredAt: checkedAt,
dimensions,
});
expect(duplicate).toMatchObject({ duplicate: true, updated: false });
expect(db.tables.securityScanHourlyRollupEvents).toHaveLength(1);
expect(db.tables.securityScanHourlyRollups).toMatchObject([
{ artifactKind: "skill", clawScanVerdict: "pass", count: 1 },
]);
});
});
+202 -1
View File
@@ -403,6 +403,32 @@ const skillCardGenerationJobSourceValidator = v.union(
v.literal("scan"),
v.literal("manual"),
);
const securityScanArtifactKindValidator = v.union(v.literal("skill"), v.literal("plugin"));
const securityScanDigestTargetKindValidator = v.union(
v.literal("skillVersion"),
v.literal("packageRelease"),
);
const clawScanDigestVerdictValidator = v.union(
v.literal("pass"),
v.literal("suspicious"),
v.literal("malicious"),
v.literal("pending"),
v.literal("failed"),
v.literal("unknown"),
);
const securityScanPipelineStatusValidator = v.union(
v.literal("none"),
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
);
const securityScanFailureStatusValidator = v.union(v.literal("none"), v.literal("failed"));
const securityScanRollupKindValidator = v.union(
v.literal("all"),
v.literal("clawscanRiskBucket"),
v.literal("clawscanCategory"),
);
const packageFilesValidator = v.array(
v.object({
@@ -970,6 +996,7 @@ const packages = defineTable({
"updatedAt",
])
.index("by_family_updated", ["family", "updatedAt"])
.index("by_family_and_soft_deleted_at_and_updated_at", ["family", "softDeletedAt", "updatedAt"])
.index("by_family_channel_updated", ["family", "channel", "updatedAt"])
.index("by_family_official_updated", ["family", "isOfficial", "updatedAt"])
.index("by_runtime_id", ["runtimeId"])
@@ -1094,7 +1121,176 @@ 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_skill_version_and_updated_at", ["skillVersionId", "updatedAt"])
.index("by_package_release", ["packageReleaseId"])
.index("by_package_release_and_updated_at", ["packageReleaseId", "updatedAt"]);
const securityScanArtifactStates = defineTable({
artifactKind: securityScanArtifactKindValidator,
targetKind: securityScanDigestTargetKindValidator,
artifactKey: v.string(),
targetKey: v.string(),
skillId: v.optional(v.id("skills")),
skillVersionId: v.optional(v.id("skillVersions")),
packageId: v.optional(v.id("packages")),
packageReleaseId: v.optional(v.id("packageReleases")),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
slug: v.optional(v.string()),
name: v.optional(v.string()),
displayName: v.string(),
version: v.optional(v.string()),
clawScanVerdict: clawScanDigestVerdictValidator,
clawScanStatus: v.optional(v.string()),
clawScanCheckedAt: v.optional(v.number()),
clawScanSummary: v.optional(v.string()),
clawScanModel: v.optional(v.string()),
clawScanPrimaryRiskBucket: v.optional(v.string()),
clawScanPrimaryCategoryKey: v.optional(v.string()),
clawScanPrimaryCategoryLabel: v.optional(v.string()),
clawScanVisibleFindingCount: v.optional(v.number()),
clawScanHighestSeverity: v.optional(v.string()),
scanJobStatus: securityScanPipelineStatusValidator,
failureStatus: securityScanFailureStatusValidator,
lastScanJobId: v.optional(v.id("securityScanJobs")),
lastScanJobSource: v.optional(securityScanJobSourceValidator),
lastScanWorkerId: v.optional(v.string()),
lastScanAttempts: v.optional(v.number()),
lastScanQueuedAt: v.optional(v.number()),
lastScanStartedAt: v.optional(v.number()),
lastScanCompletedAt: v.optional(v.number()),
lastScanFailedAt: v.optional(v.number()),
lastScanUpdatedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
skillSpectorStatus: v.optional(v.string()),
skillSpectorScore: v.optional(v.number()),
skillSpectorSeverity: v.optional(v.string()),
skillSpectorRecommendation: v.optional(v.string()),
skillSpectorIssueCount: v.optional(v.number()),
skillSpectorTopCategory: v.optional(v.string()),
skillSpectorCheckedAt: v.optional(v.number()),
staticStatus: v.optional(v.string()),
staticReasonCount: v.optional(v.number()),
staticCheckedAt: v.optional(v.number()),
vtStatus: v.optional(v.string()),
vtVerdict: v.optional(v.string()),
vtMalicious: v.optional(v.number()),
vtSuspicious: v.optional(v.number()),
vtCheckedAt: v.optional(v.number()),
evidenceUpdatedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_artifact_kind_and_artifact_key", ["artifactKind", "artifactKey"])
.index("by_artifact_kind_and_updated_at", ["artifactKind", "updatedAt"])
.index("by_target_kind_and_target_key", ["targetKind", "targetKey"])
.index("by_artifact_kind_and_claw_scan_verdict_and_updated_at", [
"artifactKind",
"clawScanVerdict",
"updatedAt",
])
.index("by_artifact_kind_and_scan_job_status_and_updated_at", [
"artifactKind",
"scanJobStatus",
"updatedAt",
])
.index("by_artifact_kind_and_failure_status_and_updated_at", [
"artifactKind",
"failureStatus",
"updatedAt",
])
.index("by_kind_claw_category_updated_at", [
"artifactKind",
"clawScanPrimaryCategoryKey",
"updatedAt",
])
.index("by_artifact_kind_and_display_name", ["artifactKind", "displayName"]);
const securityScanCurrentRollups = defineTable({
artifactKind: securityScanArtifactKindValidator,
rollupKind: securityScanRollupKindValidator,
categoryKey: v.string(),
categoryLabel: v.optional(v.string()),
clawScanVerdict: clawScanDigestVerdictValidator,
scanJobStatus: securityScanPipelineStatusValidator,
failureStatus: securityScanFailureStatusValidator,
count: v.number(),
updatedAt: v.number(),
})
.index("by_artifact_kind_and_rollup_kind_and_category_key", [
"artifactKind",
"rollupKind",
"categoryKey",
])
.index("by_kind_rollup_category_verdict_job_failure", [
"artifactKind",
"rollupKind",
"categoryKey",
"clawScanVerdict",
"scanJobStatus",
"failureStatus",
]);
const securityScanHourlyRollups = defineTable({
bucketStartMs: v.number(),
artifactKind: securityScanArtifactKindValidator,
clawScanVerdict: clawScanDigestVerdictValidator,
scanJobStatus: securityScanPipelineStatusValidator,
failureStatus: securityScanFailureStatusValidator,
count: v.number(),
updatedAt: v.number(),
})
.index("by_bucket_start_ms_and_artifact_kind", ["bucketStartMs", "artifactKind"])
.index("by_artifact_kind_and_bucket_start_ms", ["artifactKind", "bucketStartMs"])
.index("by_artifact_kind_and_bucket_start_ms_and_claw_scan_verdict", [
"artifactKind",
"bucketStartMs",
"clawScanVerdict",
])
.index("by_artifact_kind_and_bucket_start_ms_and_scan_job_status", [
"artifactKind",
"bucketStartMs",
"scanJobStatus",
])
.index("by_artifact_kind_and_bucket_start_ms_and_failure_status", [
"artifactKind",
"bucketStartMs",
"failureStatus",
])
.index("by_bucket_kind_verdict_job_failure", [
"bucketStartMs",
"artifactKind",
"clawScanVerdict",
"scanJobStatus",
"failureStatus",
]);
const securityScanHourlyRollupEvents = defineTable({
eventKey: v.string(),
bucketStartMs: v.number(),
artifactKind: securityScanArtifactKindValidator,
clawScanVerdict: clawScanDigestVerdictValidator,
scanJobStatus: securityScanPipelineStatusValidator,
failureStatus: securityScanFailureStatusValidator,
count: v.number(),
createdAt: v.number(),
})
.index("by_event_key", ["eventKey"])
.index("by_bucket_start_ms_and_artifact_kind", ["bucketStartMs", "artifactKind"]);
const securityScanDigestMetadata = defineTable({
key: v.string(),
artifactKind: v.optional(securityScanArtifactKindValidator),
cursor: v.optional(v.union(v.string(), v.null())),
isDone: v.boolean(),
scannedCount: v.number(),
upsertedCount: v.number(),
skippedCount: v.number(),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
updatedAt: v.number(),
}).index("by_key", ["key"]);
const skillCardGenerationJobs = defineTable({
skillId: v.id("skills"),
@@ -1985,6 +2181,11 @@ export default defineSchema({
packages,
packageReleases,
securityScanJobs,
securityScanArtifactStates,
securityScanCurrentRollups,
securityScanHourlyRollups,
securityScanHourlyRollupEvents,
securityScanDigestMetadata,
skillCardGenerationJobs,
packageStatEvents,
packageTrustedPublishers,
+573 -4
View File
@@ -6,9 +6,12 @@ import {
clearQueuedBackfillJobsForLocalDev,
claimQueuedJobsInternal,
completeCodexScanJob,
failJobInternal,
failCodexScanJob,
refreshJobDigestInternal,
requestPackageRescanForUserInternal,
requestPackageRescan,
succeedJobInternal,
requestSkillRescanForUserInternal,
requestSkillRescan,
} from "./securityScan";
@@ -35,6 +38,27 @@ const claimQueuedJobsInternalHandler = (
>
)._handler;
const succeedJobInternalHandler = (
succeedJobInternal as unknown as WrappedHandler<
{ jobId: string; leaseToken: string; runId?: string },
{ ok: true }
>
)._handler;
const failJobInternalHandler = (
failJobInternal as unknown as WrappedHandler<
{ jobId: string; leaseToken: string; error: string },
{ ok: true; retry: boolean }
>
)._handler;
const refreshJobDigestInternalHandler = (
refreshJobDigestInternal as unknown as WrappedHandler<
{ jobId: string },
{ synced: boolean; artifactKind?: string }
>
)._handler;
const failCodexScanJobHandler = (
failCodexScanJob as unknown as WrappedHandler<
{ token: string; jobId: string; leaseToken: string; error: string },
@@ -272,6 +296,16 @@ function makeRescanCtx(options: {
) ?? null
);
}
if (table === "securityScanArtifactStates") {
return (
Array.from(docs.values()).find(
(doc) =>
doc._id?.toString().startsWith("securityScanArtifactStates:") &&
doc.artifactKind === equals.get("artifactKind") &&
doc.artifactKey === equals.get("artifactKey"),
) ?? null
);
}
return null;
}),
};
@@ -349,13 +383,40 @@ function makeCancelCtx(jobs: ScanJob[], targets: Map<string, unknown> = new Map(
};
}
function makeClaimCtx(jobs: ScanJob[]) {
function makeClaimCtx(
jobs: ScanJob[],
targetDocs: Map<string, Record<string, unknown>> = new Map(),
) {
const patches: Array<{ id: string; patch: Record<string, unknown> }> = [];
const inserts: Array<{ table: string; doc: Record<string, unknown> }> = [];
const patch = vi.fn(async (id: string, doc: Record<string, unknown>) => {
patches.push({ id, patch: doc });
});
const insert = vi.fn(async (table: string, doc: Record<string, unknown>) => {
const id = `${table}:${inserts.length + 1}`;
inserts.push({ table, doc });
return id;
});
const get = vi.fn(async (id: string) => targetDocs.get(id) ?? null);
const query = vi.fn((tableName: string) => {
expect(tableName).toBe("securityScanJobs");
if (tableName !== "securityScanJobs") {
return {
withIndex: vi.fn(
(
_indexName: string,
buildRange: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
function eq() {
return { eq };
}
buildRange({ eq });
return {
unique: vi.fn(async () => null),
};
},
),
};
}
return {
withIndex: vi.fn(
(
@@ -415,8 +476,8 @@ function makeClaimCtx(jobs: ScanJob[]) {
db: {
query,
patch,
get: vi.fn(),
insert: vi.fn(),
get,
insert,
replace: vi.fn(),
delete: vi.fn(),
normalizeId: vi.fn(() => null),
@@ -424,7 +485,10 @@ function makeClaimCtx(jobs: ScanJob[]) {
},
},
patches,
inserts,
patch,
insert,
get,
query,
};
}
@@ -443,6 +507,7 @@ describe("securityScan", () => {
"skills:1": {
_id: "skills:1",
slug: "demo-skill",
displayName: "Demo Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
},
@@ -485,6 +550,31 @@ describe("securityScan", () => {
}),
]),
);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
artifactKind: "skill",
artifactKey: "skill:skills:1",
targetKey: "skillVersion:skillVersions:1",
scanJobStatus: "queued",
clawScanVerdict: "pending",
lastScanJobId: "securityScanJobs:1",
lastScanJobSource: "manual",
lastScanAttempts: 0,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:1:queued:created",
artifactKind: "skill",
scanJobStatus: "queued",
}),
}),
]),
);
});
it("lets skill owners request skill rescans through the API helper", async () => {
@@ -529,6 +619,66 @@ describe("securityScan", () => {
);
});
it("refreshes current digest state without replaying an hourly event for duplicate active rescans", async () => {
const activeJob = makeScanJob({
_id: "securityScanJobs:active",
source: "publish",
skillVersionId: "skillVersions:1",
createdAt: 50,
updatedAt: 75,
});
const { ctx, inserts, patches } = makeRescanCtx({
actorId: "users:moderator",
actorRole: "moderator",
activeJobs: [activeJob],
docs: {
"skills:1": {
_id: "skills:1",
slug: "demo-skill",
displayName: "Demo Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
},
"skillVersions:1": {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
},
},
});
const result = await requestSkillRescanHandler(ctx, {
skillId: "skills:1",
version: "1.0.0",
});
expect(result).toMatchObject({
jobId: "securityScanJobs:active",
alreadyQueued: true,
});
expect(patches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "securityScanJobs:active",
patch: expect.objectContaining({ source: "manual", priority: 100 }),
}),
]),
);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
scanJobStatus: "queued",
lastScanJobId: "securityScanJobs:active",
lastScanJobSource: "manual",
}),
}),
]),
);
expect(inserts.some((entry) => entry.table === "securityScanHourlyRollupEvents")).toBe(false);
});
it("lets platform moderators request package rescans", async () => {
const { ctx, inserts } = makeRescanCtx({
actorId: "users:moderator",
@@ -537,6 +687,7 @@ describe("securityScan", () => {
"packages:1": {
_id: "packages:1",
name: "@acme/demo-plugin",
displayName: "Demo Plugin",
normalizedName: "@acme/demo-plugin",
family: "plugin",
ownerUserId: "users:owner",
@@ -582,6 +733,31 @@ describe("securityScan", () => {
}),
]),
);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
artifactKind: "plugin",
artifactKey: "plugin:packages:1",
targetKey: "packageRelease:packageReleases:1",
scanJobStatus: "queued",
clawScanVerdict: "pending",
lastScanJobId: "securityScanJobs:1",
lastScanJobSource: "manual",
lastScanAttempts: 0,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:1:queued:created",
artifactKind: "plugin",
scanJobStatus: "queued",
}),
}),
]),
);
});
it("lets package owners request package rescans through the API helper", async () => {
@@ -951,6 +1127,71 @@ describe("securityScan", () => {
expect(patches.map((entry) => entry.id)).toEqual(claimed.map((job) => job._id));
});
it("updates digest state and hourly rollups when a queued skill scan is claimed", async () => {
const { ctx, inserts } = makeClaimCtx(
[
makeScanJob({
_id: "securityScanJobs:manual",
source: "manual",
skillVersionId: "skillVersions:manual",
priority: 100,
createdAt: 100,
nextRunAt: 100,
}),
],
new Map<string, Record<string, unknown>>([
[
"skillVersions:manual",
{
_id: "skillVersions:manual",
skillId: "skills:manual",
version: "1.0.0",
createdAt: 100,
},
],
[
"skills:manual",
{
_id: "skills:manual",
slug: "manual-skill",
displayName: "Manual Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:manual",
},
],
]),
);
const claimed = await claimQueuedJobsInternalHandler(ctx, {
workerId: "worker-1",
limit: 1,
leaseMs: 60_000,
});
expect(claimed).toHaveLength(1);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
artifactKind: "skill",
scanJobStatus: "running",
lastScanJobId: "securityScanJobs:manual",
lastScanWorkerId: "worker-1",
lastScanAttempts: 1,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:manual:running:1",
scanJobStatus: "running",
}),
}),
]),
);
});
it("allows up to 64 active Codex scan claims", async () => {
const { ctx } = makeClaimCtx(
Array.from({ length: 70 }, (_, index) =>
@@ -973,6 +1214,327 @@ describe("securityScan", () => {
expect(claimed).toHaveLength(64);
});
it("updates digest verdict and hourly rollups when a skill scan succeeds", async () => {
const { ctx, inserts, patches } = makeRescanCtx({
actorId: "users:owner",
docs: {
"securityScanJobs:success": {
...makeScanJob({
_id: "securityScanJobs:success",
status: "running",
source: "publish",
skillVersionId: "skillVersions:success",
attempts: 1,
createdAt: 100,
updatedAt: 200,
}),
leaseToken: "lease-token",
workerId: "worker-1",
},
"skills:success": {
_id: "skills:success",
slug: "success-skill",
displayName: "Success Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:success",
},
"skillVersions:success": {
_id: "skillVersions:success",
skillId: "skills:success",
version: "1.0.0",
createdAt: 100,
llmAnalysis: {
status: "malicious",
verdict: "malicious",
checkedAt: 300,
},
},
},
});
await expect(
succeedJobInternalHandler(ctx, {
jobId: "securityScanJobs:success",
leaseToken: "lease-token",
runId: "run-1",
}),
).resolves.toEqual({ ok: true });
expect(patches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "securityScanJobs:success",
patch: expect.objectContaining({ status: "succeeded", runId: "run-1" }),
}),
]),
);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
artifactKind: "skill",
scanJobStatus: "succeeded",
failureStatus: "none",
clawScanVerdict: "malicious",
lastScanJobId: "securityScanJobs:success",
lastScanWorkerId: "worker-1",
lastScanAttempts: 1,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:success:succeeded:run-1",
scanJobStatus: "succeeded",
clawScanVerdict: "malicious",
}),
}),
]),
);
});
it("keeps retried worker failures queued in digest state without failed rollup status", async () => {
const { ctx, inserts, patches } = makeRescanCtx({
actorId: "users:owner",
docs: {
"securityScanJobs:retry": {
...makeScanJob({
_id: "securityScanJobs:retry",
status: "running",
source: "publish",
skillVersionId: "skillVersions:retry",
attempts: 1,
createdAt: 100,
updatedAt: 200,
}),
leaseToken: "lease-token",
workerId: "worker-1",
},
"skills:retry": {
_id: "skills:retry",
slug: "retry-skill",
displayName: "Retry Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:retry",
},
"skillVersions:retry": {
_id: "skillVersions:retry",
skillId: "skills:retry",
version: "1.0.0",
createdAt: 100,
},
},
});
const result = await failJobInternalHandler(ctx, {
jobId: "securityScanJobs:retry",
leaseToken: "lease-token",
error: "Download failed https://signed.example.invalid/file?token=secret Bearer sk-secret",
});
expect(result).toEqual({ ok: true, retry: true });
expect(patches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "securityScanJobs:retry",
patch: expect.objectContaining({ status: "queued" }),
}),
]),
);
const failurePatch = patches.find((entry) => entry.id === "securityScanJobs:retry")?.patch;
expect(failurePatch?.lastError).not.toContain("token=secret");
expect(failurePatch?.lastError).not.toContain("sk-secret");
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
scanJobStatus: "queued",
failureStatus: "none",
clawScanVerdict: "pending",
lastScanWorkerId: "worker-1",
lastScanAttempts: 1,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:retry:retry:1",
scanJobStatus: "queued",
failureStatus: "none",
}),
}),
]),
);
const state = inserts.find((entry) => entry.table === "securityScanArtifactStates")?.doc;
expect(state?.lastError).not.toContain("token=secret");
expect(state?.lastError).not.toContain("sk-secret");
});
it("records final package scan failures with plugin digest failure metadata", async () => {
const { ctx, inserts, patches } = makeRescanCtx({
actorId: "users:owner",
docs: {
"securityScanJobs:failed": {
...makeScanJob({
_id: "securityScanJobs:failed",
status: "running",
targetKind: "packageRelease",
skillVersionId: undefined,
packageReleaseId: "packageReleases:failed",
source: "manual",
attempts: 3,
createdAt: 100,
updatedAt: 200,
}),
leaseToken: "lease-token",
workerId: "worker-2",
},
"packages:failed": {
_id: "packages:failed",
name: "@acme/failed-plugin",
displayName: "Failed Plugin",
normalizedName: "@acme/failed-plugin",
family: "code-plugin",
ownerUserId: "users:owner",
latestReleaseId: "packageReleases:failed",
},
"packageReleases:failed": {
_id: "packageReleases:failed",
packageId: "packages:failed",
version: "1.0.0",
createdAt: 100,
},
},
});
const result = await failJobInternalHandler(ctx, {
jobId: "securityScanJobs:failed",
leaseToken: "lease-token",
error: "Codex worker failed Authorization: Bearer sk-secret",
});
expect(result).toEqual({ ok: true, retry: false });
expect(patches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "securityScanJobs:failed",
patch: expect.objectContaining({ status: "failed" }),
}),
]),
);
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "securityScanArtifactStates",
doc: expect.objectContaining({
artifactKind: "plugin",
packageId: "packages:failed",
packageReleaseId: "packageReleases:failed",
scanJobStatus: "failed",
failureStatus: "failed",
clawScanVerdict: "failed",
lastScanWorkerId: "worker-2",
lastScanAttempts: 3,
}),
}),
expect.objectContaining({
table: "securityScanHourlyRollupEvents",
doc: expect.objectContaining({
eventKey: "securityScanJobs:failed:failed:3",
artifactKind: "plugin",
scanJobStatus: "failed",
failureStatus: "failed",
}),
}),
]),
);
const state = inserts.find((entry) => entry.table === "securityScanArtifactStates")?.doc;
expect(state?.lastError).not.toContain("sk-secret");
});
it("refreshes failed digest evidence without erasing same-job worker metadata", async () => {
const { ctx, patches } = makeRescanCtx({
actorId: "users:owner",
docs: {
"securityScanJobs:failed": {
...makeScanJob({
_id: "securityScanJobs:failed",
status: "failed",
targetKind: "packageRelease",
skillVersionId: undefined,
packageReleaseId: "packageReleases:failed",
source: "manual",
attempts: 3,
createdAt: 100,
updatedAt: 200,
}),
lastError: "Codex worker failed",
},
"packages:failed": {
_id: "packages:failed",
name: "@acme/failed-plugin",
displayName: "Failed Plugin",
normalizedName: "@acme/failed-plugin",
family: "code-plugin",
ownerUserId: "users:owner",
latestReleaseId: "packageReleases:failed",
},
"packageReleases:failed": {
_id: "packageReleases:failed",
packageId: "packages:failed",
version: "1.0.0",
createdAt: 100,
llmAnalysis: {
status: "error",
summary: "ClawScan could not complete.",
checkedAt: 300,
},
},
"securityScanArtifactStates:existing": {
_id: "securityScanArtifactStates:existing",
_creationTime: 1,
artifactKind: "plugin",
artifactKey: "plugin:packages:failed",
targetKind: "packageRelease",
targetKey: "packageRelease:packageReleases:failed",
packageId: "packages:failed",
packageReleaseId: "packageReleases:failed",
ownerUserId: "users:owner",
name: "@acme/failed-plugin",
displayName: "Failed Plugin",
version: "1.0.0",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
lastScanJobId: "securityScanJobs:failed",
lastScanWorkerId: "worker-2",
lastScanAttempts: 3,
createdAt: 200,
updatedAt: 200,
},
},
});
await expect(
refreshJobDigestInternalHandler(ctx, { jobId: "securityScanJobs:failed" }),
).resolves.toMatchObject({ synced: true, artifactKind: "plugin" });
expect(patches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "securityScanArtifactStates:existing",
patch: expect.objectContaining({
clawScanStatus: "error",
clawScanSummary: "ClawScan could not complete.",
lastScanWorkerId: "worker-2",
}),
}),
]),
);
});
it("caps SkillSpector findings before storing completed scan results", async () => {
vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret");
const longSnippet = "sensitive SkillSpector artifact text ".repeat(200);
@@ -1080,6 +1642,13 @@ describe("securityScan", () => {
}),
}),
);
expect(runMutation).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.objectContaining({
jobId: "securityScanJobs:1",
}),
);
const llmAnalysis = runMutation.mock.calls[1]?.[1]?.llmAnalysis as
| { findings?: string }
| undefined;
+254 -25
View File
@@ -6,7 +6,16 @@ import { action, internalMutation, internalQuery, mutation } from "./functions";
import { assertModerator, requireUser } from "./lib/access";
import { normalizePackageName } from "./lib/packageRegistry";
import { assertCanManageOwnedResource } from "./lib/publishers";
import {
buildPluginSecurityScanArtifactState,
buildSkillSecurityScanArtifactState,
type SecurityScanArtifactStateFields,
} from "./lib/securityScanDigest";
import { sourceSkillVersionFiles } from "./lib/skillCards";
import {
recordSecurityScanHourlyRollupEvent,
upsertSecurityScanArtifactState,
} from "./securityScanDigests";
const MAX_PARALLEL_CODEX_SCANS = 64;
const DEFAULT_VT_WAIT_MS = 10 * 60 * 1000;
@@ -105,6 +114,27 @@ type EnqueuePackageReleaseScanArgs = {
waitForVtMs?: number;
};
type DigestSecurityScanJob = Pick<
Doc<"securityScanJobs">,
| "_id"
| "targetKind"
| "skillVersionId"
| "packageReleaseId"
| "status"
| "source"
| "workerId"
| "attempts"
| "createdAt"
| "updatedAt"
| "completedAt"
| "lastError"
>;
type SecurityScanDigestEvent = {
eventKey: string;
occurredAt: number;
};
const llmAgenticRiskEvidenceValidator = v.object({
path: v.string(),
snippet: v.string(),
@@ -204,6 +234,7 @@ const internalRefs = internal as unknown as {
enqueueSkillVersionScanInternal: unknown;
failJobInternal: unknown;
getJobTargetInternal: unknown;
refreshJobDigestInternal: unknown;
succeedJobInternal: unknown;
};
skills: {
@@ -251,7 +282,12 @@ function publicWorkerErrorDetail(error: string) {
return error
.replace(/https?:\/\/[^\s"')<>]+/g, "[redacted-url]")
.replace(
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
/\b(authorization)(["']?\s*[:=]\s*["']?)(Bearer|Basic)\s+[^\s"',}]+/gi,
(_match, key: string, separator: string, scheme: string) =>
`${key}${separator}${scheme} [redacted-secret]`,
)
.replace(
/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{6,}/gi,
(_match, scheme: string) => `${scheme} [redacted-secret]`,
)
.replace(
@@ -359,6 +395,85 @@ function hasArtifactBackedLlmAnalysis(analysis: ExistingLlmAnalysis | undefined)
);
}
function securityScanDigestEventKey(
jobId: Id<"securityScanJobs">,
event: string,
discriminator: string | number,
) {
return `${jobId}:${event}:${discriminator}`;
}
function hourlyDimensionsFromState(state: SecurityScanArtifactStateFields) {
return {
artifactKind: state.artifactKind,
clawScanVerdict: state.clawScanVerdict,
scanJobStatus: state.scanJobStatus,
failureStatus: state.failureStatus,
};
}
async function buildCurrentSecurityScanArtifactStateForJob(
ctx: MutationCtx,
job: DigestSecurityScanJob,
now: number,
): Promise<SecurityScanArtifactStateFields | null> {
if (job.targetKind === "skillVersion" && job.skillVersionId) {
const version = await ctx.db.get(job.skillVersionId);
if (!version || version.softDeletedAt) return null;
const skill = await ctx.db.get(version.skillId);
if (!skill || skill.softDeletedAt || skill.latestVersionId !== version._id) return null;
return buildSkillSecurityScanArtifactState({ skill, version, scanJob: job, now });
}
if (job.targetKind === "packageRelease" && job.packageReleaseId) {
const release = await ctx.db.get(job.packageReleaseId);
if (!release || release.softDeletedAt) return null;
const pkg = await ctx.db.get(release.packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || pkg.latestReleaseId !== release._id)
return null;
return buildPluginSecurityScanArtifactState({ pkg, release, scanJob: job, now });
}
return null;
}
async function syncSecurityScanDigestForJob(
ctx: MutationCtx,
job: DigestSecurityScanJob,
event?: SecurityScanDigestEvent,
) {
const now = Date.now();
const builtState = await buildCurrentSecurityScanArtifactStateForJob(ctx, job, now);
if (!builtState) return { synced: false as const };
let state = builtState;
if (!state.lastScanWorkerId) {
const existing = await ctx.db
.query("securityScanArtifactStates")
.withIndex("by_artifact_kind_and_artifact_key", (q) =>
q.eq("artifactKind", state.artifactKind).eq("artifactKey", state.artifactKey),
)
.unique();
if (existing && existing.lastScanJobId === state.lastScanJobId && existing.lastScanWorkerId) {
state = { ...state, lastScanWorkerId: existing.lastScanWorkerId };
}
}
await upsertSecurityScanArtifactState(ctx, state);
if (event) {
await recordSecurityScanHourlyRollupEvent(ctx, {
eventKey: event.eventKey,
occurredAt: event.occurredAt,
dimensions: hourlyDimensionsFromState(state),
});
}
return { synced: true as const, artifactKind: state.artifactKind };
}
function normalizeLimit(limit: number | undefined) {
return Math.max(
1,
@@ -686,18 +801,28 @@ async function enqueueSkillVersionScan(ctx: MutationCtx, args: EnqueueSkillVersi
.collect();
const active = existing.find((job) => job.status === "queued" || job.status === "running");
if (active) {
await ctx.db.patch(active._id, {
const updatedJob = {
...active,
source: args.source,
priority: Math.max(active.priority, args.priority ?? 0),
hasMaliciousSignal,
waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil),
nextRunAt: Math.min(active.nextRunAt, nextRunAt),
updatedAt: now,
};
await ctx.db.patch(active._id, {
source: updatedJob.source,
priority: updatedJob.priority,
hasMaliciousSignal: updatedJob.hasMaliciousSignal,
waitForVtUntil: updatedJob.waitForVtUntil,
nextRunAt: updatedJob.nextRunAt,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, updatedJob);
return { ok: true as const, jobId: active._id, alreadyQueued: true as const };
}
const jobId = await ctx.db.insert("securityScanJobs", {
const job = {
targetKind: "skillVersion",
skillVersionId: args.versionId,
status: "queued",
@@ -709,7 +834,18 @@ async function enqueueSkillVersionScan(ctx: MutationCtx, args: EnqueueSkillVersi
attempts: 0,
createdAt: now,
updatedAt: now,
});
} satisfies Omit<DigestSecurityScanJob, "_id"> & {
priority: number;
hasMaliciousSignal: boolean;
waitForVtUntil: number;
nextRunAt: number;
};
const jobId = await ctx.db.insert("securityScanJobs", job);
await syncSecurityScanDigestForJob(
ctx,
{ ...job, _id: jobId },
{ eventKey: securityScanDigestEventKey(jobId, "queued", "created"), occurredAt: now },
);
return { ok: true as const, jobId, alreadyQueued: false as const };
}
@@ -739,18 +875,28 @@ async function enqueuePackageReleaseScan(ctx: MutationCtx, args: EnqueuePackageR
.collect();
const active = existing.find((job) => job.status === "queued" || job.status === "running");
if (active) {
await ctx.db.patch(active._id, {
const updatedJob = {
...active,
source: args.source,
priority: Math.max(active.priority, args.priority ?? 0),
hasMaliciousSignal,
waitForVtUntil: Math.min(active.waitForVtUntil, waitForVtUntil),
nextRunAt: Math.min(active.nextRunAt, nextRunAt),
updatedAt: now,
};
await ctx.db.patch(active._id, {
source: updatedJob.source,
priority: updatedJob.priority,
hasMaliciousSignal: updatedJob.hasMaliciousSignal,
waitForVtUntil: updatedJob.waitForVtUntil,
nextRunAt: updatedJob.nextRunAt,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, updatedJob);
return { ok: true as const, jobId: active._id, alreadyQueued: true as const };
}
const jobId = await ctx.db.insert("securityScanJobs", {
const job = {
targetKind: "packageRelease",
packageReleaseId: args.releaseId,
status: "queued",
@@ -762,7 +908,18 @@ async function enqueuePackageReleaseScan(ctx: MutationCtx, args: EnqueuePackageR
attempts: 0,
createdAt: now,
updatedAt: now,
});
} satisfies Omit<DigestSecurityScanJob, "_id"> & {
priority: number;
hasMaliciousSignal: boolean;
waitForVtUntil: number;
nextRunAt: number;
};
const jobId = await ctx.db.insert("securityScanJobs", job);
await syncSecurityScanDigestForJob(
ctx,
{ ...job, _id: jobId },
{ eventKey: securityScanDigestEventKey(jobId, "queued", "created"), occurredAt: now },
);
return { ok: true as const, jobId, alreadyQueued: false as const };
}
@@ -918,13 +1075,30 @@ export const claimQueuedJobsInternal = internalMutation({
.take(MAX_PARALLEL_CODEX_SCANS * 4);
for (const job of running) {
if ((job.leaseExpiresAt ?? 0) <= now) {
await ctx.db.patch(job._id, {
status: "queued",
const requeuedJob = {
...job,
status: "queued" as const,
leaseToken: undefined,
leaseExpiresAt: undefined,
workerId: undefined,
nextRunAt: now,
updatedAt: now,
};
await ctx.db.patch(job._id, {
status: requeuedJob.status,
leaseToken: requeuedJob.leaseToken,
leaseExpiresAt: requeuedJob.leaseExpiresAt,
workerId: requeuedJob.workerId,
nextRunAt: requeuedJob.nextRunAt,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, requeuedJob, {
eventKey: securityScanDigestEventKey(
job._id,
"queued",
`lease-expired:${job.leaseToken ?? job.updatedAt}`,
),
occurredAt: now,
});
}
}
@@ -976,23 +1150,29 @@ export const claimQueuedJobsInternal = internalMutation({
const claimed = [];
for (const job of ready) {
const leaseToken = crypto.randomUUID();
await ctx.db.patch(job._id, {
status: "running",
attempts: job.attempts + 1,
leaseToken,
leaseExpiresAt: now + leaseMs,
workerId: args.workerId,
lastError: undefined,
updatedAt: now,
});
claimed.push({
const claimedJob = {
...job,
status: "running" as const,
attempts: job.attempts + 1,
leaseToken,
leaseExpiresAt: now + leaseMs,
workerId: args.workerId,
updatedAt: now,
};
await ctx.db.patch(job._id, {
status: claimedJob.status,
attempts: claimedJob.attempts,
leaseToken: claimedJob.leaseToken,
leaseExpiresAt: claimedJob.leaseExpiresAt,
workerId: claimedJob.workerId,
lastError: undefined,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, claimedJob, {
eventKey: securityScanDigestEventKey(job._id, "running", claimedJob.attempts),
occurredAt: now,
});
claimed.push(claimedJob);
}
return claimed;
},
@@ -1027,6 +1207,17 @@ export const getJobTargetInternal = internalQuery({
},
});
export const refreshJobDigestInternal = internalMutation({
args: {
jobId: v.id("securityScanJobs"),
},
handler: async (ctx, args) => {
const job = await ctx.db.get(args.jobId);
if (!job) return { synced: false as const };
return await syncSecurityScanDigestForJob(ctx, job);
},
});
export const succeedJobInternal = internalMutation({
args: {
jobId: v.id("securityScanJobs"),
@@ -1037,13 +1228,26 @@ export const succeedJobInternal = internalMutation({
const job = await ctx.db.get(args.jobId);
if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch");
const now = Date.now();
await ctx.db.patch(args.jobId, {
status: "succeeded",
const updatedJob = {
...job,
status: "succeeded" as const,
runId: args.runId,
completedAt: now,
leaseToken: undefined,
leaseExpiresAt: undefined,
updatedAt: now,
};
await ctx.db.patch(args.jobId, {
status: updatedJob.status,
runId: updatedJob.runId,
completedAt: updatedJob.completedAt,
leaseToken: updatedJob.leaseToken,
leaseExpiresAt: updatedJob.leaseExpiresAt,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, updatedJob, {
eventKey: securityScanDigestEventKey(args.jobId, "succeeded", args.runId ?? job.attempts),
occurredAt: now,
});
return { ok: true as const };
},
@@ -1060,15 +1264,32 @@ export const failJobInternal = internalMutation({
if (!job || job.leaseToken !== args.leaseToken) throw new ConvexError("Lease mismatch");
const now = Date.now();
const retry = job.attempts < MAX_ATTEMPTS;
await ctx.db.patch(args.jobId, {
status: retry ? "queued" : "failed",
lastError: args.error.slice(0, 2000),
nextRunAt: retry ? now + Math.min(30 * 60 * 1000, 2 ** job.attempts * 60_000) : job.nextRunAt,
const lastError = publicWorkerErrorDetail(args.error).slice(0, 2000);
const nextRunAt = retry
? now + Math.min(30 * 60 * 1000, 2 ** job.attempts * 60_000)
: job.nextRunAt;
const updatedJob = {
...job,
status: retry ? ("queued" as const) : ("failed" as const),
lastError,
nextRunAt,
leaseToken: undefined,
leaseExpiresAt: undefined,
updatedAt: now,
};
await ctx.db.patch(args.jobId, {
status: updatedJob.status,
lastError,
nextRunAt,
leaseToken: updatedJob.leaseToken,
leaseExpiresAt: updatedJob.leaseExpiresAt,
workerId: undefined,
updatedAt: now,
});
await syncSecurityScanDigestForJob(ctx, updatedJob, {
eventKey: securityScanDigestEventKey(args.jobId, retry ? "retry" : "failed", job.attempts),
occurredAt: now,
});
return { ok: true as const, retry };
},
});
@@ -1255,6 +1476,7 @@ export const failCodexScanJob = action({
);
if (!result.retry) {
let wroteFailureAnalysis = false;
const target = await runQueryRef<JobTarget | null>(
ctx,
internalRefs.securityScan.getJobTargetInternal,
@@ -1271,6 +1493,7 @@ export const failCodexScanJob = action({
moderationMode: "preserve",
llmAnalysis,
});
wroteFailureAnalysis = true;
}
} else if (target.job.targetKind === "packageRelease" && target.release) {
if (!hasArtifactBackedLlmAnalysis(target.release.llmAnalysis)) {
@@ -1278,9 +1501,15 @@ export const failCodexScanJob = action({
releaseId: target.release._id,
llmAnalysis,
});
wroteFailureAnalysis = true;
}
}
}
if (wroteFailureAnalysis) {
await runMutationRef(ctx, internalRefs.securityScan.refreshJobDigestInternal, {
jobId: args.jobId,
});
}
}
return result;
+742
View File
@@ -0,0 +1,742 @@
/* @vitest-environment node */
import { getAuthUserId } from "@convex-dev/auth/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getStaffSecurityScanArtifact,
getStaffSecurityScanOverview,
listStaffSecurityScanArtifacts,
} from "./securityScanDigests";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
}));
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
type FakeRow = Record<string, unknown> & {
_id: string;
_creationTime: number;
};
type FakeTable =
| "users"
| "skills"
| "skillVersions"
| "packages"
| "packageReleases"
| "securityScanJobs"
| "securityScanArtifactStates"
| "securityScanCurrentRollups"
| "securityScanHourlyRollups";
type OverviewResult = {
window: {
hours: number;
rows: FakeRow[];
totalsByKind: Record<
string,
{
total: number;
byScanJobStatus: Record<string, number>;
}
>;
};
current: Record<
string,
{
totals: {
total: number;
byVerdict: Record<string, number>;
byScanJobStatus: Record<string, number>;
byFailureStatus: Record<string, number>;
};
rollups: Array<Record<string, unknown>>;
}
>;
failed: {
items: FakeRow[];
limit: number;
};
};
type ArtifactListResult = {
items: FakeRow[];
nextCursor: string | null;
done: boolean;
limit: number;
};
type ArtifactDetailResult = {
found: boolean;
artifactKind: "skill" | "plugin";
reason?: string;
state?: FakeRow | null;
scanJob?: Record<string, unknown> | null;
evidence?: {
clawScan: Record<string, unknown>;
skillSpector: {
issueCount?: number;
issues: unknown[];
};
};
};
const getStaffSecurityScanOverviewHandler = (
getStaffSecurityScanOverview as unknown as WrappedHandler<
{ artifactKind?: "skill" | "plugin"; windowHours?: number; failedLimit?: number },
OverviewResult
>
)._handler;
const listStaffSecurityScanArtifactsHandler = (
listStaffSecurityScanArtifacts as unknown as WrappedHandler<
{
artifactKind: "skill" | "plugin";
cursor?: string | null;
limit?: number;
clawScanVerdict?: string;
scanJobStatus?: string;
failureStatus?: string;
clawScanPrimaryCategoryKey?: string;
},
ArtifactListResult
>
)._handler;
const getStaffSecurityScanArtifactHandler = (
getStaffSecurityScanArtifact as unknown as WrappedHandler<
{ skillSlug?: string; packageName?: string },
ArtifactDetailResult
>
)._handler;
const NOW = Date.UTC(2026, 4, 26, 12, 30, 0);
const HOUR_MS = 60 * 60 * 1000;
class FakeDb {
readonly tables: Record<FakeTable, FakeRow[]>;
constructor(seed: Partial<Record<FakeTable, FakeRow[]>> = {}) {
this.tables = {
users: [],
skills: [],
skillVersions: [],
packages: [],
packageReleases: [],
securityScanJobs: [],
securityScanArtifactStates: [],
securityScanCurrentRollups: [],
securityScanHourlyRollups: [],
...seed,
};
}
async get(id: string) {
return (
Object.values(this.tables)
.flat()
.find((tableRow) => tableRow._id === id) ?? null
);
}
query(table: string) {
const tableName = table as FakeTable;
if (!this.tables[tableName]) throw new Error(`Unexpected table ${table}`);
const filters: Array<
{ op: "eq"; field: string; value: unknown } | { op: "gte"; field: string; value: number }
> = [];
let indexName = "";
let orderDirection: "asc" | "desc" | null = null;
const range = {
eq(field: string, value: unknown) {
filters.push({ op: "eq", field, value });
return range;
},
gte(field: string, value: number) {
filters.push({ op: "gte", field, value });
return range;
},
};
const select = () => {
const rows = this.tables[tableName].filter((tableRow) =>
filters.every((filter) => {
if (filter.op === "eq") return tableRow[filter.field] === filter.value;
const value = tableRow[filter.field];
return typeof value === "number" && value >= filter.value;
}),
);
if (!orderDirection) return rows;
const orderField = indexName.includes("bucket_start_ms") ? "bucketStartMs" : "updatedAt";
return [...rows].sort((a, b) => {
const left = typeof a[orderField] === "number" ? a[orderField] : 0;
const right = typeof b[orderField] === "number" ? b[orderField] : 0;
return orderDirection === "desc" ? right - left : left - right;
});
};
const queryApi = {
withIndex(name: string, buildRange: (q: typeof range) => unknown) {
indexName = name;
buildRange(range);
return queryApi;
},
order(direction: "asc" | "desc") {
orderDirection = direction;
return queryApi;
},
async unique() {
const matches = select();
if (matches.length > 1) throw new Error(`Expected unique ${tableName} row`);
return matches[0] ?? null;
},
async take(limit: number) {
return select().slice(0, limit);
},
async paginate(opts: { cursor: string | null; numItems: number }) {
const offset = opts.cursor ? Number(opts.cursor) : 0;
const matches = select();
const page = matches.slice(offset, offset + opts.numItems);
const nextOffset = offset + page.length;
const isDone = nextOffset >= matches.length;
return {
page,
isDone,
continueCursor: isDone ? null : String(nextOffset),
};
},
};
return queryApi;
}
}
function fakeRow(_id: string, fields: Record<string, unknown>): FakeRow {
return {
_id,
_creationTime: 1,
...fields,
};
}
function user(id: string, role: "admin" | "moderator" | "user") {
return fakeRow(`users:${id}`, { role });
}
function currentRollup(
fields: Record<string, unknown> & {
artifactKind: "skill" | "plugin";
rollupKind: string;
categoryKey: string;
clawScanVerdict: string;
scanJobStatus: string;
},
) {
return fakeRow(
`securityScanCurrentRollups:${fields.artifactKind}:${fields.rollupKind}:${fields.categoryKey}:${fields.clawScanVerdict}:${fields.scanJobStatus}`,
{
categoryLabel: undefined,
updatedAt: NOW - 1_000,
...fields,
},
);
}
function hourlyRollup(id: string, fields: Record<string, unknown>) {
return fakeRow(`securityScanHourlyRollups:${id}`, {
updatedAt: NOW - 1_000,
...fields,
});
}
function artifactState(id: string, fields: Record<string, unknown>) {
const artifactKind = fields.artifactKind === "plugin" ? "plugin" : "skill";
return fakeRow(`securityScanArtifactStates:${id}`, {
artifactKind,
targetKind: artifactKind === "plugin" ? "packageRelease" : "skillVersion",
artifactKey: `${artifactKind}:${id}`,
targetKey: `${artifactKind === "plugin" ? "packageRelease" : "skillVersion"}:${id}`,
ownerUserId: "users:owner",
displayName: `Artifact ${id}`,
clawScanVerdict: "pass",
scanJobStatus: "succeeded",
failureStatus: "none",
createdAt: NOW - 10_000,
updatedAt: NOW - 1_000,
...fields,
});
}
function staffCtx(seed: Partial<Record<FakeTable, FakeRow[]>> = {}) {
const db = new FakeDb({
users: [user("admin", "admin"), user("moderator", "moderator"), user("reader", "user")],
...seed,
});
return { db, ctx: { db } };
}
function authenticate(userId: string | null) {
vi.mocked(getAuthUserId).mockResolvedValue(userId as never);
}
function seedOverviewRows() {
const failedSkill = artifactState("failed-skill", {
artifactKind: "skill",
artifactKey: "skill:failed",
displayName: "Failed Skill",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
lastError: "Worker timeout",
updatedAt: NOW - 500,
});
const failedPlugin = artifactState("failed-plugin", {
artifactKind: "plugin",
artifactKey: "plugin:failed",
displayName: "Failed Plugin",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
lastError: "Worker crashed",
updatedAt: NOW - 300,
});
return {
securityScanArtifactStates: [failedSkill, failedPlugin],
securityScanCurrentRollups: [
currentRollup({
artifactKind: "skill",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "pass",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 2,
}),
currentRollup({
artifactKind: "skill",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "malicious",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 1,
}),
currentRollup({
artifactKind: "skill",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "pending",
scanJobStatus: "queued",
failureStatus: "none",
count: 4,
}),
currentRollup({
artifactKind: "skill",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "unknown",
scanJobStatus: "running",
failureStatus: "none",
count: 3,
}),
currentRollup({
artifactKind: "skill",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
count: 1,
}),
currentRollup({
artifactKind: "skill",
rollupKind: "clawscanCategory",
categoryKey: "permission_boundary",
categoryLabel: "Permission boundary",
clawScanVerdict: "malicious",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 1,
}),
currentRollup({
artifactKind: "plugin",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "pass",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 3,
}),
currentRollup({
artifactKind: "plugin",
rollupKind: "all",
categoryKey: "all",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
count: 1,
}),
],
securityScanHourlyRollups: [
hourlyRollup("recent-pass", {
bucketStartMs: NOW - HOUR_MS,
artifactKind: "skill",
clawScanVerdict: "pass",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 3,
}),
hourlyRollup("recent-queued", {
bucketStartMs: NOW - 2 * HOUR_MS,
artifactKind: "skill",
clawScanVerdict: "pending",
scanJobStatus: "queued",
failureStatus: "none",
count: 2,
}),
hourlyRollup("recent-plugin-failed", {
bucketStartMs: NOW - 3 * HOUR_MS,
artifactKind: "plugin",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
count: 1,
}),
hourlyRollup("old-skill", {
bucketStartMs: NOW - 48 * HOUR_MS,
artifactKind: "skill",
clawScanVerdict: "malicious",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 99,
}),
],
};
}
describe("staff security scan digest APIs", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(getAuthUserId).mockReset();
});
it("allows admins and moderators to read overview data but rejects ordinary users", async () => {
vi.spyOn(Date, "now").mockReturnValue(NOW);
const { ctx } = staffCtx();
authenticate(null);
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).rejects.toThrow("Unauthorized");
authenticate("users:reader");
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).rejects.toThrow("Forbidden");
authenticate("users:moderator");
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).resolves.toMatchObject({
window: { hours: 24 },
});
authenticate("users:admin");
await expect(getStaffSecurityScanOverviewHandler(ctx, {})).resolves.toMatchObject({
window: { hours: 24 },
});
});
it("returns current rollups, percentage bases, recent window rows, and failed samples", async () => {
vi.spyOn(Date, "now").mockReturnValue(NOW);
authenticate("users:moderator");
const { ctx } = staffCtx(seedOverviewRows());
const result = await getStaffSecurityScanOverviewHandler(ctx, {
windowHours: 24,
failedLimit: 1,
});
expect(result.current.skill.totals).toMatchObject({
total: 11,
byVerdict: expect.objectContaining({
pass: 2,
malicious: 1,
pending: 4,
failed: 1,
}),
byScanJobStatus: expect.objectContaining({
queued: 4,
running: 3,
failed: 1,
}),
byFailureStatus: expect.objectContaining({
failed: 1,
}),
});
expect(result.current.skill.rollups).toEqual(
expect.arrayContaining([
expect.objectContaining({
rollupKind: "clawscanCategory",
categoryKey: "permission_boundary",
count: 1,
totalForKind: 11,
percentageBasis: 11,
}),
]),
);
expect(result.window.totalsByKind.skill).toMatchObject({
total: 5,
byScanJobStatus: expect.objectContaining({ queued: 2, succeeded: 3 }),
});
expect(result.window.rows).toEqual(
expect.not.arrayContaining([expect.objectContaining({ count: 99 })]),
);
expect(result.failed.items).toMatchObject([
{
artifactKind: "plugin",
displayName: "Failed Plugin",
failureStatus: "failed",
},
]);
expect(result.failed.limit).toBe(1);
});
it("paginates artifact rows with indexed status filters and clamps page size", async () => {
authenticate("users:moderator");
const first = artifactState("plugin-old-failed", {
artifactKind: "plugin",
displayName: "Older Failed Plugin",
failureStatus: "failed",
scanJobStatus: "failed",
clawScanVerdict: "failed",
updatedAt: NOW - 10_000,
});
const second = artifactState("plugin-new-failed", {
artifactKind: "plugin",
displayName: "Newer Failed Plugin",
failureStatus: "failed",
scanJobStatus: "failed",
clawScanVerdict: "failed",
updatedAt: NOW - 100,
});
const pass = artifactState("plugin-pass", {
artifactKind: "plugin",
displayName: "Passing Plugin",
failureStatus: "none",
scanJobStatus: "succeeded",
clawScanVerdict: "pass",
updatedAt: NOW - 50,
});
const { ctx } = staffCtx({
securityScanArtifactStates: [first, second, pass],
});
const firstPage = await listStaffSecurityScanArtifactsHandler(ctx, {
artifactKind: "plugin",
failureStatus: "failed",
limit: 1,
});
expect(firstPage).toMatchObject({
items: [expect.objectContaining({ displayName: "Newer Failed Plugin" })],
nextCursor: "1",
done: false,
limit: 1,
});
const secondPage = await listStaffSecurityScanArtifactsHandler(ctx, {
artifactKind: "plugin",
failureStatus: "failed",
cursor: firstPage.nextCursor,
limit: 999,
});
expect(secondPage).toMatchObject({
items: [expect.objectContaining({ displayName: "Older Failed Plugin" })],
nextCursor: null,
done: true,
limit: 100,
});
await expect(
listStaffSecurityScanArtifactsHandler(ctx, {
artifactKind: "plugin",
failureStatus: "failed",
scanJobStatus: "failed",
}),
).rejects.toThrow("Provide at most one security scan artifact filter");
});
it("looks up a skill by slug with state, evidence summary, and sanitized scan job details", async () => {
authenticate("users:admin");
const issues = Array.from({ length: 12 }, (_, index) => ({
issueId: `issue-${index}`,
severity: "high",
explanation: "Risky tool use",
}));
const { ctx } = staffCtx({
skills: [
fakeRow("skills:demo", {
slug: "demo-skill",
displayName: "Demo Skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:demo",
}),
],
skillVersions: [
fakeRow("skillVersions:demo", {
skillId: "skills:demo",
version: "1.2.3",
createdAt: NOW - 5_000,
llmAnalysis: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "Exfiltrates secrets",
checkedAt: NOW - 1_000,
},
skillSpectorAnalysis: {
status: "suspicious",
score: 85,
severity: "high",
recommendation: "block",
issueCount: issues.length,
checkedAt: NOW - 900,
issues,
},
}),
],
securityScanArtifactStates: [
artifactState("demo-skill", {
artifactKind: "skill",
artifactKey: "skill:skills:demo",
targetKey: "skillVersion:skillVersions:demo",
skillId: "skills:demo",
skillVersionId: "skillVersions:demo",
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.2.3",
clawScanVerdict: "malicious",
scanJobStatus: "succeeded",
lastScanJobId: "securityScanJobs:demo",
}),
],
securityScanJobs: [
fakeRow("securityScanJobs:demo", {
status: "succeeded",
targetKind: "skillVersion",
skillVersionId: "skillVersions:demo",
source: "manual",
priority: 100,
hasMaliciousSignal: true,
waitForVtUntil: 0,
nextRunAt: NOW - 4_000,
attempts: 2,
leaseToken: "internal-secret-token",
leaseExpiresAt: NOW - 3_000,
workerId: "worker-a",
runId: "run-a",
completedAt: NOW - 2_000,
createdAt: NOW - 4_000,
updatedAt: NOW - 2_000,
}),
],
});
const detail = await getStaffSecurityScanArtifactHandler(ctx, { skillSlug: "demo-skill" });
expect(detail).toMatchObject({
found: true,
artifactKind: "skill",
state: expect.objectContaining({
slug: "demo-skill",
clawScanVerdict: "malicious",
}),
scanJob: expect.objectContaining({
_id: "securityScanJobs:demo",
attempts: 2,
workerId: "worker-a",
}),
evidence: {
clawScan: expect.objectContaining({
verdict: "malicious",
summary: "Exfiltrates secrets",
}),
skillSpector: expect.objectContaining({
issueCount: 12,
issues: expect.any(Array),
}),
},
});
expect(detail.scanJob).not.toHaveProperty("leaseToken");
expect(detail.evidence?.skillSpector.issues).toHaveLength(10);
});
it("looks up plugins by normalized package name and returns missing artifacts cleanly", async () => {
authenticate("users:moderator");
const { ctx } = staffCtx({
packages: [
fakeRow("packages:demo-plugin", {
name: "@openclaw/demo-plugin",
normalizedName: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
ownerUserId: "users:owner",
family: "code-plugin",
latestReleaseId: "packageReleases:demo-plugin",
}),
],
packageReleases: [
fakeRow("packageReleases:demo-plugin", {
packageId: "packages:demo-plugin",
version: "2.0.0",
createdAt: NOW - 5_000,
llmAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: NOW - 1_000,
},
}),
],
securityScanArtifactStates: [
artifactState("demo-plugin", {
artifactKind: "plugin",
artifactKey: "plugin:packages:demo-plugin",
targetKey: "packageRelease:packageReleases:demo-plugin",
packageId: "packages:demo-plugin",
packageReleaseId: "packageReleases:demo-plugin",
name: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
version: "2.0.0",
clawScanVerdict: "pass",
}),
],
});
const detail = await getStaffSecurityScanArtifactHandler(ctx, {
packageName: "@OPENCLAW/Demo-Plugin",
});
expect(detail).toMatchObject({
found: true,
artifactKind: "plugin",
state: expect.objectContaining({
name: "@openclaw/demo-plugin",
clawScanVerdict: "pass",
}),
evidence: {
clawScan: expect.objectContaining({
verdict: "benign",
}),
},
});
await expect(
getStaffSecurityScanArtifactHandler(ctx, { skillSlug: "missing-skill" }),
).resolves.toMatchObject({
found: false,
artifactKind: "skill",
reason: "missing",
});
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -39,6 +39,7 @@ Policy, API, and trust docs:
- `docs/api.md`: public REST API overview.
- `docs/http-api.md`: detailed HTTP API reference.
- `docs/security-audits.md`: user-facing security audit status, risk levels, findings, and interpretation.
- `docs/security-scan-visibility.md`: staff dashboard and `clawhub-mod` security scan overview usage.
- `docs/moderation.md`: reports, moderation holds, hidden listings, bans, and account standing.
Maintainer records:
+97
View File
@@ -945,6 +945,103 @@ Response:
}
```
### `GET /api/v1/security-scans/overview`
Moderator/admin endpoint for current ClawScan-first scan rollups and recent
time-window health.
Auth:
- Requires an API token for a moderator or admin user.
Query params:
- `artifactKind` (optional): `skill` or `plugin`; omit for both.
- `windowHours` (optional): recent window size in hours.
- `failedLimit` (optional): number of failed samples to include.
Response shape:
```json
{
"generatedAt": 1730000000000,
"window": {
"hours": 24,
"totalsByKind": {},
"rows": [],
"truncated": false
},
"current": {},
"failed": {
"items": [],
"limit": 10
}
}
```
### `GET /api/v1/security-scans/artifacts`
Moderator/admin endpoint for paginated security scan digest rows.
Auth:
- Requires an API token for a moderator or admin user.
Query params:
- `artifactKind` (required): `skill` or `plugin`.
- `limit` (optional): integer (1-100).
- `cursor` (optional): pagination cursor.
- At most one filter may be supplied:
- `clawScanVerdict`: `pass`, `suspicious`, `malicious`, `pending`, `failed`,
or `unknown`.
- `scanJobStatus`: `none`, `queued`, `running`, `succeeded`, or `failed`.
- `failureStatus`: `none` or `failed`.
- `clawScanPrimaryCategoryKey`: ClawScan category key.
Response:
```json
{
"items": [],
"nextCursor": null,
"done": true,
"limit": 25
}
```
### `GET /api/v1/security-scans/artifact`
Moderator/admin endpoint for one skill or plugin drilldown.
Auth:
- Requires an API token for a moderator or admin user.
Query params:
- Exactly one of `skillSlug` or `packageName`.
Response shape:
```json
{
"found": true,
"artifactKind": "skill",
"state": {},
"artifact": {},
"scanJob": {},
"evidence": {}
}
```
Notes:
- ClawScan/Codex is the final verdict source.
- SkillSpector, static analysis, VirusTotal, and worker details are supporting
evidence for drilldown.
- Scan-job lease tokens and other worker-only internals are not returned.
### `POST /api/v1/packages/reports/{reportId}/triage`
Moderator/admin endpoint for resolving or reopening package reports.
+118
View File
@@ -0,0 +1,118 @@
---
summary: "Staff guide for ClawScan-first production security scan visibility."
read_when:
- Checking production security scan health
- Using the management security scan dashboard
- Using clawhub-mod security-scans commands
title: "Security Scan Visibility"
sidebarTitle: "Security Scan Visibility"
---
# Security Scan Visibility
Admins and site moderators can use the management console and `clawhub-mod` to
see how the ClawHub security scan pipeline is doing across skills and plugins.
Use this view for operational questions such as:
- how many current artifacts pass, look suspicious, are malicious, are pending,
failed, or unknown
- how the last 24 hours of scan events behaved
- which scans are queued, running, or failed
- what happened for one specific skill or plugin release
The staff dashboard is available at:
```text
/management
```
Open the Security scans section there to switch between all artifacts, skills,
and plugins; change the recent window; inspect ClawScan categories; review
failed scans; and drill into one skill slug or plugin package name.
## Source Of Truth
The top-level verdict is ClawScan/Codex. SkillSpector, static analysis,
VirusTotal, and worker details are evidence that helps explain the verdict; do
not treat them as the final verdict when reporting aggregate health.
| State | Meaning |
| ------------ | ------------------------------------------------------------------ |
| `pass` | ClawScan resolved the latest artifact without visible concerns. |
| `suspicious` | ClawScan found concerns that deserve review before installation. |
| `malicious` | ClawScan determined the artifact should not be installed. |
| `pending` | ClawScan is queued/running or has not produced a final result yet. |
| `failed` | The current scan job exhausted unsuccessfully. |
| `unknown` | No current ClawScan result or scan job is available. |
Pipeline status answers a different question:
| Pipeline status | Meaning |
| --------------- | -------------------------------------------- |
| `queued` | Waiting for a worker. |
| `running` | Claimed by a worker. |
| `succeeded` | Worker completed and persisted scan results. |
| `failed` | Worker failed the current job. |
| `none` | No current worker job is attached. |
## CLI Commands
Use `clawhub-mod` when you need copyable output, JSON, or an agent-consumable
view of the same data:
```bash
bun run mod -- security-scans overview --window-hours 24
bun run mod -- security-scans overview --window-hours 24 --json
bun run mod -- security-scans failed --artifact-kind all --limit 25 --json
bun run mod -- security-scans queued --artifact-kind plugin --limit 25
bun run mod -- security-scans running --artifact-kind skill --limit 25
bun run mod -- security-scans inspect --skill <slug> --json
bun run mod -- security-scans inspect --plugin <package-name> --json
```
For production, the default registry is `https://clawhub.ai`. For local or
staging checks, pass the registry explicitly:
```bash
bun run mod -- --registry <convex-http-url> security-scans overview
```
The API token must belong to an admin or moderator. Non-staff users receive a
permission error.
## Reporting
Report the ClawScan verdict first:
```text
pass: 98/124 (79%)
malicious: 4/124 (3%)
queued: 8
running: 1
failed: 2
```
When drilling into one artifact, summarize in this order:
1. ClawScan verdict, status, category, and summary.
2. Worker job status, attempts, queue/start/finish/failure times, and last error.
3. SkillSpector score, severity, and category as supporting evidence.
4. Static analysis and VirusTotal as supporting evidence.
## Scale Notes
The dashboard reads security scan digest tables instead of live-scanning every
skill, version, package, release, and scan job. This keeps the operator view
bounded and index-driven while production data grows.
This pattern is appropriate while the product needs current rollups, recent
hourly trends, failed samples, queue health, and single-artifact drilldown.
Reconsider the design if operators need arbitrary historical slicing across many
dimensions, very long retention windows in the dashboard, or unbounded exports
from the UI. Symptoms to watch for are slow backfills, large hourly rollup
growth, repeated Convex `documentsReadLimit` or `bytesReadLimit` errors, or
operator workflows that require joining many evidence rows on every request.
For agent workflows, use the repo-local `security-scan-overview` skill. It
keeps the same ClawScan-first reporting order and prefers bounded CLI/API reads
over scraping the UI.
+12
View File
@@ -120,4 +120,16 @@ bun run mod -- plugins trusted-publisher set <name> --repository <owner/repo> --
bun run mod -- plugins trusted-publisher delete <name>
```
Security scan visibility:
```bash
bun run mod -- security-scans overview [--artifact-kind all|skill|plugin] [--window-hours 24] [--json]
bun run mod -- security-scans list [--artifact-kind all|skill|plugin] [--verdict <verdict>] [--scan-job-status <status>] [--failure-status failed] [--category <key>] [--json]
bun run mod -- security-scans failed [--artifact-kind all|skill|plugin] [--json]
bun run mod -- security-scans queued [--artifact-kind all|skill|plugin] [--json]
bun run mod -- security-scans running [--artifact-kind all|skill|plugin] [--json]
bun run mod -- security-scans inspect --skill <slug> [--json]
bun run mod -- security-scans inspect --plugin <package> [--json]
```
All skill and plugin commands accept `--json` where the underlying endpoint supports machine-readable output.
+93
View File
@@ -44,6 +44,14 @@ import {
cmdTransferPackageOwner,
cmdUpsertPackageMigration,
} from "./commands/packages.js";
import {
cmdInspectSecurityScanArtifact,
cmdListFailedSecurityScans,
cmdListQueuedSecurityScans,
cmdListRunningSecurityScans,
cmdListSecurityScanArtifacts,
cmdSecurityScanOverview,
} from "./commands/securityScans.js";
const program = new Command()
.name("clawhub-mod")
@@ -335,6 +343,13 @@ const skills = program
.showHelpAfterError()
.showSuggestionAfterError();
const securityScans = program
.command("security-scans")
.alias("security-scan")
.description("Security scan digest inspection")
.showHelpAfterError()
.showSuggestionAfterError();
registerPluginOperations(plugins);
registerPluginModerationCommands(plugins);
registerPluginGovernanceCommands(plugins);
@@ -343,6 +358,7 @@ registerPluginModerationCommands(packages);
registerPluginGovernanceCommands(packages);
registerOrgCommands(org);
registerSkillModerationCommands(skills);
registerSecurityScanCommands(securityScans);
function registerOrgCommands(command: Command) {
command
@@ -360,6 +376,83 @@ function registerOrgCommands(command: Command) {
});
}
function registerSecurityScanCommands(command: Command) {
command
.command("overview")
.description("Show ClawScan-first security scan rollups")
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
.option("--window-hours <n>", "Recent window size in hours")
.option("--failed-limit <n>", "Failed sample size")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdSecurityScanOverview(opts, options);
});
command
.command("list")
.description("List security scan digest rows")
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
.option("--verdict <verdict>", "pass|suspicious|malicious|pending|failed|unknown")
.option("--scan-job-status <status>", "none|queued|running|succeeded|failed")
.option("--failure-status <status>", "none|failed")
.option("--category <key>", "ClawScan category key")
.option("--cursor <cursor>", "Resume cursor")
.option("--limit <n>", "Number of rows to show")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdListSecurityScanArtifacts(opts, options);
});
command
.command("failed")
.description("List failed security scan rows")
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
.option("--cursor <cursor>", "Resume cursor")
.option("--limit <n>", "Number of rows to show")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdListFailedSecurityScans(opts, options);
});
command
.command("queued")
.description("List queued security scan rows")
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
.option("--cursor <cursor>", "Resume cursor")
.option("--limit <n>", "Number of rows to show")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdListQueuedSecurityScans(opts, options);
});
command
.command("running")
.description("List running security scan rows")
.option("--artifact-kind <kind>", "all|skill|plugin", "all")
.option("--cursor <cursor>", "Resume cursor")
.option("--limit <n>", "Number of rows to show")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdListRunningSecurityScans(opts, options);
});
command
.command("inspect")
.description("Inspect scanner outcomes for one skill or plugin")
.option("--skill <slug>", "Skill slug")
.option("--plugin <package>", "Plugin package name")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdInspectSecurityScanArtifact(opts, options);
});
}
function registerPluginGovernanceCommands(command: Command) {
command
.command("transfer")
@@ -0,0 +1,197 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../clawhub/test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory());
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
const {
cmdInspectSecurityScanArtifact,
cmdListFailedSecurityScans,
cmdListQueuedSecurityScans,
cmdListSecurityScanArtifacts,
cmdSecurityScanOverview,
} = await import("./securityScans");
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
function silenceStdout() {
return vi.spyOn(process.stdout, "write").mockImplementation(() => true);
}
describe("security scan commands", () => {
it("prints overview JSON from the security scans endpoint", async () => {
const stdout = silenceStdout();
const overview = {
generatedAt: 1,
window: { hours: 24, totalsByKind: {}, rows: [], truncated: false },
current: {},
failed: { items: [], limit: 10 },
};
httpMocks.apiRequest.mockResolvedValueOnce(overview);
const result = await cmdSecurityScanOverview(makeGlobalOpts(), {
artifactKind: "skill",
windowHours: "24",
failedLimit: "3",
json: true,
});
expect(result).toEqual(overview);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "GET",
token: "tkn",
url: expect.stringContaining("/api/v1/security-scans/overview?"),
}),
expect.anything(),
);
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
expect(request.url).toContain("artifactKind=skill");
expect(request.url).toContain("windowHours=24");
expect(request.url).toContain("failedLimit=3");
expect(stdout).toHaveBeenCalledWith(`${JSON.stringify(overview, null, 2)}\n`);
});
it("lists failed scans across skills and plugins for the all view", async () => {
const stdout = silenceStdout();
httpMocks.apiRequest
.mockResolvedValueOnce({
items: [
{
artifactKind: "skill",
slug: "bad-skill",
displayName: "Bad Skill",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
updatedAt: 20,
},
],
nextCursor: null,
done: true,
limit: 2,
})
.mockResolvedValueOnce({
items: [
{
artifactKind: "plugin",
name: "@bad/plugin",
displayName: "Bad Plugin",
clawScanVerdict: "malicious",
scanJobStatus: "failed",
failureStatus: "failed",
updatedAt: 10,
},
],
nextCursor: null,
done: true,
limit: 2,
});
const result = await cmdListFailedSecurityScans(makeGlobalOpts(), { limit: "2", json: true });
expect(result.items).toHaveLength(2);
const urls = httpMocks.apiRequest.mock.calls.map((call) => (call[1] as { url: string }).url);
expect(urls[0]).toContain("artifactKind=skill");
expect(urls[0]).toContain("failureStatus=failed");
expect(urls[1]).toContain("artifactKind=plugin");
expect(urls[1]).toContain("failureStatus=failed");
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"artifactKind": "all"'));
});
it("lists queued plugin scans with cursor pagination", async () => {
silenceStdout();
httpMocks.apiRequest.mockResolvedValueOnce({
items: [],
nextCursor: "next",
done: false,
limit: 5,
});
await cmdListQueuedSecurityScans(makeGlobalOpts(), {
artifactKind: "plugin",
cursor: "c1",
limit: 5,
json: true,
});
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1);
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
expect(request.url).toContain("artifactKind=plugin");
expect(request.url).toContain("scanJobStatus=queued");
expect(request.url).toContain("cursor=c1");
expect(request.url).toContain("limit=5");
});
it("inspects a specific skill scan artifact", async () => {
silenceStdout();
const detail = {
found: true,
artifactKind: "skill",
state: null,
artifact: {},
scanJob: null,
evidence: {},
};
httpMocks.apiRequest.mockResolvedValueOnce(detail);
const result = await cmdInspectSecurityScanArtifact(makeGlobalOpts(), {
skill: "demo",
json: true,
});
expect(result).toEqual(detail);
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url: string };
expect(request.url).toContain("/api/v1/security-scans/artifact?");
expect(request.url).toContain("skillSlug=demo");
});
it("rejects ambiguous inspect targets", async () => {
await expect(
cmdInspectSecurityScanArtifact(makeGlobalOpts(), {
skill: "demo",
plugin: "@demo/plugin",
}),
).rejects.toThrow(/exactly one/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("rejects multiple list filters before calling the API", async () => {
await expect(
cmdListSecurityScanArtifacts(makeGlobalOpts(), {
artifactKind: "skill",
verdict: "malicious",
scanJobStatus: "failed",
}),
).rejects.toThrow(/at most one/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("surfaces auth or permission failures from the endpoint", async () => {
httpMocks.apiRequest.mockRejectedValueOnce(new Error("Moderator role required."));
await expect(
cmdSecurityScanOverview(makeGlobalOpts(), { artifactKind: "plugin" }),
).rejects.toThrow(/moderator role/i);
expect(authTokenMocks.requireAuthToken).toHaveBeenCalled();
});
});
@@ -0,0 +1,606 @@
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
import { fail } from "../../../clawhub/src/cli/ui.js";
import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
import {
ApiRoutes,
ApiV1SecurityScanArtifactListResponseSchema,
ApiV1SecurityScanArtifactResponseSchema,
ApiV1SecurityScanOverviewResponseSchema,
parseArk,
} from "../../../clawhub/src/schema/index.js";
const ARTIFACT_KINDS = ["skill", "plugin"] as const;
const ARTIFACT_KIND_ARGS = ["all", ...ARTIFACT_KINDS] as const;
const CLAW_SCAN_VERDICTS = ["pass", "suspicious", "malicious", "pending", "failed", "unknown"];
const PIPELINE_STATUSES = ["none", "queued", "running", "succeeded", "failed"];
const FAILURE_STATUSES = ["none", "failed"];
const DEFAULT_LIST_LIMIT = 25;
const MAX_LIST_LIMIT = 100;
type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
type ArtifactKindArg = (typeof ARTIFACT_KIND_ARGS)[number];
type OverviewResponse = Record<string, unknown>;
type ArtifactListResponse = {
items: SecurityScanArtifactSummary[];
nextCursor: string | null;
done: boolean;
limit: number;
};
type ArtifactDetailResponse = Record<string, unknown> & {
found?: boolean;
artifactKind?: ArtifactKind;
state?: SecurityScanArtifactSummary | null;
evidence?: Record<string, unknown>;
scanJob?: Record<string, unknown> | null;
};
type SecurityScanArtifactSummary = Record<string, unknown> & {
artifactKind?: ArtifactKind;
slug?: string;
name?: string;
displayName?: string;
version?: string;
artifactKey?: string;
clawScanVerdict?: string;
clawScanStatus?: string;
clawScanPrimaryCategoryKey?: string;
clawScanPrimaryCategoryLabel?: string;
scanJobStatus?: string;
failureStatus?: string;
lastError?: string;
skillSpectorScore?: number;
skillSpectorTopCategory?: string;
updatedAt?: number;
};
type SecurityScanOverviewOptions = {
artifactKind?: string;
windowHours?: string | number;
failedLimit?: string | number;
json?: boolean;
};
type SecurityScanListOptions = {
artifactKind?: string;
verdict?: string;
clawScanVerdict?: string;
scanJobStatus?: string;
failureStatus?: string;
category?: string;
clawScanPrimaryCategoryKey?: string;
cursor?: string;
limit?: string | number;
json?: boolean;
};
type SecurityScanInspectOptions = {
skill?: string;
plugin?: string;
json?: boolean;
};
export async function cmdSecurityScanOverview(
opts: GlobalOpts,
options: SecurityScanOverviewOptions = {},
) {
const artifactKind = normalizeArtifactKindArg(options.artifactKind ?? "all");
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(`${ApiRoutes.securityScans}/overview`, registry);
if (artifactKind !== "all") url.searchParams.set("artifactKind", artifactKind);
setOptionalNumber(url, "windowHours", options.windowHours);
setOptionalNumber(url, "failedLimit", options.failedLimit);
const raw = await apiRequest(
registry,
{ method: "GET", url: url.toString(), token },
ApiV1SecurityScanOverviewResponseSchema,
);
const result = parseArk(
ApiV1SecurityScanOverviewResponseSchema,
raw,
"Security scan overview response",
) as OverviewResponse;
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
}
printOverview(result, { registry, artifactKind });
return result;
}
export async function cmdListSecurityScanArtifacts(
opts: GlobalOpts,
options: SecurityScanListOptions = {},
) {
const artifactKind = normalizeArtifactKindArg(options.artifactKind ?? "all");
const listOptions = normalizeListOptions(options);
const limit = clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
if (artifactKind === "all" && listOptions.cursor) {
fail("--cursor requires --artifact-kind skill or --artifact-kind plugin");
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const kinds = artifactKind === "all" ? [...ARTIFACT_KINDS] : [artifactKind];
const pages: Record<string, ArtifactListResponse> = {};
const items: SecurityScanArtifactSummary[] = [];
for (const kind of kinds) {
const page = await fetchArtifactList(registry, token, kind, { ...listOptions, limit });
pages[kind] = page;
items.push(...page.items);
}
const sortedItems = items
.sort((a, b) => asNumber(b.updatedAt) - asNumber(a.updatedAt))
.slice(0, limit);
const result =
artifactKind === "all"
? {
artifactKind,
items: sortedItems,
nextCursor: null,
done: Object.values(pages).every((page) => page.done),
limit,
pages,
}
: {
artifactKind,
...pages[artifactKind],
};
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
}
printArtifactList(sortedItems, result);
return result;
}
export async function cmdListFailedSecurityScans(
opts: GlobalOpts,
options: Omit<SecurityScanListOptions, "failureStatus"> = {},
) {
return await cmdListSecurityScanArtifacts(opts, { ...options, failureStatus: "failed" });
}
export async function cmdListQueuedSecurityScans(
opts: GlobalOpts,
options: Omit<SecurityScanListOptions, "scanJobStatus"> = {},
) {
return await cmdListSecurityScanArtifacts(opts, { ...options, scanJobStatus: "queued" });
}
export async function cmdListRunningSecurityScans(
opts: GlobalOpts,
options: Omit<SecurityScanListOptions, "scanJobStatus"> = {},
) {
return await cmdListSecurityScanArtifacts(opts, { ...options, scanJobStatus: "running" });
}
export async function cmdInspectSecurityScanArtifact(
opts: GlobalOpts,
options: SecurityScanInspectOptions = {},
) {
const skillSlug = options.skill?.trim();
const packageName = options.plugin?.trim();
if (Boolean(skillSlug) === Boolean(packageName)) {
fail("Pass exactly one of --skill or --plugin");
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(`${ApiRoutes.securityScans}/artifact`, registry);
if (skillSlug) url.searchParams.set("skillSlug", skillSlug);
if (packageName) url.searchParams.set("packageName", packageName);
const raw = await apiRequest(
registry,
{ method: "GET", url: url.toString(), token },
ApiV1SecurityScanArtifactResponseSchema,
);
const result = parseArk(
ApiV1SecurityScanArtifactResponseSchema,
raw,
"Security scan artifact response",
) as ArtifactDetailResponse;
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
}
printArtifactDetail(result, skillSlug ?? packageName ?? "artifact");
return result;
}
async function fetchArtifactList(
registry: string,
token: string,
artifactKind: ArtifactKind,
options: NormalizedListOptions,
) {
const url = registryUrl(`${ApiRoutes.securityScans}/artifacts`, registry);
url.searchParams.set("artifactKind", artifactKind);
if (options.cursor) url.searchParams.set("cursor", options.cursor);
url.searchParams.set("limit", String(options.limit));
if (options.clawScanVerdict) url.searchParams.set("clawScanVerdict", options.clawScanVerdict);
if (options.scanJobStatus) url.searchParams.set("scanJobStatus", options.scanJobStatus);
if (options.failureStatus) url.searchParams.set("failureStatus", options.failureStatus);
if (options.clawScanPrimaryCategoryKey) {
url.searchParams.set("clawScanPrimaryCategoryKey", options.clawScanPrimaryCategoryKey);
}
const raw = await apiRequest(
registry,
{ method: "GET", url: url.toString(), token },
ApiV1SecurityScanArtifactListResponseSchema,
);
return parseArk(
ApiV1SecurityScanArtifactListResponseSchema,
raw,
"Security scan artifact list response",
) as ArtifactListResponse;
}
type NormalizedListOptions = {
cursor?: string;
limit: number;
clawScanVerdict?: string;
scanJobStatus?: string;
failureStatus?: string;
clawScanPrimaryCategoryKey?: string;
};
function normalizeListOptions(options: SecurityScanListOptions): NormalizedListOptions {
const clawScanVerdict = normalizeEnum(
options.clawScanVerdict ?? options.verdict,
CLAW_SCAN_VERDICTS,
"--verdict",
);
const scanJobStatus = normalizeEnum(
options.scanJobStatus,
PIPELINE_STATUSES,
"--scan-job-status",
);
const failureStatus = normalizeEnum(options.failureStatus, FAILURE_STATUSES, "--failure-status");
const clawScanPrimaryCategoryKey = normalizeOptionalString(
options.clawScanPrimaryCategoryKey ?? options.category,
);
const filterCount = [
clawScanVerdict,
scanJobStatus,
failureStatus,
clawScanPrimaryCategoryKey,
].filter(Boolean).length;
if (filterCount > 1) {
fail("Pass at most one of --verdict, --scan-job-status, --failure-status, or --category");
}
return {
cursor: normalizeOptionalString(options.cursor),
limit: clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
clawScanVerdict,
scanJobStatus,
failureStatus,
clawScanPrimaryCategoryKey,
};
}
function printOverview(
result: OverviewResponse,
context: { registry: string; artifactKind: ArtifactKindArg },
) {
const window = asRecord(result.window);
const current = asRecord(result.current);
const kinds = ARTIFACT_KINDS.filter((kind) => current[kind]);
const totals = aggregateCurrentTotals(current);
const hours = asNumber(window.hours) || 24;
console.log(`Security scan overview (${formatKindArg(context.artifactKind)}, last ${hours}h)`);
console.log(`Registry: ${context.registry}`);
console.log(`Current artifacts: ${totals.total}`);
console.log("");
console.log("Verdicts");
for (const verdict of CLAW_SCAN_VERDICTS) {
const count = totals.byVerdict[verdict] ?? 0;
console.log(` ${verdict}: ${formatCountPercent(count, totals.total)}`);
}
console.log("");
console.log("Pipeline");
for (const status of PIPELINE_STATUSES) {
const count = totals.byScanJobStatus[status] ?? 0;
console.log(` ${status}: ${count}`);
}
console.log("");
console.log("Artifact kinds");
for (const kind of kinds) {
const totalsForKind = asRecord(asRecord(current[kind]).totals);
const total = asNumber(totalsForKind.total);
const pass = asNumber(asRecord(totalsForKind.byVerdict).pass);
console.log(` ${kind}: ${total} artifacts, pass ${formatCountPercent(pass, total)}`);
}
const windowTotals = aggregateWindowTotals(asRecord(window.totalsByKind));
console.log("");
console.log("Recent window");
console.log(` scan events: ${windowTotals.total}`);
console.log(` queued: ${windowTotals.byScanJobStatus.queued ?? 0}`);
console.log(` running: ${windowTotals.byScanJobStatus.running ?? 0}`);
console.log(` succeeded: ${windowTotals.byScanJobStatus.succeeded ?? 0}`);
console.log(` failed: ${windowTotals.byScanJobStatus.failed ?? 0}`);
const categories = collectCategoryRows(current).slice(0, 8);
console.log("");
console.log("ClawScan categories");
if (categories.length === 0) {
console.log(" none");
} else {
for (const row of categories) {
const label = row.categoryLabel ?? row.categoryKey ?? "uncategorized";
const count = asNumber(row.count);
const total = asNumber(row.totalForKind ?? row.percentageBasis ?? totals.total);
console.log(
` ${label}: ${formatCountPercent(count, total)} ${row.artifactKind} ${row.clawScanVerdict}`,
);
}
}
const failedItems = asArray(asRecord(result.failed).items) as SecurityScanArtifactSummary[];
console.log("");
console.log("Failed scans");
if (failedItems.length === 0) {
console.log(" none");
} else {
for (const item of failedItems) console.log(` ${formatArtifactRow(item)}`);
}
}
function printArtifactList(
items: SecurityScanArtifactSummary[],
page: { nextCursor?: string | null; done?: boolean; artifactKind?: string },
) {
if (items.length === 0) {
console.log("No security scan artifacts matched.");
return;
}
for (const item of items) {
console.log(formatArtifactRow(item));
const category = item.clawScanPrimaryCategoryLabel ?? item.clawScanPrimaryCategoryKey;
if (category) console.log(` category: ${category}`);
if (typeof item.skillSpectorScore === "number") {
console.log(
` SkillSpector: score=${item.skillSpectorScore}${item.skillSpectorTopCategory ? ` category=${item.skillSpectorTopCategory}` : ""}`,
);
}
if (item.lastError) console.log(` error: ${item.lastError}`);
if (item.updatedAt) console.log(` updated: ${formatDate(item.updatedAt)}`);
}
if (!page.done && page.nextCursor) console.log(`Next cursor: ${page.nextCursor}`);
if (page.artifactKind === "all") {
console.log("Use --artifact-kind skill or --artifact-kind plugin to paginate with --cursor.");
}
}
function printArtifactDetail(result: ArtifactDetailResponse, label: string) {
if (result.found === false) {
console.log(`No security scan artifact found for ${label}.`);
return;
}
const state = asRecord(result.state);
const scanJob = asRecord(result.scanJob);
const evidence = asRecord(result.evidence);
console.log(`Security scan artifact: ${formatArtifactTitle(state)}`);
if (Object.keys(state).length === 0) {
console.log("Digest state: none yet");
} else {
console.log(`ClawScan verdict: ${asDisplayString(state.clawScanVerdict)}`);
console.log(`Pipeline status: ${asDisplayString(state.scanJobStatus, "none")}`);
console.log(`Failure status: ${asDisplayString(state.failureStatus, "none")}`);
const category =
optionalDisplayString(state.clawScanPrimaryCategoryLabel) ??
optionalDisplayString(state.clawScanPrimaryCategoryKey);
if (category) console.log(`Category: ${category}`);
const summary = optionalDisplayString(state.clawScanSummary);
if (summary) console.log(`Summary: ${summary}`);
const lastError = optionalDisplayString(state.lastError);
if (lastError) console.log(`Last error: ${lastError}`);
}
const clawScan = asRecord(evidence.clawScan);
console.log("");
console.log("ClawScan evidence");
console.log(` status: ${asDisplayString(clawScan.status)}`);
console.log(` verdict: ${asDisplayString(clawScan.verdict)}`);
const confidence = optionalDisplayString(clawScan.confidence);
if (confidence) console.log(` confidence: ${confidence}`);
const clawSummary = optionalDisplayString(clawScan.summary);
if (clawSummary) console.log(` summary: ${clawSummary}`);
const skillSpector = asRecord(evidence.skillSpector);
console.log("");
console.log("SkillSpector evidence");
console.log(` status: ${asDisplayString(skillSpector.status)}`);
if (typeof skillSpector.score === "number") console.log(` score: ${skillSpector.score}`);
const severity = optionalDisplayString(skillSpector.severity);
if (severity) console.log(` severity: ${severity}`);
const recommendation = optionalDisplayString(skillSpector.recommendation);
if (recommendation) console.log(` recommendation: ${recommendation}`);
const staticScan = asRecord(evidence.staticScan);
const virusTotal = asRecord(evidence.virusTotal);
console.log("");
console.log("Other evidence");
console.log(` static: ${asDisplayString(staticScan.status)}`);
console.log(` VirusTotal: ${asDisplayString(virusTotal.verdict ?? virusTotal.status)}`);
if (Object.keys(scanJob).length > 0) {
console.log("");
console.log("Worker");
console.log(` job: ${asDisplayString(scanJob._id)}`);
console.log(` status: ${asDisplayString(scanJob.status)}`);
const workerId = optionalDisplayString(scanJob.workerId);
if (workerId) console.log(` worker: ${workerId}`);
const error = optionalDisplayString(scanJob.lastError);
if (error) console.log(` error: ${error}`);
}
}
function aggregateCurrentTotals(current: Record<string, unknown>) {
const totals = emptyAggregate();
for (const kind of ARTIFACT_KINDS) {
const kindTotals = asRecord(asRecord(current[kind]).totals);
addCounts(totals, kindTotals);
}
return totals;
}
function aggregateWindowTotals(totalsByKind: Record<string, unknown>) {
const totals = emptyAggregate();
for (const kind of ARTIFACT_KINDS) {
addCounts(totals, asRecord(totalsByKind[kind]));
}
return totals;
}
function emptyAggregate() {
return {
total: 0,
byVerdict: Object.fromEntries(CLAW_SCAN_VERDICTS.map((verdict) => [verdict, 0])),
byScanJobStatus: Object.fromEntries(PIPELINE_STATUSES.map((status) => [status, 0])),
} as {
total: number;
byVerdict: Record<string, number>;
byScanJobStatus: Record<string, number>;
};
}
function addCounts(target: ReturnType<typeof emptyAggregate>, source: Record<string, unknown>) {
target.total += asNumber(source.total);
const verdicts = asRecord(source.byVerdict);
const statuses = asRecord(source.byScanJobStatus);
for (const verdict of CLAW_SCAN_VERDICTS)
target.byVerdict[verdict] += asNumber(verdicts[verdict]);
for (const status of PIPELINE_STATUSES) {
target.byScanJobStatus[status] += asNumber(statuses[status]);
}
}
function collectCategoryRows(current: Record<string, unknown>) {
const rows: Array<Record<string, string | number | undefined>> = [];
for (const kind of ARTIFACT_KINDS) {
const rollups = asArray(asRecord(current[kind]).rollups);
for (const row of rollups) {
const record = asRecord(row);
if (record.rollupKind !== "clawscanCategory") continue;
rows.push({
artifactKind: typeof record.artifactKind === "string" ? record.artifactKind : kind,
categoryKey: typeof record.categoryKey === "string" ? record.categoryKey : undefined,
categoryLabel: typeof record.categoryLabel === "string" ? record.categoryLabel : undefined,
clawScanVerdict:
typeof record.clawScanVerdict === "string" ? record.clawScanVerdict : undefined,
count: asNumber(record.count),
totalForKind: asNumber(record.totalForKind),
percentageBasis: asNumber(record.percentageBasis),
});
}
}
return rows.sort((a, b) => asNumber(b.count) - asNumber(a.count));
}
function normalizeArtifactKindArg(value: string): ArtifactKindArg {
const normalized = value.trim().toLowerCase();
if (ARTIFACT_KIND_ARGS.includes(normalized as ArtifactKindArg)) {
return normalized as ArtifactKindArg;
}
return fail("--artifact-kind must be all, skill, or plugin");
}
function normalizeEnum(value: string | undefined, allowed: readonly string[], flag: string) {
const normalized = normalizeOptionalString(value)?.toLowerCase();
if (!normalized) return undefined;
if (allowed.includes(normalized)) return normalized;
return fail(`${flag} must be one of ${allowed.join("|")}`);
}
function normalizeOptionalString(value: string | undefined) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function setOptionalNumber(url: URL, name: string, value: string | number | undefined) {
if (value === undefined) return;
url.searchParams.set(name, String(clampPositiveInt(value, name)));
}
function clampLimit(value: string | number | undefined, fallback: number, max: number) {
if (value === undefined) return fallback;
return clampPositiveInt(value, "limit", max);
}
function clampPositiveInt(value: string | number, label: string, max = 10_000) {
const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) fail(`${label} must be a positive integer`);
return Math.min(max, Math.floor(parsed));
}
function formatKindArg(kind: ArtifactKindArg) {
if (kind === "all") return "skills and plugins";
return kind === "skill" ? "skills" : "plugins";
}
function formatCountPercent(count: number, total: number) {
if (total <= 0) return `${count}/0 (0%)`;
return `${count}/${total} (${Math.round((count / total) * 100)}%)`;
}
function formatArtifactRow(item: SecurityScanArtifactSummary) {
const title = formatArtifactTitle(item);
const version = item.version ? `@${item.version}` : "";
const verdict = item.clawScanVerdict ?? "unknown";
const job = item.scanJobStatus ?? "none";
const failure = item.failureStatus === "failed" ? " failure=failed" : "";
return `${item.artifactKind ?? "artifact"} ${title}${version} verdict=${verdict} job=${job}${failure}`;
}
function formatArtifactTitle(item: Record<string, unknown>) {
const displayName = typeof item.displayName === "string" ? item.displayName : undefined;
const slug = typeof item.slug === "string" ? item.slug : undefined;
const name = typeof item.name === "string" ? item.name : undefined;
const artifactKey = typeof item.artifactKey === "string" ? item.artifactKey : undefined;
return displayName ?? slug ?? name ?? artifactKey ?? "unknown";
}
function formatDate(value: number) {
return new Date(value).toISOString();
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function optionalDisplayString(value: unknown) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
return undefined;
}
function asDisplayString(value: unknown, fallback = "unknown") {
return optionalDisplayString(value) ?? fallback;
}
function asNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
+1
View File
@@ -23,6 +23,7 @@ export const ApiRoutes = {
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
securityScans: "/api/v1/security-scans",
souls: "/api/v1/souls",
users: "/api/v1/users",
whoami: "/api/v1/whoami",
+33
View File
@@ -563,6 +563,39 @@ export const ApiV1RemediateAutobansResponseSchema = type({
"done?": "boolean",
});
export const ApiV1SecurityScanOverviewResponseSchema = type({
generatedAt: "number",
window: "unknown",
current: "unknown",
failed: {
items: "unknown[]",
limit: "number",
},
});
export type ApiV1SecurityScanOverviewResponse =
(typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
export const ApiV1SecurityScanArtifactListResponseSchema = type({
items: "unknown[]",
nextCursor: "string|null",
done: "boolean",
limit: "number",
});
export type ApiV1SecurityScanArtifactListResponse =
(typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
export const ApiV1SecurityScanArtifactResponseSchema = type({
found: "boolean",
artifactKind: '"skill"|"plugin"',
"state?": "unknown|null",
"artifact?": "unknown",
"scanJob?": "unknown|null",
"evidence?": "unknown",
"reason?": "string",
});
export type ApiV1SecurityScanArtifactResponse =
(typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
export const ApiV1SetRoleResponseSchema = type({
ok: "true",
role: '"admin"|"moderator"|"user"',
+1
View File
@@ -23,6 +23,7 @@ export declare const ApiRoutes: {
readonly stars: "/api/v1/stars";
readonly transfers: "/api/v1/transfers";
readonly publishers: "/api/v1/publishers";
readonly securityScans: "/api/v1/security-scans";
readonly souls: "/api/v1/souls";
readonly users: "/api/v1/users";
readonly whoami: "/api/v1/whoami";
+1
View File
@@ -23,6 +23,7 @@ export const ApiRoutes = {
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
securityScans: "/api/v1/security-scans",
souls: "/api/v1/souls",
users: "/api/v1/users",
whoami: "/api/v1/whoami",
+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,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,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
+27
View File
@@ -516,6 +516,33 @@ export declare const ApiV1RemediateAutobansResponseSchema: import("arktype/inter
nextCursor?: string | null | undefined;
done?: boolean | undefined;
}, {}>;
export declare const ApiV1SecurityScanOverviewResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
generatedAt: number;
window: unknown;
current: unknown;
failed: {
items: unknown[];
limit: number;
};
}, {}>;
export type ApiV1SecurityScanOverviewResponse = (typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
export declare const ApiV1SecurityScanArtifactListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: unknown[];
nextCursor: string | null;
done: boolean;
limit: number;
}, {}>;
export type ApiV1SecurityScanArtifactListResponse = (typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
export declare const ApiV1SecurityScanArtifactResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
found: boolean;
artifactKind: "skill" | "plugin";
state?: unknown;
artifact?: unknown;
scanJob?: unknown;
evidence?: unknown;
reason?: string | undefined;
}, {}>;
export type ApiV1SecurityScanArtifactResponse = (typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
export declare const ApiV1StarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
starred: boolean;
+24
View File
@@ -470,6 +470,30 @@ export const ApiV1RemediateAutobansResponseSchema = type({
"nextCursor?": "string|null",
"done?": "boolean",
});
export const ApiV1SecurityScanOverviewResponseSchema = type({
generatedAt: "number",
window: "unknown",
current: "unknown",
failed: {
items: "unknown[]",
limit: "number",
},
});
export const ApiV1SecurityScanArtifactListResponseSchema = type({
items: "unknown[]",
nextCursor: "string|null",
done: "boolean",
limit: "number",
});
export const ApiV1SecurityScanArtifactResponseSchema = type({
found: "boolean",
artifactKind: '"skill"|"plugin"',
"state?": "unknown|null",
"artifact?": "unknown",
"scanJob?": "unknown|null",
"evidence?": "unknown",
"reason?": "string",
});
export const ApiV1StarResponseSchema = type({
ok: "true",
starred: "boolean",
File diff suppressed because one or more lines are too long
+1
View File
@@ -24,6 +24,7 @@ export const ApiRoutes = {
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
securityScans: "/api/v1/security-scans",
souls: "/api/v1/souls",
users: "/api/v1/users",
whoami: "/api/v1/whoami",
+33
View File
@@ -550,6 +550,39 @@ export const ApiV1RemediateAutobansResponseSchema = type({
"done?": "boolean",
});
export const ApiV1SecurityScanOverviewResponseSchema = type({
generatedAt: "number",
window: "unknown",
current: "unknown",
failed: {
items: "unknown[]",
limit: "number",
},
});
export type ApiV1SecurityScanOverviewResponse =
(typeof ApiV1SecurityScanOverviewResponseSchema)[inferred];
export const ApiV1SecurityScanArtifactListResponseSchema = type({
items: "unknown[]",
nextCursor: "string|null",
done: "boolean",
limit: "number",
});
export type ApiV1SecurityScanArtifactListResponse =
(typeof ApiV1SecurityScanArtifactListResponseSchema)[inferred];
export const ApiV1SecurityScanArtifactResponseSchema = type({
found: "boolean",
artifactKind: '"skill"|"plugin"',
"state?": "unknown|null",
"artifact?": "unknown",
"scanJob?": "unknown|null",
"evidence?": "unknown",
"reason?": "string",
});
export type ApiV1SecurityScanArtifactResponse =
(typeof ApiV1SecurityScanArtifactResponseSchema)[inferred];
export const ApiV1StarResponseSchema = type({
ok: "true",
starred: "boolean",
+61
View File
@@ -154,6 +154,67 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
- Packages cache VirusTotal undetected-only engine results as clean VT telemetry.
ClawHub does not request or consume VirusTotal AI/code-insight results; VT is
engine/vendor telemetry only.
- Staff production visibility reads ClawScan status from digest tables instead
of aggregating `skills`, `skillVersions`, `packages`, `packageReleases`, and
`securityScanJobs` live. `securityScanArtifactStates` has one current row per
latest skill/plugin artifact, keyed by artifact and target version/release.
ClawScan/Codex `llmAnalysis` is the verdict source of truth; SkillSpector,
static analysis, and VirusTotal fields are evidence for drilldown, not the
primary verdict.
- Staff visibility state names are operator summaries, not independent
moderation states:
- `pass`: ClawScan/Codex resolved the latest artifact without visible review
concerns.
- `suspicious`: ClawScan/Codex found review-worthy or high-impact concerns,
but the result is not a malicious block.
- `malicious`: ClawScan/Codex determined the artifact should not be installed
and normal malicious-artifact moderation applies.
- `pending`: the latest artifact is queued or running, or ClawScan has not
produced a final result yet.
- `failed`: the scan pipeline exhausted the current job unsuccessfully.
- `unknown`: no usable ClawScan result or current scan job exists for the
latest artifact.
- Pipeline status (`queued`, `running`, `succeeded`, `failed`, `none`) explains
worker state; failure status only distinguishes final failed rows from normal
non-failed rows.
- `securityScanCurrentRollups` stores current counts by artifact kind,
ClawScan verdict, pipeline status, failure status, and optional ClawScan risk
category. `securityScanHourlyRollups` stores bounded hourly scan-event counts
for recent time-window views. Hourly writes must go through idempotent
`securityScanHourlyRollupEvents` rows keyed by the source scan event/job so a
replayed lifecycle write does not double-count. These rollups are
intentionally narrow counters, not an audit ledger; detailed truth remains on
the version/release, scan job, moderation timeline, and `auditLogs` rows.
- Scan lifecycle mutations keep digest rows warm for current latest artifacts:
enqueue writes queued state, claim writes running state, success writes the
final ClawScan verdict after artifact evidence is persisted, and failure
writes sanitized reason plus worker/attempt metadata. Retry transitions move
the current row back to queued instead of incrementing a separate failed
current count; final failures set `failureStatus: failed`. If the action
writes a synthetic ClawScan error after retries are exhausted, it refreshes
the same digest row without replaying the hourly failure event.
- Staff overview APIs are read-only and role-gated to admins/moderators. They
must read current and hourly digest tables through indexes, return percentage
bases with each rollup, and paginate drilldown artifact rows instead of
scanning source artifact tables. Skill/plugin lookup resolves the public
artifact identifier, returns the current digest state, compact evidence
summaries from the latest version/release, and a sanitized scan-job summary
that excludes worker lease tokens.
- Digest repair is cursor-based and replayable. Backfill page mutations rebuild
active skill/plugin artifact states from indexed active listings and latest
version/release rows, then adjust current rollups from previous state to next
state in the same mutation. If artifact-state rows drift, operators should
rerun the relevant backfill from a null cursor and continue with returned
cursors until `isDone` is true, then run stale-state pruning pages to remove
digest rows for artifacts that were deleted or superseded while the digest was
stale. If current rollup counts drift independently, operators should clear
current rollup pages for the artifact kind and rebuild them from
`securityScanArtifactStates`.
- Scale warning: these digests are appropriate while the dashboard needs compact
operational visibility. If product requirements shift toward arbitrary
historical slicing across many dimensions, the risk is row explosion in hourly
rollups and expensive repair runs; prefer adding one specific indexed
dimension at a time over storing every scanner finding as a rollup row.
- Skill moderation state stores a structured ClawScan moderation snapshot:
- `moderationVerdict`: `clean | suspicious | malicious`
- `moderationReasonCodes[]`: canonical machine-readable reasons
@@ -0,0 +1,337 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SecurityScanOverview } from "./SecurityScanOverview";
const useQueryMock = vi.fn();
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
to,
search,
}: {
children: React.ReactNode;
to?: string;
search?: Record<string, string | undefined>;
}) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(search ?? {})) {
if (value) params.set(key, value);
}
const query = params.toString();
return <a href={`${to ?? "/"}${query ? `?${query}` : ""}`}>{children}</a>;
},
}));
vi.mock("../ui/select", () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value: string;
onValueChange: (value: string) => void;
}) => (
<select value={value} onChange={(event) => onValueChange(event.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => children,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => children,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
<option value={value}>{children}</option>
),
}));
const NOW = Date.UTC(2026, 4, 26, 12, 0, 0);
function makeCounts(overrides?: {
total?: number;
pass?: number;
suspicious?: number;
malicious?: number;
pending?: number;
failed?: number;
queued?: number;
running?: number;
succeeded?: number;
}) {
return {
total: overrides?.total ?? 0,
byVerdict: {
pass: overrides?.pass ?? 0,
suspicious: overrides?.suspicious ?? 0,
malicious: overrides?.malicious ?? 0,
pending: overrides?.pending ?? 0,
failed: overrides?.failed ?? 0,
unknown: 0,
},
byScanJobStatus: {
none: 0,
queued: overrides?.queued ?? 0,
running: overrides?.running ?? 0,
succeeded: overrides?.succeeded ?? 0,
failed: overrides?.failed ?? 0,
},
byFailureStatus: {
none: (overrides?.total ?? 0) - (overrides?.failed ?? 0),
failed: overrides?.failed ?? 0,
},
};
}
function makeOverview() {
return {
generatedAt: NOW,
current: {
skill: {
totals: makeCounts({
total: 10,
pass: 6,
suspicious: 1,
malicious: 1,
pending: 1,
failed: 1,
queued: 1,
running: 2,
succeeded: 6,
}),
rollups: [
{
artifactKind: "skill",
rollupKind: "clawscanCategory",
categoryKey: "permission_boundary",
categoryLabel: "Permission boundary",
clawScanVerdict: "malicious",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 1,
totalForKind: 10,
percentageBasis: 10,
updatedAt: NOW,
},
],
truncated: false,
},
plugin: {
totals: makeCounts({ total: 5, pass: 4, failed: 1, succeeded: 4 }),
rollups: [],
truncated: false,
},
},
window: {
hours: 24,
startMs: NOW - 24 * 60 * 60 * 1000,
endMs: NOW,
totalsByKind: {
skill: makeCounts({ total: 4, pass: 2, pending: 1, failed: 1, queued: 1, succeeded: 2 }),
plugin: makeCounts({ total: 1, failed: 1 }),
},
rows: [
{
bucketStartMs: NOW - 60 * 60 * 1000,
artifactKind: "skill",
clawScanVerdict: "pass",
scanJobStatus: "succeeded",
failureStatus: "none",
count: 2,
updatedAt: NOW,
},
],
truncated: false,
},
failed: {
limit: 8,
items: [
{
artifactKind: "plugin",
artifactKey: "plugin:packages:demo",
targetKey: "packageRelease:packageReleases:demo",
packageId: "packages:demo",
packageReleaseId: "packageReleases:demo",
ownerUserId: "users:owner",
name: "@openclaw/demo",
displayName: "Demo Plugin",
version: "1.0.0",
clawScanVerdict: "failed",
scanJobStatus: "failed",
failureStatus: "failed",
lastError: "Worker timeout",
createdAt: NOW - 10_000,
updatedAt: NOW,
},
],
},
};
}
function makeDetail() {
return {
found: true,
artifactKind: "skill",
state: {
artifactKind: "skill",
artifactKey: "skill:skills:demo",
targetKey: "skillVersion:skillVersions:demo",
skillId: "skills:demo",
skillVersionId: "skillVersions:demo",
ownerUserId: "users:owner",
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.2.3",
clawScanVerdict: "malicious",
clawScanStatus: "malicious",
clawScanPrimaryCategoryLabel: "Permission boundary",
scanJobStatus: "succeeded",
failureStatus: "none",
skillSpectorScore: 85,
skillSpectorIssueCount: 12,
staticStatus: "suspicious",
vtVerdict: "clean",
createdAt: NOW - 10_000,
updatedAt: NOW,
},
scanJob: {
_id: "securityScanJobs:demo",
status: "succeeded",
source: "manual",
attempts: 2,
workerId: "worker-a",
updatedAt: NOW,
},
evidence: {
clawScan: {
status: "malicious",
verdict: "malicious",
confidence: "high",
summary: "Exfiltrates secrets",
checkedAt: NOW,
},
skillSpector: {
status: "suspicious",
score: 85,
severity: "high",
issueCount: 12,
issues: [
{
issueId: "network-egress",
severity: "high",
finding: "Broad network egress",
},
],
},
staticScan: {
status: "suspicious",
reasonCodes: ["network-egress"],
summary: "Network use",
checkedAt: NOW,
},
virusTotal: {
verdict: "clean",
malicious: 0,
suspicious: 0,
},
},
};
}
describe("SecurityScanOverview", () => {
beforeEach(() => {
useQueryMock.mockReset();
});
it("renders a loading state while the overview query resolves", () => {
useQueryMock.mockReturnValue(undefined);
render(<SecurityScanOverview />);
expect(screen.getByText("Loading security scan overview...")).toBeTruthy();
});
it("renders verdict, category, time-window, failed, queued, and running scan summaries", () => {
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
if (args === "skip") return undefined;
return makeOverview();
});
render(<SecurityScanOverview />);
expect(screen.getByText("Security scans")).toBeTruthy();
expect(screen.getByText("15 artifacts")).toBeTruthy();
expect(screen.getByText("10/15 (67%)")).toBeTruthy();
expect(screen.getByText("Permission boundary")).toBeTruthy();
expect(screen.getByText("5 scan events")).toBeTruthy();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(screen.getByText("Worker timeout")).toBeTruthy();
expect(screen.getByText("Queued")).toBeTruthy();
expect(screen.getByText("Running")).toBeTruthy();
});
it("submits artifact drilldown and renders ClawScan and evidence scanner details", async () => {
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (typeof args === "object" && args && "skillSlug" in args) return makeDetail();
return makeOverview();
});
render(<SecurityScanOverview />);
fireEvent.change(screen.getByPlaceholderText("agentic-risk-demo"), {
target: { value: "demo-skill" },
});
fireEvent.click(screen.getByRole("button", { name: "Inspect" }));
await waitFor(() => {
expect(screen.getByText("Demo Skill")).toBeTruthy();
});
expect(screen.getByText("Exfiltrates secrets")).toBeTruthy();
expect(screen.getByText("Broad network egress")).toBeTruthy();
expect(screen.getByText("worker-a")).toBeTruthy();
});
it("uses the artifact summary when a drilldown has evidence but no digest state", async () => {
useQueryMock.mockImplementation((_query: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (typeof args === "object" && args && "skillSlug" in args) {
return {
...makeDetail(),
state: null,
artifact: {
skill: {
_id: "skills:demo",
slug: "demo-skill",
displayName: "Local Agentic Risk Demo",
},
version: {
_id: "skillVersions:demo",
version: "1.2.3",
createdAt: NOW,
},
},
};
}
return makeOverview();
});
render(<SecurityScanOverview />);
fireEvent.change(screen.getByPlaceholderText("agentic-risk-demo"), {
target: { value: "demo-skill" },
});
fireEvent.click(screen.getByRole("button", { name: "Inspect" }));
await waitFor(() => {
expect(screen.getByText("Local Agentic Risk Demo")).toBeTruthy();
});
expect(screen.getByText(/No digest state/)).toBeTruthy();
});
});
@@ -0,0 +1,803 @@
import { Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Card } from "../ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
type ArtifactKind = "skill" | "plugin";
type ArtifactKindFilter = "all" | ArtifactKind;
type ClawScanVerdict = "pass" | "suspicious" | "malicious" | "pending" | "failed" | "unknown";
type PipelineStatus = "none" | "queued" | "running" | "succeeded" | "failed";
type FailureStatus = "none" | "failed";
type CountBucket = {
total: number;
byVerdict: Record<ClawScanVerdict, number>;
byScanJobStatus: Record<PipelineStatus, number>;
byFailureStatus: Record<FailureStatus, number>;
};
type CurrentRollup = {
artifactKind: ArtifactKind;
rollupKind: "all" | "clawscanRiskBucket" | "clawscanCategory";
categoryKey: string;
categoryLabel?: string;
clawScanVerdict: ClawScanVerdict;
scanJobStatus: PipelineStatus;
failureStatus: FailureStatus;
count: number;
totalForKind: number;
percentageBasis: number;
updatedAt: number;
};
type HourlyRollup = {
bucketStartMs: number;
artifactKind: ArtifactKind;
clawScanVerdict: ClawScanVerdict;
scanJobStatus: PipelineStatus;
failureStatus: FailureStatus;
count: number;
updatedAt: number;
};
type ArtifactStateSummary = {
artifactKind: ArtifactKind;
artifactKey: string;
targetKey: string;
skillId?: Id<"skills">;
skillVersionId?: Id<"skillVersions">;
packageId?: Id<"packages">;
packageReleaseId?: Id<"packageReleases">;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
slug?: string;
name?: string;
displayName: string;
version?: string;
clawScanVerdict: ClawScanVerdict;
clawScanStatus?: string;
clawScanCheckedAt?: number;
clawScanSummary?: string;
clawScanModel?: string;
clawScanPrimaryRiskBucket?: string;
clawScanPrimaryCategoryKey?: string;
clawScanPrimaryCategoryLabel?: string;
clawScanVisibleFindingCount?: number;
clawScanHighestSeverity?: string;
scanJobStatus: PipelineStatus;
failureStatus: FailureStatus;
lastScanWorkerId?: string;
lastScanAttempts?: number;
lastScanUpdatedAt?: number;
lastError?: string;
skillSpectorStatus?: string;
skillSpectorScore?: number;
skillSpectorSeverity?: string;
skillSpectorRecommendation?: string;
skillSpectorIssueCount?: number;
skillSpectorTopCategory?: string;
staticStatus?: string;
staticReasonCount?: number;
vtStatus?: string;
vtVerdict?: string;
vtMalicious?: number;
vtSuspicious?: number;
evidenceUpdatedAt?: number;
createdAt: number;
updatedAt: number;
};
type SecurityScanOverviewResult = {
generatedAt: number;
window: {
hours: number;
startMs: number;
endMs: number;
totalsByKind: Partial<Record<ArtifactKind, CountBucket>>;
rows: HourlyRollup[];
truncated: boolean;
};
current: Partial<
Record<
ArtifactKind,
{
totals: CountBucket;
rollups: CurrentRollup[];
truncated: boolean;
}
>
>;
failed: {
items: ArtifactStateSummary[];
limit: number;
};
};
type SecurityScanArtifactDetail = {
found: boolean;
artifactKind: ArtifactKind;
reason?: "missing";
state: ArtifactStateSummary | null;
artifact?: {
skill?: {
_id: Id<"skills">;
slug: string;
displayName: string;
latestVersionId?: Id<"skillVersions">;
};
version?: {
_id: Id<"skillVersions">;
version: string;
createdAt: number;
} | null;
package?: {
_id: Id<"packages">;
name: string;
displayName: string;
family: string;
latestReleaseId?: Id<"packageReleases">;
};
release?: {
_id: Id<"packageReleases">;
version: string;
createdAt: number;
} | null;
};
scanJob?: {
_id: Id<"securityScanJobs">;
status: string;
source: string;
attempts: number;
workerId?: string;
lastError?: string;
updatedAt: number;
} | null;
evidence?: {
clawScan: {
status?: string;
verdict?: string;
confidence?: string;
summary?: string;
guidance?: string;
findings?: string;
model?: string;
checkedAt?: number;
};
skillSpector: {
status?: string;
score?: number;
severity?: string;
recommendation?: string;
issueCount?: number;
checkedAt?: number;
issues: Array<{
issueId?: string;
severity?: string;
explanation?: string;
finding?: string;
}>;
};
staticScan?: {
status: string;
reasonCodes: string[];
summary: string;
checkedAt: number;
};
virusTotal?: {
status?: string;
verdict?: string;
malicious?: number;
suspicious?: number;
checkedAt?: number;
};
};
};
const VERDICTS: ClawScanVerdict[] = [
"pass",
"suspicious",
"malicious",
"pending",
"failed",
"unknown",
];
const PIPELINE_STATUSES: PipelineStatus[] = ["queued", "running", "succeeded", "failed"];
type SecurityScanOverviewProps = {
selectedSkillSlug?: string;
selectedPluginName?: string;
};
export function SecurityScanOverview({
selectedSkillSlug,
selectedPluginName,
}: SecurityScanOverviewProps) {
const [artifactKind, setArtifactKind] = useState<ArtifactKindFilter>("all");
const [windowHours, setWindowHours] = useState("24");
const [lookupKind, setLookupKind] = useState<ArtifactKind>("skill");
const [lookupValue, setLookupValue] = useState("");
const [submittedLookup, setSubmittedLookup] = useState<{
kind: ArtifactKind;
value: string;
} | null>(null);
useEffect(() => {
if (selectedSkillSlug) {
setLookupKind("skill");
setLookupValue(selectedSkillSlug);
setSubmittedLookup({ kind: "skill", value: selectedSkillSlug });
return;
}
if (selectedPluginName) {
setLookupKind("plugin");
setLookupValue(selectedPluginName);
setSubmittedLookup({ kind: "plugin", value: selectedPluginName });
}
}, [selectedPluginName, selectedSkillSlug]);
const overviewArgs = {
artifactKind: artifactKind === "all" ? undefined : artifactKind,
windowHours: Number(windowHours),
failedLimit: 8,
};
const overview = useQuery(api.securityScanDigests.getStaffSecurityScanOverview, overviewArgs) as
| SecurityScanOverviewResult
| undefined;
const detailArgs = submittedLookup
? submittedLookup.kind === "skill"
? { skillSlug: submittedLookup.value }
: { packageName: submittedLookup.value }
: "skip";
const detail = useQuery(api.securityScanDigests.getStaffSecurityScanArtifact, detailArgs) as
| SecurityScanArtifactDetail
| undefined;
const kinds = useMemo(() => {
if (!overview) return [] as ArtifactKind[];
return (Object.keys(overview.current) as ArtifactKind[]).filter(
(kind) => overview.current[kind],
);
}, [overview]);
const combinedCurrent = useMemo(
() => combineCounts(kinds, (kind) => overview?.current[kind]?.totals),
[kinds, overview],
);
const combinedWindow = useMemo(
() => combineCounts(kinds, (kind) => overview?.window.totalsByKind[kind]),
[kinds, overview],
);
const onSubmitLookup = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const value = lookupValue.trim();
if (!value) return;
setSubmittedLookup({ kind: lookupKind, value });
};
return (
<Card className="mt-5 security-scan-overview">
<div className="management-section-header">
<div>
<h2 className="section-title text-[1.2rem] m-0">Security scans</h2>
<p className="section-subtitle m-0">
ClawScan verdicts are primary. SkillSpector, static, and VirusTotal are evidence.
</p>
</div>
{overview ? (
<div className="management-count">Updated {formatTimestamp(overview.generatedAt)}</div>
) : null}
</div>
<div className="management-controls security-scan-controls">
<div className="security-scan-segment" aria-label="Artifact kind">
{(["all", "skill", "plugin"] as ArtifactKindFilter[]).map((kind) => (
<button
key={kind}
type="button"
className="security-scan-segment-button"
aria-pressed={artifactKind === kind}
onClick={() => setArtifactKind(kind)}
>
{kind === "all" ? "All" : formatArtifactKind(kind)}
</button>
))}
</div>
<label className="management-control management-control-stack security-scan-window">
<span className="mono">Window</span>
<Select value={windowHours} onValueChange={setWindowHours}>
<SelectTrigger className="management-field">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="24">Last 24 hours</SelectItem>
<SelectItem value="72">Last 72 hours</SelectItem>
<SelectItem value="168">Last 7 days</SelectItem>
</SelectContent>
</Select>
</label>
</div>
{!overview ? (
<div className="stat security-scan-empty">Loading security scan overview...</div>
) : combinedCurrent.total === 0 ? (
<div className="stat security-scan-empty">No security scan digest rows yet.</div>
) : (
<>
<div className="security-scan-metric-grid" aria-label="Security scan status summary">
{PIPELINE_STATUSES.map((status) => (
<Metric
key={status}
label={formatPipelineStatus(status)}
value={combinedCurrent.byScanJobStatus[status]}
detail={`${formatPercent(
combinedCurrent.byScanJobStatus[status],
combinedCurrent.total,
)} of current artifacts`}
/>
))}
</div>
<div className="security-scan-grid">
<section className="security-scan-panel">
<div className="management-section-header">
<h3 className="section-title text-[1rem] m-0">Current verdicts</h3>
<span className="management-count">
{formatNumber(combinedCurrent.total)} artifacts
</span>
</div>
<div className="security-verdict-list">
{VERDICTS.map((verdict) => (
<VerdictRow
key={verdict}
verdict={verdict}
count={combinedCurrent.byVerdict[verdict]}
total={combinedCurrent.total}
/>
))}
</div>
<div className="security-kind-breakdown">
{kinds.map((kind) => {
const totals = overview.current[kind]?.totals;
if (!totals) return null;
return (
<div key={kind} className="security-kind-row">
<span>{formatArtifactKind(kind)}</span>
<span>{formatNumber(totals.total)}</span>
<span>{formatPercent(totals.byVerdict.pass, totals.total)} pass</span>
</div>
);
})}
</div>
</section>
<section className="security-scan-panel">
<div className="management-section-header">
<h3 className="section-title text-[1rem] m-0">ClawScan categories</h3>
<span className="management-count">Current primary category</span>
</div>
<CategoryRows overview={overview} kinds={kinds} />
</section>
</div>
<div className="security-scan-grid">
<section className="security-scan-panel">
<div className="management-section-header">
<h3 className="section-title text-[1rem] m-0">
Last {overview.window.hours} hours
</h3>
<span className="management-count">
{formatNumber(combinedWindow.total)} scan events
</span>
</div>
<div className="security-window-list">
{overview.window.rows.length === 0 ? (
<div className="stat">No scan events in this window.</div>
) : (
overview.window.rows.slice(0, 8).map((row) => (
<div
key={`${row.artifactKind}-${row.bucketStartMs}-${row.clawScanVerdict}-${row.scanJobStatus}-${row.failureStatus}`}
className="security-window-row"
>
<span>{formatTimeBucket(row.bucketStartMs)}</span>
<Badge>{formatArtifactKind(row.artifactKind)}</Badge>
<span>{formatVerdict(row.clawScanVerdict)}</span>
<span>{formatPipelineStatus(row.scanJobStatus)}</span>
<strong>{formatNumber(row.count)}</strong>
</div>
))
)}
</div>
</section>
<section className="security-scan-panel">
<div className="management-section-header">
<h3 className="section-title text-[1rem] m-0">Failed scans</h3>
<span className="management-count">Most recently updated</span>
</div>
<FailedScanRows rows={overview.failed.items} />
</section>
</div>
</>
)}
<section className="security-scan-drilldown">
<div className="management-section-header">
<div>
<h3 className="section-title text-[1rem] m-0">Artifact drilldown</h3>
<p className="section-subtitle m-0">Inspect current ClawScan state and evidence.</p>
</div>
</div>
<form className="management-tool-grid" onSubmit={onSubmitLookup}>
<label className="management-control management-control-stack">
<span className="mono">Artifact</span>
<Select
value={lookupKind}
onValueChange={(value) => setLookupKind(value as ArtifactKind)}
>
<SelectTrigger className="management-field">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skill">Skill slug</SelectItem>
<SelectItem value="plugin">Plugin package</SelectItem>
</SelectContent>
</Select>
</label>
<label className="management-control management-control-stack">
<span className="mono">{lookupKind === "skill" ? "Slug" : "Package"}</span>
<input
className="management-field"
value={lookupValue}
onChange={(event) => setLookupValue(event.target.value)}
placeholder={lookupKind === "skill" ? "agentic-risk-demo" : "@scope/plugin-name"}
/>
</label>
<div className="management-control management-control-stack">
<span className="mono">Action</span>
<Button className="management-action-btn" type="submit" disabled={!lookupValue.trim()}>
Inspect
</Button>
</div>
</form>
<DrilldownResult submitted={submittedLookup} detail={detail} />
</section>
</Card>
);
}
function Metric({ label, value, detail }: { label: string; value: number; detail: string }) {
return (
<div className="security-metric">
<span>{label}</span>
<strong>{formatNumber(value)}</strong>
<small>{detail}</small>
</div>
);
}
function VerdictRow({
verdict,
count,
total,
}: {
verdict: ClawScanVerdict;
count: number;
total: number;
}) {
return (
<div className="security-verdict-row" data-verdict={verdict}>
<span>{formatVerdict(verdict)}</span>
<div className="security-verdict-bar" aria-hidden="true">
<span style={{ width: `${Math.min(100, percentValue(count, total))}%` }} />
</div>
<strong>
{formatNumber(count)}/{formatNumber(total)} ({formatPercent(count, total)})
</strong>
</div>
);
}
function CategoryRows({
overview,
kinds,
}: {
overview: SecurityScanOverviewResult;
kinds: ArtifactKind[];
}) {
const categoryRows = kinds
.flatMap((kind) => overview.current[kind]?.rollups ?? [])
.filter((row) => row.rollupKind === "clawscanCategory" && row.categoryKey !== "all")
.sort((a, b) => b.count - a.count)
.slice(0, 8);
if (categoryRows.length === 0) {
return <div className="stat">No ClawScan category rollups yet.</div>;
}
return (
<div className="security-category-list">
{categoryRows.map((row) => (
<div
key={`${row.artifactKind}-${row.categoryKey}-${row.clawScanVerdict}-${row.scanJobStatus}`}
className="security-category-row"
>
<div>
<strong>{row.categoryLabel ?? row.categoryKey}</strong>
<span>
{formatArtifactKind(row.artifactKind)} · {formatVerdict(row.clawScanVerdict)}
</span>
</div>
<span>
{formatNumber(row.count)}/{formatNumber(row.percentageBasis)} (
{formatPercent(row.count, row.percentageBasis)})
</span>
</div>
))}
</div>
);
}
function FailedScanRows({ rows }: { rows: ArtifactStateSummary[] }) {
if (rows.length === 0) {
return <div className="stat">No failed scans.</div>;
}
return (
<div className="security-failed-list">
{rows.map((row) => (
<div key={`${row.artifactKind}-${row.artifactKey}`} className="security-failed-row">
<div>
<strong>{row.displayName}</strong>
<span>
{formatArtifactKind(row.artifactKind)} · {row.version ? `v${row.version}` : "latest"}{" "}
· {formatTimestamp(row.updatedAt)}
</span>
{row.lastError ? <small>{row.lastError}</small> : null}
</div>
{row.artifactKind === "skill" && row.slug ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: row.slug, plugin: undefined }}>
Inspect
</Link>
</Button>
) : row.artifactKind === "plugin" && row.name ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: undefined, plugin: row.name }}>
Inspect
</Link>
</Button>
) : null}
</div>
))}
</div>
);
}
function DrilldownResult({
submitted,
detail,
}: {
submitted: { kind: ArtifactKind; value: string } | null;
detail: SecurityScanArtifactDetail | undefined;
}) {
if (!submitted) {
return (
<div className="stat security-scan-empty">Enter a skill slug or plugin package name.</div>
);
}
if (detail === undefined) {
return <div className="stat security-scan-empty">Loading scan detail...</div>;
}
if (!detail.found) {
return (
<div className="stat security-scan-empty">
No {formatArtifactKind(submitted.kind).toLowerCase()} found for "{submitted.value}".
</div>
);
}
const state = detail.state;
const evidence = detail.evidence;
const artifactDisplayName =
state?.displayName ??
detail.artifact?.skill?.displayName ??
detail.artifact?.package?.displayName ??
submitted.value;
const artifactVersion =
state?.version ?? detail.artifact?.version?.version ?? detail.artifact?.release?.version;
return (
<div className="security-drilldown-result">
<div className="security-drilldown-heading">
<div>
<strong>{artifactDisplayName}</strong>
<span>
{formatArtifactKind(detail.artifactKind)} ·{" "}
{artifactVersion ? `v${artifactVersion}` : "latest"} ·{" "}
{state ? formatVerdict(state.clawScanVerdict) : "No digest state"}
</span>
</div>
{state ? (
<Badge>{formatPipelineStatus(state.scanJobStatus)}</Badge>
) : (
<Badge>No digest row</Badge>
)}
</div>
<div className="security-evidence-grid">
<EvidenceBlock
title="ClawScan"
rows={[
["Verdict", evidence?.clawScan.verdict ?? state?.clawScanVerdict ?? "unknown"],
["Status", evidence?.clawScan.status ?? state?.clawScanStatus ?? "unknown"],
["Confidence", evidence?.clawScan.confidence ?? "n/a"],
[
"Category",
state?.clawScanPrimaryCategoryLabel ?? state?.clawScanPrimaryCategoryKey ?? "n/a",
],
["Summary", evidence?.clawScan.summary ?? state?.clawScanSummary ?? "No summary."],
]}
/>
<EvidenceBlock
title="SkillSpector"
rows={[
["Status", evidence?.skillSpector.status ?? state?.skillSpectorStatus ?? "n/a"],
[
"Score",
evidence?.skillSpector.score !== undefined
? String(evidence.skillSpector.score)
: state?.skillSpectorScore !== undefined
? String(state.skillSpectorScore)
: "n/a",
],
["Severity", evidence?.skillSpector.severity ?? state?.skillSpectorSeverity ?? "n/a"],
[
"Issues",
String(evidence?.skillSpector.issueCount ?? state?.skillSpectorIssueCount ?? 0),
],
]}
/>
<EvidenceBlock
title="Other scanners"
rows={[
["Static", evidence?.staticScan?.status ?? state?.staticStatus ?? "n/a"],
[
"Static reasons",
String(state?.staticReasonCount ?? evidence?.staticScan?.reasonCodes?.length ?? 0),
],
[
"VirusTotal",
evidence?.virusTotal?.verdict ?? state?.vtVerdict ?? state?.vtStatus ?? "n/a",
],
[
"VT detections",
`${state?.vtMalicious ?? evidence?.virusTotal?.malicious ?? 0} malicious / ${
state?.vtSuspicious ?? evidence?.virusTotal?.suspicious ?? 0
} suspicious`,
],
]}
/>
<EvidenceBlock
title="Worker"
rows={[
["Job", detail.scanJob?._id ?? "n/a"],
["Source", detail.scanJob?.source ?? "n/a"],
["Attempts", String(detail.scanJob?.attempts ?? state?.lastScanAttempts ?? 0)],
["Worker", detail.scanJob?.workerId ?? state?.lastScanWorkerId ?? "n/a"],
["Error", detail.scanJob?.lastError ?? state?.lastError ?? "None"],
]}
/>
</div>
{evidence?.skillSpector.issues.length ? (
<div className="management-sublist security-issue-list">
<div className="section-subtitle m-0">SkillSpector issues</div>
{evidence.skillSpector.issues.slice(0, 5).map((issue, index) => (
<div key={issue.issueId ?? index} className="management-report-item">
<span className="management-report-meta">
{issue.severity ?? "unknown"} · {issue.issueId ?? `issue ${index + 1}`}
</span>
<span>{issue.finding ?? issue.explanation ?? "No explanation."}</span>
</div>
))}
</div>
) : null}
</div>
);
}
function EvidenceBlock({ title, rows }: { title: string; rows: Array<[string, string]> }) {
return (
<section className="security-evidence-block">
<h4>{title}</h4>
{rows.map(([label, value]) => (
<div key={label} className="management-report-item">
<span className="management-report-meta">{label}</span>
<span>{value}</span>
</div>
))}
</section>
);
}
function combineCounts(
kinds: ArtifactKind[],
getCounts: (kind: ArtifactKind) => CountBucket | undefined,
): CountBucket {
const combined = makeEmptyCounts();
for (const kind of kinds) {
const counts = getCounts(kind);
if (!counts) continue;
combined.total += counts.total;
for (const verdict of VERDICTS) combined.byVerdict[verdict] += counts.byVerdict[verdict] ?? 0;
for (const status of ["none", ...PIPELINE_STATUSES] as PipelineStatus[]) {
combined.byScanJobStatus[status] += counts.byScanJobStatus[status] ?? 0;
}
combined.byFailureStatus.none += counts.byFailureStatus.none ?? 0;
combined.byFailureStatus.failed += counts.byFailureStatus.failed ?? 0;
}
return combined;
}
function makeEmptyCounts(): CountBucket {
return {
total: 0,
byVerdict: {
pass: 0,
suspicious: 0,
malicious: 0,
pending: 0,
failed: 0,
unknown: 0,
},
byScanJobStatus: {
none: 0,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
},
byFailureStatus: {
none: 0,
failed: 0,
},
};
}
function formatArtifactKind(kind: ArtifactKind) {
return kind === "skill" ? "Skills" : "Plugins";
}
function formatVerdict(verdict: string) {
return verdict.charAt(0).toUpperCase() + verdict.slice(1);
}
function formatPipelineStatus(status: string) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function formatNumber(value: number) {
return value.toLocaleString();
}
function percentValue(count: number, total: number) {
if (total <= 0) return 0;
return (count / total) * 100;
}
function formatPercent(count: number, total: number) {
return `${Math.round(percentValue(count, total))}%`;
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
function formatTimeBucket(value: number) {
return new Date(value).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
+85
View File
@@ -0,0 +1,85 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const useAuthStatusMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const navigateMock = vi.fn();
let searchMock: { skill?: string; plugin?: string } = {};
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: ComponentType }) => ({
__config: config,
useSearch: () => searchMock,
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to?: string;
search?: Record<string, string | undefined>;
}) => <a href={to ?? "/"}>{children}</a>,
useNavigate: () => navigateMock,
}));
vi.mock("convex/react", () => ({
useMutation: (...args: unknown[]) => useMutationMock(...args),
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../components/management/SecurityScanOverview", () => ({
SecurityScanOverview: () => <div>Security overview marker</div>,
}));
async function loadManagementRoute() {
return (await import("./management")).Route as unknown as {
__config: {
component: ComponentType;
};
};
}
describe("management route", () => {
beforeEach(() => {
vi.resetModules();
searchMock = {};
navigateMock.mockReset();
useMutationMock.mockReset();
useMutationMock.mockReturnValue(vi.fn());
useQueryMock.mockReset();
useQueryMock.mockImplementation((_query: unknown, args: unknown) =>
args === "skip" ? undefined : [],
);
useAuthStatusMock.mockReset();
});
it("hides the management console from ordinary users", async () => {
useAuthStatusMock.mockReturnValue({ me: { _id: "users:reader", role: "user" } });
const route = await loadManagementRoute();
const Component = route.__config.component;
render(<Component />);
expect(screen.getByText("Management only.")).toBeTruthy();
expect(screen.queryByText("Security overview marker")).toBeNull();
});
it("shows the security scan overview inside the staff management console", async () => {
useAuthStatusMock.mockReturnValue({ me: { _id: "users:moderator", role: "moderator" } });
const route = await loadManagementRoute();
const Component = route.__config.component;
render(<Component />);
expect(screen.getByText("Management console")).toBeTruthy();
expect(screen.getByText("Security overview marker")).toBeTruthy();
});
});
+6
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { SecurityScanOverview } from "../components/management/SecurityScanOverview";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -301,6 +302,11 @@ function Management() {
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<SecurityScanOverview
selectedSkillSlug={selectedSlug}
selectedPluginName={selectedPluginName}
/>
<Card>
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
<div className="management-controls">
+273
View File
@@ -8046,6 +8046,254 @@ code {
color: var(--ink-soft);
}
.management-section-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.security-scan-overview {
display: grid;
gap: 16px;
}
.security-scan-controls {
justify-content: space-between;
}
.security-scan-segment {
display: inline-flex;
min-height: 42px;
padding: 3px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--surface) 82%, black 3%);
}
.security-scan-segment-button {
min-width: 76px;
border: 0;
border-radius: calc(var(--r-sm) - 3px);
padding: 8px 12px;
color: var(--ink-soft);
background: transparent;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.security-scan-segment-button[aria-pressed="true"] {
color: var(--ink);
background: var(--surface);
box-shadow: 0 1px 6px rgba(29, 59, 78, 0.12);
}
.security-scan-window {
min-width: 190px;
}
.security-scan-metric-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.security-metric {
display: grid;
gap: 3px;
min-width: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--surface) 86%, transparent);
}
.security-metric span,
.security-metric small {
color: var(--ink-soft);
}
.security-metric strong {
font-size: 1.35rem;
line-height: 1.1;
}
.security-scan-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.security-scan-panel,
.security-scan-drilldown,
.security-drilldown-result,
.security-evidence-block {
display: grid;
gap: 12px;
min-width: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--surface) 72%, transparent);
}
.security-verdict-list,
.security-category-list,
.security-window-list,
.security-failed-list {
display: grid;
gap: 8px;
}
.security-verdict-row {
display: grid;
gap: 10px;
grid-template-columns: 88px minmax(80px, 1fr) minmax(100px, auto);
align-items: center;
font-size: 0.9rem;
}
.security-verdict-bar {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 72%, transparent);
}
.security-verdict-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.security-verdict-row[data-verdict="pass"] .security-verdict-bar span {
background: #2f8f5b;
}
.security-verdict-row[data-verdict="suspicious"] .security-verdict-bar span,
.security-verdict-row[data-verdict="pending"] .security-verdict-bar span {
background: #b47515;
}
.security-verdict-row[data-verdict="malicious"] .security-verdict-bar span,
.security-verdict-row[data-verdict="failed"] .security-verdict-bar span {
background: #b83f35;
}
.security-kind-breakdown {
display: grid;
gap: 6px;
padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
}
.security-kind-row,
.security-category-row,
.security-window-row,
.security-failed-row {
display: grid;
gap: 8px;
align-items: center;
}
.security-kind-row {
grid-template-columns: 1fr auto auto;
color: var(--ink-soft);
font-size: 0.9rem;
}
.security-category-row {
grid-template-columns: minmax(0, 1fr) auto;
padding: 10px 0;
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
}
.security-category-row:first-child {
padding-top: 0;
border-top: 0;
}
.security-category-row div,
.security-failed-row div,
.security-drilldown-heading div {
display: grid;
gap: 3px;
min-width: 0;
}
.security-category-row span,
.security-failed-row span,
.security-drilldown-heading span {
color: var(--ink-soft);
font-size: 0.86rem;
}
.security-window-row {
grid-template-columns: minmax(88px, auto) auto 1fr auto auto;
padding: 8px 0;
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
font-size: 0.9rem;
}
.security-window-row:first-child {
padding-top: 0;
border-top: 0;
}
.security-failed-row {
grid-template-columns: minmax(0, 1fr) auto;
padding: 10px 0;
border-top: 1px solid color-mix(in srgb, var(--line) 68%, transparent);
}
.security-failed-row:first-child {
padding-top: 0;
border-top: 0;
}
.security-failed-row small {
color: var(--ink-soft);
overflow-wrap: anywhere;
}
.security-drilldown-result {
margin-top: 12px;
}
.security-drilldown-heading {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-start;
justify-content: space-between;
}
.security-evidence-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.security-evidence-block h4 {
margin: 0;
font-size: 0.92rem;
}
.security-evidence-block span {
overflow-wrap: anywhere;
}
.security-issue-list {
margin-top: 0;
}
.security-scan-empty {
margin-top: 0;
}
@media (max-width: 900px) {
.management-item {
grid-template-columns: 1fr;
@@ -8058,6 +8306,31 @@ code {
.management-subitem {
justify-content: flex-start;
}
.security-scan-metric-grid,
.security-scan-grid,
.security-evidence-grid {
grid-template-columns: 1fr;
}
.security-window-row,
.security-failed-row,
.security-kind-row,
.security-category-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.security-scan-segment,
.security-scan-segment-button,
.security-scan-window {
width: 100%;
}
.security-verdict-row {
grid-template-columns: 1fr;
}
}
/* Security Scan Results */